diff --git a/.github/workflows/api3-contract.yml b/.github/workflows/api3-contract.yml new file mode 100644 index 000000000..fe5b47952 --- /dev/null +++ b/.github/workflows/api3-contract.yml @@ -0,0 +1,56 @@ +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 + 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: 79ea62f45fb2ea8f65d081a109431e4eb1bbdb36 + path: gen1-reference + sparse-checkout: | + src + data + tests + assets/fonts + - name: install LuaJIT + run: sudo apt-get update && sudo apt-get install -y luajit + - name: identical mod contracts on both engines + run: >- + python3 gen2/tools/api3-contract-check/check.py + --gen1 gen1-reference --gen2 gen2 + --report "${{ runner.temp }}/api3-report.json" + - uses: actions/upload-artifact@v7 + with: + name: api3-contract-report + path: ${{ runner.temp }}/api3-report.json + if-no-files-found: error diff --git a/.github/workflows/charge-required-contract.yml b/.github/workflows/charge-required-contract.yml new file mode 100644 index 000000000..948479389 --- /dev/null +++ b/.github/workflows/charge-required-contract.yml @@ -0,0 +1,44 @@ +name: portable charge-required contract +on: + pull_request: + workflow_dispatch: +permissions: + contents: read +concurrency: + group: charge-required-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 battle charge decisions and compatibility + run: | + luajit tests/engine/charge_required_contract.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/charge-required-contract + python3 tools/charge-required-contract-check/check.py --gen1 .contract-reference/gen1 \ + --api 1 --format native --report /tmp/charge-required-contract/api1-native.json + python3 tools/charge-required-contract-check/check.py --gen1 .contract-reference/gen1 \ + --api 2 --format native --report /tmp/charge-required-contract/api2-native.json + python3 tools/charge-required-contract-check/check.py --gen1 .contract-reference/gen1 \ + --api 2 --format unified --report /tmp/charge-required-contract/api2-unified.json + python3 tools/charge-required-contract-check/check.py --gen1 .contract-reference/gen1 \ + --api 3 --format unified --report /tmp/charge-required-contract/api3.json + - uses: actions/upload-artifact@v6 + if: always() + with: + name: charge-required-contract + path: /tmp/charge-required-contract + if-no-files-found: ignore diff --git a/.github/workflows/critical-order-contract.yml b/.github/workflows/critical-order-contract.yml new file mode 100644 index 000000000..d4c927fe7 --- /dev/null +++ b/.github/workflows/critical-order-contract.yml @@ -0,0 +1,37 @@ +name: native critical/type ordering +on: + pull_request: + workflow_dispatch: +permissions: + contents: read +concurrency: + group: critical-order-${{ 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: 535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91 + path: .contract-reference/gen1 + - name: Install runtime + run: sudo apt-get update && sudo apt-get install -y luajit + - name: Check critical/type boundaries + run: | + luajit tests/engine/gen3_critical_order.lua + luajit tests/engine/critical_order_contract.lua + - name: Compare unchanged public consumer + run: | + mkdir -p /tmp/critical-order + python3 tools/critical-order-contract-check/check.py --gen1 .contract-reference/gen1 --host-api 3 --report /tmp/critical-order/api3.json + python3 tools/critical-order-contract-check/check.py --gen1 .contract-reference/gen1 --host-api 2 --report /tmp/critical-order/api2.json + - uses: actions/upload-artifact@v6 + if: always() + with: + name: critical-order + path: /tmp/critical-order + if-no-files-found: ignore diff --git a/.github/workflows/enemy-action-contract.yml b/.github/workflows/enemy-action-contract.yml new file mode 100644 index 000000000..6ae4a4b17 --- /dev/null +++ b/.github/workflows/enemy-action-contract.yml @@ -0,0 +1,130 @@ +name: Enemy move choice contracts +on: + pull_request: + paths: + - '.github/workflows/enemy-action-contract.yml' + - 'src/**' + - 'examples/portable_enemy_action/**' + - 'tests/enemy_actions/**' + - 'tests/engine/portable_enemy_action_*.lua' + - 'tests/engine/portable_enemy_move_records.lua' + - 'tests/engine/portable_enemy_targets.lua' + - 'tests/engine/gen3_follow_me.lua' + - 'tests/engine/gen3_helping_hand.lua' + - 'tests/engine/gen3_spit_up.lua' + - 'tests/engine/gen3_beat_up.lua' + - 'tests/engine/gen3_beat_up_adjustment.lua' + - 'tests/engine/gen3_beat_up_side_party.lua' + - 'tests/engine/gen3_critical_context.lua' + - 'tests/engine/gen3_constrained_party.lua' + - 'tests/engine/gen3_native_accuracy.lua' + - 'tests/engine/gen3_native_secondary.lua' + - 'tests/engine/gen3_battle_protocol.lua' + - 'tests/engine/gen3_party_codec.lua' + - 'tests/engine/gen3_party_transport.lua' + - 'tests/engine/gen3_lock_on.lua' + - 'tests/engine/gen3_lock_on_link.lua' + - 'tests/engine/gen3_baton_pass*.lua' + - 'tests/engine/gen3_encore*.lua' + - 'tests/engine/gen3_disable*.lua' + - 'tests/engine/gen3_multi_hit*.lua' + - 'tests/engine/gen3_rage*.lua' + - 'tests/engine/gen3_triple_kick*.lua' + - 'tests/engine/gen3_sleep_talk*.lua' + - 'tests/engine/gen3_choice*.lua' + - 'tests/engine/gen3_move_used*.lua' + - 'tests/engine/gen3_post_hit*.lua' + - 'tests/engine/gen3_nature_power*.lua' + - 'tests/engine/gen3_assist*.lua' + - 'tests/engine/gen3_mirror*.lua' + - 'tests/engine/gen3_substitute*.lua' + - 'tests/engine/gen3_effect_presentation.lua' + - 'tests/engine/gen3_native_sound.lua' + - 'tests/engine/gen3_sound_player_lifecycle.lua' + - 'tests/engine/gen3_sound_script_waits.lua' + - 'tests/engine/gen3_native_cry.lua' + - 'tests/engine/gen3_bgm_commands.lua' + - 'tests/engine/gen3_bgm_streams.lua' + - 'tests/engine/gen3_native_fanfare.lua' + - 'tests/engine/gen3_audio_recovery.lua' + - 'tests/engine/gen3_music_hooks.lua' + - 'tests/engine/gen3_audio_options.lua' + - 'tests/engine/gen3_audio_cold.lua' + - 'tests/native_bgm/**' + - 'tests/native_cry/**' + - 'tests/native_sound/**' + - 'tests/substitute_art/**' + - 'tests/tools/test_gba_substitute_art.py' + - 'tools/gba_substitute_art.py' + - 'tools/gen3_discover.py' + - 'tools/rom_manifest_firered.json' + - 'tools/rom_manifest_emerald.json' + - 'tests/post_hit_items/**' + - 'tests/held_item/**' + - 'tests/charge_required/**' + - 'tools/enemy-action-contract-check/**' + push: + branches: [main, dev] +permissions: + contents: read +concurrency: + group: enemy-action-contract-${{ github.ref }} + cancel-in-progress: true +jobs: + compare: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + with: + path: gen2 + - uses: actions/checkout@v7 + with: + repository: bryanthaboi/gen1recomp + ref: 535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91 + path: gen1-reference + # Pin the maintained VM: the local 2.1.1700008891 ARM64 runtime + # produces JIT-dependent Thick Fat results in unchanged reference code. + - uses: actions/checkout@v7 + with: + repository: LuaJIT/LuaJIT + ref: a4c064539ea931274d407e1249b4bb575424a0f4 + path: luajit-runtime + - name: Build pinned LuaJIT + run: make -C luajit-runtime -j2 + - name: Validate owned Substitute import locator inputs + run: nice -n 19 python3 -m unittest discover -s gen2/tests/tools -p test_gba_substitute_art.py + - name: Actual public hook and native move execution + run: >- + python3 gen2/tools/enemy-action-contract-check/check.py + --gen1 gen1-reference --gen2 gen2 + --lua "${{ github.workspace }}/luajit-runtime/src/luajit" + --report "${{ runner.temp }}/enemy-action-report.json" + - name: Initialized game and mod audio session teardown + shell: bash + run: | + for version in firered emerald; do + for variant in native shared; do + ( + cd gen1-reference + nice -n 19 "${{ github.workspace }}/luajit-runtime/src/luajit" \ + "${{ github.workspace }}/gen2/tools/enemy-action-contract-check/native-game-audio-session-smoke.lua" \ + "${{ github.workspace }}/gen2" gen1 "$version" "$variant" \ + > "${{ runner.temp }}/game-audio-session-$version-$variant-gen1.log" 2>&1 + ) + ( + cd gen2 + nice -n 19 "${{ github.workspace }}/luajit-runtime/src/luajit" \ + tools/enemy-action-contract-check/native-game-audio-session-smoke.lua \ + "${{ github.workspace }}/gen2" gen2 "$version" "$variant" \ + > "${{ runner.temp }}/game-audio-session-$version-$variant-gen2.log" 2>&1 + ) + done + done + - uses: actions/upload-artifact@v7 + if: always() + with: + name: enemy-action-contract-report + path: | + ${{ runner.temp }}/enemy-action-report.json + ${{ runner.temp }}/game-audio-session-*.log + if-no-files-found: error diff --git a/.github/workflows/gba-damage-base-contract.yml b/.github/workflows/gba-damage-base-contract.yml new file mode 100644 index 000000000..33febed1a --- /dev/null +++ b/.github/workflows/gba-damage-base-contract.yml @@ -0,0 +1,38 @@ +name: ordinary GBA base damage +on: + pull_request: + workflow_dispatch: +permissions: + contents: read +concurrency: + group: gba-damage-base-${{ 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: 535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91 + path: .contract-reference/gen1 + - name: Install runtime + run: sudo apt-get update && sudo apt-get install -y luajit + - name: Check GBA damage and older-profile retention + run: | + luajit tests/engine/gba_damage_base.lua + luajit tests/engine/gen3_held_damage.lua + luajit tests/engine/gen3_held_damage_link.lua + - name: Compare actual ordinary damage calculators + run: | + mkdir -p /tmp/gba-damage-base + python3 tools/gba-damage-base-contract-check/check.py --gen1 .contract-reference/gen1 --report /tmp/gba-damage-base/records.json + python3 tools/held-damage-contract-check/check.py --gen1 .contract-reference/gen1 --report /tmp/gba-damage-base/held-records.json + - uses: actions/upload-artifact@v6 + if: always() + with: + name: gba-damage-base + path: /tmp/gba-damage-base + if-no-files-found: ignore diff --git a/.github/workflows/gen3-ai-item-knowledge-contract.yml b/.github/workflows/gen3-ai-item-knowledge-contract.yml new file mode 100644 index 000000000..95c783b40 --- /dev/null +++ b/.github/workflows/gen3-ai-item-knowledge-contract.yml @@ -0,0 +1,34 @@ +name: native GBA AI item knowledge +on: + pull_request: + workflow_dispatch: +permissions: + contents: read +concurrency: + group: gen3-ai-item-knowledge-${{ 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: Roxas2712/gen1recomp-Mac-fix + ref: 12c9e5b5a2325470d6bb266c9a1f37b7954b698e + path: .contract-reference/gen1 + - name: Install runtime + run: sudo apt-get update && sudo apt-get install -y luajit + - name: Check native item queries and observation producers + run: luajit tests/engine/gen3_ai_item_knowledge.lua + - name: Compare actual native AI and effect paths + run: | + mkdir -p /tmp/gen3-ai-item-knowledge + python3 tools/gen3-ai-item-knowledge-contract-check/check.py --gen1 .contract-reference/gen1 --report /tmp/gen3-ai-item-knowledge/records.json + - uses: actions/upload-artifact@v6 + if: always() + with: + name: gen3-ai-item-knowledge + path: /tmp/gen3-ai-item-knowledge + if-no-files-found: ignore diff --git a/.github/workflows/gen3-ai-order-contract.yml b/.github/workflows/gen3-ai-order-contract.yml new file mode 100644 index 000000000..42c6d3827 --- /dev/null +++ b/.github/workflows/gen3-ai-order-contract.yml @@ -0,0 +1,41 @@ +name: native GBA AI order +on: + pull_request: + workflow_dispatch: +permissions: + contents: read +concurrency: + group: gen3-ai-order-${{ 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: pret/pokefirered + ref: ce6406d8c2eaf46d543c8a2606de0c3cb1951982 + path: .contract-reference/firered + - uses: actions/checkout@v7 + with: + repository: pret/pokeemerald + ref: 731ad5bfd6e6f265508d0efcca0ba42f9dcf5881 + path: .contract-reference/emerald + - name: Install runtime and compiler + run: sudo apt-get update && sudo apt-get install -y luajit build-essential + - name: Check real AI queries and battle planning + run: luajit tests/engine/gen3_ai_order.lua + - name: Compare actual VMs and sorting with original native C + run: | + python3 tools/gen3-ai-order-check/check.py \ + --firered-main .contract-reference/firered/src/battle_main.c \ + --emerald-main .contract-reference/emerald/src/battle_main.c \ + --report /tmp/gen3-ai-order/records.json + - uses: actions/upload-artifact@v6 + if: always() + with: + name: gen3-ai-order + path: /tmp/gen3-ai-order + if-no-files-found: ignore diff --git a/.github/workflows/held-item-contract.yml b/.github/workflows/held-item-contract.yml new file mode 100644 index 000000000..52dfcec4c --- /dev/null +++ b/.github/workflows/held-item-contract.yml @@ -0,0 +1,46 @@ +name: portable held-item contract +on: + pull_request: + workflow_dispatch: +permissions: + contents: read +concurrency: + group: held-item-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 held readers, metadata authority and SDK compatibility + run: | + luajit tests/engine/held_item_contract.lua + luajit tests/engine/held_item_authority.lua + luajit tests/engine/held_item_scheduling.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/held-item-contract + python3 tools/held-item-contract-check/check.py --gen1 .contract-reference/gen1 \ + --api 1 --format native --require-native-schedule-parity --report /tmp/held-item-contract/api1-native.json + python3 tools/held-item-contract-check/check.py --gen1 .contract-reference/gen1 \ + --api 2 --format native --require-native-schedule-parity --report /tmp/held-item-contract/api2-native.json + python3 tools/held-item-contract-check/check.py --gen1 .contract-reference/gen1 \ + --api 2 --format unified --require-native-schedule-parity --report /tmp/held-item-contract/api2-unified.json + python3 tools/held-item-contract-check/check.py --gen1 .contract-reference/gen1 \ + --api 3 --format unified --require-native-schedule-parity --report /tmp/held-item-contract/api3.json + - uses: actions/upload-artifact@v6 + if: always() + with: + name: held-item-contract + path: /tmp/held-item-contract + if-no-files-found: ignore diff --git a/.github/workflows/pokemon-roll-contract.yml b/.github/workflows/pokemon-roll-contract.yml new file mode 100644 index 000000000..7ef8959a1 --- /dev/null +++ b/.github/workflows/pokemon-roll-contract.yml @@ -0,0 +1,40 @@ +name: portable Pokemon identity hooks +on: + pull_request: + workflow_dispatch: +permissions: + contents: read +concurrency: + group: pokemon-roll-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: 535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91 + path: .contract-reference/gen1 + - name: Install test runtime + run: sudo apt-get update && sudo apt-get install -y luajit + - name: Validate native definitions and authority + run: | + luajit tests/engine/pokemon_identity_rolls.lua + luajit tests/engine/pokemon_identity_authority.lua + luajit tests/engine/mod_supports.lua + - name: Compare both SDKs in all four modes + run: | + mkdir -p /tmp/pokemon-roll + python3 tools/pokemon-roll-contract-check/check.py --gen1 .contract-reference/gen1 --api 1 --format native --report /tmp/pokemon-roll/api1.json + python3 tools/pokemon-roll-contract-check/check.py --gen1 .contract-reference/gen1 --api 2 --format native --report /tmp/pokemon-roll/api2-native.json + python3 tools/pokemon-roll-contract-check/check.py --gen1 .contract-reference/gen1 --api 2 --format unified --report /tmp/pokemon-roll/api2-unified.json + python3 tools/pokemon-roll-contract-check/check.py --gen1 .contract-reference/gen1 --api 3 --format unified --report /tmp/pokemon-roll/api3.json + - uses: actions/upload-artifact@v6 + if: always() + with: + name: pokemon-roll-contract + path: /tmp/pokemon-roll + if-no-files-found: ignore diff --git a/docs/api3-portability.md b/docs/api3-portability.md new file mode 100644 index 000000000..65d3f2479 --- /dev/null +++ b/docs/api3-portability.md @@ -0,0 +1,157 @@ +# Shared API 3 entry + +Use the same manifest and entry file on Gen1Recomp and Gen2Recomped: + +```json +{ + "id": "portable_example", + "name": "Portable Example", + "version": "1.0.0", + "api": 3, + "games": ["gen1", "gen2", "gen3"], + "entry": "main.lua" +} +``` + +```lua +local mod = ... +if mod.content.moves:has("TACKLE") then + local ok, reason = mod.content.moves:patch("TACKLE", {power = 45}) + if not ok then mod.log:warn("%s", reason) end +end +``` + +Create a portable starter with: + +```sh +python3 tools/modkit.py scaffold portable_example --api 3 +``` + +It generates the shared games list and a guarded canonical move patch. It +omits an engine-release range because Gen1 and Gen2 have independent engine +version numbers. The default scaffold remains API 2 with its existing range +and profile templates. The `--api` value is now honored instead of silently +emitting API 2 for every value. + +API 3 automatically selects unified content, callbacks and portable scripts. +No `content_format`, `callback_format` or `script_format` fields are needed. +An explicit `"native"` for one of those fields is an error under API 3; +`"unified"` is accepted. API 1/2 retain native defaults, and API 2 retains +its existing individual opt-ins. The engine advertises `Version.modApi = 3`. +A higher unknown API remains a load error. + +This is a common entry and a tested compatibility subset, not a claim that +all of Gen1Recomp's services have been ported. It builds on PRs #90–#99. +The existing unified-content limitations still apply: unsupported writes +return `nil, reason` before recording an operation, native diagnostics are +read-only, and ordinary schema errors raise an attributed load error. +Entry failure rolls back the mod's content and subscriptions. Content is +frozen after loading. + +## Public contracts + +Core `pokemon`, `moves`, `items`, `trainers` and `encounters` handles use the +existing unified adapters. Other registries retain their native record +shapes, rather than inventing canonical fields. Every content handle exposes +`has(id)`, `supports()` and `available`. API-3 passthrough reads and iteration +return detached values too; change data through the write methods. + +The API-3 Pokemon boundary uses short statuses (`SLP`, `PSN`, `BRN`, `FRZ`, +`PAR`, `TOX`), nickname-first display names and a common Special alias. +Writing `stats.special` in a writable callback sets both later Special stats; +contradictory split values are rejected before commit. API-2 unified status +names and callback behavior are unchanged. DVs/shiny presentation is read-only +in the callback boundary. Party and battle reads remain detached snapshots. + +Capture `mod.world` and `mod.battle` at entry time. World methods can be called +before Game exists and return `nil, reason`; reading the facade never boots +Game. The same world handle follows the loader's current Game/save instead of +retaining an earlier one. Battle snapshots and intents retain their existing +revision/replay validation. + +The common world calls currently enabled for generations 1–3 are: + +- `overworld`, `current`, `party`, `activeBlockAt`, `liveMaps`, `mapOverview`, + `effectiveEncounters`; +- `canReorderParty`, `reorderParty`, `availableFieldActions`, `useFieldAction`, + `canFly`, `flyTo`; +- `getFlag`/`setFlag` for private `mod:` boolean flags; +- `queueScript` for the existing bounded portable script language. + +Check `mod:supports("world", name)` before relying on a call. The other +Gen1 world method names remain callable but explicitly return +`nil, "unsupported: ..."`: warp/object/block mutations, runtime NPC operations, +`startWildBattle` and `invalidateMap` have not yet been verified against the +Gen1 API-3 contract. Their native/API-2 counterparts are unaffected. Native +symbolic flag mappings and `mod.datasets:open` are also explicitly unavailable. +Gen4/5 do not claim the shared world surface in this first entry PR. + +Private flags use `save.modData[modId].worldFlags`, independently of native +flags and other mods. A read does not create a bucket. Clearing removes the +stored key. A change emits `flag.changed` with +`name = "mod.."` and the boolean value; setting the same value +emits nothing. Invalid save stores are refused without overwriting them. + +Gen1 API-3 callback names from the pinned reference are recognized even when +this engine has no call site for them. Registration is allowed; availability +still requires a native call site in the selected version. For example, +`render.output` can be subscribed to but reports unavailable here. Unknown +names remain errors. An accepted subscription is not proof that a feature +runs. Source-specific engine handles in lifecycle/UI payloads remain native. + +For scripts, use `mod:supports("scripts", verb)` to test Gen2Recomped's current +bounded vocabulary. A `queueScript` method does not imply support for every +Gen1 script verb. See [portable world scripts](portable-world-scripts.md). + +## Reproducible contract checks + +```sh +luajit tests/engine/api3_entry.lua +luajit tests/engine/api3_compat.lua +python3 tools/api3-contract-check/check.py \ + --gen1 /path/to/clean/pinned/gen1recomp --lua luajit \ + --report /tmp/api3-contract-report.json +``` + +The external reference must be a clean checkout at +`bryanthaboi/gen1recomp@79ea62f45fb2ea8f65d081a109431e4eb1bbdb36`. +Use a separate checkout; do not reset a working installation. No Gen1 engine +implementation is vendored into this repository. Only public callback names +are recorded as a compatibility catalog. + +The exact same manifest and mod entry run on both engines for Red, Blue, +Yellow, Gold, Silver, Crystal, FireRed and Emerald, in both success and +intentional-rollback cases. Each engine/version/case has its own LuaJIT +process. The shared harness constructs native-shaped synthetic datasets; +the mod fixtures contain no engine imports or engine-specific branches. +The runner also generates an API-3 mod through the real scaffold CLI and +loads those same generated files on both engines for each version. This adds +eight comparisons to the sixteen normal/rollback cases. This native-command +stack also includes eight effect cases: 32 configured comparisons and 64 +isolated loader runs. It compares JSON projections of public results and +records hashes of both shared and generated fixture files. It refuses a different or modified reference. + +Coverage includes canonical content reads/writes/iteration and native merge +results, detached core and passthrough reads, callable early handles, later +Game replacement, party fields, private flags, Gen1 battle snapshot/invalid-intent boundaries, callback payloads and native +writeback, custom events, unsupported-name handling, frozen content and failed +owner cleanup. Separate Gen2 tests check API-1/2 behavior, contradictory +manifest fields, honest availability and malformed flag stores. CI pins the +same external commit and uploads the report. + +These are ROM-free loader/runtime-boundary tests, not retail-ROM gameplay +or a proof of every exposed native callback's payload. Native action details +remain covered by the prerequisite PR suites. Extend these shared fixtures +whenever another API-3 service becomes portable; do not make a failing +comparison pass by changing only one engine's mod fixture. + +For the native damage-order correction used by portable `battle.crit` mods, +see [Gen-3 critical/type rounding](gen3-critical-order.md). + +See [portable Pokemon identity hooks](portable-pokemon-rolls.md) for +`shiny.roll`, `gender.roll`, native DV/PID semantics and shared contracts. + +[Portable held-item reads](portable-held-items.md) documents shared names and native consumers and GBA scheduling phases. + +[Portable charge decisions](portable-charge-required.md) document +`battle.charge_required`, native first-turn/release rules and remaining PP-policy differences. 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/beat-up-held-item-contracts.md b/docs/beat-up-held-item-contracts.md new file mode 100644 index 000000000..b666f552d --- /dev/null +++ b/docs/beat-up-held-item-contracts.md @@ -0,0 +1,57 @@ +# Beat Up held-item adjustment contracts + +These contracts verify the composition of #206's native Beat Up executor +and #182's native held-item damage adjustment. They add no new runtime +behavior: both existing changes must reach the same build to match the +unchanged Gen1 reference. + +FireRed and Emerald query Focus Band once after each reached strike's +critical/variation calculation, including nonlethal hits, Substitute hits +and Endure's zero-damage hits. Each strike receives its own roll; a previous +successful nonlethal or Substitute roll does not protect a later strike. +The separate Ruby/Sapphire reference policy keeps move-local success state; +that policy is not selected by these FireRed/Emerald profiles and is not +emulated here. + +The same API-3 consumer and shared native fixture execute actual selected +moves on both engines. Seventeen cases at all four user positions cover: + +- No item, physical native Focus Band (196), and an imported-shaped custom + item with adjustable hold metadata (1196). +- Successful/failed nonlethal and lethal rolls, the strict 9/10 threshold, + and a zero-percent item that must still consume its random draw. +- Breaking/surviving Substitute, Endure, later successful rolls, removal + and equipping inside real public damage-event callbacks. +- A second move that cannot reuse a previous successful roll, owned PP, + per-hit public event amounts/metadata, special counter memory and the + precise number/order/value of Focus Band draws. + +Native physical IDs have built-in hold metadata in the reference. Parameter +mutation therefore uses the imported-shaped custom ID rather than pretending +that changing a cached physical row overrides that native table. The test +restores source-process fixture tables when finished; the Gen1 checkout is +never edited. + +```sh +luajit tests/engine/gen3_beat_up_adjustment.lua +python3 tools/enemy-action-contract-check/check.py \ + --gen1 /path/to/clean/reference --lua /path/to/pinned/luajit \ + --report /tmp/enemy-action-report.json +``` + +The focused suite passes **1,672 checks**. The full comparison passes +**44 isolated workers / 4,622 paired observations** against clean read-only +Gen1Recomp `535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91`, using JIT-enabled +LuaJIT `a4c064539ea931274d407e1249b4bb575424a0f4`. The worker also retains +all previous action, target, Helping Hand, Follow Me, Spit Up and Beat Up +contracts. RNG observations normalize critical/variation operands; the new +Focus Band checks assert the actual 0..99 draws and their per-hit order, +not a complete raw engine RNG trace. + +Negative control: #206 without #182 fails **56 assertions** in the new +FireRed cases. The clean composed prerequisite is +`45220e44` (#206 plus #182); it already passes the new behavior. No built-in +record revision or fingerprint golden changes are necessary for test-only +coverage. Full gameplay, animations, joined multi-trainer/link parties, +Ruby/Sapphire controllers and other scripted damage executors remain +separate acceptance surfaces. diff --git a/docs/behavior-porting-notes.md b/docs/behavior-porting-notes.md index 8693bcef2..c888d4049 100644 --- a/docs/behavior-porting-notes.md +++ b/docs/behavior-porting-notes.md @@ -513,3 +513,34 @@ What was ported from pokered's engine code and where it came from. - The autopilot run reproduces the original's early flow on real map data: Pallet sign text, lab door warp target (5,11), Oak's Lab exit by walking off the mat, connection into Route 1 at matching x. + +## Native GBA received-move history and presentation + +FireRed/Emerald Mirror Move and API-3 `MIRROR_MOVE_EFFECT` registrations use +the recipient's native received-move history, caller eligibility and parent PP +commands. The actual queue executes Substitute lowering/restoration/fade, +Focus Band and Poison controllers with native sound controls; native doll +art comes from the user's cartridge. [Contract and validation](native-gen3-mirror-move.md) +describe the scope, revision 17 compatibility, retained failure evidence and +remaining audio/profile/graphics qualification. + +## Native GBA sound-player ownership + +Native effects now replace the previous source on their imported M4A player +across sound IDs, named roles and pan/mono variants, while independent players +retain their voices. Native queries/stops, override behavior and cache/device +cleanup are covered by [the source-bound sound-player contract](native-sound-player-lifecycle.md). +This follow-up uses native revision 18, with unchanged party codec 1 and legacy +digests. The earlier Mirror Move receipt remains historical validation of its +revision-17 foundation. Public sound-event timing remains separate qualification work; native +world-script waits are covered by the follow-up below. + +## Native GBA world-script sound waits + +Native `waitse` now yields on current SE-player state, preserving its latched +180-tick real-time audio callback and separate 181-poll logic fallback. The +actual game audio loop services callbacks after logic; field objects and input +locks are released after the wait, with command hooks and source metadata +preserved. [The source-bound contract](native-sound-script-waits.md) records +actual native-controller, game-update and imported-PCM qualification, complete +retained negative controls and the remaining sound/visual/session limits. diff --git a/docs/gba-base-damage.md b/docs/gba-base-damage.md new file mode 100644 index 000000000..170ae5b9f --- /dev/null +++ b/docs/gba-base-damage.md @@ -0,0 +1,50 @@ +# Ordinary GBA base-damage contract + +The shared damage calculator still applied earlier generations' base-damage +steps to the GBA profile. This could quarter wide stats, cap a large base at +997, halve the attack stat for burn, and apply weather after the final +2. +These steps produce different GBA results at rounding boundaries. + +The GBA profile now selects `baseDamageStyle = "gba"`. It uses wide stats +without the older base cap and applies burn, screens, spread and weather to +the truncated base before +2. Its physical zero base becomes one; the special +base can remain zero. Solar Beam's bad-weather penalty follows the weather +type modifier, and weather does not apply the older minimum-one clamp. +Profiles without this opt-in retain their existing calculations. + +For example, at level 1 with power 2, equal stats and STAB, an ordinary +physical move deals 4 damage at roll 100 rather than 3. At level 30, Fire +power 35, equal stats, STAB and rain, the base produces 9 damage rather than +7. These are noncritical calculations; critical/type rounding is addressed +separately by PR #175. + +## Shared calculator checks + +The same case file invokes Gen1Recomp's actual GBA `Damage.calc` and +Gen2Recomped's actual `Damage.compute`. It uses equal typed session stats, +move records, status/screen/weather flags and pinned noncritical rolls. No +copy of either damage formula supplies the expected result. + +The matrix covers FireRed and Emerald, 18 contexts, six levels, four powers, +four Attack/Defense pairs and rolls 85/100: **4 workers / 6,912 paired numeric +observations**. Contexts include STAB, no STAB, burn, single/double screens, +spread with screens, resistance, immunity, rain/sun, and Solar Beam in rain, +sand and hail. Both calculators preserve the supplied Pokemon HP. +Six additional host checks preserve the older profiles' minimum base, +paired stat scaling and base cap. The host suite has **20,742 checks**. + +```sh +luajit tests/engine/gba_damage_base.lua +python3 tools/gba-damage-base-contract-check/check.py --gen1 /path/to/gen1 +``` + +The reference must be clean at +`535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91`. Each worker loads its own actual +engine modules. The workflow retains its comparison report. No ROM or +cartridge-derived assets are needed or included. + +This contract covers ordinary noncritical base damage with the listed +inputs. It does not establish every move effect, ability, held-item rule, +critical rule, ordinary turn/controller, or a full graphical playthrough. +The unfinished enemy switch/item hook and AI simulation wiring are outside +this PR. diff --git a/docs/gen3-ai-item-knowledge.md b/docs/gen3-ai-item-knowledge.md new file mode 100644 index 000000000..e00dd24ae --- /dev/null +++ b/docs/gen3-ai-item-knowledge.md @@ -0,0 +1,88 @@ +# Native GBA AI item knowledge + +Native AI scripts can now use `get_hold_effect` and `if_holds_item` through +Gen2Recomped's actual GBA interpreter. Both commands share a battle-local +observed-effect cache populated by actual effect producers. Querying it does +not consume RNG, mutate history, call a public item-substitution hook or +reveal a hidden current opponent item. + +The cartridge profiles intentionally have different rules: + +- FireRed records player-side observations in one shared side byte; enemy + observations do not populate it. Its getter reads that byte for player + battlers and actual item metadata for enemy battlers. +- Emerald keeps slot bytes. The current AI battler reads its actual item; + another battler passes its observed EFFECT byte through the ITEM metadata + reader. This native second lookup is preserved, including live metadata. +- Membership checks own-side physical item IDs and opposing observed bytes. + The two operand bytes are OR-ed as in retail; they are not silently decoded + as a corrected little-endian word. + +The cache lasts for the battle and preserves unrelated history families. +It records successful Focus Band rolls and actual Leftovers healing. Failed +rolls, full-HP Leftovers, pure reads, unrelated HP loss and berry consumption +do not invent item knowledge. Loaded metadata supports explicit NONE and +zero-percent effects. + +Producing the same observations requires the same damage-adjustment order. +GBA Focus Band rolls on nonlethal hits, through Substitute and before Endure. +GBA confusion uses the native two-value decision and 85–100 damage variance; +self-hits can trigger the band. GBA OHKO effects roll before Sturdy, +level/chance failure and preserve native Lock-On behavior. The separate +`Gen3OHKO` policy applies to the default record only; explicit custom gates, +damage or accuracy policies retain their behavior. Other generations retain +their existing confusion and OHKO policies. + +This is native battle behavior, not a new public inventory API. Other missing +AI commands remain gated rather than being declared supported. + +Primary references: +[FireRed AI commands](https://github.com/pret/pokefirered/blob/master/src/battle_ai_script_commands.c), +[Emerald AI commands](https://github.com/pret/pokeemerald/blob/731ad5bfd6e6f265508d0efcca0ba42f9dcf5881/src/battle_ai_script_commands.c), +[Emerald battle commands](https://github.com/pret/pokeemerald/blob/731ad5bfd6e6f265508d0efcca0ba42f9dcf5881/src/battle_script_commands.c), +[Emerald item reader](https://github.com/pret/pokeemerald/blob/731ad5bfd6e6f265508d0efcca0ba42f9dcf5881/src/item.c). + +## Reproducible comparison + +```sh +luajit tests/engine/gen3_ai_item_knowledge.lua +python3 tools/gen3-ai-item-knowledge-contract-check/check.py --gen1 /path/to/fixed/gen1 +``` + +The checker requires the clean counterpart +`Roxas2712/gen1recomp-Mac-fix@12c9e5b5a2325470d6bb266c9a1f37b7954b698e`, +based on upstream dev `535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91`. +Gen1Recomp also needed its placeholder getter and item-history rules fixed; +the workflow explicitly checks out that counterpart. This is not an upstream +merge claim or agreement with the original placeholder implementation. + +One shared case file exercises actual VMs and actual direct-damage, +attack-canceller and OHKO paths. Only source ROM text presentation is +substituted; cartridge-shaped item and move records are input fixtures. +Coverage includes all 67 effect values, single/double selectors, physical +IDs, byte folding, live metadata, cache lifetime, no-leak reads and complete +RNG traces. It checks Focus Band success/failure, zero-percent, nonlethal and +lethal hits, Substitute, Endure, confusion, Sturdy, OHKO chance boundaries, +Protect, invulnerability and Lock-On. Host OHKO cases start at the damaging +effect stage after PP; they do not establish the whole move controller. + +## Local validation + +- Host focused checks: **1,491/1,491**; counterpart: **1,240/1,240**. +- Actual-engine comparison: **4 workers / 364 paired observations**, no + differences across FireRed and Emerald. +- Negative controls fail on unchanged upstream revisions. Gen1 dev reports + 116/113 failed checks and 117/127 differing observations (FR/EM); Gen2 main + reports 310/320 failed checks and 157/161 differing observations. +- Full host run: **65/75** engine suites, SDK **3/3**, B30 passes. The exact + **27 FAIL lines in ten suites** match clean main, which passes **64/74**. +- The source standard run passes all selected tiers, including **1,116/1,116** + engine suites and SDK **79/79**; game3 reports **425/425**, including **261 + self-skips** without cartridge caches. Expanded focused cases pass after + that run with unchanged source production code. +- Shared fixture formatting and workflow lint pass. + +Reports remain in ignored `tmp/ai-item-knowledge-qa/` and the external QA +folder of the same name. These are bounded CPU VM/effect checks, without +ROMs or generated assets in the commit. Full AI, graphical gameplay and +hosted CI approval remain separate validation work. diff --git a/docs/gen3-ai-order.md b/docs/gen3-ai-order.md new file mode 100644 index 000000000..69ca0594c --- /dev/null +++ b/docs/gen3-ai-order.md @@ -0,0 +1,78 @@ +# Native GBA order prediction and planning + +The native AI commands `if_user_goes` and `if_user_doesnt_go` compare the +literal result of `GetWhoStrikesFirst(user, target, TRUE)` with their operand. +They do not select a battler through that operand. Result 0 means the user +goes first, 1 means the target has greater speed, and 2 is the odd random +result of an equal-speed tie. The inverse compares for inequality. + +This adds both commands and makes actual GBA move planning share their speed +arithmetic: weather before integer stage division, native third-badge policy, +Macho Brace, paralysis, and the strict Quick Claw threshold. Speed can be zero. +Enigma Berry uses the physical holder's per-battler berry definition. Living +Cloud Nine or Air Lock holders suppress weather, including either right-hand +slot in a double battle. AI prediction ignores chosen move priority; actual +planning applies it before resolving equal-priority speed ties. + +A single unsigned 16-bit word is prepared before enemy selection, reused for +both holders and actual planning, and released after planning or end of turn. +No query samples another Quick Claw word. Both successful claws set their +holder's speed to UINT32_MAX and therefore tie rather than favoring one side. +The native strict threshold can fail even at a 100-percent parameter when the +stored word is 65535. The existing actual comparator RNG interface stays +`rng(0, 1)`; AI tie queries use `rng(0, 65535)`. This tests native decisions and +draw placement, not bit-for-bit equivalence between different RNG algorithms. + +Double battles compare each later pair in the native nested i,j order. Item +and switch choices form a stable position-ordered prefix and do not undergo +speed comparisons. Both enemy choices precede new-turn flag reset. The public +`battle.turn_order` chain remains active per real comparison and honors +arguments forwarded by a wrapping mod. Actual priority item reads keep the +held-item extension seam; AI reads use physical metadata. + +## References and validation + +The primary references are `GetWhoStrikesFirst`, +`SetActionsAndBattlersTurnOrder`, and the surrounding action-selection/turn +transitions in: + +- [FireRed at ce6406d8](https://github.com/pret/pokefirered/blob/ce6406d8c2eaf46d543c8a2606de0c3cb1951982/src/battle_main.c) +- [Emerald at 731ad5bf](https://github.com/pret/pokeemerald/blob/731ad5bfd6e6f265508d0efcca0ba42f9dcf5881/src/battle_main.c) + +Run the ROM-free focused suite with: + +```sh +luajit tests/engine/gen3_ai_order.lua +``` + +The matrix executes real Gen2Recomped AI programs for both profiles over all +13 speed stages, rounding boundaries, weather masks, suppressors, badge modes, +status words, per-battler Enigma data, Quick Claw boundary words and literal +comparison operands. It also invokes the owned comparator and sorter. The +oracle compiles the original C function from pinned, read-only decompilation +checkouts. It validates the function's SHA-256 before adding observations of +its local speed variables; no native function or ROM content is copied into +this repository. The authored shim supplies declared native input data and +the relevant item, flag, weather-ability and RNG interfaces. + +```sh +python3 tools/gen3-ai-order-check/check.py \ + --firered-main /path/to/pokefirered/src/battle_main.c \ + --emerald-main /path/to/pokeemerald/src/battle_main.c \ + --report /tmp/gen3-ai-order/records.json +``` + +The oracle covers 262 cases per profile: 5,240 actual VM queries, 524 actual +pair comparisons and 524 sorts in total. A separate real `BattleState` fixture +constructs battles, selects via a registered enemy-action consumer, plans and +executes ordinary single/double move queues, then advances through end of turn. +It covers word reuse/renewal, both claws, priority, right-slot weather abilities, +real save badge flags and forwarded hook arguments. Metadata cases inspect the +actual queued entries; complete trainer item/switch controllers are outside +that fixture's scope. Validation is CPU-only and does not claim a graphical +playthrough or complete stock AI semantic coverage. + +This PR is for Gen2Recomped only. It requires no change, PR, or patched checkout +of Gen1Recomp. Gen1Recomp remains a read-only portability reference; its existing +speed-only predictor is not treated as a native correctness oracle. Existing +non-GBA ordering policies stay on their original path. diff --git a/docs/gen3-critical-order.md b/docs/gen3-critical-order.md new file mode 100644 index 000000000..d809f2320 --- /dev/null +++ b/docs/gen3-critical-order.md @@ -0,0 +1,57 @@ +# Gen-3 critical/type rounding + +A critical hit against a resistant target could deal one HP less than the +same move in Gen1Recomp's native GBA engine. With base damage 11, critical x2, +Steel resistance and random roll 85, the result must be **9 HP**, not 8: +`floor(floor(11 * 2 / 2) * 85 / 100)`. + +The Gen-3 ruleset now opts into `critBeforeType`, applying its damage multiplier +after weather and before STAB and per-type rounding. Profiles without that +flag retain their existing order. This is an engine ruleset correction, with +no new public mod API or change to critical-hit probability. + +It matters to portable mods that override `battle.crit`: the same public +critical decision should reach the same tested native damage result on both +engines. The unchanged `examples/portable_critical_order` consumer forces or +suppresses critical hits through that hook, using no engine imports. + +The shared checker runs actual move-resolution pipelines on FireRed and +Emerald. Ninety-six cases per profile cover player/enemy attacks, critical and +ordinary hits, STAB, neutral/resisted/doubly resisted/immune targets and random +rolls 85/92/100. HP, PP and public hook-call counts are compared. Fixture stats +and type rows are explicitly equivalent; neither engine's damage calculation +is replaced. Both API modes compare with the clean Gen1 API-3 reference. +See [the checker](../tools/critical-order-contract-check/README.md). + +The focused arithmetic suite also checks authored super-effectiveness rows, +immunity and profiles without the new flag. These tests establish critical/type +ordering for the covered conditions, not universal damage-formula or native +battle-rule parity. Other modifiers and battle-policy gaps remain separate. + +Install the example in an isolated imported-data test identity to run the +native LÖVE driver: + +```sh +POKEPORT_VERSION=firered POKEPORT_DRIVER=tests/drivers/critical_order.lua \ + bash scripts/run-portability-test.sh +``` + +FireRed and Emerald each run six constructed battles with actual FIGHT/move +input and full native turn queues. The same public mod controls both critical +decisions; HP and PP are checked. The driver starts New Game in memory and +stops before battle finish, afterBattle or normal SAVE. It does not perform a +complete playthrough or fresh full-ROM import. + +## Validation + +The feature passes **15/15** arithmetic regressions and **780/780** real-loader +checks. Each host API mode passes **4 workers / 192 paired observations**. +Unchanged #174 differs in **18 cases** and fails **2 Steel regression assertions +per GBA profile** with the same current fixtures. Both native imported-data +drivers pass all six battles; the temporary example was removed with hash +guards after both processes exited. + +The complete feature passes **141/151** engine suites, **3/3** SDK suites and +B30. All **26 FAIL lines** exactly match #174's ten baseline failure suites; +their separate repairs in #128–134 are outside this feature branch. Hosted CI +approval/execution is separate from these local results. diff --git a/docs/known-differences.md b/docs/known-differences.md index f0f1b78e9..66953dc82 100644 --- a/docs/known-differences.md +++ b/docs/known-differences.md @@ -40,3 +40,27 @@ functional in the original game. The door layout lives in `tools/rom_manifest.json` (`field.cardKeyDoors.closedDoors`), hand-ported since no retail ROM data encodes it; `src/import/RomExtractor.lua` copies it straight through on ROM import. + +## Native GBA Beat Up party damage + +Beat Up now reads each healthy party member's level/species base Attack +and target species base Defense, with per-hit native critical/variation +rolls, live callback eligibility, Substitute continuation and last-strike +special counter memory. The effect revision intentionally changes the link +fingerprint; see [the contract and remaining acceptance](native-gba-beat-up.md). + +## Native GBA Beat Up shared side parties + +Beat Up now reads the entire side party in two-trainer and player/ally +battles, retaining live slot replacement and the active user's HP/status. +Replacement benches remain owned separately. The reviewed effect revision +changes the link fingerprint; see [contract coverage and remaining scope](native-gba-beat-up-side-parties.md). + +## Native GBA critical context and hit metadata + +Critical exclusions now preserve native draw order for first/tutorial battles +and Teachy TV, including scripted Beat Up. Shared critical context fields, +physical item/current-species stages, Lansat/Focus Energy behavior and GBA +hit kind/Substitute metadata are covered by cross-engine contracts. The +reviewed scripted effect revision changes the link fingerprint; see +[verification and remaining scope](native-gba-critical-context.md). diff --git a/docs/modding.md b/docs/modding.md index f6f71594f..065f6b47c 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -18,6 +18,65 @@ Regenerate the reference straight into a wiki checkout: luajit tools/gen_registry_docs.lua ../gen1recomp.wiki ``` +## Shared API 3 entry + +The [API-3 entry and cross-engine contracts](api3-portability.md) describe +using the same manifest and mod source on Gen1Recomp and Gen2Recomped. +API 3 selects the unified adapters automatically; API 1/2 keep their defaults. + +## Feature queries (`mod:supports`) + +`mod:supports(surface, name)` returns `true` when the selected host exposes an +existing API-2 capability, or `false, reason` when it does not. Unknown names, +unknown surfaces and invalid names return `false` with an explanation. Check +for the method itself when supporting older engine builds: + +```lua +if mod.supports and mod:supports("hooks", "battle.damage") then + mod.hooks:wrap("battle.damage", function(next, ctx) + return next(ctx) + end) +end + +local canFly = mod.supports and mod:supports("world", "flyTo") -- false here +``` + +The four surfaces are: + +| Surface | Meaning of `true` | +| --- | --- | +| `content` | An API-2 registry exists, e.g. `pokemon` or `render_pipelines`. | +| `world` | A public `mod.world` method exists, e.g. `warpTo` or `terrainAt`. | +| `hooks` | The engine has a native hook call site for this version. | +| `events` | The engine has a native event call site for this version, or the name is a non-empty `mod..` namespace. | + +Queries work in the entry chunk without an active overworld, save, graphics +context or imported ROM. They do not subscribe, wrap hooks, load the Game or +materialize `mod.world`. Answers are bound to the host selected when that mod +API was built; changing the mod's copy of `gameVersion` or `host` cannot change +them. Availability does not imply the current game state permits the action: +`warpTo` can still return `nil, "no overworld"`, and elevation readers can +return `nil, "map carries no elevation"`. + +Callback availability follows Gen2Recomped's actual implementation, rather +than Gen1Recomp's newer catalog. For example, `core.logic_speed`, `item.use` +and `egg.hatched` have no native call sites here; the hatch event here is +`pokemon.egg_hatched` (Gen 2–5). `battle.status_hud_visible` is available on +Gen 4–5, `battle.overlay` on Gen 1–3, and the normal start-menu, party-submenu +and naming-grid hooks on Gen 1–3. `ui.pc.items` is available on Gen 1–2. +Oak-speech callbacks apply to Gen 1–2 except Prism; Birch-speech events apply +to Emerald. Other versions use their own intros. An event still needs its +trigger to occur; custom events also need another mod to emit them. + +For API 1/2, the query describes the existing interfaces and does not itself +normalize records, IDs or callback payloads. API 3 selects the portable +adapters described in [Shared API 3 entry](api3-portability.md), with explicit +limits on availability. Existing API-1/2 registration/subscription behavior +remains unchanged. When adding a native callback or changing screen routing, +update `src/mods/Capabilities.lua` and the capability tests in the same change. + +Additional read-only world summaries: [map overview and encounter queries](world-overview.md). + ## Bulk storage (`mod.storage`) `mod.save` is the right home for a handful of per-playthrough values: it lives @@ -70,6 +129,14 @@ is a normal answer rather than a failure, and telling it apart from per frame, and a directory walk over several hundred blobs per frame is not something a menu can afford. +## Read-only world and party queries + +`mod.world:party()`, `activeBlockAt(mapId, bx, by)` and `liveMaps()` expose +common Pokémon snapshots and current runtime map data across generations +1–5. Reads return detached data and do not load maps or change gameplay. +See [record fields, coordinate units and DS limitations](world-read-api.md). +The existing `loadedMaps()` method retains its legacy shape. + ## Editing maps in Tiled Maps are data, not assets, so they can be authored in a real map editor and @@ -87,6 +154,13 @@ An edited vanilla map becomes a `mod.content.maps:patch` carrying only the fields that moved; a new map becomes a `:register`. See `docs/new-features.md` and the extension's own README. +## Portable dialogue scripts (opt-in) + +API-2 mods can select `"script_format": "unified"` for prevalidated, copied +`mod.world:queueScript()` dialogue/control-flow programs with exactly-once +completion and owner-only cancellation. Existing manifests keep native script +behavior. See [portable commands, ownership and limits](portable-world-scripts.md). + ## Rendering pipelines Most registries hand the engine *content*. `render_pipelines` hands it @@ -152,6 +226,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 +277,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 +312,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 +356,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 +387,13 @@ The launcher hands the link to the operating system to open, so only `http://` and `https://` links are accepted. Any other value, such as a `file:` link or a custom scheme, is dropped with a warning. The mod itself still loads; it just gets no button. + +## Unified content records (opt-in) + +API-2 mods can select `"content_format": "unified"` to read and edit common +Pokémon, move, item, trainer and encounter definition shapes. Existing mods +keep native formats. See [the format, supported operations and limitations](unified-mod-content.md) +for the manifest guard, generation coverage, symbolic IDs and failure behavior. + +[Portable charge decisions](portable-charge-required.md) document +`battle.charge_required`, native first-turn/release rules and remaining PP-policy differences. diff --git a/docs/native-gba-beat-up-side-parties.md b/docs/native-gba-beat-up-side-parties.md new file mode 100644 index 000000000..c582c58c0 --- /dev/null +++ b/docs/native-gba-beat-up-side-parties.md @@ -0,0 +1,67 @@ +# Native GBA Beat Up side parties + +A two-trainer opponent side and a player/ally side each use one native party +for Beat Up. Gen2Recomped stores the trainers' parties separately for +replacement, payout and defeat handling. Its Beat Up helper previously used +only the active trainer's party, so three eligible members produced three +strikes where the reference used the combined six. + +Keep the owning arrays separate and expose a private, live indexed view to +the native Beat Up executor. Both positions use primary-party slots followed +by partner-party slots. A battle-scoped player party takes precedence over +the save party. Eligibility and replacement of an upcoming member are read +live; the active user's current HP/status still overrides its party slot, +including a detached active battler with an explicit native party index. + +The source captures the current party array for a move. Reassigning the +battle's entire party field in a hit callback therefore does not replace +that in-flight iteration. The host likewise retains its owning array +references for the move while reading each upcoming slot live. This avoids +copying a stale list of member objects or rereading a newly assigned party. +Native six-slot bounds and the ordinary single-owner fallback remain. + +## Verification + +```sh +luajit tests/engine/gen3_beat_up_side_party.lua +python3 tools/enemy-action-contract-check/check.py \ + --gen1 /path/to/clean/reference --lua /path/to/pinned/luajit \ + --report /tmp/enemy-action-report.json +``` + +The same API-3 consumer and shared native fixture run on unchanged read-only +Gen1Recomp `535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91`. Gen1 uses the actual +native State constructor with player/foe ownership halves. Gen2 uses the +actual trainer constructor, `setAlly`, `addOpponentTrainer` and +`sendOutSecondPair`, with prebuilt imported-shaped party members. Source +ownership guards and host replacement benches are checked before action +execution. A separately seeded battle-scoped player view is also exercised; +this is not a new replacement/selection API. + +Eighteen cases at all four positions on FireRed/Emerald cover six/five +members, filtering, either owner having no eligible members, a fully blocked +side, a participant awakened in the accuracy hook, waking/blocking/replacing +an upcoming member in hit callbacks, whole-array reassignment, live active +HP and a detached active battler, Substitute, fainting, criticals and low +variation. Actual PP, hit amounts/order, public event metadata, callback +counts, special counter memory and semantic critical/variation observations +are compared. The focused suite passes **1,728 checks**; the complete matrix +passes **48 isolated workers / 4,766 paired observations**. #209 without this +change fails **58 assertions** in the new FireRed cases. +The full feature quick run passes **91/100 engine suites** versus **90/99** +on clean #209; exact Counters of the **25 inherited FAIL lines across nine +suites** match with no additions. SDK 3/3 and B30 pass. + +The runtime is JIT-enabled LuaJIT v2.1 commit +`a4c064539ea931274d407e1249b4bb575424a0f4`, also pinned in the contract +workflow. Full raw RNG-trace parity, graphical intros/animations, arbitrary +mid-move ownership reshaping, link/multi-owner controller execution and +complete gameplay remain separate surfaces. + +The feature stacks on #209. `BEAT_UP_EFFECT.rev` increases from 1 to 2, so +builds with different native party execution cannot silently negotiate the +same link fingerprint. The fixture digest changes from `d518411055f17444` +to `fdbfa30b20cbbaef`; only `move_effects` changes, from `f45a80ce5e45ef42` to +`e4d28f474c15cd8b`. Restoring revision 1 restores the prior full digest; all +six other populated sections remain identical. No Gen1 source edit or +publication is required. diff --git a/docs/native-gba-beat-up.md b/docs/native-gba-beat-up.md new file mode 100644 index 000000000..aeed10844 --- /dev/null +++ b/docs/native-gba-beat-up.md @@ -0,0 +1,76 @@ +# Native GBA Beat Up party strikes + +Beat Up (Gen-3 effect 154) calculates a separate hit for each healthy, +non-egg party member. It reads that member's level and species base Attack, +and the target species' base Defense; the active battler's ordinary combat +stats, STAB and type effectiveness do not substitute for those values. +The ordinary multi-hit calculator previously reused one ordinary damage +result for the entire party. + +The native path reads the party after accuracy and rechecks eligibility +between hits. Mod callbacks can wake, poison, faint or change the species of +upcoming participants. The current user's live HP/status takes precedence +for its party slot. Each hit applies Helping Hand before its own native +critical and variation rolls. Ordinary `battle.damage` and `battle.crit` +callbacks do not run for this dedicated command. Accuracy and interruption +hooks remain active, and owned PP is consumed once. + +Remaining participants continue after breaking a Substitute and stop when +the target faints. Substitute event amounts are clipped to its remaining +HP. The source's scripted event metadata is retained: special damage, +neutral multiplier and `crit=false`, including when the actual hit was a +critical. Special counter memory keeps the last non-Substitute strike, +including zero HP loss under Endure, rather than summing the entire move. +Earlier-generation fallback behavior remains separate. + +## Reproduction + +```sh +luajit tests/engine/gen3_beat_up.lua +python3 tools/enemy-action-contract-check/check.py \ + --gen1 /path/to/clean/reference --lua /path/to/pinned/luajit \ + --report /tmp/enemy-action-report.json +# With the canonical public effect adapter (#121) integrated: +luajit tests/beat_up/public_effect.lua +``` + +The composed public patch uses canonical `EFFECT_BEAT_UP` (semantic 154); +`BEAT_UP` is a native move name, not that public effect ID. + +The shared API-3 consumer is loaded through each engine's real mod SDK. +FireRed and Emerald comparisons use unchanged read-only Gen1Recomp +`535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91`, all four user positions and +six mixed party members. Cases include filtering, empty/blocked parties, +per-member base/level rounding, independent critical rolls, low variation, +Helping Hand, type/battle-stat independence, Protect, sleep, hidden targets, +misses, Substitute, K. o., Endure and changes inside public callbacks. +The active target species is read separately from its retained party species, +while an attacker's transformed species does not replace the party-member +Attack operand. These cases seed the native battler fields; they do not claim +the entire Transform animation/controller path. +A second shared entry patches MEW's base Attack through the public content +API and verifies that the actual native executor consumes that patch. + +The focused suite passes **1,330 checks**. The full shared comparison passes +**40 isolated workers / 4,486 paired observations**, including the existing +action, target, Follow Me, Helping Hand and Spit Up contracts. +The full local quick run passes **88/97 engine suites** versus **87/96** on +clean #203; exact Counters of all **25 inherited FAIL lines across nine +suites** match with no additions. SDK 3/3 and B30 pass. The unchanged #203 host fails 168 +assertions in the new FireRed cases. Both sides use JIT-enabled LuaJIT v2.1 +commit `a4c064539ea931274d407e1249b4bb575424a0f4`, as pinned in the contract +workflow. RNG observations normalize native critical/variation operands; +they do not claim a complete raw engine RNG trace. + +This change stacks on #203. `BEAT_UP_EFFECT.rev = 1` intentionally changes +the fixture fingerprint from `397df21576af62f1` to `d518411055f17444`, and +only its `move_effects` section from `c62807cde682a669` to +`f45a80ce5e45ef42`. Removing only that revision restores the prior digest; +all six other populated sections remain unchanged. + +The native adjustment schedule for Focus Band, joined multi-trainer/link +parties, intro/tutorial controllers, animation fidelity and full gameplay +are separate remaining acceptance surfaces. Synthetic imported-shaped rows +and ordinary side parties are used here; a complete ROM playthrough or all +held-item/controller parity is not claimed. Gen1 remains read-only and +requires no source change or publication. diff --git a/docs/native-gba-bgm-development.md b/docs/native-gba-bgm-development.md new file mode 100644 index 000000000..d5c5914ff --- /dev/null +++ b/docs/native-gba-bgm-development.md @@ -0,0 +1,445 @@ +# Native GBA background music: historical development checkpoints + +The current contract and qualification are documented in +[native GBA music commands](native-gba-bgm.md) and its source-bound receipt. +The checkpoint results below retain their original scope and counts; they +record development after #289 and do not establish complete music parity. + +The world VM preserves `playbgm`, `savebgm`, `fadedefaultbgm`, `fadenewbgm`, +`fadeoutbgm` and `fadeinbgm` as native commands. Logical current, map and +saved music remain independent of physical song labels. Only `playbgm`'s +keep flag `1` or `true` saves an override; zero and `0xFFFF` stop playback. +The actual native world adapters use fixed speed four for outgoing and +incoming fades, regardless of the encoded speed. `fadenewbgm` dispatches +immediate playback in those adapters. + +The admitted FireRed/Emerald identifier catalogue includes 305 `MUS_*` +names, including aliases and native reverse names. Named mod file music +can be selected by numeric native commands without renaming its definitions. +Shared mod music retains its own transport and user-volume behavior. + +Emerald's default selection reads live map identity, player position, +saved weather and story variables/flags through the host's existing formats: +`MAP_Gxx_Nxx`, `save.gen3Vars`, `save.flags` and `save.gen3Weather`. +It handles Route 111's sandstorm, Sootopolis silence, legendary weather, +the occupied Space Center and Weather Institute, and Route 118's split at +x=24. Repeated commands re-read the current state. FireRed retains its +separate map-song default. This does not yet replace every map/bike/surf +transition entry point. + +## Checked at this checkpoint + +All comparisons run unchanged native Gen1 code at +`535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91`, using the real production world +adapters, VMs, music services and owned audio hardware doubles. No native +audio method is replaced with a host result. LuaJIT is pinned to +`a4c064539ea931274d407e1249b4bb575424a0f4`. + +- **40 workers / 2,648 complete paired observations**, independently + compared with JSON booleans distinguished from numeric zero/one. +- The new BGM contracts contribute **1,970 rows**: 1,844 symbol rows, + 32 command/lifecycle cases with all 81 frames and play traces per case, + and 94 default-policy cases with all five frames and play traces. + File cases retain every created source, including stopped sources and + their actual gains. Another 40 rows cover native fanfare commands and + waits; twelve rows cover initial missing-output state, fades and replacement + of shared file music, 46 rows cover public music hooks and events, 26 rows + cover audio options/reentrant world actions, and the remaining 554 rows + recheck native cry contracts. +- **80/80** BGM command, **62/62** stream and **7,364/7,364** fanfare engine + checks pass, as do **124/124** missing-device/session recovery and + **4,616/4,616** public music hook checks, **102/102** audio option and + **51/51** cold cache/invalid-handle checks at that historical checkpoint. + The full feature quick run passes **174/183** engine suites, + SDK **3/3** and B30. A fresh preceding development checkpoint `3f3f473d` + passes **173/182**; + the exact anchored `FAIL ` line Counters are identical: + **25 lines / nine inherited failing suites**, with no additions/removals. +- The changed workflow passes actionlint. New modules and BGM test files + pass StyLua verification. These are local results, not executed hosted CI. + +The earlier development default-policy probe reproduced differences in +44/88 Emerald cases and none of the six FireRed controls. Failed and +superseded reports remain retained. The corrected implementation passes +all 94 cases, including live state changes after an earlier default command. + +## Native stream follow-up + +Native M4A cue construction now leaves an empty, stopped queue. Each +synchronous native audio service produces one buffer, after applying that +tick's fade, and then starts playback. A stop on the final fade tick cannot +produce another buffer. Finite native sequences retain their silent carrier +until a stop or replacement command. Shared named mod music keeps its +separate transport; a chip source is started only by its streaming owner. + +Worker recovery preserves the native source owned by `Music`, including +queued PCM and the current fade, both before and after the first worker +buffer. Stale worker generations cannot enter the queue. The stream engine +suite passes **62/62 checks**, covering synchronous playback and controlled +worker-channel failures. The corrected recovery removes six reproduced +assertion failures; failed and superseded logs remain retained. + +A separate real-sequencer comparison runs **four workers / 18 cases / +1,534 complete observed frames**. All portable BGM control fields, runner +state, active source counts and gains relative to each backend's retained +baseline agree. Sixteen native/file cases also have equal queue, produced +buffer and play counters. Both chip-override cases retain the reference's +different existing GB lookahead cadence; their complete counter differences +are recorded, not treated as native-queue parity. + +Every produced PCM buffer is retained: **7,929,856 scalar samples** from the +reference and **8,323,072** from the host. The native synthesizers still +produce different PCM; this comparison does not establish waveform parity. +Named mod M4A definitions remain a host extension: the reference's shared +mod player does not accept that format. Host tests verify that the extension +does not incorrectly allocate a native background carrier. + +## Native fanfare follow-up + +`playfanfare` resolves script variables and begins a native, nonblocking +fanfare. Its timer reads imported `constants.gen3Fanfares` metadata and native +song information, including the level-up fallback when a present table does +not admit the requested song. Missing timer metadata retains the native +160-frame default. Rendered sound duration does not decide script completion. + +The real audio service advances the timer. `waitfanfare` also retains the +reference's separate **361-poll** script safety limit and latched completion +callback. Accelerated logic can reach that safety limit before a long +fanfare's audio timer finishes; both behaviors are compared explicitly. +New/repeated fanfares, explicit same/different/silent BGM cues, deferred +defaults and BGM dispatch to a native fanfare preserve their distinct native +rules. Shared mod music is not paused as though it were a native carrier. + +Native fanfare sources use the music option and occupy the declared SE +player. Repeated cues reuse the cached source; replacement stops the former +fanfare and the target player's previous sound. The audible tail can outlive +the native script timer. Fanfare pause clears queued native BGM PCM and +stops its producer. Worker packets retain a pause epoch so an old packet +cannot re-enter the resumed source; stale resume commands are ignored. + +The fanfare engine suite passes **7,364 checks**, including actual +`Game`/`FixedStep`/`StateStack` execution at 1x/4x/10x, field ownership, +public script hooks, completion events, physical map-music restoration, +the real worker body and controlled worker transport. The audio service +uses its game instance's dataset, so a correct logical song cannot mask a +failed physical replay against another dataset. The previous behavior +reproduced six physical-restoration assertion failures. + +The 40 complete fanfare command rows compare unchanged native production +adapters and VMs. A separate **four-worker / 12-case / 156-frame** comparison +uses the real native/host renderers and keeps all distinct PCM, source +inventories, SE queries and queue counters. The earlier checkpoint warmed +the reference cache to accommodate table-backed headless SoundData. The cold +admission follow-up below removes that workaround. This does not claim +whole-waveform parity or real-device qualification. + +## Missing output and session follow-up + +An initial native map cue retains its logical song when a queueable Source +cannot be created. A transient device failure does not permanently mark the +native song as a bad mod definition: after output becomes available, another +cue can play, followed by the originally failed song. The command comparison +covers missing factories and factories returning nil on both engines. Host +recovery tests also cover a throwing factory; the reference's native queue +factory call itself is unguarded, so this is a host robustness check rather +than a claim of matched exception behavior. + +External music stop and reload cancel the native fanfare, finish its old wait +and discard its owned static Sources. Native script BGM stops retain their +separate behavior, including a still-active fanfare timer and audible tail. +Suspension cancels the interrupted fanfare before foreground recovery, so a +failed re-cue cannot revive an old wait. `Game:audioSession` resumes against +its own dataset, including the delayed retry when the device is still absent. + +The **124-check** engine suite exercises cold static factory nil/errors, +successful later replay, SE player ownership, stop/reload cache disposal, +latched cancellation and the actual mobile Game interruption/retry path. +When a native cue replaces shared mod music, the previous shared source is +released before constructing the native carrier. A failed native carrier +cannot leave the previous mod playing. A bad shared mod definition retains +the existing shared song instead. The unchanged parent reproduces **12 +failures in 124 checks** for the native replacement path. The twelve +complete paired no-output rows include every observed control state and all +source inventories. Existing real-sequencer BGM/fanfare comparisons still +pass with their previously documented waveform and shared-GB limitations. + +A separate actual-import stage probe reads private ROMs and existing audio +images without modifying normal imports. The current importer regenerates +**14 FireRed and 18 Emerald fanfare rows**. All 32 native timer resolutions +agree with their ROM table values and imported SE player identities. The +existing Emerald cache has no fanfare metadata; fresh extraction produces +it correctly. Cached absence and fresh output are reported separately. +This metadata check does not establish actual-import playback or native +waveform parity. + +## Actual-import playback follow-up + +Four workers run the actual native/host importers, renderers, world adapters +and VMs over owned FireRed/Emerald ROMs and their existing imported audio +images. The native audio pack is produced by the unchanged reference's +production extractor in memory; the host uses its existing image and freshly +extracted native fanfare metadata. No normal cache is rewritten. + +The **eight cases / 688 observed frames** cover native BGM fades, the real +80-frame level-up fanfare wait, a deferred default map cue and a BGM stop +during the fanfare. Complete portable BGM/fanfare state, script completion, +static-source inventories, active BGM gains relative to each backend's +baseline, and queue/produced/play counters agree. Each case renders audible +actual-import PCM. All **2,681,636 reference / 2,169,890 host scalar samples** +are retained separately. Distinct waveforms and rendered tail lengths are +not treated as equality. The cold admission follow-up below also removes +the earlier actual-import probe's reference-cache prewarming. + +## Public music hook follow-up + +The generic music service now receives the same admission as the reference's +native mod bridge: named shared mod music enters `music.select`, +`music.volume`, `music.started` and `music.stopped`; native M4A carriers +retain their separate native route. A shared selector cannot replace or +silence a native carrier. Shared overrides still support selection, nil +silencing and volume changes on their actual Sources. + +Shared hook contexts carry the generic player's map/bike/surf state rather +than a native `SONG_*` identity. The engine world's location and time of day +are included when that actual engine context is present. Map cues use the +native selection/ownership path and defer while a native fanfare owns the +clock. They produce the shared bridge's `gen3` reason instead of a second +map-specific callback shape. + +The shared audio service runs before the native timer/fade service, matching +the reference's order. Options reapply the previous shared level before the +new value, including the previous fractional level during a fade. A completed +fanfare observes the old shared volume before its stop/select/start events. +Native cry start, completion and explicit stop do not introduce additional +shared volume callbacks. + +The **46 complete cases / 736 observed frames** cover both profiles, unchanged +and modifying wrappers, file failures, options during fades, source replacement, +intro/loop chaining, exact map cues, map changes during fanfares and native +world cry commands rendering real owned PCM. The engine suite passes **4,616 +checks**. The complete unchanged parent `a29c4158` differs in all **46/46** +cases using the same current fixtures; every parent/reference worker exits +zero, and the reference observations agree with the corrected run. + +An early location fixture accidentally obtained a read-only mod facade on +FireRed. Its failure is retained as superseded evidence. The final fixture +loads the actual engine class, supplies world data and restores the module +entry afterwards; no reference engine method is replaced. Separate rendered +BGM/fanfare and actual-import regressions retain their waveform limitations. + +## Audio options and reentrant music hooks + +Native and shared music now retain independent volume and mode state. The +native master becomes live before shared `music.volume` callbacks run, so a +world cue, fade or fanfare started inside a callback receives the new option. +`external_only` mutes actual native and shared music Sources without discarding +playback ownership; `game_only` disables system mixing and `both` enables it. +Platform calls are protected, deduplicated after success and retried after +an unavailable backend. No audio device is started by the tests. + +Missing native options retain the reference's native defaults, while missing +shared options retain the generic player's existing trim and mode. These +defaults can issue two distinct system-mixing policy changes in one options +application; the contract preserves their order. Applying native-only options +does not overwrite the independent legacy player's trim. `Game:applyOptions` +binds its actual dataset, including newly registered mod audio before its +first cue, rather than relying on a previous music command's dataset. +Shared fade-ins set their independent trim to zero immediately; the existing +world-command regression catches an audible full-volume first frame. + +The `native_bgm_options` contract compares **26 complete scenarios / 364 +observed frames** across FireRed and Emerald, including mode transitions, +omitted options, early mod registration and reentrant native/shared/fanfare +actions. `tests/engine/gen3_audio_options.lua` adds **102 checks**, including +the real Game options path, cross-generation trim preservation and platform +API failure/recovery. The complete unchanged parent `fb48ae31` differs in +all **26/26** scenarios using the current fixtures; all four workers exit +zero. Complete callback contexts, results, controller state, Sources and +boolean platform policies are retained. This does not claim every audio +option or arbitrary mod callback combination is qualified. + +## Cold cache admission and invalidated handles + +The stream hardware fixture now returns LuaJIT userdata-shaped SoundData +with the headless sample store behind it. Both engines still execute their +unchanged production renderers. Only the hardware constructors are wrapped; +the CPU Source retains the complete backing PCM for observation. The fixture +restores both constructors when closed and requires the same LuaJIT runtime +as the engine and CI. + +Owned and actual-import fanfare probes no longer call `bakeSong` to populate +the reference cache. Public native script dispatch must render cold PCM and +create an actual static Source from userdata. Assertions reject prewarmed +SoundData or a missing static constructor call. The **12 owned cases / 156 +frames** and **eight imported cases / 688 frames** retain all source records +and distinct rendered PCM. BGM stream regressions still cover **18 cases / +1,534 frames**, including the documented two GB lookahead differences. + +`tests/engine/gen3_audio_cold.lua` passes **51 checks**. It verifies sample +preservation, cold public fanfare/wait dispatch, same-session Source reuse, +new Sources after reload, cached static playback failure/recovery and SE +queries against a refusing handle. The real host Game interruption path +survives native streaming handles whose audio methods all raise, an unavailable +foreground replacement and a later retry using that Game's dataset. Old +handles cannot restart behind the replacement stream. These tests qualify +the existing production implementation; this follow-up changes QA, not +runtime behavior. + +Cold SoundData/Source admission is not a claim of a cold ROM/sample-decoder +cache, real-device recovery or cross-engine full-session teardown parity. +The host interruption test and the paired native audio contracts have distinct +scopes. The existing native gain/PCM differences also remain recorded. + +A separate owned audio-service teardown/re-entry probe compares native and +shared music, each with and without a fanfare, on both profiles. Portable +song/map/save state, static Sources, SE queries and VM completion agree. +It also observes every new audio tick and asserts that every old static and +queue Source stays stopped. Its complete hardware inventories retain a +backend difference: native cue replacement reuses one reference queue but +creates a second host queue. Both are stopped after teardown. This probe does +not establish equal allocation counts or full game/mod-session lifecycle parity. + +## Production session entry invalidation + +The broader initialized-session probe uncovered a real gap hidden by the old +fixture's explicit `Music.reload`: the host's actual launcher return stopped +Sources and detached mod listeners, but the asset invalidation used before +the next Game did not clear native map/saved music. The unchanged reference's +game and mounted-session teardown released both. All four host cases failed +the stricter entry assertion; all four reference cases passed. These failed +results are retained. The incomplete full feature matrix and feature/combined +regressions were deliberately cancelled as superseded, not accepted as passes. + +Music now subscribes its existing native reload path to the actual asset +invalidation bus. This retires the native controller when launcher entry or +mod-loader invalidation changes the dataset; an ordinary playback stop still +retains its separate semantics. The expanded cold engine suite passes +**69/69 checks**, including the actual bus, saved/map state, a live VM wait +and every old static/streaming Source. Broad qualification of this correction +is pending. + +`tools/enemy-action-contract-check/native-game-audio-session-smoke.lua` uses +the real reference `Game3:reset` and game/mounted-session orchestrator. On the +host it loads the production main chunk without calling `love.load`, assigns +its initialized Game upvalue and enters the real `Game:returnToLauncher`, +launcher teardown and launcher constructor. It then invokes the actual asset +entry stage before any fixture cleanup can conceal stale state. The same +public API-3 consumer loads through a fresh Loader. Each subsequent tick must +leave old Sources stopped, the old VM complete and old listeners detached, +while the fresh listener and fanfare wait work. Full raw inventories retain +backend queue-count/gain differences. This is initialized-session teardown and +loader re-entry coverage, not a complete ROM boot, GPU, device, hot-reload +screen or arbitrary full-mod/playthrough qualification. The workflow runs +both music variants on both profiles and retains their raw output. + +## Native command compatibility revision + +GBA command revision **20** separates these music and script-wait semantics +from revision 19. The party codec stays at revision **1**. The frozen parent +`4994a41c` serializer independently predicts the new native digest +`43fe7ea27b438e0e` by changing only `[native_gba_commands]19` to +`[native_gba_commands]20`; the current complete surface must equal that +derived string. Red, Gold, Diamond and Black keep `e33d251961f2a41d`. + +The protocol suite passes **175 checks**, including both link-controller roles +refusing old native semantics and full mirrored synthetic battles. The native +party codec's **194,540 checks** also pass. This targeted compatibility check +does not replace the pending complete contract matrix and combined runtime. + +## Historical pre-publication gates + +These checkpoints still required the corrected initialized-session matrix, +its independent raw audit, broader actual-import checks, the complete +existing contract matrix and a qualified combined runtime. Their final +results belong to the current contract/receipt linked above. + +Tests start no game, GPU or audio device and write no normal saves/imports. +No ROM, generated recording or private import is committed. This checkpoint +does not establish arbitrary full-mod compatibility or a completed playthrough. + +## Reproduction + +From this checkout, with the pinned LuaJIT and a clean read-only reference: + +```sh +nice -n 19 env LUA=/path/to/pinned/luajit bash scripts/test.sh --quick +nice -n 19 python3 tools/enemy-action-contract-check/check.py \ + --gen1 /path/to/clean/gen1-reference --gen2 . \ + --lua /path/to/pinned/luajit \ + --mode native_bgm_symbols --mode native_bgm_commands --mode native_bgm_policy \ + --mode native_fanfare_commands --mode native_bgm_devices \ + --mode native_bgm_hooks --mode native_bgm_options \ + --mode native_cry_commands --mode native_cry_lifecycle --mode native_cry_pcm \ + --report /path/to/bgm-development-report.json +``` + +Keep every raw worker and failed report. The canonical checker records the +complete observations; checkpoint qualification additionally performs an +independent recursive comparison that distinguishes JSON boolean types. + +The owned native-stream probe emits the full raw source/PCM inventory: + +```sh +nice -n 19 /path/to/pinned/luajit \ + tools/enemy-action-contract-check/native-bgm-stream-smoke.lua \ + . gen2 firered > /path/to/host-native-bgm-streams.log +``` + +Repeat with `emerald`. For the reference, run from its clean checkout, +invoke the tool by absolute path, pass this checkout's absolute path as +the first argument and change `gen2` to `gen1`. Compare the admitted +control/queue properties while preserving all backend-specific raw source +records and PCM; do not report whole raw waveform equality. + +The same invocation pattern applies to +`tools/enemy-action-contract-check/native-fanfare-stream-smoke.lua`, which +retains the real fanfare renderers' complete outputs and source ownership. + +The private actual-import metadata probe runs the production import stage +with the headless filesystem, then checks every generated timer against its +owned ROM table and the native fanfare resolver: + +```sh +nice -n 19 /path/to/pinned/luajit \ + tools/enemy-action-contract-check/imported-native-fanfare-metadata.lua \ + /path/to/existing/import-root /path/to/owned-firered.gba /path/to/owned-emerald.gba +``` + +No normal imports are updated by this probe. Keep the missing-table discovery +and the fresh extraction results; they describe different input states. + +The actual-import playback probe emits all state and PCM, using the same +backend invocation convention as the owned-stream tools: + +```sh +nice -n 19 /path/to/pinned/luajit \ + tools/enemy-action-contract-check/imported-native-bgm-smoke.lua \ + . gen2 firered /path/to/existing/import-root /path/to/owned-firered.gba \ + > /path/to/imported-firered-host.log +``` + +Repeat for Emerald and the clean reference. Admission compares the documented +control/ownership/queue properties; retain the complete backend PCM outputs. + +The owned audio-service teardown probe needs no ROM and retains the complete +old Source inventories, including the documented allocation-count difference: + +```sh +nice -n 19 /path/to/pinned/luajit \ + tools/enemy-action-contract-check/native-audio-session-smoke.lua \ + . gen2 firered > /path/to/session-firered-host.log +``` + +Use the same reference/host invocation convention and repeat for Emerald. +Compare portable song/map/save state, VM completion and static Sources while +retaining the different native queue inventories. Every old Source must stay +stopped throughout re-entry. These are owned audio-service transitions, not +full game/mod-session lifecycle qualification. + +For the independent protocol audit, use a clean frozen parent at `4994a41c`: + +```sh +nice -n 19 /path/to/pinned/luajit \ + tools/enemy-action-contract-check/native-protocol-audit.lua \ + /path/to/frozen-parent/src/link/Fingerprint.lua +``` diff --git a/docs/native-gba-bgm.md b/docs/native-gba-bgm.md new file mode 100644 index 000000000..dd7a799c2 --- /dev/null +++ b/docs/native-gba-bgm.md @@ -0,0 +1,133 @@ +# Native GBA music commands and fanfare waits + +Imported FireRed/Emerald world scripts retain native background-music and +fanfare commands through the real world adapters and VMs. Named mod music +continues through the shared music service, including its public hooks. +This continues the cry and world-SE work in #289 and its prerequisites. + +This changes native imported-script execution. API-3 `queueScript` retains +its separately documented [bounded portable vocabulary](portable-world-scripts.md). + +## Commands and ownership + +- `playbgm` keeps logical song identity separate from the physical Source. + Only keep flag `1` or `true` saves an override; song identifiers `0` and + `0xFFFF` stop BGM. +- `savebgm` records the saved music independently of current and map music; + a silence identifier clears the saved value. +- `fadeoutbgm` and `fadeinbgm` use the native world adapters' fixed speed four, + including their final-tick source/queue behavior. `fadenewbgm` dispatches + immediate replacement in this admitted adapter path. +- `fadedefaultbgm` selects the current map default and preserves native + saved/map ownership and deferred restoration while a fanfare is active. +- `playfanfare` resolves native variables and starts a nonblocking native + fanfare; `waitfanfare` waits on its native clock and retains a latched + completion callback. + +The catalogue admits 305 `MUS_*` names, including aliases and reverse names. +A numeric native command can select an existing named shared mod override. +Emerald default selection re-reads live map identity, player position, +weather and story state. FireRed retains its own default map-song policy. + +The audio service advances fanfare time independently of accelerated logic. +The reference also has a separate 361-poll script safety limit, which is +preserved: accelerated polling can reach that limit before a long audio +timer ends. Native imported timer metadata determines normal completion; +rendered PCM duration does not. Missing timer metadata retains the native +160-frame fallback. A present table that omits a requested cue retains the +native level-up fallback. + +Fanfares occupy their declared SE player, stop the replaced fanfare and +that player's previous sound, and can retain an audible tail after their +script timer finishes. Repeated cues reuse same-session cached Sources. +Fanfare pause stops the native BGM producer and clears queued native PCM; +worker generation and pause-epoch checks reject stale packets after resume. +Shared mod music retains its own transport and ownership rules. + +## Public mod hooks and options + +Shared named music follows `music.select`, `music.volume`, `music.started` +and `music.stopped` with the reference's admission, order and callback +context. Native M4A carriers use their native route. Shared hook context +retains generic map/bike/surf state, including location/time when the actual +engine world provides them. File selection, silencing, volume overrides, +intro/loop chaining and map changes during fanfares are covered separately. + +Native and shared music retain independent master/trim and mode state. +`Game:applyOptions` binds its own dataset and applies native options before +shared callbacks can start another world action. `external_only` mutes +Sources while retaining playback ownership; system-mixing changes are +protected, deduplicated after success and retried when unavailable. + +## Recovery and session entry + +An unavailable native queue factory preserves logical music state and can +recover on a later cue. A failed native replacement releases the former +shared Source; a bad shared definition retains its existing shared song. +Invalidated queue/static handles cannot crash the covered command/update +paths. Mobile interruption cancels an interrupted fanfare, and foreground +recovery retries against the actual Game's dataset. + +An ordinary stop retains its playback semantics. The actual asset +invalidation bus used before the next game/mod dataset also invokes native +music reload, retiring old map/saved music, waits and Sources. This fixes a +production session-entry gap hidden by an earlier fixture's explicit reload. +The initialized-session probe uses actual reference Game3/mounted teardown +and the host's actual main/launcher return path, then loads the same public +API-3 consumer through a fresh Loader. Old Sources/listeners/VMs remain +retired during every observed fresh-session tick. + +## Compatibility and qualification + +Native GBA command revision **20** refuses prior music semantics before +native link-battle setup. Party codec revision remains **1**. The independent +frozen-parent serializer audit changes only revision 19 to 20: native digest +`43fe7ea27b438e0e`, with legacy `e33d251961f2a41d` retained. + +The [source-bound QA receipt](qa/native-gba-bgm.json) identifies qualified +runtime commits/trees, complete matrix and regression results, independent +raw-observation audits, physical audio probes and retained negative controls. +Each runtime passes 268 workers / 178,264 complete paired observations. +The combined runtime passes 292/292 engine suites, SDK 3/3 and B30. The +isolated feature passes 174/183, retaining exactly the same 25 failure lines +across nine inherited suites as its fresh parent; SDK 3/3 and B30 pass too. +The [historical development notes](native-gba-bgm-development.md) preserve +earlier checkpoints and discoveries. Results are local; hosted validation is +reported separately in the PR. + +## Reproduction and limits + +Use the clean read-only Gen1 reference +`535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91` and LuaJIT +`a4c064539ea931274d407e1249b4bb575424a0f4`: + +```sh +nice -n 19 env LUA=/path/to/pinned/luajit bash scripts/test.sh --quick +nice -n 19 python3 tools/enemy-action-contract-check/check.py \ + --gen1 /path/to/clean/gen1-reference --gen2 . \ + --lua /path/to/pinned/luajit --report /path/to/full-contract-report.json +nice -n 19 /path/to/pinned/luajit \ + tools/enemy-action-contract-check/native-protocol-audit.lua \ + /path/to/frozen-parent/src/link/Fingerprint.lua +``` + +The frozen fingerprint parent is +`4994a41ca09fe50e4f186260ed8f5b7b1f233f4d`. Owned stream, fanfare, +audio-service and initialized-game session probes live beside the checker. +Invoke each from the backend checkout with the absolute host checkout, +`gen1`/`gen2`, and `firered`/`emerald`. The initialized-game probe also takes +`native`/`shared`. Existing import playback additionally needs owned ROMs +and an existing import root; no normal import is rewritten. + +Actual production renderers execute with CPU hardware doubles, including +cold userdata-shaped SoundData admission. No game boot, GPU, audio device, +normal save or full playthrough is started by these probes. Complete raw +PCM and backend inventories are retained separately. Native synthesizer +PCM/baseline gains, shared GB lookahead and native queue allocation counts +remain distinct; those are not whole-waveform or allocation parity results. + +Older imports missing fanfare timer metadata need reimporting to receive +the new extractor output (14 FireRed and 18 Emerald entries). Complete +map/bike/surf transition coverage, other editions, actual device recovery, +full hot-reload screens, arbitrary complete mods and playthroughs remain +open qualification surfaces. diff --git a/docs/native-gba-cries.md b/docs/native-gba-cries.md new file mode 100644 index 000000000..c7584ac76 --- /dev/null +++ b/docs/native-gba-cries.md @@ -0,0 +1,129 @@ +# Native GBA cry playback and world-script waits + +The admitted FireRed/Emerald profiles use the native species recording, +cry mode, integer release envelope, pitch, chorus and reverse controls. +`playmoncry` resolves both species and mode through the script variable +store and starts playback immediately. `waitmoncry` yields the actual +runner until the tracked cry finishes. Generic GB `play_cry` retains its +existing text-dialog behavior. + +The cry clock runs with the real 60 Hz audio service. A waiting script +advances it only if no audio tick occurred since its previous poll. +Recording-derived duration wins over a stuck audio source; early source +completion also releases the wait. Missing native audio data retains the +native 64-frame fallback. SE-player ownership and waits stay independent. + +Native WaveData supports ordinary signed PCM and Game Freak DPCM. The +renderer applies the native modes at the GBA frame rate, including Emerald's +mode-six volume. Native Hoenn cry IDs follow their species control table, +not the species position after the old Unown slots. New imports advertise +`gen3Cries.mapping = "native-species-v1"`; existing stock sequential Hoenn +entries are corrected during native playback. Explicit mod-owned, file, +string and disabled definitions retain their override path. + +Rendered PCM is cached in a bounded per-data cache. Each playback owns a +fresh source, preserving overlapping cries. `stopCry` stops the latest +tracked native source. Species invalidation and session suspension release +associated sources. Isolated dex playback uses native PCM without replacing +the world's wait state, controls or music attenuation. + +Native command revision **19** prevents lockstep with revision 18's cry +semantics. Party codec revision remains **1**. Independent frozen-parent +serialization changes only command revision 18 to 19: native fixture digest +`22e453523676534e`; legacy digest `e33d251961f2a41d` remains unchanged. + +## Validation and scope + +The [source-bound QA receipt](qa/native-gba-cries.json) records qualified +runtime commits, source/test trees, fixture and raw-report hashes, complete +comparison counts, the retained timeout and its successful bounded replay. + +- Combined runtime `0286d291`: **285/285** engine suites, SDK **3/3**, B30 + and every quick tier pass. +- Feature runtime `1322ee81`: **167/176** engine suites versus fresh parent + `c7cda532` **166/175**. The exact anchored FAIL-line Counters match: the + same **25 lines / nine inherited suites**, with no additions or removals. + SDK **3/3** and B30 pass on both. +- Each runtime: **240 qualified workers / 176,170 complete paired + observations** against clean unchanged Gen1 code at + `535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91`. All admitted + mode/backend/profile combinations execute exactly once; complete nested + fields, events, RNG, PP, animation data, PCM and JSON boolean types remain + compared. The three new cry contracts contribute **554 paired rows**. +- Native cry suite: **10,983 checks**, including actual + Game/FixedStep/StateStack clocks at 1x/2x/10x, field locks, public command + hooks, script-ended events, isolated playback, cache/source ownership, + missing devices, recovery and the real structural importer over owned + bytes. Protocol **175**, SE-player lifecycle **110** and world-SE-wait + **6,306** checks also pass. +- Each runtime on existing local FireRed/Emerald imports and owned ROMs: + **18 actual cry-script cases**, all **221 frames per case** and every PCM + sample equal; separately **48 complete actual PCM outputs** match. + Imported M4A bytes equal the original ROM slices. All **135** runtime + Hoenn cry IDs equal both native ROM control tables. +- Actual nonfading BGM sources receive the native attenuation/restoration + across modes, release, stop, overlap and option changes. These checks + retain raw volume readings and verify attenuation relative to each + backend's baseline gain; they do not assert whole-music waveform parity. +- Existing import regressions retain **10 native sound cases** and + **24 Mirror Move scenarios / 48 public-submitted turns / 120 move-used + events** per runtime, with complete observations equal between candidates. +- Removing only the Game cry-clock call produces **six exact expected + failures**, 10,977/10,983 checks. The unchanged parent separately reproduces + deferred cries, dropped mode/variable handling and no-op cry waits. + +One original feature FireRed `nature_power` worker was terminated at its +recorded 60-second deadline without observations. Its original report +remains retained. Four fresh workers pass with a bounded 180-second budget, +9.6–19.2 seconds; every previously completed observation is unchanged. The +checker gives this exhaustive mode the same bounded budget. No failed or +superseded raw evidence is discarded. + +Tests use CPU hardware doubles with the real engines, native controllers, +VMs and renderers. They start no game, GPU or actual audio device and write +no normal saves/imports. No ROM, generated audio image or private pack is +committed. Results cover admitted FireRed/Emerald contracts, not every +public audio-event timing, unsupported native profile, arbitrary full mod +or playthrough. Native BGM commands remain separate follow-up work: actual +`fadeoutbgm 4` still stops immediately on the host instead of following the +native 64-frame fade. + +## Reproduction + +Use LuaJIT `a4c064539ea931274d407e1249b4bb575424a0f4` and a clean read-only +Gen1 checkout at the reference above. From this checkout: + +```sh +nice -n 19 env LUA=/path/to/pinned/luajit bash scripts/test.sh --quick +nice -n 19 python3 tools/enemy-action-contract-check/check.py \ + --gen1 /path/to/clean/gen1-reference --gen2 . \ + --lua /path/to/pinned/luajit --report /path/to/all-contracts.json +``` + +A targeted run accepts `--mode native_cry_commands`, +`--mode native_cry_lifecycle` and `--mode native_cry_pcm` together. The +canonical checker records raw workers and timings. The receipt also records +an independent full JSON comparison which distinguishes booleans from +numbers, including `false` versus `0`. + +The existing-import tools take explicit read-only inputs. No import is +created and no normal cache is rewritten. For example, from this checkout: + +```sh +nice -n 19 /path/to/pinned/luajit \ + tools/enemy-action-contract-check/imported-native-cry-smoke.lua \ + . gen2 firered /path/to/existing/imports /path/to/owned-firered.gba +nice -n 19 /path/to/pinned/luajit \ + tools/enemy-action-contract-check/imported-native-cry-render.lua \ + gen2 firered /path/to/existing/imports /path/to/owned-firered.gba +nice -n 19 /path/to/pinned/luajit \ + tools/enemy-action-contract-check/native-cry-bgm-smoke.lua . gen2 firered +``` + +Repeat with `emerald` and its ROM. For the native counterparts, run from the +clean reference checkout, invoke these tools by absolute path, change +`gen2` to `gen1`, and replace the smoke tools' `.` argument with this +checkout's absolute path. Keep every raw tagged observation when comparing +outputs. The BGM-source probe checks attenuation properties against its +retained raw per-backend gains rather than equating different baseline +music streams. diff --git a/docs/native-gba-critical-context.md b/docs/native-gba-critical-context.md new file mode 100644 index 000000000..a5c0deba4 --- /dev/null +++ b/docs/native-gba-critical-context.md @@ -0,0 +1,79 @@ +# Native GBA critical context and hit events + +Ordinary damage and dedicated Beat Up strikes now share the native critical +command policy. Birch's first battle and Wally's tutorial return before the +critical random draw. Teachy TV consumes that draw and then discards its +result. FireRed's Oak predicate also checks the inflicted-damage flag; +the HP-damage command advances it after the public hit event and only for +player damage to a non-Substitute opponent. This includes later strikes of +the same Beat Up action. Old-man control cases still roll normally. + +The actual first-battle script forwards its metadata through the ordinary +battle constructor, and the existing Wally/Teachy TV constructors provide +their native state. Ordinary critical hooks retain their override/delegation +semantics; dedicated Beat Up bypasses them. Both hooked and unhooked ordinary +paths share the policy. Earlier-generation policies remain unchanged. + +## Public context and native stages + +The shared public `battle.crit` context carries battle, target, canonical +move ID, numeric move ID and the current critical stage. Stage calculation +uses native physical item IDs: Scope Lens, Lucky Punch and Stick; the last +two inspect the current battler species rather than the retained party mon. +Mutating a physical Scope Lens's held-effect metadata does not remove its +native stage bonus, and custom Scope-Lens-like metadata does not give an +unrelated item that native ID's behavior. Portable custom behavior can use +the shared public critical hook instead. + +Focus Energy, high-critical effects and the native cap are covered. Lansat +Berry uses the Focus Energy marker and is not consumed when that marker is +already active. Ordinary GBA hit events now also carry physical/special kind +and the Substitute flag; scripted Beat Up retains its native special kind +and noncritical event metadata even for critical damage. + +## Verification + +```sh +luajit tests/engine/gen3_critical_context.lua +python3 tools/enemy-action-contract-check/check.py \ + --gen1 /path/to/clean/reference --lua /path/to/pinned/luajit \ + --report /tmp/enemy-action-report.json +``` + +The same API-3 consumer uses actual action selection and move executors on +unchanged read-only Gen1Recomp +`535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91`. The new matrix has 27 FireRed +and 29 Emerald cases at all four positions, each with ordinary damage and +scripted Beat Up. It compares critical/variation operands, damage and hit +metadata, public context fields, callbacks and the Oak flag's event timing. +Cases cover normal and special damage, Focus Energy, native/custom held +items, active species, high-critical effects/cap, the actual Lansat producer, +armor, hook force/delegation/suppression, unhooked paths, first-battle kinds, +Wally, Poke Dude, old-man controls and Substitute. + +Gen2 first-battle cases run `Gen3Commands.firstBattle` through +`Commands.start_battle` and `BattleState.newWild`. Wally and Teachy TV use +their actual constructors. The source fixture seeds the native kind fields; +Oak laboratory flags and detached/current species are also seeded explicitly. +Stats are controlled and imported-shaped; full controller/intro playback, +constructor stat generation, advice text, animations and a fresh ROM import +are not claimed. + +The focused suite passes **3,396 checks**, including non-GBA passthrough +controls. The complete comparison passes **52 isolated workers / 5,214 +paired observations**. #210 fails **288 assertions** in the new FireRed +cases. The full feature quick run passes **92/101 engine suites** versus +**91/100** on clean #210; exact Counters of the **25 inherited FAIL lines +across nine suites** match with no additions. SDK 3/3 and B30 pass. + +Both engines and the workflow use JIT-enabled LuaJIT v2.1 commit +`a4c064539ea931274d407e1249b4bb575424a0f4`. Critical operands are normalized +across native range conventions; complete raw RNG traces and full +link/multi-owner controller execution remain separate surfaces. + +The feature stacks on #210. The scripted Beat Up policy change increments +`BEAT_UP_EFFECT.rev` from 2 to 3. The fixture digest changes from +`fdbfa30b20cbbaef` to `a000e3862d11a73a`; only `move_effects` changes, from +`e4d28f474c15cd8b` to `cca86e08b893522c`. Restoring revision 2 restores the +prior full digest; all six other populated sections remain identical. +Gen1 remains read-only and requires no source change or publication. diff --git a/docs/native-gba-follow-me.md b/docs/native-gba-follow-me.md new file mode 100644 index 000000000..513355d2e --- /dev/null +++ b/docs/native-gba-follow-me.md @@ -0,0 +1,75 @@ +# Native GBA Follow Me + +FireRed/Emerald's imported effect 172 (`FOLLOW_ME_EFFECT`, move 266) now has +an ordinary native effect record. A successful move marks its user's side +with a round-local position. Selected opposing attacks resolve the current +living occupant of that position, including public API-3 `battle.enemy_action` +choices. Mod code does not need an engine-specific redirection hook. + +The position is shared by a side: the last successful caster wins. A slot +replacement inherits the position for the rest of the round. A fainted +occupant does not draw attacks, but its active timer still suppresses +Lightning Rod, matching the covered native reference behavior. Live Follow +Me takes precedence over Lightning Rod. Self and spread moves retain their +own target policy. The native effect consumes normal owned PP, executes at +its imported priority and remains subject to ordinary status interruption. +Substitute and Protect do not block the imported self effect. + +Both sides reset when the next turn is planned, before turn-started observers +and action execution. Residual collection does not prematurely clear the +flag. The producer and targeting state are limited to GBA; existing GB/DS +rules remain separate. + +## Validation + +```sh +luajit tests/engine/gen3_follow_me.lua +python3 tools/enemy-action-contract-check/check.py \ + --gen1 /path/to/clean/reference --lua luajit \ + --report /tmp/enemy-action-report.json +``` + +The 180 focused checks cover both GBA profiles, both sides/flanks, successive +casters, default and mod-selected targets, fainting and replacement, +Lightning Rod precedence, self/spread/single policies, sleep refusal, +Substitute/Protect, residual and next-turn lifetime, real priority queues, +PP and non-GBA guards. Actual move/damage events verify execution targets. +The shared fixtures use synthetic extractor-shaped move rows, not a fresh +cartridge import or full gameplay capture. + +The same API-3 consumer and fixture match unchanged read-only Gen1Recomp +`535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91`: 24 engine workers and 354 paired +observations, including prior choice/argument/record/target contracts. +Running the new FireRed cases on unchanged #195 fails 29 assertions. + +This feature stacks on #195. The local quick run passes 82/91 engine suites, +3/3 SDK suites and B30, versus 80/90 on that prerequisite. Exact FAIL-line +Counters show no additions: only the two fingerprint failure lines disappear; +all 25 remaining lines across nine inherited suites are identical. Registering +only this new effect changes the prerequisite fingerprint from +`41e69caf4afa35ab` to `128fc071ae7249cd`; removing just that entry and invalidating +the fingerprint cache restores the former. The committed golden pins the new +native registry. Baseline repair PRs remain separate. + +## Canonical effect composition + +The canonical public effect adapter is a separate dependency (#121). When +both changes are integrated, public `effect = "FOLLOW_ME"` writes select the +real native handler automatically; no generated contract-table edit is needed. +An explicit composed proof loads a real API-3 mod, patches a move through +`mod.content.moves`, reads the canonical effect back, then casts it and checks +that a real mod-selected attack is redirected with owned PP: + +```sh +luajit tests/follow_me/public_effect.lua +``` + +That proof requires both features and deliberately fails if either is absent. +The standalone feature's normal suite does not pretend to include #121. +In the combined checkout the old missing-handler assertion in +`tests/engine/unified_move_effects.lua` must be updated to assert availability. + +Remaining native effect producers (Helping Hand, Magic Coat, Snatch, +Imprison and called/terrain effects), random-target/RNG parity, AI scoring, +link/called-move execution and full damage/gameplay parity are separate work. +No Gen1 source change or publication is required. diff --git a/docs/native-gba-held-damage.md b/docs/native-gba-held-damage.md new file mode 100644 index 000000000..5bb53c18e --- /dev/null +++ b/docs/native-gba-held-damage.md @@ -0,0 +1,113 @@ +# Native GBA held damage commands + +The ordinary GBA calculator previously read the attacker's held effect twice: +once for stat items, then again for type items on finished damage. A stateful +`held_item.trigger` consumer could therefore supply incompatible answers inside +one native command. Type boosts also rounded at the wrong point. + +Resolve attacker and defender held effects once per base-damage command, in +that order, and reuse the attacker answer. Apply type/stat item modifiers before +stat stages, using the current battler species. Share the native pre-stage +ability modifiers with ordinary and scripted set-damage commands. Preserve GBA +stage flooring and critical-stage bypass; apply pinch abilities and sports to +power for ordinary attacks and Flash Fire before the final base `+2`. Preserve +Spit Up's separate opcode, which does not forward field sport flags. Other profile paths retain +their existing code. + +Native GBA empty hands expose `item = 0`, including the public callback view. +Zero is a native sentinel, not a content registry item. Other generations and +ordinary saved-mon item views retain their existing semantics. + +This stacks on #228. Gen1 remains a clean, read-only reference at +`535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91`; no Gen1 source is patched or published. + +## Validation and reproduction + +Accepted full-test runtimes are feature +`9ad4f6ab90524eda893d0d6dbfb6f12ab71f9e9f` and combined +`7a681ca8b81be0e77d3447ff3069459cda0bb974`. Later commits only apply +AST-verified fixture formatting and document acceptance/publication. The +formatted fixture is checked again against both engines on clean commits. + +- **10,202 focused assertions**, **48 real-link assertions** and the feature's + **154 protocol assertions** pass. Both linked roles select the GBA profile, + complete two menu-driven turns, observe matching ordered reads and agree on + every completed hash/component and real learned PP. +- **124 workers / 86,620 paired observations** agree with unchanged Gen1: + 2,040 held-command rows, 6,912 ordinary base-damage rows and the existing + 77,668 selection/execution observations. The new matrix includes sport-before- + pinch flooring, Flash Fire, Charge and stateful successive commands. +- Full feature: **132/141 engine suites**, SDK **3/3**, B30. The exact Counter + of **25 inherited FAIL lines in nine suites** equals clean #228 + `491d3a2f0b997f8f5c801ebcd12524e70ba5cf54` (130/139); its nonzero exit is + retained. Combined: **251/251 engine suites**, SDK **3/3**, B30. +- The unchanged prerequisite fails **2,041 assertions per profile**, with all + 1,020 complete command rows differing. The additional isolated sport/pinch + probe retained 24 differing rows across both profiles, including native 42 + versus host 44 damage; corrected ordering passes. +- Twelve existing CPU-only imported FireRed/Emerald Sleep Talk scenarios pass + again on both branches. These are regression checks of actual loaded attack + metadata and public menu submissions, not certification of the entire item + catalog. No assets or normal SAVE are distributed/issued. +- Scoped StyLua (including AST verification), actionlint and whitespace pass. + +The shared fixture executes the actual calculators and public API-3 hook chains +in both engines. It compares every recorded read field, ordered actor identity, +calculated damage, bounded RNG call/value and HP observation. Inputs include +empty hands, native stat/type items, species restrictions, stages, criticals, +ability combinations, burn, weather, screens, public substitutions/suppression +and a stateful answer changing between consecutive commands. Fixtures own their +in-memory inputs; no comparison rows or RNG calls are filtered. + +```sh +nice -n 19 "$LUA" tests/engine/gen3_held_damage.lua +nice -n 19 "$LUA" tests/engine/gen3_held_damage_link.lua +nice -n 19 "$LUA" tests/engine/gen3_battle_protocol.lua +nice -n 19 python3 tools/held-damage-contract-check/check.py \ + --gen1 /path/to/clean/gen1-reference --gen2 "$PWD" --lua "$LUA" \ + --report /tmp/held-damage-contracts.json +nice -n 19 python3 tools/gba-damage-base-contract-check/check.py \ + --gen1 /path/to/clean/gen1-reference --gen2 "$PWD" --lua "$LUA" \ + --report /tmp/ordinary-damage-contracts.json +nice -n 19 python3 tools/enemy-action-contract-check/check.py \ + --gen1 /path/to/clean/gen1-reference --gen2 "$PWD" --lua "$LUA" \ + --report /tmp/enemy-contracts.json +nice -n 19 env LUA="$LUA" bash scripts/test.sh --quick +``` + +Local evidence is retained under `tmp/held-damage-qa/` in each worktree: +`full-complete.log`, `enemy-complete.json`, `held-complete.json`, +`base-complete.json`, `held-formatted.json`, `imported-complete.log` and +`accepted-evidence.json`. Feature evidence also retains baseline/negative, +initial mismatched-fixture, set-damage gating, link-profile and formatting +failures. The clean prerequisite's full log is in the Sleep Talk worktree's +`tmp/sleep-talk-qa/held-damage-baseline-full.log`. + +The native command revision is 10; peers at revision 9 cannot enter a full +battle. Party codec remains 1. The native fixture hash changes from +`f1bec52ffc687ea3` to `3a2adb7fa1caee3b`. An independent copy of the unchanged +parent hasher with only the revision token changed produces that result; GB, +GSC, DS and BW fixture hashes remain `e33d251961f2a41d`. + +## Remaining boundaries + +This is base-command parity, not complete held-item script parity. The full +Sleep Talk attack trace still differs: the Choice Band check/binding is absent +and King’s Rock/Shell Bell still run separate reads. Equal total read counts do +not establish ordered payload equality. Keep the raw full-phase failure probe. +Failed awake/empty Sleep Talk and blocked ordinary-move traces are separate. + +Unbadged controlled inputs are compared here. Full badge/obedience/controller +orchestration, arbitrary imported catalogs and playthrough/graphics parity are +not established by these calculator tests. The real-link fixture must explicitly +select its GBA profile: the generic link helper creates a Red save with Gen1 +options. On this isolated command stack, a fresh-save probe reproduces +`gen1_faithful` for `SaveData.newGame({version = "firered"})` and Emerald. +The existing [#162 automatic-default fix](https://github.com/UNDERdecoded/Gen2Recomped/pull/162) +already resolves this and is integrated in the combined fork: the same probe +there selects `gen3_emerald` for both games. Its 926-check regression suite +also passes, including preservation of explicit selections. No duplicate +defaults fix is needed. These calculator/link fixtures explicitly select GBA. + +Historical held-item workflows using a modified reference are not proof against +unchanged Gen1. The new damage comparison pins the clean reference above. diff --git a/docs/native-gba-helping-hand.md b/docs/native-gba-helping-hand.md new file mode 100644 index 000000000..7ef90586d --- /dev/null +++ b/docs/native-gba-helping-hand.md @@ -0,0 +1,79 @@ +# Native GBA Helping Hand + +Imported GBA effect 176 (`HELPING_HAND_EFFECT`, move 270) now registers an +ordinary primary effect. A successful use supports the user's current living +partner for the round. It fails in singles, without a partner, or if either +the user or the partner is already supported. Repeating it does not stack. +Ordinary status interruption and imported Protect flags remain authoritative. + +The shared damage calculator applies the native 1.5 multiplier after the +critical multiplier and before STAB/type rounding. Fixed damage is unchanged; +confusion self-damage uses its unmodified base. The support persists across +actions and residual collection until the next native turn-planning boundary. +A replacement battler does not inherit it. Other generations ignore the new +state. No mod-specific damage hook or separate port is needed for these paths. + +For example, level-30 Tackle with equal Attack/Defense, normal STAB and roll +100 deals 16 without support and 24 with support. The actual turn queue runs +the slow helper's imported priority-five move before faster ordinary attacks. + +## Dependencies and scope + +The feature builds on #196's common enemy-action fixture and native turn +integration, plus the existing #178 base-damage and #175 critical-order +corrections. Those two published patches are reused as prerequisite commits; +review the new implementation relative to `45f5b4e3386f704ab011ca3b6690aaa79f0bc425`. +Its prerequisite fixture is taken from #175, without importing unrelated +production changes from that historical stack. + +Canonical public `effect = "HELPING_HAND"` writes additionally use #121's +existing effect adapter. Registration enables its binding automatically; no +generated effect table is rewritten. The explicit composed proof requires +both features, patches content during one real API-3 load, and executes a +publicly selected right-flank enemy move with the supported numeric damage. + +This verifies the native producer and the shared ordinary damage path, +including fixed-damage and confusion exclusions. Dedicated set-damage/delayed +move paths (Spit Up, Beat Up, Future Sight/Doom Desire), their other existing +semantic differences, full held-item/ability ordering, called/link moves, +AI scoring and full gameplay/RNG parity remain separately auditable surfaces. +The ordinary calculator's boost is not a proof of those special executors. + +## Validation + +```sh +luajit tests/engine/gen3_helping_hand.lua +python3 tools/enemy-action-contract-check/check.py \ + --gen1 /path/to/clean/reference --lua luajit \ + --report /tmp/enemy-action-report.json +# With the canonical effect adapter integrated: +luajit tests/helping_hand/public_effect.lua +``` + +The focused host suite passes **5,322 checks**. Cases exercise both GBA +profiles, every helper flank, actual selected attacks and public move/damage +events, critical/noncritical hits, STAB, physical/special/spread moves, +resistance/immunity, variation 85/100, unchanged fixed damage and confusion, +status/refusal/repetition gates, both round boundaries, replacement, reused +support, actual priority queues, PP and non-GBA guards. + +The same public consumer and shared fixture match unchanged read-only Gen1 +`535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91` in **28 isolated engine workers / +1,916 paired observations**. These include the prior choice, selector, record, +target and Follow Me cases. Shared sessions use equal stats and synthetic +extractor-shaped move rows; they do not claim a fresh cartridge import. +The unchanged prerequisite fails 391 assertions in the initial new FireRed +matrix. The existing GBA base-damage and critical-order paired checks pass. + +The full feature quick run passes **86/95** engine suites versus **85/94** on +the clean prerequisite. Its exact Counter of all **25 FAIL lines across nine +inherited suites** matches, with no additions. SDK 3/3 and B30 pass. Their +baseline repairs are already present in the combined fork. + +Adding only `HELPING_HAND_EFFECT` changes the native fixture digest from +`128fc071ae7249cd` to `032184f68094e9ca`; removing that entry and invalidating +the fingerprint cache restores the former. The new golden records this +intentional registry change. The combined checkout must likewise audit its +section pin and replace the former missing-handler assertion. + +Gen1 is a read-only reference. No Gen1 code change or publication is required. diff --git a/docs/native-gba-spit-up.md b/docs/native-gba-spit-up.md new file mode 100644 index 000000000..124f261b3 --- /dev/null +++ b/docs/native-gba-spit-up.md @@ -0,0 +1,90 @@ +# Native GBA Stockpile release + +Gen-3 Spit Up (effect 161) uses a native set-damage command. Its calculated +base, including the final +2, is multiplied by the operative Stockpile count +before Helping Hand, STAB and type rounding. Multiplying the move's power +inside ordinary damage calculation produces a different result: at level 30, +equal Attack/Defense and normal STAB, two stacks release 90 instead of 87. + +The native path bypasses ordinary `battle.damage` and `battle.crit` hooks and +has no damage-variation roll. The accuracy hook and ordinary status +interruption still apply. This matches the reference's dedicated command, +not a fabricated ordinary hit that happens to have the same amount. + +Successful release, Protect and type immunity consume the stored count; +normal misses, semi-invulnerability, sleep and absorption preserve it. An +empty use still spends ordinary PP. Four real Stockpile casts cap at three; +a second release cannot reuse spent energy. Swallow consumes the same count. +Helping Hand, exact stage ratios, pre-stage stat modifiers, screens, burn, +type rounding and clipped Substitute damage use the covered native order. +The native set-damage event retains its reference metadata (no critical and +neutral event multiplier), separately from the actual type-adjusted amount. + +This feature stacks on #198. The existing record receives `rev = 1`, so +builds with the changed native effect cannot silently share an older link +fingerprint. Earlier-generation fallback behavior remains separate. + +## Contract verification + +```sh +luajit tests/engine/gen3_spit_up.lua +python3 tools/enemy-action-contract-check/check.py \ + --gen1 /path/to/clean/reference --lua /path/to/pinned/luajit \ + --report /tmp/enemy-action-report.json +# With the canonical effect adapter (#121) integrated: +luajit tests/spit_up/public_effect.lua +``` + +The same API-3 consumer and fixture use the actual native planner/action +selection and move executors on unchanged read-only Gen1Recomp +`535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91`. The matrix includes all four user +positions and counts 0–3, physical and authored special types, STAB, single +and dual resistance, immunity/Foresight, Helping Hand, wide/low stats, +Protect, Substitute, Endure at one HP, miss/sleep/semi-invulnerability, +absorption, burn/screens, rain/sun, selected ability/item/stage combinations, +Stockpile's cap, repeated release and Swallow interaction. Actual move/hit +events, HP, operative counter, PP and public damage/critical callback counts +are compared. Native sessions use equal stats and synthetic imported-shaped +rows; this is not a fresh ROM import or full graphical playthrough. + +The focused suite passes **8,292 checks**. The full comparison passes +**32 isolated workers / 3,974 paired observations**, including the existing +enemy-choice, selector, record, target, Follow Me and Helping Hand contracts. +The unchanged #198 host fails **770 assertions** in the new FireRed cases. +The full quick feature run passes **87/96** engine suites versus **86/95** on +clean #198. Exact Counters of all **25 inherited FAIL lines across nine +suites** match, with no additions. SDK 3/3 and B30 pass. + +Counters compare the operative native operands: `expStockpile` on the +reference and `stockpile` on this host. The reference's legacy `stockpile` +alias remains stale after Protect; raw alias equality is not claimed. +Native set-damage event metadata is checked explicitly, not replaced by the +ordinary calculator's effectiveness metadata. + +## Pinned test runtime + +Both sides and the contract workflow use maintained LuaJIT v2.1 commit +[`a4c064539ea931274d407e1249b4bb575424a0f4`](https://github.com/LuaJIT/LuaJIT/commit/a4c064539ea931274d407e1249b4bb575424a0f4), +reporting `LuaJIT 2.1.1792588164`, with JIT enabled. It is built from the +official mirror, not patched engine code. LuaJIT's +[release policy](https://luajit.org/status.html) uses a maintained rolling +branch; the workflow pins the exact source revision for reproducibility. + +The former local ARM64 runtime `2.1.1700008891` produced JIT-dependent Thick +Fat results in unchanged reference code (30 rather than 33 in one covered +case). Disabling JIT on that old VM removes the discrepancy; the maintained +VM passes the unfiltered comparison with JIT enabled. The original failing +report is retained separately under ignored QA output. This is not a claim +of parity on that older VM, and no source-reference patch or row/value filter +is used to make the comparison pass. + +Only the reviewed Spit Up record revision changes the fixture fingerprint: +`032184f68094e9ca` becomes `397df21576af62f1`, and the `move_effects` section +changes from `52cc8398513ed12c` to `c62807cde682a669`. All six other populated +sections remain identical. Removing only that revision restores the former +full digest. The combined checkout must update its audited section pin. + +Full ability/item/badge coverage, ordinary damage's remaining ordering gaps, +other set/delayed executors, RNG traces outside these recorded operations, +called/link controller execution, animation and full gameplay remain separate +surfaces. Gen1 is a read-only reference; no Gen1 publication is required. diff --git a/docs/native-gen3-assist.md b/docs/native-gen3-assist.md new file mode 100644 index 000000000..91bbd5246 --- /dev/null +++ b/docs/native-gen3-assist.md @@ -0,0 +1,99 @@ +# Native GBA Assist and portable content + +This contribution builds on #252. The unchanged Gen1 reference is +`535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91`; all changes target Gen2Recomped. + +## Public mod contract + +The same API-3 registration uses the canonical effect on both engines: + +```lua +mod.content.moves:register("PORTABLE_ASSIST", { + id = "PORTABLE_ASSIST", name = "PORTABLE_ASSIST", type = "NORMAL", + power = 0, accuracy = 100, pp = 10, effect = "ASSIST", +}) +``` + +The builtin is installed for GBA. Existing content reads return the canonical +`ASSIST` effect, and registered party moves execute their own native effects. +Mods do not need a private `BattleState` replacement or a generation-specific +implementation of the party candidate scan. + +## Native command behavior + +After the parent `battle.move_used` subscribers, Assist scans the current side's +party in order and the first four move slots in order. Each eligible occurrence +has its own weight. Fainted members and zero-PP party moves are eligible; the +current user, eggs, empty species and the reference's forbidden callers/moves +are excluded. Sparse move slots do not terminate the scan. Active partners can +contribute. The host's two trainer party arrays are read as one side party. + +The pool is captured before the caller's PP command. PP is spent before a +successful Assist animation and its selection RNG. The child announces with +`isCalled = true`, consumes no party member PP and uses its native targeting. +Nature Power may be a child and can dispatch its own terrain move. Empty pools +preserve the previous move history, including when an outer Metronome selected +Assist. The same early-return history handling applies to failed native Mirror +Move. Native Metronome/Mirror Move children use the shared called +move targeting path in double battles. + +GBA lockstep advertises command revision **15**; party codec revision stays **1**. +Revision-14 peers cannot start a native battle. The native fixture digest is +`662d7e5ee823ce82`, independently computed with the frozen parent serializer and +only the revision token changed. The legacy digest remains `e33d251961f2a41d`. + +## Validation scope + +The shared fixture executes the real native dispatch on the clean reference and +the actual Gen2 battle implementation. It retains full move/damage events, raw +and public PP, held-item callbacks, RNG bounds/values, queued move animations, +active state and party PP. Separate native linked-role and actual-import menu +submission tests cover the host execution path. + +These checks use owned controlled definitions and separate existing imported +FireRed/Emerald data. They do not establish whole-catalog Metronome parity, +visual animation parity, arbitrary mod compatibility or a complete playthrough. +Final acceptance counts and retained negative evidence are recorded with the PR. + +## Accepted source-bound results + +The combined runtime passes **275/275 engine suites**, **3/3 SDK suites**, B30 +and every quick tier. The isolated feature passes **157/166** and retains the +exact 25 inherited FAIL lines across the same nine suites as fresh #252 +(**153/162**). No additional failure is accepted. + +Each runtime passes **188 enemy/action workers / 160,724 identical paired +observations**, including **38,712 new Assist observations**. All workers exit +zero without timeout. The ordinary-damage and held-command comparisons add +**8 workers / 8,952 rows**, for **196 workers / 169,676 selected paired rows**. +Four held-item formats pass strict native schedules (**40 workers / 1,914 +rows**); charge contracts pass four formats (**64 workers / 1,020 rows**), with +strict raw native policy on the combined runtime. + +Actual linked host/guest/spectator menus pass **120 checks** across two turns +per profile, retaining every event and paired completed hash/state component. +Existing FireRed/Emerald imports pass **160 controlled public-submit scenarios** +with native/registered Assist, direct party children and nested Nature Power, +Choice Band and Shell Bell. The result queue returns to its native menu. + +The unchanged parent differs in **4,860/6,480** complete native rows and all +**36/36** selected failed-caller histories; public `ASSIST` registration is +rejected there. Initial fixture failures, the incorrect local PP assertion, +the too-strict imported Choice Band expectation, superseded observations and +the first native-targeting RNG difference remain retained as negative evidence. + +See [the receipt](qa/native-gen3-assist.json) for tested refs and raw-log digests. +Reproduce the portable comparison with the pinned LuaJIT and clean reference: + +```sh +python3 tools/enemy-action-contract-check/check.py \ + --gen1 /path/to/clean/gen1 --lua /path/to/pinned/luajit \ + --report /tmp/enemy-action-report.json +``` + +The actual-import CPU scenarios additionally accept an existing cache root: + +```sh +luajit tools/enemy-action-contract-check/imported-assist-smoke.lua /path/to/cache +luajit tools/enemy-action-contract-check/imported-assist-smoke.lua /path/to/cache --nature-power +``` diff --git a/docs/native-gen3-baton-pass.md b/docs/native-gen3-baton-pass.md new file mode 100644 index 000000000..1fd31e16e --- /dev/null +++ b/docs/native-gen3-baton-pass.md @@ -0,0 +1,82 @@ +# Native GBA Baton Pass continuation + +This development branch stacks on #217 and the existing held-item phase work +in #165/#166. Gen1 remains an unchanged, read-only reference. + +The native move selects a healthy, non-egg replacement from its owner's +party, transfers stages and the native Baton Pass volatiles, emits the public +switch event, and applies switch-in effects. NPC selection follows the native +type heuristic, including its bounded RNG call in doubles. Player selection +uses the actual generation-specific party screen and cannot be cancelled. +Queued attacks resolve against the new occupant rather than the retired donor. + +Linked players transmit their actual mid-turn selection using a validated +turn/order/owner packet. Early reliable-ordered packets are buffered; identical +duplicates are idempotent, conflicting or invalid choices reach the controller +failure path. Spectators consume the same choices without opening a personal +picker. Disconnects remain serviced while the party screen owns input. +The picker uses clamped link-party copies rather than the persistent save party. + +Native command revision 3 prevents older rules from entering a full GBA battle. +The raw fixture changes from `cf73a8e083fb8a54` to `c2a03195bc4588f9`, audited +with the unchanged #217 hasher and only the explicit revision token. Native +party revision 1 and older-generation fingerprint goldens remain unchanged. + +Focused commands, using the maintained pinned LuaJIT described in the contract +workflow: + +```sh +LUA=/absolute/path/to/luajit +"$LUA" tests/engine/gen3_baton_pass.lua +"$LUA" tests/engine/gen3_baton_pass_ui.lua +"$LUA" tests/engine/gen3_baton_pass_link.lua +"$LUA" tests/engine/gen3_baton_pass_packets.lua +"$LUA" tests/engine/gen3_baton_pass_composition.lua +"$LUA" tests/engine/gen3_battle_protocol.lua +"$LUA" tests/engine/ui_actions.lua +python3 tools/enemy-action-contract-check/check.py \ + --gen1 /absolute/path/to/clean-reference --gen2 . --lua "$LUA" \ + --report /absolute/path/to/report.json +``` + +The shared fixtures execute both real move resolvers and the real NPC selector. +They retain all RNG calls, PP/HP, stages, volatile state, seed-source identities +and public switch events. Reference names live on `mon.nickname`; reference +infatuation has a separate active flag and owner pointer, whereas the host's +pointer is the active state. These are explicit schema mappings. Original +failed comparison reports are retained locally. + +Validation at clean runtime `d5ad56afccca21d107f21b5b561b908fa650e59e`: + +- 915 focused Baton Pass checks pass: 654 command, 52 actual party UI, 72 link/ + spectator/disconnect, 95 packet/signature/compatibility and 42 authored-effect/ + called-move checks. The 154 native protocol and 3,002 existing UI checks pass. +- All 88 workers / 60,132 paired observations equal clean Gen1 + `535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91`. The new mode has 156 complete + observations per profile, including actual commands, resumed selections, + called moves, all four slots, seeds/infatuation, excluded/reserved members, + one/two/three Spikes layers and the real NPC type selector with empty PP. +- Full feature regression is 110/119 engine suites, SDK 3/3 and B30. The exact + Counter of all 25 FAIL lines across nine inherited suites equals clean + prerequisite `c27c37a667e8a40613c22506448ab4886fc9b431` (105/114). Their + independent repairs are present in the combined fork. +- Combined runtime `86b12d7b7a816a2782ba5cfba804b103630eda5b` passes all + 231/231 engine suites, SDK 3/3 and B30, plus the same clean-reference + comparison and imported-record smoke. Its existing 4,849 party-navigation + checks remain green. Ordinary link screens follow live/late-installed battle + rosters; forced owner-specific choices retain the explicit party. +- The same public canonical selector on the clean prerequisite spends PP and + sets `wantsSwitch`, but leaves the donor active. Its failing assertion and + raw state are retained. The initial Baton implementation also fails the + later nested-choice item-phase checks; the fix suspends ancestor frames. +- Actual locally imported FireRed/Emerald records select canonical + `BATON_PASS` through the public enemy-action mod, then exercise the real + party screen and queued turn with the third member selected. Both profiles + retain stages and spend native PP once. This is a CPU-only imported-record + smoke; no ROM/cache assets or saves are distributed. + +Evidence is retained under `tmp/baton-pass-qa/`, including complete failed and +accepted reports. Combined-fork acceptance and hosted workflow status are +recorded separately in the combined testing document and the published PR. +CPU fixtures do not certify GPU animation, pixel equality, a complete +playthrough, global RNG/VBlank scheduling or all native side/volatile producers. diff --git a/docs/native-gen3-battle-effect-presentation.md b/docs/native-gen3-battle-effect-presentation.md new file mode 100644 index 000000000..5ac32802a --- /dev/null +++ b/docs/native-gen3-battle-effect-presentation.md @@ -0,0 +1,66 @@ +# Native GBA Poison presentation and native sound controls + +This path extends native Substitute art and the existing battle queue. +Gen1 remains read-only at `535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91`. + +## Implemented native Poison path + +Successful native poison status changes queue their status-animation command. +The actual battle queue executes the native shake, two palette cycles and six +edition-specific sounds. Presentation does not change HP, status, PP or battle +RNG. Hidden/protected actors and disabled animations follow native skip rules. +Mono/stereo uses the existing game sound option. Live sprite offsets and tint +reach the battle renderer through its normal presentation methods. + +Native numbered sounds also resolve through cartridge songs when no named +role exists. Explicit panorama and mono controls preserve the original mix; +each control combination owns a separate cached source. Existing named-role, +native-name file/string overrides and disabled sounds retain their behavior. +Cache invalidation refreshes the native number lookup after a mod audio edit. + +## Evidence + +- Unchanged native sequencer/VM versus actual host queue: **144 paired Poison + inputs / 1,848 ticks / 144 actual sound controls**, all equal. Inputs cover + all six single/double positions, animation options, stereo/mono and native + hidden/Substitute skips. Every actor's captured display state, HP, status, + protection, and RNG count participates in comparison. +- A common owned PCM carrier runs through the native mixer and host pan + processor: **56 paired cases**, all samples and format metadata equal. + The initially mismatched 32768-Hz carrier fixture is retained; the qualified + fixture uses the native default 44100-Hz input, without rewriting results. +- Actual host M4A sequencing, source creation and dispatch: **165,455 checks**, + including source separation/reuse, all PCM samples, mono, retained defaults, + file/string overrides, silence and legacy forwarding. Existing mod audio: + **116/116**. Native status presentation unit checks: **3,074/3,074**. +- The broader complete command regression retains **11,520 multi-hit rows**, + **1,920 Mirror Move rows** and **360 Substitute-controller cases**. The 192 + poison-command differences are resolved. The complete comparison remains + **failed at that checkpoint** on **1,152 animation observations**, all + Substitute fade or Focus Band. No observation, event, RNG field or animation was removed. + +Audio controls are measured at the output source. The Source carrier uses +owned cached PCM; the host carrier runs through its actual M4A sequencer. +The separate mixer comparison covers the same input samples on both engines. +This does not establish whole-synthesizer waveform equality, all public audio +event parity, or final scene pixel equality. CPU hardware doubles launch no +game, audio device or GPU preview and modify no normal cache/save. + +## Combined qualification + +Actual Substitute-fade and Focus Band playback are now implemented. The final +32-worker / 14,816-observation matrix passes with all captured fields retained. +The original Poison runtime completes 161/170 engine suites, SDK 3/3 and B30, +with exactly the same 25 inherited FAIL lines as fresh #258. Native revision +17, real linked roles, received-history and public imported-submission evidence +are recorded in `native-gen3-mirror-move.md`. Earlier failures remain retained. + +Raw successes, failed/superseded inputs and complete observations remain in +`tmp/battle-effect-qa/`. Reproduce the focused contracts with: + +```sh +nice -n 19 python3 tools/enemy-action-contract-check/check.py \ + --gen1 /path/to/clean/reference --lua /path/to/pinned/luajit \ + --mode battle_effect_presentation --mode native_sound_pan \ + --report /path/to/new-report.json +``` diff --git a/docs/native-gen3-choice-band.md b/docs/native-gen3-choice-band.md new file mode 100644 index 000000000..052710b58 --- /dev/null +++ b/docs/native-gen3-choice-band.md @@ -0,0 +1,113 @@ +# Native GBA Choice Band and move selection + +This change is stacked on #230. It brings the native Choice Band command, +manual/AI selection consumers and forced-move PP ownership into the shared +Gen2 engine for FireRed and Emerald. + +## Behavior + +The move-end command performs its held-effect read unless the action was +disobedient. A new choice remembers the selected native caller, excludes +Struggle and successful Baton Pass, and clears when the move disappears from +learned slots. Losing the item retains a still-learned choice. Planner queries +read the current held effect again instead of caching a previous hook answer. + +Manual admission checks physical item 186. The AI's four-slot limitations +command uses the held-effect hook and combines Choice Band with PP, Disable, +Encore, Taunt, Torment and Imprison. Ordinary wild selection uses that mask +for the all-unusable shortcut; otherwise it draws nonempty native slots before +forced planning. Explicit non-link unlimited-enemy-PP rules remain supported. + +Forced planning binds the actual learned slot, priority and target byte while +keeping the declared turn event separate. Replacing the move table rebinds PP +to the current slot. Removing the selected slot still permits the planned move +without spending retired records or another current move's PP. Pressure spends +two PP when that replacement slot exists. + +Native caller provenance is explicit. Sleep Talk and unchanged default +Mirror Move/Metronome selectors differ from custom `ctx.callMove` handlers. +Active Baton Pass frames follow the replacement battler; failed Baton Pass +can bind a choice while successful Baton Pass does not. Failed native Mirror +Move returns before move-end. Custom callers retain both commands and resolve +canonical GBA move references such as `ctx.callMove("TACKLE")`. + +API-1/2 native choice handles retain their identity. Selection-only metadata +stays private until execution; API-3 positional actions remain separate from +learned records. Public snapshots, stale-intent checks and the actual keyboard +refusal/message-queue return path use the same native restrictions. + +GBA Struggle skips STAB and the type chart while retaining ordinary base, +critical and random-damage commands. Other-generation paths remain guarded. + +## Linked roles and protocol + +Choice identity participates in GBA link state and public-intent staleness. +Host, guest and actual spectator use canonical actor order for held scans, +planning and speed reads. Initial item-entry waits for both send-outs; later +individual switches keep their normal entry command. The link test retains +complete ordered held payloads and a stateful damage override depending on +all earlier reads, rather than accepting equal hashes alone. + +Native command revision is **11**; party codec remains **1**. Older peers are +refused before party exchange. An independent audit applies only the revision +10-to-11 token change to the unchanged #230 hasher and reproduces native digest +`3e2986da83083f86`; the legacy digest remains `e33d251961f2a41d`. + +## Local acceptance + +Production was tested at feature `60d5a6fe` (runtime change `137d775b`) and +combined candidate `83346a19` (runtime change `bdd07012`). Later documentation +and workflow-path changes do not change those production sources. + +| Check | Isolated feature | Combined candidate | +| --- | --- | --- | +| Engine suites | 143/152 | 262/262 | +| SDK suites | 3/3 | 3/3 | +| Quick tiers including B30 | same inherited engine-tier failure | all pass | +| Enemy/action contracts | 148 workers, 80,132 paired rows | same counts, all equal | +| Ordinary GBA damage | 6,912 paired rows | same counts, all equal | +| Imported FireRed/Emerald scenarios | both modes pass | both modes pass | + +The isolated feature retains exactly the same Counter of 25 anchored FAIL +lines across nine inherited suites as a fresh unchanged #230 parent. No +failures are filtered. The combined candidate has zero FAIL lines. + +All four held-item CI formats also pass the strict native-schedule gate: +API-1 native, API-2 native, API-2 unified and API-3 unified; 40 workers / 1,914 +paired rows. The removed-slot/Pressure comparison passes 276 rows over both +profiles. The exact saved old module fails 24 rows by spending retired PP; +its output is retained as the negative control. + +The complete matrix executes unchanged Gen1 reference +`535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91`; held-item CI uses its separately +pinned `79ea62f45fb2ea8f65d081a109431e4eb1bbdb36`. Both checkouts stay clean +and read-only. The maintained LuaJIT runtime is pinned at +`a4c064539ea931274d407e1249b4bb575424a0f4`, matching contract CI. Tests run +with `nice -n 19`. New Lua sources/fixtures pass AST-verified formatting; +the workflow passes actionlint. Actual imported scenarios use explicit GBA +rules, constructed parties and public menus without a normal SAVE or +ROM/cache distribution. + +Raw local successes, failures, interrupted matrices, negative controls and +full-suite logs remain under `tmp/choice-band-qa/`. Interrupted matrices were +repeated completely on clean commits before these acceptance results. + +## Remaining native execution gaps + +The planner comparison retains all recorded RNG, events, HP, learned moves, +current/retired PP and complete planning held payloads. It does not observe +PP inside `battle.move_used` or certify the entire damaging script. + +A separate 72-row actual-script diagnostic retains 48 differences: ordinary +and Pressure callbacks observe PP before reduction in Gen1 and after reduction +in Gen2; native zero-PP failures still emit the event whereas Gen2 does not. +The initial probe incorrectly supplied a PP slot for an ordinary called child; +its raw result is retained separately from the corrected native-call probe. +No observation fields were dropped to obtain the corrected result. + +A separate complete held-read phase probe still observes 10 native reads +versus 11 host reads for successful Sleep Talk; awake, empty-pool and blocked +probes retain their matching 2/2/4 counts. King's Rock/Shell Bell command +ordering remains open. Successful Mirror Move target/history selection, +Metronome catalog/RNG parity, other attack-canceller phases and full native +playthrough/graphics are also not certified by this PR. diff --git a/docs/native-gen3-constrained-party.md b/docs/native-gen3-constrained-party.md new file mode 100644 index 000000000..d1e057ed9 --- /dev/null +++ b/docs/native-gen3-constrained-party.md @@ -0,0 +1,68 @@ +# Native Gen-3 constrained party creation + +Teachy TV previously called the ordinary random-mon builder with midpoint IVs, +then overwrote its nature and gender. That left a PID inconsistent with its +stored identity and passed the nature into the wrong `Stats.calc` argument. +Hard-coded English species/move keys also failed against native-index or +translated registries. + +The private `Gen3ConstrainedMon` builder now follows the unchanged Gen1 native +`Pokedude.build` creation path. It consumes low/high 16-bit PID words until the +requested native nature and gender match, with `gender.roll` evaluated only for +nature-matching candidates. It creates zero IVs/EVs, native nature-adjusted +stats, full HP/PP, the PID-selected ability, experience, friendship and the +native Poke Ball. It does not invoke generic encounter generation, random +IV/DV generation or the shiny-odds roll. No new public API is introduced. + +Species, moves, nature and ball use cartridge order maps, falling back to +explicit native indices. Missing or ambiguous rows fail explicitly. All party +members are created before taking ownership of the player's actual party; +normal restoration keeps its original reference and contents. Catching retains +its already-correct move order `{93, 77, 79, 78}`. + +This change composes the critical-context work in PR #212 and the published +identity hooks in PR #169. Gen1 remains a clean, read-only reference at +`535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91`. + +## Reproduce + +Run `luajit tests/engine/gen3_constrained_party.lua`, or the full ROM-free +`scripts/test.sh --quick`. The enemy-action checker now also runs the shared +`tests/enemy_actions/constrained_party.lua`: + +```sh +python3 tools/enemy-action-contract-check/check.py \ + --gen1 /path/to/clean/gen1-reference \ + --lua /path/to/pinned/luajit \ + --report /tmp/enemy-action-report.json +``` + +The maintained LuaJIT source pin is +`a4c064539ea931274d407e1249b4bb575424a0f4`, as in the existing dedicated workflow. +The fixture runs all four native presets, three seeds, three registry layouts +(canonical, raw native IDs without order maps, translated names/keys) and five +public gender policies (absent, delegation, first-candidate rejection, invalid +result fallback, call-local threshold steering). It compares the actual created +fields, every consumed word, final input-stream state and public gender callback +order, rather than overwriting generated stats afterward. + +The source executes its original LCRNG; a pass-through observer records its +actual output. The host receives an independently generated equivalent word +stream through the constructor's private RNG argument. Observations normalize +only engine schema differences: registry IDs, stat/IV/EV field names, native +gender codes and growth-curve IDs. Every observation is retained by the checker. +Host-only assertions also cover party restoration, incomplete opposing data, +the default word source and absence of a shiny callback during private creation. + +## Scope + +There are 180 paired presets per GBA data profile. The existing comparison +matrix remains intact: 56 worker runs compare 5,574 paired observations. Emerald +runs prove the shared Gen-3 builder contract with Emerald data; the retail +Teachy-TV entry point is FireRed-specific. These ROM-free tests do not certify +live UI playback or a complete retail tutorial playthrough. + +Global game RNG/VBlank scheduling and the subsequent demo controller's existing +midpoint battle RNG are still separate gaps. This constructor-level comparison +does not claim to close them. The move-effects protocol and fingerprint are +unchanged. diff --git a/docs/native-gen3-disable.md b/docs/native-gen3-disable.md new file mode 100644 index 000000000..5c9e46630 --- /dev/null +++ b/docs/native-gen3-disable.md @@ -0,0 +1,125 @@ +# Native GBA Disable and portable move eligibility + +GBA Disable now follows the target's last executed learned move, including +known authored moves without cartridge indices. Previously it randomly selected +a positive-PP slot, drew 1–8 phases and aged the counter on attempted actions. +That could disagree with both the public mod snapshot and an already queued move. + +The native producer checks accuracy first, then rejects an existing disabled +move, an unknown last move or a learned slot with no PP. It draws `0..3 + 2` +for 2–5 residual phases. `disabledMove` owns the native host ID; the affected +slots are read from the current move list. Reordering follows the identity, +every duplicate is restricted, and removing the identity clears Disable in its +next residual phase. Reaching zero PP does not prematurely end Disable. + +A disabled queued action stops before announcement, `battle.move_used`, confusion +and PP consumption. The interruption clears the last executed move and native +multi-turn state, including the existing Bide continuation path. Called moves +retain their native bypass and consume no PP from the called slot. End-of-turn +processing ages Disable before Encore. Switches and Baton Pass do not transfer it. + +Move selection, `mod.battle:snapshot()`, public command admission, the move-list +hint and native AI Disable predicates now read the live identity. Snapshot and +admission are read-only; identity/timer changes invalidate previously offered +commands. Legacy authored `disabledSlot` states retain their previous policy. + +Builtin adaptation happens per dataset before mods load: a mod can capture and +delegate `mod.content.move_effects:get("DISABLE_EFFECT").run`. An explicit +custom handler remains authoritative, and shared legacy module records are never +mutated. Haze preserves native Disable; a successful legacy Transform clears it, +even when the copied move list still includes the disabled ID. These adapters +only address Disable's lifecycle: they do not certify all Haze/Transform rules. +Older-generation builtins and their slot/countdown behavior stay unchanged. + +## Lockstep compatibility + +Native GBA command revision is **5**, with native party codec revision **1**. +The active state hash includes the disabled move and timer. Both link roles +refuse older command revisions before party exchange. + +The raw native fixture changes from `8be423c61a4477a2` to `da62fe7b50f40107`. +It was independently calculated with the unchanged #219 hasher and only the +explicit revision token changed from 4 to 5. Handler functions serialize as +opaque markers, so per-load builtin delegation adapters do not silently add a +function-byte digest. No older-generation golden was reblessed. + +## Validation + +The clean feature runtime is `55f4301ab6ef190051b1f6d420f22636a61a1809`. +Gen1 is an unchanged, clean read-only reference at +`535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91`. No Gen1 development or publication +is required or performed. + +- **930 focused checks**: 768 command/lifecycle, 108 public-command/delegation/ + switch and 54 actual link/compatibility checks. The protocol suite also passes. +- **96 workers / 62,976 paired observations** agree. The new Disable mode adds + 810 observations per FireRed/Emerald profile, retaining every bounded RNG + call/value, each battler's HP/PP/learned moves/last move, actual move events, + timer and move identity. It covers all four battler slots and duration words, + failure guards, custom/duplicate moves, removal/reordering, PP depletion, + confusion/called/queued execution, Bide and multi-turn interruption, + Haze/Transform, and actual residual expiry. +- Eligibility observations compare Gen1's real native `Commands.selectionError` + against the host's real read-only `BattleCommands.reason`. Gen1's GBA snapshot + omits `disabled`; inventing a source value or comparing an omitted field as + false would not prove native selection agreement. +- Full feature run: **116/125 engine suites**, SDK **3/3**, B30 passed. + The exact Counter of all **25 inherited FAIL lines across nine suites** equals + clean #219 (113/122). The full command correctly retains its nonzero exit. + Independent repairs for those suites belong to the combined test branch. +- The unchanged #219 public-command negative control fails both new assertions: + it retains random slot 2 and duration 8, rather than the last move and duration + 5. The raw negative output is retained. +- Two actual link roles complete three turns: ordinary Tackle, a faster Disable + cancelling the already selected Tackle without PP, then an independent Scratch. + Every completed paired hash and state component agrees. +- Four CPU-only scenarios with actual local FireRed/Emerald imports pass: + canonical public enemy-action selection, preapplied and same-turn Disable, + public move submission, full native turn/result queues and residual expiry. + Disable's real imported accuracy is retained; the controlled hit roll is 1. + No cartridge caches, ROMs or saves are distributed and no normal SAVE is issued. +- Scoped StyLua, actionlint and whitespace checks pass. + +Reproduce from the feature checkout, using the maintained LuaJIT pin documented +in the workflow (`a4c064539ea931274d407e1249b4bb575424a0f4`): + +```sh +nice -n 19 "$LUA" tests/engine/gen3_disable.lua +nice -n 19 "$LUA" tests/engine/gen3_disable_continuation.lua +nice -n 19 "$LUA" tests/engine/gen3_disable_link.lua +nice -n 19 "$LUA" tests/engine/gen3_battle_protocol.lua +nice -n 19 python3 tools/enemy-action-contract-check/check.py \ + --gen1 /path/to/clean/gen1-reference --gen2 . --lua "$LUA" \ + --report /tmp/disable-contracts.json +nice -n 19 env LUA="$LUA" \ + LUA_INIT='io.stdout:setvbuf("no"); io.stderr:setvbuf("no")' \ + bash scripts/test.sh --quick +``` + +Private local evidence is retained under `tmp/disable-qa/`, including the full +feature log, complete comparison report, exact baseline FAIL-Counter audit, +negative control and imported smoke. Earlier exploratory/failed logs are retained. + +## Remaining boundaries + +This is a native command/lifecycle and mod eligibility contract, not complete +battle equivalence. The broader canceller ordering, legacy Rage/other locked-move +controllers, all Haze/Transform rules, whole-game RNG/VBlank orchestration, +animations, live UI pixels and a full cartridge playthrough need separate work. +The existing prepared Quick Claw boundary in the public Encore planning cases +remains documented in [the Encore contract](native-gen3-encore.md); no RNG calls +or differing observations are filtered from this checker. + +## Combined fork acceptance + +The tested combined runtime `abd621282715d072b421e7b30f3f5c60628d771d` +passes **237/237 engine suites**, SDK **3/3**, B30, the same **96 workers / +62,976 paired observations**, and all four imported CPU smoke scenarios. +Both comparison reports identify a clean host at their respective runtime. +The integration preserves the existing GBA item/residual scheduling and native +AI-order preparation. Later commits only record validation/publication. + +Combined evidence is retained in `tmp/disable-full.log`, +`tmp/disable-contracts.json`, `tmp/disable-imported.log` and +`tmp/disable-accepted-evidence.json`. The source checkout remains clean at the +pinned Gen1 revision. Hosted CI status is reported separately in the PR. diff --git a/docs/native-gen3-encore.md b/docs/native-gen3-encore.md new file mode 100644 index 000000000..2e6469621 --- /dev/null +++ b/docs/native-gen3-encore.md @@ -0,0 +1,95 @@ +# Native GBA Encore + +This branch stacks on #218. Gen1 is an unchanged read-only reference. + +Encore records its target's last known move and original slot. It rejects +Struggle, Encore, Mirror Move, unknown/removed moves, exhausted slots and an +already-active Encore after the native accuracy check. A successful command +draws `0..3` and stores three to six residual phases. Known authored moves do +not require a cartridge index. Existing custom handlers and older-generation +policies retain their execution paths. + +Turn planning uses the forced move's priority while preserving a chosen +target. An Encore applied after planning also changes the queued action, +recomputing its target with native bounded draws and redirection. Execution +spends the actual learned slot's PP, preserves called-move PP rules and emits +the actual move through public events. Forced FIGHT returns a native move +instance rather than an unrecognized nested descriptor. +`battle.turn_started` retains the original declared action. A planned action +also retains its PP binding if Encore is cleared before execution. + +Actual residual processing cancels Encore on expiry, depleted PP or a changed +original slot. It does not transfer through Baton Pass. Link state components +include move, slot and duration. Native command revision 4 refuses older battle +rules before party exchange; the native party codec revision stays unchanged. +The raw fixture changes from `c2a03195bc4588f9` to `8be423c61a4477a2`, audited +with the unchanged #218 hasher and only the explicit revision token. + +Focused commands: + +```sh +LUA=/absolute/path/to/pinned/luajit +"$LUA" tests/engine/gen3_encore.lua +"$LUA" tests/engine/gen3_encore_continuation.lua +"$LUA" tests/engine/gen3_encore_link.lua +"$LUA" tests/engine/gen3_battle_protocol.lua +python3 tools/enemy-action-contract-check/check.py \ + --gen1 /absolute/path/to/clean-reference --gen2 . --lua "$LUA" \ + --report /absolute/path/to/report.json +``` + +The shared matrix retains every bounded RNG call, PP/HP for all battlers, +move/slot/timer state and public move events, including their actual target. +The reference's `expEncore*` fields and numeric move IDs are mapped explicitly +to the host's native fields and fixture registry IDs. Failed/raw reports remain +under `tmp/encore-qa/`; the checker never edits source code or filters calls. + +The public-choice/planning cases start with the same explicit prepared +Quick Claw word. The combined fork's C-validated native AI-order controller +prepares that word earlier than Gen1's standalone command; the original failed +comparison retains that extra `0..65535` call. This is an input-state boundary, +not proof that the engines' complete selection/RNG orchestration agrees. The +comparison retains every subsequent bounded draw and records the prepared word. + +## Validation + +Clean feature runtime `6998a429a99dcc94d3f7c8b9a337ddfbcdaea3ba` passes: + +- **637 focused Encore checks**: 480 command/lifecycle, 99 public-command/ + queue/authored-policy/legacy checks, and 58 actual link/compatibility checks. + The native protocol suite also passes 154 checks. +- **92 workers / 61,356 paired observations** agree with clean Gen1 + `535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91`. The new mode retains 612 complete + observations per profile, including all four slots, all duration draws, + native failure guards, custom moves without cartridge indices, actual + residual expiry/slot edits, called moves, late retargeting, public choices + and cancellation after planning. No compared observations or calls are removed. +- Full ROM-free feature run: **113/122 engine suites**, SDK 3/3 and B30. + The exact Counter of all **25 inherited FAIL lines across nine suites** equals + clean #218 (110/119); the combined fork carries their independent repairs. +- The same public canonical command on unchanged #218 returns three phases + and no slot binding, failing both native assertions. Its raw state/failures + remain in `tmp/encore-qa/negative-command.log`. +- Both link roles complete three ordinary native turns: Tackle, a faster + Encore overriding the queued opposing Encore, then an independently selected + Scratch overridden again. Actual PP ownership and every paired completed + turn hash/state component agree. Older rules are refused even with an equal + custom fingerprint. +- Actual local FireRed/Emerald records pass four CPU-only scenarios: the public + canonical enemy selector applies Encore, then public FIGHT or a separately + selected move runs a real turn. The bound Tackle slot spends PP once and the + actual residual scheduler ages the timer. No ROM/cache assets or saves are + distributed, and no normal SAVE is issued. +- Scoped StyLua, actionlint and whitespace checks pass. + +Clean combined runtime `ae385bae803e8a94eb4b2c73e2c1425d1a68868d` passes all +**234/234 engine suites**, SDK 3/3, B30, the same 92/61,356 comparisons and the +same imported-record smoke. Integration preserves the existing native AI-order +word preparation and both preselected double-battle enemy actions. Its first +unprepared-input mismatch is retained in `tmp/encore-contracts-first.json`. + +These CPU fixtures do not certify a complete playthrough, GPU/pixel equality, +global RNG/VBlank orchestration, or every other native state producer and its +interactions (including legacy repeated-move controllers). The broader native +alignment goal remains open. Publication and hosted-CI status are recorded in +the PR and the combined testing document. diff --git a/docs/native-gen3-focus-band-presentation.md b/docs/native-gen3-focus-band-presentation.md new file mode 100644 index 000000000..4a0acf519 --- /dev/null +++ b/docs/native-gen3-focus-band-presentation.md @@ -0,0 +1,32 @@ +# Native GBA Focus Band presentation + +This path queues the native Focus Band general +animation before the held-on message in both native damage paths. The real +battle queue owns its frame clock, sound, red palette fades and signed +fixed-point movement. Animation options retain the native general-animation +pause. Visible Substitute and hidden actors skip playback; a temporarily +lowered Substitute permits playback without changing its logical HP. + +The complete native sequencer/VM comparison currently matches **384 paired +inputs / 11,112 tick states / 192 sound controls** across FireRed and Emerald. +It covers Poison and Focus Band, all six single/double actor positions, +animation options, mono/stereo, ordinary, hidden, Substitute and lowered +Substitute states. The expanded host unit covers **13,778 checks**. + +The first controller and incomplete Emerald fixture failures are retained in +`tmp/focus-band-qa/`. Emerald requires native picture coordinates to execute +its return task. The owned test dataset supplies those coordinates through +the existing virtual cache filesystem; no reference functions or files are +patched. The comparison checker now rejects native VM error/wait-cap +diagnostics even if the worker exits zero, and fingerprints the sound fixture. + +The frozen runtime completes 161/170 engine suites, SDK 3/3 and B30, with +exactly the same 25 inherited FAIL lines as fresh #258. The corrected frozen +controller rerun passes all eight Poison/Focus Band and PCM workers at +`d470eeee`. FireRed retains its built-in back offset 10; the owned Emerald +input uses 8. The earlier incorrect shared-offset assertion remains retained +as a failed fixture. The final 32-worker matrix also passes on the combined +fork. Native revision 17, Substitute fade and linked/imported qualification +are documented in `native-gen3-mirror-move.md`. The reference remains read-only +at `535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91`; final GPU pixels and whole +synthesizer waveforms are outside these CPU results. diff --git a/docs/native-gen3-hit-commands.md b/docs/native-gen3-hit-commands.md new file mode 100644 index 000000000..0803521af --- /dev/null +++ b/docs/native-gen3-hit-commands.md @@ -0,0 +1,97 @@ +# Native GBA hit and secondary-chance commands + +The GBA battle path previously reused the older byte accuracy/secondary model. +That changed bounded RNG consumption, stage arithmetic and public hook entry +points. Thunder's post-hit roll also used `0..255` rather than the reference's +`0..99`. The clean reference is Gen1 commit +`535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91`; it remains read-only. + +`Gen3Accuracy` now implements the observed native percentage command: combined +accuracy/evasion stages, sequential ability/item floors, identification, +all-occupied-slot weather suppression, Protect/semi-invulnerability, separate +Oak damage/stat guards and Teachy-TV guarantees. Public `battle.accuracy` +contexts include native move identity and the calculated threshold. As in the +reference, `accuracy` is steerable; its `rng` field is informational rather +than a replacement for the live battle stream. Absorb abilities resolve before +damage computation without a generic miss message. Older games keep their +existing byte path and fingerprints. + +`Gen3Secondary` shares the ordinary secondary command across status, flinch, +confusion and stat-change families. It preserves the reference's inclusive +`roll <= secondaryChance` boundary, including explicit zero, Serene Grace and +the certain-opcode bypass. The chance draw precedes effect-level faint, +Substitute, Shield Dust, status/type, Safeguard and stat-protection gates. +Zero-length Substitute/protection states are inactive. Default built-in +handlers use this path; authored `run` handlers keep their own chance policy. +A real special Fire hit thaws the target after secondary processing; a +Substitute hit does not thaw it, even when the Substitute breaks. + +Strong poison uses the existing native `PSN` representation plus a boolean +`mon.toxic` marker. Native status application, API-3 callback views and edits, +party/battle snapshots, save serialization, battle construction and party-wire +transport retain that identity. Editing a bound mon's status updates its +marker and the owning battler's counter in the same callback transaction. +The public API-3 value is `TOX`, as in the reference; this is an explicit +schema mapping, not an observation filter. + +## Validation + +Run these ROM-free suites with the maintained LuaJIT pin +`a4c064539ea931274d407e1249b4bb575424a0f4`: + +```sh +luajit tests/engine/gen3_native_accuracy.lua +luajit tests/engine/gen3_native_secondary.lua +luajit tests/engine/gen3_battle_protocol.lua +bash scripts/test.sh --quick +python3 tools/enemy-action-contract-check/check.py \ + --gen1 /path/to/clean/gen1-reference \ + --lua /path/to/pinned/luajit --report /tmp/native-hit-report.json +``` + +The shared fixture invokes the actual reference accuracy context and +`Secondary.withChance`, plus both engines' real move resolvers through the +public mod loader. Execution probes use the same public critical override to +isolate these phases from the separately tested critical command. They retain +all bounded RNG calls/values, accuracy contexts, damage-hook counts, PP, +HP/stages, status events and confusion/flinch/Substitute state. Conditions +include stage limits, zero-valued guards, all four actor slots, explicit +0/30/100 chances, forced effects, existing statuses, strong poison, abilities, +Fire thaw and Substitute breakage. All earlier comparison modes remain. + +Local acceptance: 15,082 accuracy checks, 53,598 secondary checks and 156 +protocol checks pass. All 72 workers / 48,524 paired observations agree. +The full feature run passes 98/107 engine suites, SDK 3/3 and B30. The exact +Counter of the 25 inherited FAIL lines across nine suites matches clean +PR #214 (`440a5da6`, 95/104); no failure is suppressed. Clean pre-secondary +`184fdaaa` retains 56 differing Thunder execution observations in the negative +control. The original failing reports remain in ignored private QA outputs. + +A separate CPU-only smoke loads actual locally imported FireRed/Emerald +Pokemon/move/item/type/constant tables and executes imported Thunder through +the public enemy-action and status-event path. Both profiles use the native +accuracy and secondary ranges and apply paralysis. Controlled HP avoids a KO; +this is imported-record execution acceptance, not live rendering or a full +cartridge playthrough. No imported data/assets/saves are distributed here. + +## Link compatibility + +The full GBA fingerprint carries native command revision 1. GBA move hashes +also include the native index/effect, secondary chance, target, flags and +Protect eligibility. Cache keys include the runtime native-rule revision so +switching game contexts cannot reuse an older digest. Non-GBA fixture digests +remain unchanged; no golden is reblessed. The audited raw fixture moves from +legacy `e33d251961f2a41d` to native `3a76cc5fce47a5c3`. + +Identical revised peers remain compatible. Older GBA fingerprints negotiate +only a safe content subset; the actual link controller stops before battle +setup. Unversioned peers are refused for these native rules with an engine +update message. Both role directions, per-field mutations, marker wire/save +round trips and two complete synthetic seeded link mirrors are tested. Each +mirror compares multiple completed turn hashes and all state components; +native toxic identity is included in active/bench signatures. + +This is a rule-compatibility and toxic-marker guarantee. Complete cartridge +party-codec fidelity (including GBA genetics), timed Lock-On/side/volatile +producers, whole-game RNG/VBlank scheduling and demo midpoint playback remain +separate work. These controlled command tests do not certify those lifecycles. diff --git a/docs/native-gen3-lock-on.md b/docs/native-gen3-lock-on.md new file mode 100644 index 000000000..0c8eeefec --- /dev/null +++ b/docs/native-gen3-lock-on.md @@ -0,0 +1,73 @@ +# Target-owned native GBA Lock-On + +The generic producer stored a one-use `user.lockedOn` pointer. Native GBA +instead owns sure-hit state on the target, starting at two residual phases, +with the originating side and (in doubles) battler slot. Repeated accuracy +commands and attacks do not consume this timer. The source is unchanged Gen1 +`535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91`; all Gen1 files stay read-only. + +The native Lock-On/Mind Reader producer now runs Substitute, accuracy and +Protect/semi-invulnerability checks in the observed order, then records target +ownership. The regular accuracy and OHKO consumers share that ownership check. +The real residual sweep expires the timer; normal target replacement drops it, +owner replacement clears its bindings and unrelated partner replacement leaves +it intact. Both direct primary-slot assignment and placed/forced replacements +pass through the lifecycle. The existing Baton Pass switch entry transfers the +target timer/side with the reference's slot-owner fallback behavior. The full +Baton Pass move/UI chooser is separate work; these tests exercise its explicit +switch-control entry, not a complete player chooser lifecycle. + +Authored legacy GBA consumers can still supply their old one-use pointer; +older games keep their existing producer/consumer. Authored effect handlers +retain their own policies. Source constructor/party schemas and game-wide RNG +scheduling are not changed by these command contracts. + +Native command revision 2 participates in full GBA link fingerprints. The +explicit revision guard prevents an overridden/custom fingerprint from admitting +different native battle rules. Matching party codecs can still negotiate shared +trade data; older command revisions cannot start a battle. Duration and owned +side/slot enter active-state components, normalized to host coordinates so the +guest's mirrored view agrees. The audited raw fixture changes from #216's +`2fd444270c3d90db` to `cf73a8e083fb8a54`: unchanged #216 hasher, only the explicit +`[native_gba_commands]1` to `2` token changes. Non-GBA golden values stay unchanged. + +## Reproduce + +Use maintained LuaJIT `a4c064539ea931274d407e1249b4bb575424a0f4`: + +```sh +luajit tests/engine/gen3_lock_on.lua +luajit tests/engine/gen3_lock_on_link.lua +luajit tests/engine/gen3_battle_protocol.lua +bash scripts/test.sh --quick +python3 tools/enemy-action-contract-check/check.py \ + --gen1 /path/to/clean/gen1-reference \ + --lua /path/to/pinned/luajit --report /tmp/lock-on-report.json +``` + +The comparison executes actual Lock-On/Mind Reader move resolvers, accuracy +commands, native residual sweeps and switch functions in both engines. All four +actor slots, singles/doubles, reapplication/ownership transfer, owner/partner/ +target replacement and the explicit Baton Pass transfer entry are covered. +Ordinary real hits, Protect and semi-invulnerability, Substitute including zero, +OHKO level/Sturdy/guard paths, accuracy overrides and both public accuracy/damage +entry points remain observable. Rows retain all bounded RNG calls/values, +public hook/event calls, PP, HP and raw timer/side/slot ownership. Only engine +schema representations are mapped; no observations are omitted. + +A clean #216 negative control completes all 213 new rows but differs in 207, +failing 103 assertions. Its complete failing output is retained privately. +Two full seeded link mirrors aim on the first turn, attack thereafter, and +verify both marks at one residual phase then expiry, with every completed +paired turn hash/component agreeing. These are CPU/controller fixtures, not +GPU or whole-game playthrough acceptance. + +A private CPU-only smoke loads actual FireRed/Emerald imports, selects canonical +`LOCK_ON` through the shared public enemy-action consumer, then executes two real +Tackle hits and two end-of-turn sweeps. It verifies one accuracy-hook call, +target/slot ownership, unchanged duration across hits and eventual expiry. +Controlled HP avoids a KO. No ROM/cache assets or saves are distributed. + +Full Baton Pass chooser/playback, other side/volatile producers, native egg +legality, whole-game RNG/VBlank scheduling and live UI/full playthrough +acceptance remain separate work. diff --git a/docs/native-gen3-mirror-history-gates.md b/docs/native-gen3-mirror-history-gates.md new file mode 100644 index 000000000..eb226c010 --- /dev/null +++ b/docs/native-gen3-mirror-history-gates.md @@ -0,0 +1,30 @@ +# Native Mirror Move received-history gates + +These tests extend `f9016f1c`; runtime files are unchanged. The +native, API-3 registered and Metronome-to-registered-Mirror matrices now include +a second incoming move that KOs the recipient, plus an opponent self-target. +The KO sequence ends without attempting to act with the fainted recipient. +Every previously captured state, event, held read, RNG draw and animation row +remains in comparison. + +The expanded comparison executes **12 workers / 2,208 paired observations** +across FireRed and Emerald, all equal without timeouts or native VM diagnostics. +The actual-host regression adds **994 checks**. A KO preserves the recipient's +earlier successful received-move history; another actor's self-target cannot +overwrite it. The existing miss, immunity, protection, flags, callback mutation, +zero-PP, Pressure, different target, item and Substitute cases remain covered. + +This qualification applies to the registered FireRed/Emerald profiles. A +separate exploratory Ruby/Sapphire run at `f9016f1c` executes the unchanged +reference but the feature-stack host rejects those unregistered versions. +Their separate support PRs must be integrated and tested before extending the +claim; the fixture does not invent version registration or substitute profiles. + +The byte-identical parent runtime completes 163/172 engine suites, SDK 3/3 +and B30, with exactly the same 25 inherited FAIL lines as fresh #258. The +frozen expanded matrix passes 32 workers / 14,816 paired observations. CI +path coverage includes the new controllers, shared fixtures and importer; +actionlint and all eight importer tests pass. Combined-fork qualification is +recorded in `native-gen3-mirror-move.md`. No Gen1 files, upstream branch or normal game data are modified. +Raw expanded observations remain in `tmp/mirror-history-qa/`; the exploratory +profile reports remain in the workspace's `tmp/mirror-rs-profile-qa/`. diff --git a/docs/native-gen3-mirror-move-contracts.md b/docs/native-gen3-mirror-move-contracts.md new file mode 100644 index 000000000..b0e36af59 --- /dev/null +++ b/docs/native-gen3-mirror-move-contracts.md @@ -0,0 +1,59 @@ +# Native Mirror Move integration contracts + +This contract extends native Substitute fade. Its runtime change +advances the native GBA command revision from 16 to 17: additional queued +animation commands and sound callbacks change runtime behavior even with the +same imported records. Party codec revision remains 1. Older native peers +stop before party exchange; non-GBA fingerprints remain unchanged. + +An independent frozen-parent serializer audit changes only the revision token. +It yields native fingerprint `351a5becf7f236a0`, replacing +`b251a4d1054d6555`; legacy fingerprint remains `e33d251961f2a41d` for Red, +Gold, Diamond and Black. The updated protocol suite passes **154 checks**. + +The new actual host/guest/spectator test passes **108 checks** for FireRed and +Emerald. Each role runs two real menu turns: Mirror first fails without +received history, an incoming Tackle records the recipient's history, then +Mirror calls Tackle. All public events, parent/child PP, completed turn hashes, +state components, history and spectator HP agree. This is not a graphical or +full-playthrough test. + +The separate existing-import smoke test executes **24 scenarios / 48 turns / +120 complete move-used events** through public battle snapshots and submitted +fight/move intents. It reads existing FireRed/Emerald imports, registers an +unchanged API-3 Mirror Move where requested, and tests Tackle, Scratch and Growl +with Choice Band or Shell Bell. It checks real received history, failed first +calls, copied public identities, callback errors and parent PP ownership. No +normal cache or save is written. The same helper on frozen #258 fails at the +received-history assertion, establishing a runtime negative control. + +The first imported fixture used the wrong public effect spelling; that failed +log is retained. The shared API-3 effect is `MIRROR_MOVE_EFFECT`, as specified +by the existing contract and used by the earlier cross-engine tests. + +The parent frozen complete command matrix is now fully equal: **32 workers / +14,528 paired cases**, no timeout or native VM diagnostic. It retains every +captured state, event, held read, RNG draw and animation row. The earlier +Substitute/Focus Band/Poison differences have been implemented and qualified, +not removed from comparison. + +The exact runtime at `f9016f1c` completes 163/172 engine suites, SDK 3/3 +and B30, with exactly the same 25 inherited FAIL lines as fresh #258. The +additional KO/self-target history cases pass 994 host checks and extend the +complete matrix to 14,816 equal paired observations. Combined-fork qualification +and the limits of native profile coverage are recorded in +`native-gen3-mirror-move.md`. The unchanged Gen1 +reference remains read-only at `535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91`. +No Gen1 PR or write is involved. GPU scene parity and whole-synthesizer waveform +parity are not established by these CPU contracts. + +Reproduce the existing-import smoke test with: + +```sh +nice -n 19 /path/to/luajit \ + tools/enemy-action-contract-check/imported-mirror-smoke.lua \ + /path/to/existing/Gen2Recomp-cache +``` + +Raw reports and independent digest inputs are retained in +`tmp/mirror-contracts-qa/`. diff --git a/docs/native-gen3-mirror-move.md b/docs/native-gen3-mirror-move.md new file mode 100644 index 000000000..e4cc86adf --- /dev/null +++ b/docs/native-gen3-mirror-move.md @@ -0,0 +1,127 @@ +# Native GBA Mirror Move and battle presentation + +This change builds on Gen2Recomped #258. One unchanged API-3 move registration +using `MIRROR_MOVE_EFFECT` now executes native GBA received-move semantics. +FireRed and Emerald are the qualified native profiles. The reference remains +read-only at `535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91`. + +## Gameplay contract + +Mirror Move reads the user's received-move history at its native command, +instead of the opponent's last selected action. Move-end records a successful, +mirror-eligible chosen caller after held-item commands. Misses, protection, +immunities, self-targets and KOs do not overwrite earlier valid history. Native +and registered callers share targeting, child events and parent PP ownership. +Mirror Move has no caller animation of its own. Public registrations use the +existing shared fields; neither raw `flags` nor writable `native` envelopes +are added to the API. + +The history participates in native linked-state signatures. Command revision +17 also covers the added presentation commands and sound callbacks, rejecting +older native peers before battle setup. Party codec revision remains 1. An +independent frozen-parent serializer audit changes only revision 16 -> 17: +GBA digest `351a5becf7f236a0`; legacy digest `e33d251961f2a41d` is unchanged. + +## Native presentation prerequisites + +The expanded complete command comparison exposed existing missing Substitute, +Focus Band and Poison animation paths. These are implemented in the actual +battle queue rather than omitted from observation: + +- Each called move owns its Substitute lowering/restoration group. Multi-hit + calls restore only after the native last-hit command. Sparse queue rows and + holes are preserved. Logical protection is independent of the displayed doll. +- The shared swap controller slides five pixels per update, exchanges offscreen + on its own update and returns. Broken Substitute runs the native alpha fade, + white palette ramp, copied-background lifetime, delays and exchange. +- The importer locates and validates both native doll sheets and palette before + publishing versioned art metadata. Native OAM positions, all double-battle + slots and live controller offsets reach the existing renderer. Existing + caches need a reimport for this art; missing metadata retains the prior path. +- Focus Band runs both red fades, native motion, signed fixed-point return and + edition-specific sound. Poison runs its shake, palette cycles and six sounds. + Animation options, hidden/protected actors and temporarily lowered dolls + follow their separate native rules. +- Numbered native sounds resolve without a named role. Explicit panorama/mono + settings preserve the original cached mix and isolate source variants. + Existing named roles, file/string overrides, silence and cache invalidation + retain their contracts. + +## Validation + +The focused frozen comparison at `74602df7` executes **32 workers / 14,816 +paired observations**, all equal, with every worker exiting zero, no timeout +and no native VM diagnostic. It retains all captured events, held reads, RNG, +PP, damage, actor/display state and animation commands. The combined fork at +`01315149` repeats that comparison successfully and passes the same actual +public-submission smoke test. + +The matrix includes 11,520 multi-hit observations, 2,208 native/registered/ +Metronome-to-registered Mirror observations, 360 swap-controller inputs, +384 Poison/Focus Band inputs, 288 fade inputs and 56 common-PCM inputs. +Controller observation includes every tick: 5,240 swap ticks, 11,112 +Poison/Focus Band ticks and 23,488 fade ticks. Host checks include 994 received- +history, 13,778 effect-controller, 104,512 fade/producer, 70 art/draw and 165,455 +actual M4A sound checks. Eight structural importer tests and actionlint pass. + +Actual host/guest/spectator menus pass 108 checks over two turns per profile, +including events, PP, history and paired completed hashes/state components. +Existing local FireRed/Emerald imports pass 24 scenarios / 48 actual public +fight/move-submitted turns / 120 move-used events, with native or registered +Mirror, Tackle/Scratch/Growl, Choice Band and Shell Bell. + +The original 80-sequence negative control differs on #258 in 68 observations. +The same existing-import helper fails on #258 at the received-history +assertion. Earlier invalid registrations, incomplete coordinates, controller +errors and all differing complete animation reports remain retained privately. +The checker rejects caught native VM task errors and wait caps even when its +process exits zero, and fingerprints the shared sound fixture. + +The runtime at `f9016f1c` completes 163/172 engine suites, SDK 3/3 and B30. +Its exact anchored FAIL-line Counter equals fresh #258 (157/166): the same +25 lines in nine inherited suites, with no additions or removals. The later +history/CI commits change tests and workflow only; their added host suite +passes 994 checks. The combined fork completes **282/282** engine suites, SDK **3/3** and B30; +fresh stable baseline completes **275/275**, SDK **3/3** and B30. Their exact +anchored FAIL-line Counters are both empty. Each runtime also qualifies all +**220 workers / 175,540 paired observations** across every existing enemy-action +mode. Two original combined Assist workers terminated at 60 seconds. Those +failed reports remain retained; eight fresh workers with a bounded 180-second +budget complete in at most 36.2911 seconds and retain every earlier completed +observation unchanged. The source-bound evidence is in +`qa/native-gen3-mirror-move.json`. + +## Scope and reproduction + +CPU hardware doubles run unchanged native functions and the actual host queue, +M4A sequencer and drawing methods. Four actual decoded doll sheets match the +read-only reference imports in every RGBA channel: 16,384 pixels, zero changes. +No ROM, cartridge image or private generated pack is committed. Tests write no +normal cache/save and launch no game, audio device or GPU preview. + +This evidence does not establish final scene pixels, whole-synthesizer waveform +identity, all public audio-event timing, arbitrary full-mod compatibility or a +complete playthrough. Native player replacement across cached pan/mono/sound +variants is reproduced separately in 16/20 lifecycle cases and remains a +follow-up; these controls do not claim that player lifecycle is fixed. +Ruby/Sapphire are unregistered in this feature stack; +their separate support prerequisites require integration before qualification. + +```sh +nice -n 19 python3 tools/enemy-action-contract-check/check.py \ + --gen1 /path/to/clean/reference --lua /path/to/pinned/luajit \ + --mode substitute_fade --mode battle_effect_presentation \ + --mode native_sound_pan --mode substitute_multi_hit \ + --mode substitute_presentation --mode mirror_move \ + --mode mirror_move_content --mode mirror_move_chained \ + --report /path/to/new-report.json +nice -n 19 /path/to/pinned/luajit \ + tools/enemy-action-contract-check/imported-mirror-smoke.lua \ + /path/to/existing/Gen2Recomp-cache +``` + +Component contracts and their earlier failure evidence are documented in +`native-gen3-substitute-presentation.md`, `native-gen3-substitute-art.md`, +`native-gen3-battle-effect-presentation.md`, `native-gen3-focus-band-presentation.md`, +`native-gen3-substitute-fade.md`, `native-gen3-mirror-move-contracts.md` and +`native-gen3-mirror-history-gates.md`. diff --git a/docs/native-gen3-move-used-pp.md b/docs/native-gen3-move-used-pp.md new file mode 100644 index 000000000..502f77500 --- /dev/null +++ b/docs/native-gen3-move-used-pp.md @@ -0,0 +1,97 @@ +# Native GBA move-used and PP command ordering + +Stacked on #241 and the existing #161 charge-decision producer. This aligns +the tested native GBA command boundaries without adding a new mod API. + +Ordinary GBA attack strings emit `battle.move_used` before the selected PP +command. The command reads the current selected slot and Pressure abilities +after callbacks, rather than spending a retired learned record. Removing a +slot does not cancel an already admitted attack or spend a different slot. + +Zero-PP admission is decided before the event. Such a failure still emits its +attack-string event, then refuses the move without animation, damage or a +move-end command. A callback refill cannot retroactively admit it. A failed +spread action must not continue attacking its remaining targets. + +Charge commands are different: the charge-decision hook precedes PP reduction. +A retained first charge consumes PP without an attack-string event. Hook +bypass and native sunlight consume PP before the subsequent event. Release +does not consume a second PP. Non-GBA ordering is preserved. + +The new shared fixture executes unchanged native scripts for FireRed and +Emerald. Its 408 rows retain event-visible PP, the public API-3 move records, +the next subscriber's view after native callback changes, charge-hook PP, all RNG, +current/retired PP and all active HP. They cover ordinary, Pressure, one/zero +PP, callback refill/edit/replacement/removal, changed Pressure, forced slots, +actual Sleep Talk, awake/asleep failures, authored `ctx.callMove`, Struggle, +charge/release/bypass/sunlight and spread targets. Callback mutations are +controlled native fixture inputs; this does not introduce a new public PP +writer or promise writeback of event snapshots. + +All 408 rows currently agree. The exact composed prerequisite fails 360 rows +with that same fixture. Raw initial failures and corrected results remain in +`tmp/move-used-pp-qa/`. The actual host/guest/spectator test passes 84 checks +over two menu-driven turns per profile, with a stateful public held-damage +consumer depending on event-visible PP. The first linked probe attempted to +write an event snapshot and lacked a two-turn input barrier; its failed raw +result is retained separately from the corrected read-only public consumer. + +The fixture also repeats actual planning before charge release, including +release after the last PP was spent. A wrapped action must release the stored +move by identity, rather than charge again or refuse for zero PP. The exact +previous module fails 24 rows for this case; the fixed path clears charge and +invulnerability, deals native damage and spends no second PP. + +Native command revision is 12; party codec remains 1. An independent audit +applies only token 11 -> 12 to the unchanged prerequisite hasher and reproduces +`6ce58ced7ffad4d9`; older-generation digest `e33d251961f2a41d` stays unchanged. + +## Local acceptance + +Production and final fixtures were tested on clean feature +`05927b85d01d9b230abadb9d309541da2d6d0df3` and combined candidate +`b70f28ef071b019c88829b30488dcd7f6eba086e`. + +| Check | Feature | Combined | +| --- | --- | --- | +| Engine suites | 146/155 | 264/264 | +| SDK suites | 3/3 | 3/3 | +| Quick tiers / B30 | inherited engine-tier failure / B30 passes | all pass | +| Enemy/action contracts | 152 workers / 80,540 equal paired rows | same, all equal | +| Held-damage command | 2,040 equal paired rows | same, all equal | +| Ordinary GBA damage | 6,912 equal paired rows | same, all equal | +| Strict held-item scheduling, four API formats | 40 workers / 1,914 equal paired rows | same, all equal | +| Charge decision, four API formats | 64 workers / 1,020 equal paired rows | same, strict native-policy parity | +| Actual FireRed/Emerald imports | both existing modes pass | both existing modes pass | + +The feature retains the exact Counter of 25 inherited anchored FAIL lines +across nine suites from the fresh unchanged composed prerequisite `167f6c91` +(144/153 engine suites, SDK 3/3 and B30). The combined candidate has no FAIL +lines. Feature charge-decision checks retain the prerequisite's documented +legacy enemy-PP policy; the combined branch already includes #162's native +AUTO defaults and passes the stricter raw-policy comparison. + +The default contract matrix includes the new fixture; no selected modes or +observation fields are omitted from final acceptance. Source checkouts remain +clean and unchanged at `535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91` (native +execution) and `79ea62f45fb2ea8f65d081a109431e4eb1bbdb36` (older shared +contracts). The maintained LuaJIT runtime is pinned at +`a4c064539ea931274d407e1249b4bb575424a0f4`. CPU checks use `nice -n 19`. +AST-verified formatting, whitespace checks and actionlint pass. + +The imported checks use actual local FireRed/Emerald data with explicit GBA +rules, constructed parties and public menus. They distribute no ROM/cache or +normal SAVE and do not establish graphical playthrough acceptance. + +## Remaining scope + +A repeated complete held-phase probe still has 10 native versus 11 host reads +for successful Sleep Talk; awake/empty/blocked calls retain matching 2/2/4 +counts. King's Rock/Shell Bell orchestration remains a separate native gap. +Other special command boundaries and callers such as Nature Power, complete +catalogs/controllers and graphical playthroughs are not certified here. + +Raw initial failures, negative controls, earlier completed runs and final +acceptance reports remain under `tmp/move-used-pp-qa/`. Earlier runs did not +cover the copied planned-release path; the final full suites and complete +matrices were repeated after that correction. diff --git a/docs/native-gen3-multi-hit.md b/docs/native-gen3-multi-hit.md new file mode 100644 index 000000000..cf0000f33 --- /dev/null +++ b/docs/native-gen3-multi-hit.md @@ -0,0 +1,115 @@ +# Ordinary native GBA multi-hit execution + +Native multi-hit moves need independent damage commands for each reached strike. +The host previously drew an eight-value hit count before accuracy, computed the +ordinary damage only once, and stopped when a Substitute broke. This changed RNG +consumption, individual damage events and live continuation seen by mods. + +The GBA paths for raw effects **29** (two to five), **44** (two hits) and **77** +(Twineedle) now calculate ordinary damage for each reached strike. Explicit +`chooseDamage` and `perHitDamage` handlers remain authoritative. Scripted Beat Up +retains its independent party eligibility and Endure rules. + +- Accuracy precedes hit count. The native count opcode draws `0..3`; values 0/1 + give 2/3 hits, while 2/3 require a second `0..3 + 2` draw. +- Every reached ordinary strike gets its own critical/damage calculation and + event. GBA crits draw `0..denominator-1` and compare with zero. The previous + `1..denominator` draw had the same probability but a different observable RNG + ABI. The raw comparison now covers every critical stage without normalizing + away bounds or returned values. +- Later strikes continue against real HP after Substitute breaks. Actual Endure + stops an ordinary multi-hit sequence; Focus Band can save later strikes too. + PP is spent once, with called moves preserving their native PP bypass. +- Later strikes inspect live user/target state. Sleep, user/target fainting and + a new type immunity can stop continuation. A later immunity suppresses the + native Twineedle chance command, retaining the first landed damage event. +- The existing nested dispatcher carries the calling move, so a Sleep Talk + caller can exempt sleeping continuation. This is tested after the caller's + move selection; complete Sleep Talk selection and status-controller parity + are not certified here. +- Native hit-count adapters are installed per dataset before mods capture + `mod.content.move_effects:get(...).hitCount`. Overrides and delegation work + without changing shared legacy statics. A no-loader path uses the same native + adapters. Other generations retain their existing count/damage policy. + +Public events are detached views. Continuation probes mutate the owned fixture's +real battlers when a public hit event arrives; they assert the mutation and its +consequences. They do not claim that writing into a detached event view changes +engine state. + +## Compatibility and scope + +GBA command revision is **6**, native party codec remains **1**. The raw fixture +hash changes from `da62fe7b50f40107` to `a7db0874db78aec0`, independently computed +with the unchanged #220 hasher and only the revision token changed from 5 to 6. +Older-generation goldens were not reblessed. Link peers reject revision 5 before +party selection. + +The ordinary native rules are deliberately separate from scripted Beat Up. +The first integration incorrectly applied ordinary Endure/user-faint termination +to Beat Up; the original full logs and eight differing comparison runs are +retained. The accepted implementation preserves all existing Beat Up contracts. + +Triple Kick's dedicated per-hit accuracy/power script, Ruby/Sapphire move-end +scheduling, complete ability interactions, broader canceller/controller rules, +Rage production, whole-game RNG/VBlank timing, animations and full playthroughs +remain separate work. This PR is a prerequisite for testing native Rage against +multi-hit attacks; it does not claim Rage itself has been ported. + +## Accepted evidence + +Feature runtime: `00e67c40abcb224ff693e4f543a4d018014edf73`. +Combined runtime: `1a20cb1f104972c46c756ba6a55e814fc27619c8`. +Gen1 reference: clean, unchanged and read-only at +`535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91`. + +- **17,974 focused checks** pass: 17,856 native command/live-continuation, 68 + composition/caller/legacy and 50 actual link/compatibility checks. The native + protocol suite also passes. +- Both clean runtimes match the source in **100 workers / 74,496 paired + observations**. The new mode contributes **5,760 observations per profile**, + covering all four battler slots, all count words, all critical stages, + Substitute/Endure/Focus Band/KO, Protect/hidden/accuracy/type immunity, + Twineedle chance, called PP, and live sleep/type/faint changes between hits. + Every RNG bound/value, individual public damage/move event, all battler HP/PP, + status, substitute HP, last move and attack stage is retained. +- Feature regression: **119/128 engine suites**, SDK **3/3**, B30 passed. + The exact Counter of all **25 inherited FAIL lines in nine suites** matches + clean #220 (116/125), without additions. The full command keeps its nonzero exit. +- Combined regression: **240/240 engine suites**, SDK **3/3**, B30 passed, + including the independent baseline repairs and all earlier Beat Up contracts. +- Unchanged #220 fails **1,524 focused assertions** from the exact accepted + fixture. Of its 5,760 FireRed observations, **5,088 differ** from the unchanged + source. The fixture SHA-256 matches the source report; raw negative output and + earlier exploratory/failed logs remain retained. +- Two real link roles complete three turns using the variable-count, double-hit + and Twineedle paths. All paired completed hashes/components and learned PP agree. +- **18 CPU-only scenarios** with actual local FireRed/Emerald imports pass public + canonical enemy-action selection and execution for DoubleSlap, Double Kick and + Twineedle, each ordinary/with a breaking Substitute/with Endure. Imported + accuracy, type, power and PP are retained; a controlled valid accuracy roll is + used. No caches, ROMs or saves are distributed and no normal SAVE is issued. +- Scoped StyLua, actionlint and whitespace checks pass. + +Use the maintained LuaJIT pin in the workflow +(`a4c064539ea931274d407e1249b4bb575424a0f4`): + +```sh +nice -n 19 "$LUA" tests/engine/gen3_multi_hit.lua +nice -n 19 "$LUA" tests/engine/gen3_multi_hit_composition.lua +nice -n 19 "$LUA" tests/engine/gen3_multi_hit_link.lua +nice -n 19 "$LUA" tests/engine/gen3_battle_protocol.lua +nice -n 19 python3 tools/enemy-action-contract-check/check.py \ + --gen1 /path/to/clean/gen1-reference --gen2 . --lua "$LUA" \ + --report /tmp/multi-hit-contracts.json +nice -n 19 env LUA="$LUA" \ + LUA_INIT='io.stdout:setvbuf("no"); io.stderr:setvbuf("no")' \ + bash scripts/test.sh --quick +``` + +Feature evidence is in `tmp/multi-hit-qa/`: the accepted full/comparison/import +logs, `accepted-evidence.json`, exact FAIL-Counter audit and unchanged-parent +negative control. Combined evidence is in `tmp/multi-hit-full-accepted.log`, +`tmp/multi-hit-contracts-accepted.json`, `tmp/multi-hit-imported.log` and +`tmp/multi-hit-accepted-evidence.json`. Later commits only record validation and +publication. Hosted CI is tracked separately in the published PR. diff --git a/docs/native-gen3-nature-power.md b/docs/native-gen3-nature-power.md new file mode 100644 index 000000000..b53a271d0 --- /dev/null +++ b/docs/native-gen3-nature-power.md @@ -0,0 +1,123 @@ +# Native GBA Nature Power caller + +Tested contribution stacked on #248. Gen1 is an unchanged, read-only +reference at `535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91`. + +## Implemented contract + +Nature Power announces before its terrain-selected child. The child owns the +selected Nature Power PP slot, reduces it after its public event subscribers, +and uses its own target/Pressure rules. Ordinary called moves continue to own +no PP slot. Zero PP is admitted/refused before the parent script; changing PP +inside a later callback cannot rewrite that admission decision. + +The native terrain table covers grass, long grass, sand, underwater, water, +pond, mountain, cave, building and plain. Explicit numeric/string terrain +inputs retain the reference's coercion and fallback. Cached host terrain names +use the existing GBA terrain identifiers. The new builtin is installed only for +GBA, including the ordinary Loader path and no-Loader native fallback. + +Shared called-target dispatch preserves existing Sleep Talk targets and caller +provenance. Nature Power lends PP ownership only to the first child body, so +spread continuations do not spend it repeatedly. Choice Band and move-end +commands identify the native parent. Generic authored calls keep their existing +ownership path. Nested native callers preserve the innermost caller's resulting +history: Sleep Talk -> Nature Power -> terrain move records Nature Power. Sleep +Talk scans all four native slots even when a callback removes an earlier slot. This is a public-event/native-command change, not a claim of +complete graphical or full-mod acceptance. + +Native command revision is 14; party codec remains 1. The independent unchanged +#248 hasher, with only revision 13 changed to 14, reproduces +`cb314093f24370e7`; legacy digest `e33d251961f2a41d` is retained. + +## Checkpoint evidence + +- **6,048 equal paired rows** across FireRed and Emerald, all single/double + actors, fourteen terrain inputs, four held-item cases, ordinary/Pressure/zero + PP, callback zero/refill, replaced/removed/reordered slots, and consecutive + public event subscribers. Captured payloads, native/public PP, every held + read, RNG, damage, actor state and retired PP are compared without filtering. +- Exact #248 production differs in **5,376** of the same rows. Both negative + workers exit zero: these are behavioral differences, not fixture crashes. +- A second **6,048-row** matrix runs the same authored API-3 mod on both engines. + It registers `PORTABLE_NATURE` with canonical effect `NATURE_POWER`, verifies + its public record round trip and retains the same full execution evidence. + Its direct regression passes **17,474** checks. +- Another **6,048** paired rows exercise native Sleep Talk -> Nature Power + chains, including slot removal, replacement, reordering and Pressure. The + previous feature production differs in **4,032** chained rows. A complete + actual-script probe originally showed last move 267 in the reference versus + 214 in the host; that failure is retained. +- Related Sleep Talk, Choice Band, Baton Pass, authored calls and PP contracts + pass **36 workers / 21,474 paired rows**, including all three Nature Power + modes. The direct chained regression passes **15,458** checks. +- The direct engine suite passes **28,926** checks, including generation + boundaries and event-visible child PP. Actual host/guest/spectator completes + two native menu turns per profile with stateful held steering, **108** checks, + matching event traces, PP and every paired state hash/component. +- The initial fixture lacked a descriptive Choice Band record on both sides; + the reference's intrinsic fallback then exposed different public IDs. Both + sides now receive identical owned item definitions. All failed/superseded raw + observations remain in local QA; no production fallback or relaxed assertion + hides them. + +The fixture uses owned, controlled native-shaped child definitions to isolate +caller behavior; it does not certify the complete cartridge child catalog. +Actual local FireRed/Emerald imports additionally pass **160 controlled public-menu +scenarios**: native and API-3 registered callers, all ten terrains, Choice Band +and Shell Bell, direct and Sleep Talk callers, event-visible PP, selected-slot +ownership, resulting history, advertised child IDs, +actual Stun Spore status and damaging children. Every move is submitted through +`mod.battle:submit()`, and the native text/result queue returns to its menu. This +is CPU-only constructed-battle acceptance with the intro skipped, not a graphics +or complete-playthrough result. + +The first full feature/combined batches were explicitly interrupted after the +nested-history failure was proven. Partial raw logs and interruption records +are retained separately from final acceptance. + +## Final acceptance + +Feature `0536521ea23e41af8c206961a266db96f5ff6eb1` passes **153/162** engine +suites, SDK **3/3** and B30. The exact Counter of **25 inherited FAIL lines in +nine suites** equals fresh unchanged #248 (`1ea0f875`, **149/158**). +Combined `0ed0602be9019102a06f4df3ee22480bca1d800f` passes **271/271**, SDK +**3/3**, all quick tiers and B30, with zero FAIL lines. + +Each runtime passes **172 workers / 122,012 identical paired enemy/action +rows**, plus **2,040 held-command** and **6,912 ordinary-damage rows**: +**180 workers / 130,964** selected paired observations. Every accepted worker +exits zero; there is no accepted timeout or filtered observation. The native, +public-content and chained Nature Power matrices each contribute 6,048 rows. + +All four held-item formats pass strict scheduling (**40 workers / 1,914 rows**). +Charge contracts pass four formats (**64 workers / 1,020 rows**), preserving +the feature prerequisite's explicit legacy enemy-PP policy; the combined fork +requires raw native policy through existing #162. Existing imported Sleep Talk +and post-hit scenarios pass alongside the 160 new Nature Power scenarios. +Independent protocol audit, AST-verified formatting, actionlint and whitespace +checks pass. Later publication edits preserve these runtime, test and tool trees. + +Source-bound summaries and raw-report digests are in +[the QA receipt](qa/native-gen3-nature-power.json). + +## Remaining scope + +Additional special callers/catalogs/controllers, broad public-effect overrides, +Ruby/Sapphire-specific qualification, native text-resource/pixel parity and +unchanged full-mod/complete graphical playthrough acceptance remain open. +No claim of complete battle or mod portability is made by this contribution. +No ROMs, caches, normal saves or mod assets +are included. + +## Reproduction + +Use maintained LuaJIT at `a4c064539ea931274d407e1249b4bb575424a0f4`. + +```sh +python3 tools/enemy-action-contract-check/check.py --gen1 /path/to/read-only-gen1 --lua /path/to/luajit --mode nature_power --report /tmp/nature-power-report.json +/path/to/luajit tests/engine/gen3_nature_power.lua +/path/to/luajit tests/engine/gen3_nature_power_link.lua +/path/to/luajit tools/enemy-action-contract-check/imported-nature-power-smoke.lua /path/to/local-cache +/path/to/luajit tools/enemy-action-contract-check/imported-nature-power-smoke.lua /path/to/local-cache --sleep-talk +``` diff --git a/docs/native-gen3-party-codec.md b/docs/native-gen3-party-codec.md new file mode 100644 index 000000000..262c852c7 --- /dev/null +++ b/docs/native-gen3-party-codec.md @@ -0,0 +1,68 @@ +# Native GBA party codec + +The generic link codec serialized only DVs/stat experience. Native GBA mons +lost their personality, IVs, EVs, nature, ability slot and secret trainer ID. +A clean #215 negative control loses all six fields and changes the example's +HP maximum from 92 to 67. The failing report is retained in private QA output. + +`Protocol.packMon`/`unpackMon` now dispatch GBA games to a separate versioned +codec. It carries six-stat IV/EV data and PID, rebuilds native stats/nature and +gender, preserves the selected valid ability slot independently of PID parity, +and retains original-trainer identity (including both host secret-ID aliases), +PP-Ups, current PP, held item, ball, friendship, contest/origin data, ribbons, +markings, language, Pokerus and extension namespaces. Names use native character +limits without splitting UTF-8. Incoming stats are recomputed, never trusted. +Auto-level rebuilds experience/stats and heals status/HP while keeping genotype. +Native toxic identity and sleep counts survive ordinary party transport. + +The live path retains both `gender.roll` entry points, at pack and unpack, +including delegated, forced, invalid-result fallback and metadata-steering +policies. Standalone tools without a mounted species registry omit the sender's +derived gender; the decoder still derives it from authoritative data/PID. +Gen1 is an unchanged read-only reference. Comparison maps species/move IDs, +stat-key schemas and gender codes; it retains all compared observations and +callback calls. This does not assert cross-engine network packet compatibility. + +Both hellos advertise native party revision 1. Different/missing native codecs +are refused before party exchange in either trade or battle roles. GBA full +fingerprints include `[native_gba_party]1`; cache keys include its revision. +The raw fixture digest changes from #215's `3a76cc5fce47a5c3` to +`2fd444270c3d90db`. This was independently audited by feeding the additional +surface token to the unchanged #215 hasher. Other games' digests remain unchanged; +no golden is reblessed. Active/bench state components include native genotype. + +## Reproduce + +Use maintained LuaJIT `a4c064539ea931274d407e1249b4bb575424a0f4`: + +```sh +luajit tests/engine/gen3_party_codec.lua +luajit tests/engine/gen3_party_transport.lua +luajit tests/engine/gen3_battle_protocol.lua +bash scripts/test.sh --quick +python3 tools/enemy-action-contract-check/check.py \ + --gen1 /path/to/clean/gen1-reference \ + --lua /path/to/pinned/luajit --report /tmp/party-codec-report.json +``` + +The shared fixture calls actual `packMon3`/`unpackMon3` on unchanged Gen1 +`535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91`, and the actual Gen2 codec. It covers +all 25 natures; levels 1, 35 and 100, ordinary/forced levels; two native species +including Shedinja; alternate and single abilities; zero/max/mixed investments; +canonical/raw/translated registries; original-trainer and other native metadata. +It retains full normalized stats/identity/metadata and both public gender calls. + +Host transport checks exercise strict malformed-packet refusal, JSON/save +round trips, actual two-role trade negotiation/application, and complete seeded +link mirrors with nonzero IV/EVs and distinct PIDs/ability slots. Genotype is +checked against the original sender, not only against the other equally rebuilt +peer. Every completed paired turn hash and state component is compared. + +A CPU-only private smoke also round-trips actual locally imported FireRed and +Emerald Poliwag data, comparing all IVs/EVs/stats, selected ability, secret ID, +current PP and public API-3 `TOX`/personality reads. No ROM/assets/saves are +published. This is imported-record acceptance, not a live UI/full playthrough. + +Full native egg-legality checks, other-generation codecs, timed side/volatile +producers, whole-game RNG/VBlank scheduling and demo controller playback remain +separate work. This codec does not certify those lifecycles. diff --git a/docs/native-gen3-post-hit-items.md b/docs/native-gen3-post-hit-items.md new file mode 100644 index 000000000..162d06e63 --- /dev/null +++ b/docs/native-gen3-post-hit-items.md @@ -0,0 +1,112 @@ +# Native GBA post-hit item command + +This change is stacked on #244. It aligns the shared King's Rock / Shell Bell +move-end command with the pinned, unchanged Gen1 reference +`535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91`. Gen1 is read-only. + +## Contract + +The native damaging command retains its first damage, including its native +substitute convention, rather than summing every hit. The shared held read runs +after Choice Band and the ordinary move-end cure sweep. An empty hand still +produces the same read. Public hook substitutions remain authoritative. + +King's Rock performs the reference's extra canonical player-slot read, including +when the attacker belongs to the other side. A changed first answer selects its +own parameter; otherwise the second read selects the chance. RNG runs before the +move flag and living-target checks, and secondary refusal checks preserve native +Shield Dust, substitute, Inner Focus and already-moved behavior. The player-slot +parameter rule intentionally matches the pinned reference; this is not a claim +that it reproduces every cartridge edition's intended behavior. + +Shell Bell uses the first damage and current divisor, with minimum one HP and +maximum-HP clamping. Its native held-item animation identifies the holder in both +positions. Physical item identities 186, 187 and 219 retain intrinsic GBA effects +even if mutable descriptive metadata clears them; public hooks can replace them. + +Spread scripts retain their chosen target order and count occupied defending +slots until queued removal, including a just-fainted defender. An attacker that +faints before a later ordinary spread body does not run its damage commands. +Non-GBA damage and item handling retain their existing paths. + +Already-moved state belongs to the current logical turn. Spectator action pairs +are buffered FIFO until the current animation/command queue finishes, preventing +an early next-turn packet from changing the turn under a running command. +Native battle protocol revision is 13; party codec remains 1. The independent +unchanged-hasher token audit changes only revision 12 to 13, yielding +`beacb71804a6af34`; legacy digest `e33d251961f2a41d` is retained. + +## Validation + +Full gate source/test trees: feature `d88be1fc` passes **149/158** engine suites, +SDK **3/3** and B30. Its exact Counter of **25 inherited FAIL lines in nine +suites** equals fresh unchanged #244 (`f7c18701`, **146/155**). Combined +`d9cf080d` passes **267/267**, SDK **3/3**, every quick tier and B30, with zero +FAIL lines. Subsequent checker, reproduction-tool and documentation changes +preserve those production and test trees. + +The final checker (`80c8bd8e` / combined `f8294625`) passes **160 workers / +103,868 identical paired enemy/action rows** per runtime, with every worker +exiting zero and no timeout. Additional held-damage and ordinary-damage +comparisons pass **2,040 + 6,912** rows: **168 workers / 112,820** selected +paired observations per runtime. + +- Shared post-hit command: **7,488 paired rows**, retaining all held reads, + public parameters, RNG, activation, first-damage consumption, HP, flinch and + animation metadata. +- Actual scripts: **15,840 paired rows** for Tackle, Double Slap, Triple Kick, + Surf, Earthquake and Sleep Talk; single/double actor slots, substitutes, + misses/immunity, target/attacker fainting, live item changes and overrides. + The exact #244 production differs in **12,032** of these rows. Nothing is + filtered from failed or successful comparisons. +- Actual host/guest/spectator: **172 checks** across both profiles, physical + items, stateful canonical reads, already-moved targets, skipped PP, all held, + event and RNG traces, and completed state hashes. The old spectator module + fails **14** checks in the same test. +- Strict held scheduling: four API formats, **40 workers / 1,914 paired rows**. + Charge-decision contracts: four formats, **64 workers / 1,020 rows**, retaining + the feature prerequisite's explicit legacy enemy-PP policy; the combined + fork requires raw native-policy parity through existing #162. +- Fresh full Sleep Talk held-phase probes match every captured payload: + **10/2/2/4 reads** for success/awake/empty/blocked, on both profiles and both + runtimes. The old successful host trace had eleven. +- Existing imported-data modes pass. The additional reproduction tool exercises + **32 CPU-only scenarios** using actual imported item/move definitions and + controlled battlers: physical items, both sides, single/multi-hit and public + overrides. These are not graphical or complete-playthrough acceptance. +- Independent unchanged-hasher token audit, AST-verified formatting, + whitespace checks and actionlint pass. + +Failed and superseded observations remain separate in local QA. The first link +fixture omitted its move flag; supplying native-shaped flags exposed the real +spectator turn-overlap bug. The older held fixture likewise supplied flag 51 +only on its source side. Matching the host input preserves all expectations and +passes **5,511** checks; the initial eight failures remain recorded. The large +script worker also hit the old sixty-second wall-clock limit under concurrent +QA. Its bound is now 180 seconds. A forced-timeout probe proves failure is +retained with partial text and exact captured bytes, while subsequent comparisons +complete. Final accepted matrices contain no timeout or nonzero worker. + +### Reproduction + +Use the pinned maintained LuaJIT `a4c064539ea931274d407e1249b4bb575424a0f4`. +The shared checker requires a clean read-only Gen1 checkout at the revision above. + +```sh +LUA=/path/to/luajit bash scripts/test.sh --quick +python3 tools/enemy-action-contract-check/check.py --gen1 /path/to/read-only-gen1 --lua /path/to/luajit --report /tmp/enemy-action-report.json +/path/to/luajit tests/engine/gen3_post_hit_link.lua +/path/to/luajit tools/enemy-action-contract-check/imported-post-hit-smoke.lua /path/to/existing/local-cache +``` + +The last command needs existing FireRed/Emerald imports; it never creates or +publishes cartridge data. Source-bound acceptance metadata and raw-report digests +are in [the QA receipt](qa/native-gen3-post-hit-items.json). + +## Scope + +The executable reference comparison covers registered FireRed and Emerald paths. +Ruby/Sapphire's separate per-hit item scheduling is not certified. Other special +script boundaries, full catalogs and controllers, graphical playthroughs and +unchanged full-mod acceptance remain open. No ROM data, imported caches, normal +saves or third-party mod assets are committed. diff --git a/docs/native-gen3-rage.md b/docs/native-gen3-rage.md new file mode 100644 index 000000000..e0df6ebbf --- /dev/null +++ b/docs/native-gen3-rage.md @@ -0,0 +1,124 @@ +# Native GBA Rage state and move-end boosts + +The shared legacy Rage handler installs `rageMove`: it locks later choices and +can exempt a continued move from PP. GBA Rage instead owns a boolean `rage` +state. A separately selected use spends its learned PP, and the player can +choose another move, item, switch or escape. + +The native builtin adapter activates Rage after successful accuracy and before +damage/type handling. Accuracy misses, Protect and absorption clear it; type +immunity after accuracy retains the native flag. A cancelled same-Rage action +preserves the previous flag, and called moves retain their native PP bypass. +Known authored Rage effects do not require cartridge indices to activate. + +Rage raises the defender's Attack once at the completed incoming move boundary, +after public hit/secondary events and before move-end item reads. The native +guards require a living opposing defender, a landed positive-power attack and +Attack below +6. Substitute and zero HP loss under Endure still count. Ally, +self, missed/ineffective and residual damage do not build Rage. An ordinary +multi-hit attack builds it once, including after a Substitute breaks; a later +immunity prevents the boost. + +The actual planners clear Rage for a different effective choice before actions +and turn-start observers, including Encore-forced choices. Native double choices +are resolved once and reused. Spent non-move actions prepare one enemy reply +before Rage cleanup, including successful escape cleanup and capture replies. +Switches and Baton Pass do not transfer the flag. Older generations and explicit +legacy `rageMove` controllers retain their existing behavior. + +Native AI STATUS2 queries read bit `0x800000`. Changed Rage state invalidates an +old offered mod command. Host builtin adaptation occurs per dataset before mods +capture/delegate `afterDamage`; complete authored overrides keep their own policy. +The native accuracy-stage callback is private (`gbaAfterAccuracy`), not a new +portable public effect stage. + +## Dependencies and compatibility + +This feature stacks on #222 and includes the already published #188 native +AI/order prerequisite. The separate clean prerequisite checkout is +`fa5cb30ec4050980dc940e2181d6943a9de669b4`: #222 plus #188, preserving held-item +reads, original declared actions, Encore and cached native enemy selections. +The combined fork already contained #188; it is not applied twice there. + +GBA command revision is **7**, native party codec remains **1**. Active hashes +include Rage and treat false/absent as the same inactive state. Revision 6 is +refused before party exchange. The raw fixture digest changes from +`a7db0874db78aec0` to `dcc938191b0bb075`, independently calculated with the +unchanged #222 hasher and only the command token changed from 6 to 7. +Older-generation goldens were not reblessed. + +## Accepted validation + +Feature runtime: `03c4a841ee327d232ca504a1ae5df1c5159064b6`. +Combined runtime: `e1a9295e57c1545ec69deec2c4541a4da193b7b6`. +Gen1 remains clean, unchanged and read-only at +`535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91`. No Gen1 development or publication +is performed. + +- **796 focused checks** pass: 584 native command/planning/lifecycle, 122 host + public-command/composition/reply/legacy and 90 actual link/compatibility checks. + Native protocol and the reused native AI/order regression suite also pass. +- Both clean runtimes match Gen1 in **104 workers / 75,210 paired observations**. + The new Rage mode adds **357 complete rows per FireRed/Emerald profile**, + retaining every bounded RNG call/value, accuracy-hook state, move/damage event, + all battler HP/PP/status/substitute HP/Attack/last move and native/legacy flags. + Cases cover all four actor slots, activation/failure/cancellation/called/custom + moves, repeated PP, opponent/ally/self attacks, ordinary multi-hits, stage caps, + Substitute/Endure, later immunity, residuals, actual planners and Encore choices. +- Move planning compares actual turn-word preparation with fixed choices. The + non-move planning observations begin with the same explicitly prepared word; + they compare the actual committed reply/cleanup boundary. Original unmatched + planner reports remain retained. This does not certify earlier/global enemy + selection, capture/escape RNG orchestration or all controller timing. +- Full feature: **123/132 engine suites**, SDK **3/3**, B30 passed. The exact + Counter of all **25 inherited FAIL lines across nine suites** equals the clean + #222+#188 prerequisite (120/129). The full command retains its nonzero exit. +- Combined: **243/243 engine suites**, SDK **3/3**, B30, the same complete + comparison and all four imported smoke scenarios pass. Changes after the + runtime snapshots only record validation/publication. +- The unchanged prerequisite fails all **four** assertions of the same public + selector/delegated-native continuation: no native flag, a legacy lock, only one + PP spent across two uses, and a locked menu. Raw negative output is retained. +- Two real link roles complete four turns: Rage twice, one side choosing a + multi-hit while the other retains Rage, then both selecting Tackle. All paired + completed hashes/components, PP and Attack stages agree. The whole incoming + multi-hit increases Attack once; the cleared owner gets no stale boost. +- Four CPU-only scenarios using actual local FireRed/Emerald imports pass public + FIGHT/move submissions, repeated Rage PP, alternative Tackle and the actual + committed item-reply pipeline. Imported Rage is move 99 / effect 81; PP goes + 20 to 18. Accuracy/power/type/PP are retained with controlled valid hit rolls. + No ROM/cache/save data is distributed and no normal SAVE is issued. The item + test starts at the spent-item callback, not a full bag/healing playthrough. +- Scoped StyLua, actionlint and whitespace checks pass. + +Host content overrides/delegation are tested separately from native default +behavior in the shared checker. An additional synthetic Gen1 GBA content probe +could not retrieve `RAGE_EFFECT`; its raw failure is retained. This is not proof +of effect-catalog ID/delegation equivalence across engines and needs a separate +catalog/fixture audit. The public event views used by contract tests are detached; +controlled live fixture mutations do not assert event-view write-through. + +```sh +nice -n 19 "$LUA" tests/engine/gen3_rage.lua +nice -n 19 "$LUA" tests/engine/gen3_rage_continuation.lua +nice -n 19 "$LUA" tests/engine/gen3_rage_link.lua +nice -n 19 "$LUA" tests/engine/gen3_battle_protocol.lua +nice -n 19 python3 tools/enemy-action-contract-check/check.py \ + --gen1 /path/to/clean/gen1-reference --gen2 . --lua "$LUA" \ + --report /tmp/rage-contracts.json +nice -n 19 env LUA="$LUA" \ + LUA_INIT='io.stdout:setvbuf("no"); io.stderr:setvbuf("no")' \ + bash scripts/test.sh --quick +``` + +Use the workflow's maintained LuaJIT pin +`a4c064539ea931274d407e1249b4bb575424a0f4`. Private evidence is retained in +`tmp/rage-qa/`, including accepted full/comparison/import logs, exact baseline +FAIL-Counter evidence and negative/exploratory reports. Combined evidence is in +`tmp/rage-full-accepted.log`, `tmp/rage-contracts-accepted.json`, +`tmp/rage-imported-accepted.log` and `tmp/rage-accepted-evidence.json`. + +Broader canceller ordering, all full/custom/delayed scripts, Ruby/Sapphire +per-hit scheduling, complete content-catalog parity, all AI/item/capture/escape +controllers, whole-game RNG/VBlank, rendering and full playthroughs remain work. +Hosted CI is reported separately in the published PR. diff --git a/docs/native-gen3-sleep-talk.md b/docs/native-gen3-sleep-talk.md new file mode 100644 index 000000000..64a69ced9 --- /dev/null +++ b/docs/native-gen3-sleep-talk.md @@ -0,0 +1,134 @@ +# Native GBA Sleep Talk and authored move calls + +The shared core previously cancelled Sleep Talk at the ordinary sleep check. +Its legacy effect also selected forbidden calls, reused the caller's target +and remembered the resulting move instead of the selected caller. + +Install GBA sleep and Sleep Talk adapters per dataset before mods capture +builtin handlers. Sleeping move numbers 214/173 can proceed, native wake lets +an ordinary move act, Early Bird decrements twice, and Uproar respects +Soundproof across all active slots. Keep the existing fork's party-side +remaining sleep counter synchronized. Other generations retain their current +status behavior and shared builtin statics are unchanged. + +Sleep Talk reads the current first four slots, including zero-PP choices. +Exclude the native move-number blacklist, two-turn scripts, Disable and active +Encore restrictions. Fixed-number sleep admission remains native: a custom +move with effect 97 does not automatically become move 214. Named authored +moves need no imported numeric index. Dispatch the resulting move as called, +retarget user/foe/spread calls and use controller slot order for all-other- +battlers attacks. Only the caller spends PP. Native bookkeeping remembers the +caller; failed calls preserve the previous move and skip the move-end script +while retaining the separate after-action item sweep. + +Stacks on #226 and reuses the existing [#121 effect adapter](https://github.com/UNDERdecoded/Gen2Recomped/pull/121). +The adapter was absent from this isolated native-command stack and is already +in the combined branch. Reuse its production implementation unchanged; adapt +its harness to this stack's available modes and reuse the current Follow Me/ +Helping Hand assertions. A separate clean #226+#121 prerequisite is tested. +The abandoned narrow adapter is superseded by #121 in the final tree. + +## Accepted evidence + +Feature runtime `cb6ddfadc2d29b878efcb0eb4f76869dc7b8c16a` and combined +runtime `4941d8d6c753925ab47180aaa80bf37b665ff7b6` are clean, tested commits. +Later commits document acceptance/publication. + +- **1,068 focused checks** pass: 846 command/public-authored-content, 162 + effect/status/composition/failed-call/legacy and 60 real link/compatibility. + The 154-check native protocol suite and the reused adapter's 860 host checks + also pass. +- **116 workers / 77,668 paired observations** agree with unchanged read-only + Gen1 `535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91`. The new modes add **648 + command rows and five public authored-content rows per FireRed/Emerald + profile**. Command cases cover singles/doubles/all actor slots, choices, + wake/Early Bird/Uproar/Soundproof, exclusions, edited forbidden metadata, + current slots, zero PP, Disable/Encore, called execution, multi-hit/Triple + Kick/Snore/Recover/Rage and spread calls. These are controlled native-shaped + inputs rather than cartridge copies. +- The identical API-3 authored entry registers a named move without an index + and its own effect through both public loaders. Ordinary, zero-PP, awake, + primary/stat-change and patched-Fly cases execute the actual registered + handlers. The source fixture mounts the loader's merged authored row/name + and binds its actual merged registries; it does not recreate the handler or + formula. Mounts are fixture-owned and restored in memory, with no Gen1 file + changes. Hook/event failures must also leave both loaders' error lists empty. +- Every bounded RNG call/value, selected public move/hit event and all recorded + battler HP/PP/status/active sleep/last-move/stage/Substitute/Rage observations + remain in those comparisons. No failed rows or RNG draws are filtered out. +- Full feature: **130/139 engine suites**, SDK **3/3**, B30. The exact Counter + of **25 inherited FAIL lines across nine suites** equals clean prerequisite + `63bf2a9b9b856027c27c65ae1031febbb9e5d7e5` (**127/136**). Its full command's + nonzero exit remains retained. Independent baseline repairs are in the + combined runtime: **249/249 engine suites**, SDK **3/3**, B30, the same full + comparison and imported tests pass there. +- Unchanged prerequisite fails **108 assertions** and all **648/648** complete + FireRed command rows differ. Its authored-content mode fails **eight + assertions**, with **4/5** rows differing. The same final fixtures are used. +- Two real link roles complete four turns: sleeping Sleep Talk, blocked + ordinary move, awake Sleep Talk failure and independently selected ordinary + move. Every paired completed hash/component, sleep state and learned PP + agrees. Command revision **9** rejects revision 8 before party exchange; + party codec stays **1**. Raw fixture `b9ec4e8a94f4ba6e` becomes + `f1bec52ffc687ea3`, independently audited with the unchanged #226 hasher and + only the explicit revision token changed. Other-generation goldens stay. +- **Twelve CPU-only scenarios with actual local FireRed/Emerald imports** pass + public move/menu submission and completed turns: three calls followed by an + ordinary move on waking, zero-PP children, empty/forbidden pools, Early Bird + wake and awake failure. Extracted attack metadata stays intact. Native + Sleep Talk's DEPENDS target is observed rather than assuming a self target; + assertions run outside callbacks and loader errors are checked explicitly. + No ROM/cache/save assets are distributed and no normal SAVE is issued. +- Scoped StyLua, actionlint and whitespace checks pass. + +ROM-free reproduction with maintained LuaJIT +`a4c064539ea931274d407e1249b4bb575424a0f4`: + +```sh +nice -n 19 "$LUA" tests/engine/gen3_sleep_talk.lua +nice -n 19 "$LUA" tests/engine/gen3_sleep_talk_composition.lua +nice -n 19 "$LUA" tests/engine/gen3_sleep_talk_link.lua +nice -n 19 env LUA="$LUA" bash scripts/test.sh --quick +nice -n 19 python3 tools/enemy-action-contract-check/check.py \ + --gen1 /path/to/clean/gen1-reference --gen2 "$PWD" --lua "$LUA" \ + --report /tmp/sleep-talk-contracts.json +``` + +Optional imported-data check (root contains `firered/data/generated` and +`emerald/data/generated`): + +```sh +nice -n 19 "$LUA" tools/enemy-action-contract-check/imported-sleep-talk-smoke.lua \ + /path/to/local/Gen2Recomp +``` + +Feature evidence is in `tmp/sleep-talk-qa/accepted-evidence.json`, +`full-final-accepted.log`, `contracts-final-accepted.json` and +`imported-final-accepted.log`. Combined evidence uses +`tmp/sleep-talk-accepted-evidence.json`, `tmp/sleep-talk-full-final-accepted.log`, +`tmp/sleep-talk-contracts-final-accepted.json` and +`tmp/sleep-talk-imported-final-accepted.log`. Prerequisite evidence is retained +in the clean baseline worktree's `tmp/sleep-talk-baseline-qa/`. +Initial fixture errors, unmatched spread order, missing prerequisite probes, +the invalid unmerged integration run and all negative reports remain retained. + +## Remaining boundaries + +An extra **complete `held_item.trigger` probe still differs**. Raw source +`tmp/sleep-talk-qa/phase-gen1.log` and final host `phase-gen2-fixed.log` retain +all observed fields/reads. Empty source items carry numeric zero while the +host omits that field. A valid called attack has missing/extra check/damage/ +flinch reads (10 source reads, 11 host reads). Failed awake/empty calls now have +two reads on each engine, and blocked ordinary actions have four, but the +complete payloads still differ. These are unresolved findings, not passing +held-item-trace evidence; the accepted matrix above covers its stated scope. + +Complete held-item/choice-item and attack-canceller orchestration (including +Truant, freeze and recharge), arbitrary child-controller/interactive timing, +Ruby/Sapphire scheduling, full catalogs, global RNG/selection, graphics and +full playthrough parity remain separate work. Host builtin capture/delegation +and party-counter preservation are scoped host checks. The native Gen1 GBA +catalog still does not expose those builtin records for capture. The older +general API-3 checker's historical Gen1 pin is not new evidence against the +unchanged reference used here. No Gen1 development or publication is required +or performed. Hosted CI is recorded separately on the PR. diff --git a/docs/native-gen3-substitute-art.md b/docs/native-gen3-substitute-art.md new file mode 100644 index 000000000..a7b03e29d --- /dev/null +++ b/docs/native-gen3-substitute-art.md @@ -0,0 +1,66 @@ +# Native GBA Substitute doll art + +This importer and renderer extend the native Substitute command/controller work. +It imports the doll from the player's cartridge and uses its native 64x64 OBJ +geometry, including each double-battle slot and the controller's live offsets. +Existing caches need a reimport to populate the new art metadata. The previous +draw path remains available while that metadata is absent. + +## Cartridge contract + +The discovery helper identifies one verified loader literal pool: DMA3's +register/control pair, matching graphics-buffer literals, two distinct 2048-byte +LZ sheets and a valid 32-byte BGR555 palette. Missing, ambiguous, unmapped or +malformed candidates are refused. Symbols are recorded in the two supported +English manifests, each checked against its exact cartridge SHA-1. + +The asset stage validates every blob before writing either sheet. A versioned +`constants.gen3SubstituteArt` record names only successfully extracted images. +The animation-pack RGB555 contract retains the high five bits of each channel, +including RGB behind transparent palette index zero. No cartridge images are +included in this branch. + +## Verified evidence + +- Eight discovery rejection/acceptance tests, including ambiguous loaders, + invalid palette, mismatched buffer, oversized/truncated inputs and duplicate + sheets. +- 59 owned import/placement/render checks: 4bpp nibble order, separate facings, + native dimensions, rejection before partial output, all six battler slots, + widened native field, live offsets and graphics-color preservation through + the actual battle draw path. +- Actual targeted FireRed/Emerald stage imports into a separate CPU QA output + directory. Every RGBA channel in all four native sheets matches the existing + read-only Gen1 imports: **16,384 pixels, zero differences**, including + transparent RGB. No ordinary cache, save or game process is modified. +- The structural locator uniquely resolves complete art in five existing + cartridges: BPRE, BPEE, BPRD, BPED and BPGE, revision zero. The extra three + locator checks do not claim support for additional game profiles. +- Frozen runtime `bff8ec57`: 159/168 engine suites, SDK 3/3 and B30. The exact + anchored FAIL-line Counter matches fresh #258: the same 25 inherited lines, + with no additions or removals. The failed broader animation matrix remains + retained separately as historical failure evidence. + +The extended actual-draw suite now passes 70 checks, including palette +composition, full-wrapper fade alpha and graphics-state restoration. The final +32-worker / 14,816-observation matrix includes implemented Substitute fade, +Focus Band and Poison; all captured fields agree. Historical differing reports +remain retained. History, linked-role and actual public-submission evidence is +recorded in `native-gen3-mirror-move.md`. These CPU checks establish decoded +artwork and draw commands, not final scene screenshots or whole-battle pixels. + +Raw local QA is retained in `tmp/substitute-art-qa/`. Run the owned checks with: + +```sh +nice -n 19 python3 tests/tools/test_gba_substitute_art.py +nice -n 19 /path/to/pinned/luajit tests/engine/gen3_substitute_art.lua +``` + +For an actual targeted import, supply the matching manifest/cartridge and an +existing separate QA directory: + +```sh +nice -n 19 /path/to/pinned/luajit \ + tools/enemy-action-contract-check/imported-substitute-art.lua \ + tools/rom_manifest_firered.json /private/firered.gba /private/qa-output +``` diff --git a/docs/native-gen3-substitute-fade.md b/docs/native-gen3-substitute-fade.md new file mode 100644 index 000000000..2efa14759 --- /dev/null +++ b/docs/native-gen3-substitute-fade.md @@ -0,0 +1,45 @@ +# Native GBA Substitute fade + +This controller extends Focus Band presentation. Native damage +queues `SUBSTITUTE_FADE` before the break message and preserves the visible +doll while logical protection is removed. Actual playback executes the native +three-callback alpha decrement, white palette ramp, copied-background lifetime, +script delays and outward/exchange/return swap. The swap shares the previously +qualified controller; it does not maintain a second movement implementation. + +The animation runs with battle animations disabled, following the native +general-animation exception. An already hidden doll exchanges immediately. +The native end callback and end-of-sequence visibility reconciliation remain +separate. Same-side double-battle partners retain their copied-background +state while only the acting battler fades. No HP, status, protection or RNG +is changed by playback. + +The existing native doll renderer now applies the live palette contribution; +the complete battler wrapper applies its live alpha. Color and blend mode are +restored after drawing. CPU graphics doubles exercise actual drawing methods; +no GPU application or normal save/cache is opened. + +Controller evidence: + +- **288 paired fade inputs / 23,488 tick states**, every captured field equal + through the unchanged reference sequencer/VM and actual host battle queue. + All six single/double positions, animation options, mono/stereo, visible, + hidden, lowered and absent Substitute states are included. +- Existing Poison/Focus Band **384 inputs / 11,112 ticks**, and existing native + swap **360 inputs / 5,240 ticks**, remain equal in the same run. All twelve + workers exit zero without native VM diagnostics or timeouts. +- **104,512 checks** cover the full host fade and real damage producer; + **70 checks** cover native art import and drawing, including complete-wrapper + alpha and palette composition. +- Initial incomplete-coordinate and visibility/partner-background failures + remain retained in `tmp/substitute-fade-qa/`. FireRed uses its native built-in + picture coordinates; Emerald uses owned data through the virtual cache. + The corrected fixture explicitly validates each edition's expected input. + +The frozen fade runtime completes 162/171 engine suites, SDK 3/3 and B30, +with exactly the same 25 inherited FAIL lines as fresh #258. Its complete +32-worker matrix passes 14,528 paired cases; the later history extension +passes 14,816. Native revision 17 and linked/imported qualification are +documented in `native-gen3-mirror-move.md`. The reference is read-only at +`535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91`. CPU state/draw results are not +a final GPU scene-parity claim. diff --git a/docs/native-gen3-substitute-presentation.md b/docs/native-gen3-substitute-presentation.md new file mode 100644 index 000000000..dafb97769 --- /dev/null +++ b/docs/native-gen3-substitute-presentation.md @@ -0,0 +1,64 @@ +# Native GBA Substitute presentation + +This controller accompanies native Mirror Move received-history support. +Gen1 remains a clean read-only reference at +`535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91`. + +## Implemented + +Surviving move-animation rows receive their native Substitute-to-Pokemon and +Pokemon-to-Substitute commands. Each called move owns its own animation group. +Ordinary multi-hit moves lower once and restore only when the native last-hit +command is reached; early termination does not invent a restore command. + +The actual battle queue executes the offscreen exchange controller, including +five-pixel movement ticks, the separate exchange tick and the return trip. +Presentation is independent of gameplay Substitute HP. Animation options retain +the Transform/Substitute exceptions. Skipped commands add no waiting tick. +The renderer's sprite selection and native OAM Y consult that presentation; +queue completion clears its transient state. Legacy battles retain their path. + +## Controller evidence and retained failures + +- The first 1,920 complete Mirror Move observations now match unchanged Gen1, + including all previously differing 144 animation command streams. +- The native sequencer/VM and actual host queue agree on all 360 controller + inputs and all 5,240 observed ticks. Both versions cover all six single/double + battler slots, animation options, ordinary/Transform/Substitute contexts, + lowering, restoration, missing protection and broken protection. +- Controller inputs use owned native-shaped animation pack and move records. + Restoration preparation executes the preceding move through the native VM + before observation begins at the actual restoration command. No Source + functions are replaced. CPU graphics doubles launch no game or GPU preview. +- The expanded multi-hit contract retains all existing state, events and RNG, + and adds the complete move/general animation stream. All 11,520 actions + execute; at this earlier checkpoint **1,344 observations differed** in animation commands: + Substitute fade, Focus Band and poison presentation. Those differences and + every failed/superseded fixture report remain in local ignored QA output. + +Native doll import/drawing and all three general/status controllers are now +implemented. The final complete matrix passes 32 workers / 14,816 paired +observations, including every earlier differing animation stream. Full evidence +and scope are recorded in `native-gen3-mirror-move.md`. CPU draw/state checks +are not final GPU scene acceptance. + +Reproduce the complete paired CPU contracts with: + +```sh +nice -n 19 python3 tools/enemy-action-contract-check/check.py \ + --gen1 /path/to/clean/reference --lua /path/to/pinned/luajit \ + --mode substitute_multi_hit --mode substitute_presentation \ + --mode mirror_move --mode mirror_move_content --mode mirror_move_chained \ + --report /path/to/new-report.json +``` + +Local raw observations: `tmp/substitute-presentation-qa/`. + +The frozen `63084eca` full run passes 156/167 engine suites, SDK 3/3 and B30. +Its exact FAIL-line Counter adds two queue-hole crashes to the inherited 25 +lines. The follow-up preserves every sparse queue row/hole, avoids grouping +unprotected users, and passes the controller, Follow Me and move-used PP suites. +The failed full run remains retained. Runtime `729c07fa` passes the repeated +full gate at 158/167 engine suites, SDK 3/3 and B30. Its exact anchored FAIL-line +Counter equals fresh #258: the same 25 inherited lines, with no additions or +removals. diff --git a/docs/native-gen3-triple-kick.md b/docs/native-gen3-triple-kick.md new file mode 100644 index 000000000..f29b771b6 --- /dev/null +++ b/docs/native-gen3-triple-kick.md @@ -0,0 +1,116 @@ +# Native GBA Triple Kick execution + +Triple Kick previously used the legacy approximation: one accuracy/critical/ +damage calculation, repeated three times at twice the extracted power. Native +GBA effect 104 instead checks every reached kick, assigns power 10/20/30 and +calculates each critical and damage roll independently. Rounding, misses and +move-end state make the approximation observably different. + +This change stacks on #225. It adapts the builtin per dataset before mods +capture it and preserves the shared static fallback for other generations. +Complete authored records own their script; captured records retain native +behavior, authored counts, explicit damage handlers and `neverMiss` policy. +The native damage hook receives the assigned power in `ctx.opts.power`; a +public wrapper can delegate or change that value independently per kick. + +Stop before unreachable kicks when either battler faints, the user sleeps +outside the existing Sleep Talk continuation, a new accuracy/protection/hidden +check fails, a reached damage calculation is immune, or actual Endure ends the +sequence. Continue through a broken Substitute; Focus Band can save subsequent +strikes. A separately selected attack costs one learned PP regardless of how +many kicks land. Called execution keeps its own PP semantics. + +FireRed/Emerald move-end Rage runs once for a completed qualifying attack. +A later missed kick retains success from earlier kicks; a reached immunity +suppresses it. Accuracy failure precedes the next damage/type calculation, so +an immunity introduced after hit one does not suppress Rage when kick two +misses before reaching that calculation. + +## Verification + +At clean feature runtime `10a5c325a7852c6e8f7fea1502fafd9cfdc4a393`: + +- **2,220 focused checks** pass: 2,100 command/hook/lifecycle, 70 content + composition and 50 real link/compatibility checks. The 154-check native + protocol suite also passes. +- **108 workers / 76,362 paired observations** agree with unchanged, clean, + read-only Gen1 `535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91`. + The new mode contributes **576 complete rows per FireRed/Emerald profile**: + singles/doubles and all four actor slots; each possible missed kick; + critical stages/items; unchanged and edited extracted power; public damage + power overrides; Substitute, Endure, Focus Band, faint/sleep, called moves, + immunity/protection/hidden state before and after hit one, and Rage. +- Every bounded RNG call/value, public accuracy/damage calculation, hit/move + event and all battler HP/PP/status/Substitute/last-move/Attack/Rage observations + stay in the comparison. Public events are detached views; lifecycle probes + mutate and assert the fixture's owned live battlers. No draws or mismatches + are filtered out. +- Full feature: **126/135 engine suites**, SDK **3/3**, B30. The exact Counter + of **25 inherited FAIL lines across nine suites** equals clean #225 + `8e543618bc3a6ec9e0c6d8f1f31f9e12b5259f2e` (**123/132**). The full command's + nonzero exit remains retained. Their independent repairs are in the combined + branch, whose runtime `c1dac0994aa9fe0d1659e510874065697dca2534` passes + **246/246 engine suites**, SDK **3/3**, B30 and the same full comparison. +- Unchanged #225 fails **240 assertions** with the exact final fixture; + **396/576** complete FireRed rows differ from the unchanged reference. + Exploratory failures, the initial hook-signature error and all raw negatives + remain retained separately from accepted evidence. +- Two actual link roles complete three turns (Triple Kick, Tackle, Triple Kick) + with every completed paired hash/component equal and correct learned PP. + Native command revision **8** refuses revision 7 before party selection; + party codec remains **1**. Raw fixture `dcc938191b0bb075` becomes + `b9ec4e8a94f4ba6e`, audited using the unchanged #225 hasher with only its + explicit revision token changed. Other-generation goldens are unchanged. +- **Twelve CPU-only scenarios with actual local FireRed/Emerald imports** pass + public move submission and the completed turn/menu: three landed kicks, + misses on kick 1/2/3, Substitute breaking, and a faster enemy actually using + Endure. Extracted effect/power/accuracy and type metadata remain intact; + controlled valid RNG exercises the branches. Public events use canonical + `TRIPLE_KICK`, without relying on private move-index fields. No ROM/cache/ + save files are distributed and no normal SAVE is issued. +- Scoped StyLua, actionlint and whitespace checks pass. + +Reproduce the ROM-free tests with the maintained pinned LuaJIT +`a4c064539ea931274d407e1249b4bb575424a0f4`: + +```sh +nice -n 19 "$LUA" tests/engine/gen3_triple_kick.lua +nice -n 19 "$LUA" tests/engine/gen3_triple_kick_composition.lua +nice -n 19 "$LUA" tests/engine/gen3_triple_kick_link.lua +nice -n 19 env LUA="$LUA" bash scripts/test.sh --quick +nice -n 19 python3 tools/enemy-action-contract-check/check.py \ + --gen1 /path/to/clean/gen1-reference --gen2 "$PWD" --lua "$LUA" \ + --report /tmp/triple-kick-contracts.json +``` + +Optional imported-data test (the root contains `firered/data/generated` and +`emerald/data/generated`): + +```sh +nice -n 19 "$LUA" tools/enemy-action-contract-check/imported-triple-kick-smoke.lua \ + /path/to/local/Gen2Recomp +``` + +Feature acceptance is retained in `tmp/triple-kick-qa/accepted-evidence.json`, +`full-feature-accepted.log`, `contracts-feature-accepted.json` and +`imported-final.log`; combined evidence is in `tmp/triple-kick-accepted-evidence.json`, +`tmp/triple-kick-full-accepted.log`, `tmp/triple-kick-contracts-accepted.json` +and `tmp/triple-kick-imported-accepted.log`. Later commits only document results. +Hosted workflow execution is recorded separately on the PR. + +## Catalog boundary and remaining work + +The earlier `RAGE_EFFECT` retrieval failure is a confirmed catalog difference, +not a missing fixture import. At the pinned Gen1 reference, generation-3 +`Builtins.registries(3)` seeds only types, growth and evolution. Both +`RAGE_EFFECT` and `TRIPLE_KICK_EFFECT` are absent from its public effect catalog; +Gen2 exposes both. A minimal authored primary effect registers successfully in +both engines, tested through the same public loader for FireRed and Emerald. +Raw audits are `tmp/triple-kick-qa/catalog-gen1.log` and `catalog-gen2.log`. + +Portable consumers must not assume native builtin capture/delegation is a +shared GBA contract. Host composition checks here are separate from the +cross-engine native execution comparison. This change neither publishes nor +requires any Gen1 modification. Complete builtin catalogs, full Sleep Talk/ +other controllers, Ruby/Sapphire per-hit scheduling, global RNG/selection +orchestration, rendering and full playthrough parity remain separate work. diff --git a/docs/native-sound-player-lifecycle.md b/docs/native-sound-player-lifecycle.md new file mode 100644 index 000000000..e02cd8a58 --- /dev/null +++ b/docs/native-sound-player-lifecycle.md @@ -0,0 +1,109 @@ +# Native M4A sound-player ownership + +Starting a native GBA sound must replace the preceding sound on its imported +M4A player. Previously Gen2Recomped cached by role and pan/mono variant, so +different IDs or controls could leave several effects playing on the same +player. Gen1Recomp's unchanged native `Audio.playSe` stops that player's +previous source before preparing its replacement. + +`Sound.playId`, `Sound.playSe`, named native roles and native effect names now +share player ownership. Other players retain their sources. If a cached PCM +variant is still owned by another player, the new player receives a separate +cached voice. Volume changes, invalidation and device suspension reach those +voices through the existing cache lifecycle. A failed replacement releases the +preceding native source before source construction, matching the reference's +ordering. Suspended sessions cannot create or restart sources. + +Native stop-by-ID reaches every player with that ID; stop-all releases every +native voice. Explicit-ID queries include the special player three. Emerald's +regular aggregate query excludes player three, following its native policy; +FireRed's aggregate query includes it. File/string and disabled overrides keep +their separate override behavior and do not cut or impersonate a native player. +Legacy and NitroAudio dispatch retain their existing paths. + +## Compatibility + +Native battle revision **18** prevents admission of peers using revision 17 +semantics. Actual host/guest compatibility checks stop before battle setup. +Party codec revision remains **1**. An independently loaded frozen-parent +serializer, changing only the revision token, produces native digest +`c82a14f754706103`; the legacy digest remains `e33d251961f2a41d` for Red, Gold, +Diamond and Black. + +This extends [the Mirror Move and controller foundation](native-gen3-mirror-move.md) +in Gen2Recomped #270. That foundation's historical revision-17 QA receipt is +preserved. The current source-bound receipt is +[`qa/native-sound-player-lifecycle.json`](qa/native-sound-player-lifecycle.json). + +## Validation + +The original 20 paired source/host cases reproduced **16 differences** with +four zero-exit workers. The current owned controller contract has **40 paired +cases**, covering native/shared/named entrypoints, pan/mono variants, independent +players, same-ID voices, replacement after cloning, native stop/query rules, +special-player policy, and file/string/disabled overrides. Every recorded +command trace, active voice and query participates in direct comparison. + +Actual-host regression passes **110 checks**, including every current +controller case, all cached voice volumes, name invalidation/query/stop, +suspend/resume, source-build failure, legacy overlap and bilateral revision-17 +rejection. The protocol suite passes **154 checks**. + +The combined fork passes **283/283 engine suites**, SDK **3/3** and B30, with +an empty anchored `^FAIL ` Counter, matching the accepted 282/282 baseline. +The feature passes **165/174 engine suites**, SDK **3/3** and B30. Against a +fresh #270 parent (**164/173**), its anchored FAIL-line Counter is identical: +the same **25 lines in nine inherited failing suites**. Neither the baseline +nor candidate failures are hidden. + +Both frozen runtimes pass **224 workers / 175,580 complete paired observations**, +including all 40 new lifecycle cases. An independent audit checks every expected +backend/profile/API/mode key and re-compares complete decoded observations. +JSON booleans remain distinct from numeric zero and one. +All workers exit zero without timeout or native-VM diagnostics. Full state, +events, item reads, RNG, PP and animation metadata remain in the comparisons. + +Existing FireRed/Emerald imports also exercise the unchanged public Mirror Move +menu/queue path: **24 scenarios / 48 turns / 120 events** per runtime. A separate +read-only actual-audio smoke test synthesizes **10 cartridge sources**, checking +replacement on player one, independence of players two and three, a mono +variant and native stops. Its parent negative control fails at the first +same-player replacement. Sixteen imported player/header records independently +match their actual ROM entries. + +The controller speaker is owned hardware; no reference engine function is +replaced. Cached source objects remain in its inventory after stopping, so +later reuse stays observable. The actual-audio smoke mounts existing imported +bytes only in the test's in-memory filesystem. No ROM, audio asset, normal save +or normal cache is written or included. No game, GPU or audio device is launched. + +The raw negative controls, first incomplete-inventory fixture, missing-file +invocation and superseded floating-point assertion remain retained locally. +The corrected volume assertion measures both voices within `1e-12`; no +controller observations are discarded or normalized. Report paths and hashes +are recorded in the receipt. + +These contracts qualify FireRed and Emerald native player ownership. They do +not establish full synthesizer waveform parity, every public sound-event's +timing, GPU scene parity, unregistered Ruby/Sapphire/LeafGreen profiles, arbitrary +whole-mod compatibility or a full playthrough. + +## Reproduction + +Use LuaJIT at `a4c064539ea931274d407e1249b4bb575424a0f4` and a clean read-only +Gen1Recomp checkout at `535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91`: + +```sh +nice -n 19 env LUA=/path/to/pinned/luajit bash scripts/test.sh --quick +nice -n 19 /path/to/pinned/luajit tests/engine/gen3_sound_player_lifecycle.lua +nice -n 19 python3 tools/enemy-action-contract-check/check.py \ + --gen1 /path/to/clean/reference --lua /path/to/pinned/luajit \ + --report /path/to/new-all-contracts-report.json +nice -n 19 /path/to/pinned/luajit \ + tools/enemy-action-contract-check/imported-native-sound-smoke.lua \ + /path/to/existing/import-cache-root +``` + +The import cache must contain `firered/` and `emerald/` directories with their +already generated `data/generated/audio.lua` and M4A blobs. No ROM is required +for this smoke, and CI's owned fixtures do not depend on private imports. diff --git a/docs/native-sound-script-waits.md b/docs/native-sound-script-waits.md new file mode 100644 index 000000000..79a1efa4f --- /dev/null +++ b/docs/native-sound-script-waits.md @@ -0,0 +1,104 @@ +# Native GBA world-script sound waits + +Native `waitse` previously lowered to no operation, so a script could change +variables, release field objects or continue dialogue while its sound was +still playing. It now yields through the actual script runner until the native +SE query is complete, with both clocks and the latched callback used by the +unchanged reference. + +The script polls current native players rather than a captured source. A sound +replaced on the same player or a remaining independent player therefore keeps +the wait active. File overrides remain separate. Emerald's aggregate query +excludes player three; FireRed includes it. Silence and override-only cases +continue immediately. + +The callback has a **180-tick real-time audio budget**, serviced after logic in +the existing `Game:update` 60 Hz audio loop. The script also retains its native +**181-poll logic fallback**. At accelerated logic speeds those are different +clocks. Once the audio callback observes silence, completion stays latched even +if another sound starts before the next script poll. Consecutive waits each +start their own clocks. + +This extends Gen2Recomped #277. Native battle revision **18**, party codec **1** +and their existing fingerprints remain unchanged: this is world-script +scheduling, with no new portable command or advertised public capability. +The native command hook still wraps the yielding command, source/strictness +metadata survives and the script-ended event follows the post-wait write once. + +## Validation + +- **36 paired actual-controller cases** across FireRed and Emerald, including + normal/accelerated/logic-only clocks, callback latching, consecutive waits, + replacement, stops, independent players, special-player policy and overrides. + The reference's native VM and the host's native lowering/ScriptRunner execute + the same owned operation sequence; no engine function is replaced. +- **6,306 actual-host checks**, including real Game.update, FixedStep and + StateStack at 1X/2X/10X. Actual field freeze/release commands retain object and + input locks while waiting, then release them; the owning state keeps updating. +- Removing only the real-time `Sound.update` call from Game.update reproduces + two exact failures: each profile completes at tick 181 instead of 180. The + local negative variant and its report are retained, not integrated. +- With existing actual audio imports, native + `lockall; playse; waitse; setvar; releaseall; end` waits **25 ticks in FireRed** + and **34 in Emerald**, derived from the actual 17,641/24,256-sample PCM buffers + at 44,100 Hz. Every one of the 100 recorded frames per profile keeps objects + locked before the write and releases them afterward. Complete feature and + combined outputs agree, including JSON types. The unchanged #277 parent + fails with tick 0 instead of tick 25. +- Existing actual imported player-ownership tests still synthesize ten native + sources per runtime. The public Mirror Move smoke still executes 24 scenarios, + 48 turns and 120 move-used events per runtime, retaining complete outputs. + +The combined runtime passes **284/284 engine suites**, SDK **3/3** and B30, +with an empty anchored FAIL-line Counter against the accepted 283/283 parent. +The feature passes **166/175 engine suites**, SDK **3/3** and B30; its exact +anchored FAIL-line Counter matches a fresh #277 parent (**165/174**): the same +**25 lines in nine inherited failing suites**, all retained. + +Each frozen runtime passes **228 zero-exit workers / 175,616 complete paired +observations**. The independent audit checks every expected backend, profile, +API and mode key, immutable fixture hashes, clean source commits and every +decoded value. JSON booleans remain distinct from numeric zero and one. +No timeout or native-VM diagnostic remains; complete state, events, held reads, +RNG, PP and animation fields remain included. + +The original frozen negative contract has **7 differences in 12 paired cases**, +with four zero-exit workers and identical active voices/queries at every frame. +The original observations, source-only clock oracle, first invalid StateStack +constructor invocation and all later reports remain retained. The +[negative receipt](qa/native-sound-script-waits-negative.json), +[native clock oracle](qa/native-sound-script-wait-clocks.json) and current +[source-bound qualification receipt](qa/native-sound-script-waits.json) identify +the exact commits, inputs and report hashes. + +## Reproduction and scope + +Use LuaJIT `a4c064539ea931274d407e1249b4bb575424a0f4` and a clean read-only +reference at `535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91`: + +```sh +nice -n 19 env LUA=/path/to/pinned/luajit bash scripts/test.sh --quick +nice -n 19 /path/to/pinned/luajit tests/engine/gen3_sound_script_waits.lua +nice -n 19 python3 tools/enemy-action-contract-check/check.py \ + --gen1 /path/to/clean/reference --lua /path/to/pinned/luajit \ + --report /path/to/new-all-contracts-report.json +nice -n 19 /path/to/pinned/luajit \ + tools/enemy-action-contract-check/imported-native-world-sound-wait-smoke.lua \ + /path/to/this/checkout /path/to/existing/import-cache-root +``` + +The optional imported smoke requires existing `firered/` and `emerald/` imports. +It reads their generated audio metadata and M4A blobs, mounts them only in the +test's in-memory filesystem and feeds actual decoded PCM to an owned speaker. +Speaker completion follows the buffer duration. No audio device, GPU, normal +cache/save write or redistributed ROM/audio asset is involved. + +Run the source-only clock oracle from the pinned reference checkout with +`tools/enemy-action-contract-check/native-sound-wait-clock-oracle.lua` addressed +in this checkout and this checkout's path as its argument. It preserves every +observed frame, including callback latching with a replacement still playing. + +These are native world-controller/game-update and owned-hardware contracts. +They do not establish every public sound-event timing, synthesizer waveform +parity, complete mobile audio-session parity, rendered scene parity, unregistered +profiles, arbitrary whole-mod compatibility or a full playthrough. diff --git a/docs/new-features.md b/docs/new-features.md index 532cbf3ae..9133c678d 100644 --- a/docs/new-features.md +++ b/docs/new-features.md @@ -508,3 +508,27 @@ file pickers are ordinary desktop dialogs and still appear normally, and a run started from a terminal (`lovec.exe`, what `scripts\run.ps1` prefers) keeps its terminal and its printed output. Set `POKEPORT_CONSOLE=1` to opt out. + +## Native GBA Beat Up party damage + +Beat Up now reads each healthy party member's level/species base Attack +and target species base Defense, with per-hit native critical/variation +rolls, live callback eligibility, Substitute continuation and last-strike +special counter memory. The effect revision intentionally changes the link +fingerprint; see [the contract and remaining acceptance](native-gba-beat-up.md). + +## Native GBA Beat Up shared side parties + +Beat Up now reads the entire side party in two-trainer and player/ally +battles, retaining live slot replacement and the active user's HP/status. +Replacement benches remain owned separately. The reviewed effect revision +changes the link fingerprint; see [contract coverage and remaining scope](native-gba-beat-up-side-parties.md). + +## Native GBA critical context and hit metadata + +Critical exclusions now preserve native draw order for first/tutorial battles +and Teachy TV, including scripted Beat Up. Shared critical context fields, +physical item/current-species stages, Lansat/Focus Energy behavior and GBA +hit kind/Substitute metadata are covered by cross-engine contracts. The +reviewed scripted effect revision changes the link fingerprint; see +[verification and remaining scope](native-gba-critical-context.md). diff --git a/docs/portable-charge-required.md b/docs/portable-charge-required.md new file mode 100644 index 000000000..a1c70ae95 --- /dev/null +++ b/docs/portable-charge-required.md @@ -0,0 +1,79 @@ +# Portable charge-turn decisions + +`battle.charge_required(next, ctx)` lets a mod skip the initial charge turn +of a native two-turn move. Gen1–3 hosts expose it; DS reports unavailable and +retains its current native move flow. + +```lua +local mod = ... +if mod:supports("hooks", "battle.charge_required") then + mod.hooks:wrap("battle.charge_required", function(next, ctx) + if myOptions.instantChargeMoves then return false end + return next(ctx) + end) +end +``` + +The context has `battle`, `user`, `target`, `move`, `charge = true` and +`isCalled`. `next(ctx)` returns `ctx.charge`. Only an explicit false skips the +charge; nil, zero and other values retain it, as in the reference engines. +Setting `ctx.charge = false` before `next(ctx)` is another way to bypass. + +The native pipeline invokes the hook only when it would otherwise enter a +first charge turn. Regular moves, releases and native sunny SolarBeam skips do +not invoke it. It cannot force a charge turn for an ordinary move or override +a native weather skip. Called moves set `isCalled = true` and keep their +existing caller-owned PP semantics. + +A bypass continues ordinary native damage/effects on the same turn. It stores +no charge continuation or semi-invulnerable state. GBA's queued move animation +uses the release branch (`moveTurn = 1`). A delegated charge retains native +continuation, invulnerability, charge text and animation; release clears that +state and does not spend a second player PP. No subscriber means the engine +does not call Runtime for this seam. Unsubscribe and failed-entry rollback +restore this path. Wrapper errors retain normal hook-bus recovery; repeated +API-3 `next` calls reuse the downstream verdict. + +API 3 and Gen2Recomped's API-2 unified mode expose common move/actor records. +Legacy native IDs and object layouts remain engine-specific; forward opaque +battle handles and use API 3 for portable field access. The producer does not +translate the whole battle implementation into the other engine's layout. + +## Validation and remaining native differences + +`examples/portable_charge_required` has no engine imports. The same entry is +loaded through both SDKs. The shared harness drives the actual GB +`BattleState:performMove`, Gold `Battle:useMove` and GBA `Engine.resolveMove` +paths in Gen1Recomp, and Gen2Recomped's native `BattleState:performMove`. +Fixtures supply native data/cache records; the hook producer, verdict, +charge state and damage pipeline are not mocked. + +Tests cover SolarBeam, Fly, Dig, Razor Wind, Skull Bash and Sky Attack, player +and enemy actions, release, caller PP, native sunlight, context/ID formats, +false versus nil/zero, errors, API-3 replay, unsubscribe, genuine no-mod cases +and failed-entry rollback. Native tests cover every registered host, SDK mode, +DS exclusion and the GBA release animation branch. + +This proves the tested public decision contract, not identical damage amounts, +text, all move side effects or every underlying battle rule. In particular, +Gen2Recomped currently leaves Gold's default battles on its Gen-1 ruleset: +enemy moves spend zero PP, while the reference Gold engine spends one. The +checker records each engine's `native_policy` costs separately and compares +whether charge/bypass preserve that cost. Enemy PP numbers are not counted +as equal observations. That existing rule difference needs a separate fix; +the new hook must not silently change native PP policy. Native secondary move +effects also remain host-owned, including Skull Bash's defense increase. + +```sh +luajit tests/engine/charge_required_contract.lua +python3 tools/charge-required-contract-check/check.py \ + --gen1 /path/to/clean/gen1 --api 3 --format unified \ + --report /tmp/charge-required.json +``` + +The reference is pinned to +`79ea62f45fb2ea8f65d081a109431e4eb1bbdb36`. Red/Blue/Yellow, +Gold/Silver/Crystal and FireRed/Emerald are compared in four SDK modes. +Gen1 API 2 has no unified opt-in; that run compares Gen2's adapter against +Gen1's native callback. Imported-data native smoke checks remain bounded; +they do not establish full-playthrough, whole-battle or pixel parity. diff --git a/docs/portable-enemy-action-arguments.md b/docs/portable-enemy-action-arguments.md new file mode 100644 index 000000000..7a4162234 --- /dev/null +++ b/docs/portable-enemy-action-arguments.md @@ -0,0 +1,56 @@ +# API-3 GBA enemy-action arguments + +On FireRed and Emerald, API 3 exposes the same `battle.enemy_action` input as +Gen1Recomp: the opaque battle handle and a positional battler ID. An omitted +request remains nil, which selects the default left opponent. The native +single controller omits the selector; the double controller requests 1 and 3. + +```lua +mod.hooks:wrap("battle.enemy_action", function(nextAction, battle, battlerId) + if battlerId == 3 then return { move = "SCRATCH" } end + return nextAction(battle, battlerId) +end) +``` + +`nextAction()` retains the current arguments. `nextAction(battle)` and +`nextAction(battle, nil)` request the native default; an explicit positional +ID requests that actor's choice. A nested public hook sees the forwarded +selector. The native Gen2 hook layer continues to receive its actual battler +handle. API 1/2 native and API 2 unified retain their existing actor argument +contracts. This argument change applies to API 3 on generation 3 only. + +A request and each forwarded call own a synchronous context. Nested queries +of the same battle restore the outer selector, and a fatal native error +clears the context before propagation. Native AI is not retried after error. +There is no extra public/native argument or persistent flag on the battle. + +As in the native reference planner, querying another actor does not transfer +execution ownership: the caller executes the returned choice and spends its +own selected PP slot. A delegated native action retains its slot index. A new +replacement choice still validates the slot against the caller's learned +moves. Querying another flank does not consume that flank's PP. + +## Verification + +```sh +luajit tests/engine/portable_enemy_action_arguments.lua +luajit tests/engine/portable_enemy_action_choices.lua +python3 tools/enemy-action-contract-check/check.py \ + --gen1 /path/to/clean/reference --lua luajit \ + --report /tmp/enemy-action-report.json +``` + +The checker runs the identical consumer and shared fixtures against unchanged +read-only Gen1Recomp `535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91`. API-3 argument +cases cover default/explicit requests, position-based choices, all forwarding +forms, nested public hooks, foreign native queries, real move execution and +PP ownership, and reentrant selection. Existing API-2/API-3 move-result cases +remain in the comparison. Local Gen2 checks additionally execute complete +native single/double turn queues, mix native/public callbacks, retain legacy +API-1/2 argument/handle identities, and verify native-error cleanup. + +Native move result fields are covered by the follow-up +[move record contract](portable-enemy-move-records.md). Public item/switch/watch actions, explicit +move-target conversion, damage amounts and RNG traces remain separate +contracts. No Gen1 source change or publication is required. This feature is +stacked on #190; review its feature commit relative to that prerequisite. diff --git a/docs/portable-enemy-move-choices.md b/docs/portable-enemy-move-choices.md new file mode 100644 index 000000000..6e724f8ac --- /dev/null +++ b/docs/portable-enemy-move-choices.md @@ -0,0 +1,63 @@ +# Portable GBA enemy move choices + +This change closes the result-normalization gap at `battle.enemy_action` for +FireRed and Emerald. A shared API-3 mod (or API-2 unified mod) can return: + +```lua +mod.hooks:wrap("battle.enemy_action", function(nextAction, ...) + return { kind = "move", move = "SCRATCH" } +end) +``` + +Bare canonical names, numeric native move IDs, numeric strings, and records +with `move` or `id` are accepted. IDs resolve through this game's declared move +rows, rather than a hard-coded move-number table. A supplied matching `slot` +is retained; otherwise the first matching learned slot is selected. Returning +nil, false, or an unknown name falls back to the captured native choice, or +asks native AI once if the hook did not delegate. + +Actions own execution choices; the native slot owns PP. Replacement records +cannot supply extra party PP, and selecting an exhausted learned slot does +not attack. A declared unlearned move runs without consuming another slot's +PP. Duplicate learned moves respect a valid explicit slot. Pressure charges +the actual selected slot. Native charge continuations retain identity and +spend PP once. Unmodified delegated choices and explicitly returned native +move handles retain identity. A delegated move edit uses the already-selected +slot without rewriting the learned move; the existing Gen2 `action.id` edit +remains supported. Mimic can recover a detached action's owned move slot. + +The boundary change is scoped to generation 3 and this action result. Other +generations and unrelated record types keep their current validation. + +## Verification and limits + +`tests/engine/portable_enemy_action_choices.lua` loads the actual public SDK +consumer and executes real battle actions on FireRed/Emerald, across API-1 +native, API-2 native/unified, and API-3 unified modes. It also covers right-flank +PP ownership and the existing delegated `id` editing convention. + +```sh +LUA=luajit bash scripts/test.sh --quick +python3 tools/enemy-action-contract-check/check.py \ + --gen1 /path/to/clean/gen1-reference --lua luajit \ + --report /tmp/enemy-action-report.json +``` + +The paired checker pins unchanged Gen1Recomp +`535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91` as a read-only reference. It runs the +same mod files through both real loaders, then compares selected move numbers, +party PP, learned move preservation, actual damage occurrence, and charge +continuation outcomes. Reports retain both processes' output, exit status, +fixture hashes, and host cleanliness. It does not compare damage amounts, +RNG traces, native AI scoring, or entire battles. + +This is a move-result contract. API-3 GBA input selectors are covered by the +follow-up [enemy-action argument contract](portable-enemy-action-arguments.md). +Native move result fields are covered by the follow-up +[move record contract](portable-enemy-move-records.md); other action records still differ. Portable consumers should forward +arguments with `...` and return a fresh choice record. Public item/switch/watch +records and explicit double-battle target conversion remain separate work. +No Gen1 changes or publications are needed. + +The feature is stacked on the API-3 entry prerequisite (#102); review the +feature commit relative to that prerequisite to isolate this change. diff --git a/docs/portable-enemy-move-records.md b/docs/portable-enemy-move-records.md new file mode 100644 index 000000000..dc67f73bb --- /dev/null +++ b/docs/portable-enemy-move-records.md @@ -0,0 +1,64 @@ +# API-3 GBA native move records + +A native move returned by `next()` in `battle.enemy_action` now exposes the +common Gen1Recomp fields on Gen2Recomped, for FireRed/Emerald API 3: + +| Field | Common native meaning | +| --- | --- | +| `kind` | `"move"` | +| `move` | Declared native numeric move ID; native exhausted fallback uses `"STRUGGLE"` | +| `slot` | Native selected learned slot, or nil when no slot owns PP | +| `user` | `"enemy"` | +| `battler` | Explicit/default native positional selection; single default omits it | +| `targetType` | Native target byte on a double-battle action; absent on the single action | + +```lua +mod.hooks:wrap("battle.enemy_action", function(nextAction, ...) + local action = nextAction(...) + if type(action) == "table" and action.kind == "move" then + action.move = "SCRATCH" + end + return action +end) +``` + +The view does not rewrite learned moves or the native handle. Returning an +unchanged native delegate keeps its identity. Editing a bound delegate keeps +its native selected PP slot, as Gen1's native action does. Returning a new +replacement record validates the slot against its requested move instead. +The existing Gen2 canonical `id` field remains available, and a legacy `id` +edit still works when the synthesized `move` alias is unchanged. + +Reference forms authored by another API-3 hook remain intact between hooks: +`{move=10}`, `{move="SCRATCH"}`, `{id=10}` and `{id="SCRATCH"}` retain their +original reference forms between public hooks. Native hook links retain +physical Gen2 representations. A native rewrite invalidates the previous +reference snapshot so an outer public hook sees the new choice. Final move +normalization still runs after the whole hook chain. Native delegate metadata and authored provenance +are held in weak side tables, without public engine bookkeeping fields. +API 1/2 views retain their existing format. Legacy native queries with detached +actors also remain supported. A native row without a declared numeric move +index is not assigned an invented number. + +## Checks and boundaries + +```sh +luajit tests/engine/portable_enemy_move_records.lua +python3 tools/enemy-action-contract-check/check.py \ + --gen1 /path/to/clean/reference --lua luajit \ + --report /tmp/enemy-action-report.json +``` + +The same consumer/fixtures run against unchanged read-only Gen1Recomp +`535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91`. Cases compare actual common fields, +mutation versus replacement semantics, selected-slot PP, nested authored and +native-edited references, charge continuations, and the real Struggle fallback. +`battle.move_used` proves which move actually executes, alongside HP/PP checks. +The existing move-result and selector contract cases remain in the checker. + +This is the native **move** record contract, not complete action/AI parity. +Mod-selected double-battle targets are covered by the follow-up +[target contract](portable-enemy-targets.md). Scores, item/switch/watch records, other native +engine fields, damage amounts, RNG traces and full gameplay remain separate. +No score or target-selection result is fabricated. No Gen1 source change or +publication is required. The feature is stacked on #191. diff --git a/docs/portable-enemy-targets.md b/docs/portable-enemy-targets.md new file mode 100644 index 000000000..61079b5c2 --- /dev/null +++ b/docs/portable-enemy-targets.md @@ -0,0 +1,62 @@ +# Portable GBA enemy action targets + +On FireRed/Emerald, a mod-selected `battle.enemy_action` move now honors its +positional `target` field through the native Gen2 planner/executor path: + +```lua +mod.hooks:wrap("battle.enemy_action", function(nextAction, battle, battlerId) + local action = nextAction(battle, battlerId) + if action.kind == "move" then action.target = 2 end + return action +end) +``` + +Positions are 0 player-left, 1 enemy-left, 2 player-right, 3 enemy-right. +The numeric choice stays on the action until execution. The engine resolves +the current occupant, so a replacement is hit and an absent selected slot +redirects through native targeting. A move's explicit target takes precedence +over an older UI choice. Existing native actor handles are also accepted; +malformed hints use the ordinary safe fallback. + +The move's target policy remains authoritative. Self moves stay on the user, +random-target moves retain their existing selection path, both-foe moves +start with the chosen foe, and all-other-battler moves execute in positional +order. Targeted Electric attacks respect active Lightning Rod holders and +the actual turn order, without later-generation immunity/stat boosts. +Native AI scoring/automatic target selection is not fabricated. + +GBA Pressure PP costs count all relevant holders for both-foe, all-other and +opponents-field moves. Self-targeting does not charge the user's own Pressure. +Spread execution spends PP once and emits `battle.move_used` once per action; +actual hit targets remain available through `battle.damage_dealt`. The GBA +zero-power default path no longer publishes a zero-damage hit it did not deal; +explicit custom/fixed damage paths and earlier-generation events are retained. + +## Verification + +```sh +luajit tests/engine/portable_enemy_targets.lua +python3 tools/enemy-action-contract-check/check.py \ + --gen1 /path/to/clean/reference --lua luajit \ + --report /tmp/enemy-action-report.json +``` + +The identical public consumer and shared fixture run against unchanged +read-only Gen1Recomp `535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91`. The reference uses +its actual native planner and move executor; the host additionally drains +actual single-turn double-battle queues. Cases cover all four selected +positions from both opposing flanks, mutation/replacement, move/damage event +cardinality and order, PP, field/self/spread policies, Pressure, Lightning Rod +priority, cached targets and replaced/absent occupants. The fixture declares +native-shaped synthetic move rows to isolate target policies, not real-game +Spikes or other unimplemented field effects. + +Gen2-only regressions cover malformed hints, legacy handles and unchanged +random-target behavior/target RNG against a control with identical combat +stats. Existing move-result, selector and record contracts stay in the runner. + +This validates selected action targets, not complete targeting/gameplay parity. +Follow Me and other unported native effect producers, automatic AI target +scoring, full random-target/native RNG parity, called moves, link ordering, +damage amounts and item/switch/watch actions remain separate work. No Gen1 +source change or publication is required. The feature is stacked on #194. diff --git a/docs/portable-held-items.md b/docs/portable-held-items.md new file mode 100644 index 000000000..c5ff66370 --- /dev/null +++ b/docs/portable-held-items.md @@ -0,0 +1,127 @@ +# Portable held-effect decisions + +`held_item.trigger(next, ctx)` wraps a native held-effect **read** on Gen 2/3 +hosts. It lets a mod suppress an effect, substitute another `HELD_*` name, or +change its parameter. Native code still owns rolls, damage/healing, messages, +item consumption and battle queues. GB/DS report this seam unavailable. + +```lua +local mod = ... +if mod:supports("hooks", "held_item.trigger") then + mod.hooks:wrap("held_item.trigger", function(next, ctx) + if ctx.item == "LEFTOVERS" and ctx.trigger == "residual" then + return "HELD_BERRY", 7 + end + return next(ctx) + end) +end +``` + +Use API 3 for common item/Pokemon IDs. The unchanged +`examples/portable_held_item` consumer also runs through legacy/native and +API-2 unified adapters. Legacy IDs and other engine objects retain their +native representations. An opaque `ctx.battle` is the actual controller; +`ctx.mon` is the holder's Pokemon. Constructors, external/link battlers and +replacements bind to their current battle without adding a public engine +handle to Pokemon records. + +The context also has `item`, `def`, `effect`, `parameter` and `trigger`. +`def` is absent for an empty hand; an ordinary held item with no native effect +still has a definition. The public effect names include `HELD_BERRY`, +`HELD_LEFTOVERS`, `HELD_QUICK_CLAW`, `HELD_BRIGHTPOWDER`, `HELD_FOCUS_BAND`, +`HELD_FLINCH`, `HELD_CRITICAL_UP`, `HELD_HEAL_*` and the `HELD_*_BOOST` family. +GBA's Scope Lens and pinch-critical berry remain distinct (`HELD_CRITICAL_UP` +and `HELD_PINCH_CRITICAL_UP`). All 66 GBA native enum names are covered. + +`next(ctx)` returns `ctx.effect, ctx.parameter`. A non-string effect suppresses +it. An omitted/non-numeric parameter falls back to the native parameter; zero +is meaningful and is preserved. Mutating `ctx.effect` / `ctx.parameter` before +`next` is supported. GBA probability parameters for Quick Claw, BrightPowder, +Focus Band and King's Rock use the common byte scale: the view is +`floor(nativePercent * 256 / 100)`. An unchanged forwarded pair retains its +exact native percentage; changed odds clamp to 0–256 before converting back. +Other native parameter meanings remain generation-specific. + +Native comparison reasons include `priority`, `damage`, `endure`, `flinch`, +`accuracy`, `confuse`, `residual` and `check`. Gold also uses `status` for +immediate cures and `pp` for MysteryBerry; GBA escape uses `run`. A read is not +an activation event: calls may repeat and an effect can be ineligible after +it is read. Filter by the actual item/effect and trigger rather than counting +calls. Field/party scans are not battle hook producers. + +API-3 definition scalar metadata retains the reference's transaction behavior. +`def.id` uses the common item identity; `def.heldEffect` / `def.heldParameter` +use the public names/parameters. Valid changes are visible to downstream +callbacks. Gold writes to its live item definition, so those metadata changes +persist; GBA supplies a temporary info record and discards them after the read. +The already captured `ctx.effect` is independent of definition edits. Use the +returned pair to steer the current comparison and `mod.content` for deliberate +content changes. Native diagnostic snapshots are not a portable write surface. +Invalid transactional definition edits cannot partially commit Pokemon edits. + +No subscriber means no hook dispatch or context construction. Unsubscribe and +entry rollback restore native reads. Wrapper errors retain normal bus recovery; +API-3 repeated `next` calls reuse both downstream results. + +## Native integration and boundaries + +The hook is wired through the actual priority, critical/type/stat damage, +accuracy, lethal-hit endurance, King's Rock, confusion, PP and residual readers. +GBA also reads it for Macho Brace speed, Shell Bell, held escape, entry prize +multipliers and White Herb. Gold Focus Band now reaches the direct-damage +path, including a holder already on 1 HP. Gold's Berry threshold is strictly +below half HP, matching the reference; GBA retains at-half activation. The GBA +100%-accuracy shortcut no longer bypasses a held accuracy reduction. + +This is a held-read contract, not complete native battle equivalence. Other +move/rule differences and GBA's full Choice Band locking remain separate work. +The GBA host now separates selective move-end cures/White Herb from normal +item checks. After a completed action it retries the normal scan up to four +times, restarting at the first slot after each successful activation. Called +moves do not add another action sweep; spread moves finish all targets first. +Queued switch-in abilities complete before their normal item sweep. Leftovers +is eligible only in the regular end-of-turn phase. + +The GBA residual path orders Wish before weather, then Ingrain, abilities, +items, Leech Seed and status damage per battler. The post-residual item sweep +precedes Future Sight/Perish Song and the public field-residual hook. Weather +uses all active slots and the GBA sixteenth-max-HP chip; Leech Seed uses its +current source slot, eighth-max-HP drain and Liquid Ooze. Pinch berries respect +stage caps and existing Focus Energy; Leppa restores only up to the move's PP +cap. These changes do not replace the GB/Gen2/DS turn loops. + +The formerly unequal unrestricted 7-HP Berry fixture now heals **35 HP on both +engines**, with six residual reads. Native move/called/spread, PP, cure-before- +poison, Ingrain, Wish/weather, Seed/status, speed-order and four-slot cases run +through the same consumer and native handlers. The strict checker compares +both the raw HP result and read count and is required by CI. This proves the +listed item phases, not all battle rules: Choice Band locking, deferred +ability interactions, Quick Claw turn-RNG ordering and further volatile rules +still need their own cross-engine coverage. + +## Validation + +```sh +luajit tests/engine/held_item_contract.lua +luajit tests/engine/held_item_authority.lua +python3 tools/held-item-contract-check/check.py \ + --gen1 /path/to/clean/gen1 --api 3 --format unified \ + --report /tmp/held-item-contract.json +``` + +The reference is clean and pinned to +`79ea62f45fb2ea8f65d081a109431e4eb1bbdb36`. Gold/Silver/Crystal and +FireRed/Emerald run the same consumer via real SDKs and native held readers, +actual native moves/order, and registered residual code. The producer and +native effect handlers are not replaced or dispatched manually. Extractor- +shaped fixture items also cover all 66 GBA enums through the actual item-data +fallback reader. Four SDK modes are compared; Gen1 API 2 has no unified opt-in, +so that comparison uses its native callback. + +The host suites additionally cover registered generations, replacement/link +binding, no-mod/entry rollback, byte-odds clamping, field/party isolation and +metadata transactions. `tests/drivers/held_item.lua` checks the installed public +consumer against imported Gold/FireRed/Emerald in isolated LÖVE: native +FIGHT/move input and result queues, HP/consumption, endurance and GBA entry/RUN. +It starts New Game and constructs bounded in-memory battles; it is not a full +playthrough or cross-engine pixel-equivalence proof. diff --git a/docs/portable-pokemon-rolls.md b/docs/portable-pokemon-rolls.md new file mode 100644 index 000000000..38db2ee75 --- /dev/null +++ b/docs/portable-pokemon-rolls.md @@ -0,0 +1,62 @@ +# Portable Pokemon identity hooks + +`shiny.roll` and `gender.roll` wrap the native Gen-2/3 identity predicates. +API 3 supplies common species IDs and definition records; native API 1/2 +retain native IDs. Host API-2/unified is also supported. GB and DS report +both hooks unavailable through `mod:supports("hooks", name)`. + +```lua +local mod = ... +if mod:supports("hooks", "shiny.roll") then + mod.hooks:wrap("shiny.roll", function(next, ctx) + if ctx.species == "CHARMANDER" then return true end + return next(ctx) + end) +end +``` + +Both contexts carry `species` and `def`. Gen 2 carries the native `dvs` +(`attack`, `defense`, `speed`, `special`, derived `hp`) and construction/ +refresh `level`. Its bare breeding gender query has no level. Gen 3 carries +`personality`; shiny queries also carry `level`, `ivs`, `otId` (or the native +trainer-ID fallback) and optional `otSecretId`. The native unhooked GBA +reader also handles split secret IDs and the `trainerId` alias; installing a +delegating hook is not required for those explicit identity layouts. Unified GBA IV keys follow +the reference: `hp`, `atk`, `def`, `spe`, `spa`, `spd`. IVs are a detached +query copy, so editing them does not edit the Pokemon. + +The gender context includes the informational ratio byte. To steer the +native threshold, edit `ctx.def.genderRatio` and delegate, or return a gender +directly. Changing `ctx.ratio` alone does not replace the definition's byte. +Gen-2 definition steering has the reference's live-definition scope; Gen-3 +metadata is call-local. Other unified definition metadata is read-only. + +Shiny results use Lua truthiness (including truthy zero); nil means false. +Gender returns `male`, `female` or `unknown`. Invalid gender returns fall +back to the native predicate. Gen 2 re-reads its current DV/definition state; +Gen 3 retains the native value captured before its hook. Its PID low byte +and the Gen-2 two-DV-byte comparison keep their native boundary rules. + +Gen 2 stores the construction results on the Pokemon. Its native summary +refresh re-evaluates gender and a false shiny result, while an already true +shiny remains true, matching the reference. Gen 3 evaluates gender at birth +and retains the native display cache; breeding/query reads remain separate +live evaluations. Unsubscribing stops callbacks but does not erase a native +cached value. Explicit cartridge-forced shininess bypasses the normal shiny +hook, including the native forced-wild construction path. + +Plain result overrides and Gen-3 queries do not change DV/PID/IV values or +draw RNG. Deliberate Gen-2 DV edits retain native write-back semantics; API-3 +replacement contexts/DV records retain the original native table identity. +Do not treat these mutation cases as pure read-only predicates. Unified +callback writes are committed through the existing call-local transaction; +pre-next failure and invalid threshold writes cannot partially edit the +engine. Retained views and late `next` calls cannot edit native state later. + +See `examples/portable_pokemon_rolls` and +`tools/pokemon-roll-contract-check/`. The shared tests use both real SDKs, +Gen-2 constructors/refresh/breeding and native GBA party creation/predicates. +They compare every outcome in the 65,536-case Gen-2 ratio/DV matrix rather +than relying only on an aggregate checksum. This is a native hook contract, +not a claim that every screen recomputes cached identity at the same time or +that all battle and save-format differences are resolved. diff --git a/docs/portable-world-scripts.md b/docs/portable-world-scripts.md new file mode 100644 index 000000000..1391146ba --- /dev/null +++ b/docs/portable-world-scripts.md @@ -0,0 +1,164 @@ +# Portable dialogue scripts + +API-2 mods can opt into a bounded, prevalidated language for +`mod.world:queueScript(rows, options)`: + +```json +{ + "id": "quest_dialogue", + "name": "Quest dialogue", + "version": "1.0.0", + "api": 2, + "entry": "main.lua", + "script_format": "unified" +} +``` + +Check `mod.scriptFormat == "unified"` in the entry file. Older engines may +ignore unknown manifest fields; checking the marker prevents a portable row +list from silently reaching their native script runner. Omitted or explicit +`"native"` formats preserve existing API-1/API-2 queueScript behavior. Other +values, or API 1 with `"unified"`, are load errors. Content and callback format +opt-ins are independent. The engine's Mod API major remains 2. + +## Contract + +```lua +return function(mod) + assert(mod.scriptFormat == "unified", "portable scripts required") + + -- Run this from an idle-world interaction/update, not at entry time. + local function offerQuest() + local ok, why = mod.world:queueScript({ + { "ask", "Help with the quest, {PLAYER}?" }, + { "jump_if_false", "declined" }, + { "show_text", "Thank you!" }, + { "jump", "end" }, + { "label", "declined" }, + { "text", "Maybe another time." }, + }, { + onDone = function(completed, reason) + if not completed then mod.log:warn("Dialogue stopped: %s", reason) end + end, + }) + if not ok then mod.log:warn("Dialogue not started: %s", why) end + end +end +``` + +Admission returns `true`, or `nil, reason` without executing any prefix of a +rejected program. The entire list, every row/argument and every jump target is +validated and copied first, including unreachable rows. Caller edits to the +original list, choice labels/options or callback options cannot change an +accepted program. Reading the facade and an early request do not construct +Game. A request with no live save/world fails quietly. + +`true` means admitted, not successfully completed. After admission, +`onDone(true)` reports completion and `onDone(false, reason)` reports a runtime +failure or cancellation. It fires exactly once, including failures during the +initial dispatch. It is not called for rejected requests. A throwing completion +callback is isolated and attributed to its mod; completion is not retried. +Already entered native work is not rolled back or automatically replayed. + +Only `onDone` is accepted in options. Programs and rows must be dense plain +arrays without metatables or extra keys. Functions/userdata/native handles are +not command arguments. Choice options are a closed plain table. + +## Initial command set + +| Row | Meaning | +| --- | --- | +| `{ "show_text", text }` / `{ "text", text }` | Native manual TextBox; wait for all pages and final dismissal | +| `{ "ask", text }` | Native text and YES/NO overlay; YES is true, NO/B is false | +| `{ "choice", labels, opts }` | Native N-way menu; choice 1 is true, every other choice is false | +| `{ "wait", frames }` | Native logic-frame wait, integer 0–36000; zero waits until the next world update | +| `{ "label", name }` | Named jump destination | +| `{ "jump", target }` | Unconditional jump | +| `{ "jump_if_true", target }` | Jump after a true answer | +| `{ "jump_if_false", target }` | Jump after false/nil, including before any answer | +| `{ "end" }` | Finish the program | + +Text is literal UTF-8, up to 4096 bytes per row, without NUL. Native TextBox +placeholder substitution (e.g. `{PLAYER}`), prose pagination, typing, scroll/ +page holds and answer holds remain in use. These rows do not resolve native +text registry IDs or lower ROM script bytecode. Future pages are not skipped. + +`labels` contains 1–16 nonempty UTF-8 strings of at most 128 bytes each. +Optional `opts.default` and `opts.cancel` are valid 1-based indices; defaults +are 1 and the last choice. B follows `cancel`. Answers remain internal to the +program for conditional jumps; the completion callback reports success/failure. + +Labels contain letters, digits, `_`, `-` or `:`, at most 64 bytes, and are unique. +`end` is reserved. Jump targets are existing labels, valid 1-based row indices, +or the string `"end"`. No arbitrary native verbs, custom command handlers, +extra callback arguments or executable rows are accepted. + +A program has at most 1024 rows and 128 KiB of text/choice labels. Execution +yields after each slice of 128 instructions and fails after 65536 instructions +in total. This bounds authored jump loops without a Lua debug hook or a frame +stall; it is not a wall-clock deadline on a player reading text. + +## Ownership and cancellation + +Admission requires the current overworld to own the top of the stack. Native +scripts, queued/parallel scripts, movement, transitions, field animations, +input/controller locks, link sessions and another portable program decline. +Only one portable foreground program can own a Game at a time. + +An accepted program temporarily installs an isolated instance of the native +ScriptRunner. Its `isRunning()` participates in the world's existing input and +script-scheduling gates. It uses native TextBox, ChoiceBox and Menu classes, +including their normal keyboard/touch handling and delayed answer completion. +The idle runner is restored at completion/cancellation, provided it has not +already been replaced by another controller. Global runner/UI classes and the +native command registry are unchanged. + +`mod.world:cancelScript()` cancels only this facade's active portable program. +It returns `true`, or `false, "no portable script"`. It cannot cancel another +mod or a native story script. Cancellation removes only the run's exact native +windows, even beneath a foreign overlay, and releases its coroutine. It does +not pop unrelated screens or release another controller's locks. A covering +foreign screen pauses normal progress. Removing an awaited owned window +externally cancels at the next input-step boundary. + +A replaced Game/save/data/stack/world/map/player/runner, changed map identity or +player position/version, transition, link session, mod rollback, reload or +teardown cancels the program. Session changes are checked at input-step and UI/ +runner boundaries, including while the world is covered. Retained callbacks +cannot resume a cancelled program, skip a later row's wait or pop a new screen. +Facades from an unloaded/reloaded/failed mod are retired. + +Native `script.started`/`script.ended` lifecycle events retain their context +shape; `source.modId` attributes the portable run and `completed` describes its +outcome. End notification/completion follow owned cleanup. Portable rows use +this validated language rather than resolving native/custom handlers or firing +`script.command` wrappers; native-format scripts retain those contracts. +Direct changes to engine objects from such event handlers or other mods remain +outside this boundary's guarantees; this is not a mod security sandbox. + +When optional capability queries are installed, unified mods can probe +`mod:supports("scripts", verb)` and `mod:supports("world", "cancelScript")`. +Unknown/unimplemented portable verbs return false. The existing native +`world/queueScript` capability is not, by itself, evidence of this opt-in. + +## Scope and verification + +This first adapter covers dialogue/control flow across the 15 supported +version selections (Gen 1–5). Flags, items/Pokémon, world movement/warps, +battles, shops and other Gen1Recomp portable verbs need separately verified +adapters; they refuse the complete list before the first effect here. + +The contract is informed by Gen1Recomp's +[UnifiedScripts](https://github.com/bryanthaboi/gen1recomp/blob/79ea62f45fb2ea8f65d081a109431e4eb1bbdb36/src/mods/UnifiedScripts.lua). +This implementation uses this host's native shared UI and runner architecture. + +Run `luajit tests/engine/portable_world_scripts.lua` and +`LUA=luajit bash scripts/test.sh`. The ROM-free suite uses the real Loader, +ScriptRunner, state stack and native UI classes. It compares complete per-frame +native/portable dialogue traces, checks preflight/argument copying, rejection +immutability, identity/session changes, replay, cancellation/reentry, budget and +native failures. Fixture runs do not establish rendered retail-ROM coverage. + +When the unified content/callback PRs are also present, run +`PORTABLE_SCRIPT_ALL_FORMATS=1 luajit tests/engine/portable_world_scripts.lua` +to repeat the suite with all three format opt-ins enabled together. diff --git a/docs/qa/native-gba-bgm.json b/docs/qa/native-gba-bgm.json new file mode 100644 index 000000000..ddf0595f5 --- /dev/null +++ b/docs/qa/native-gba-bgm.json @@ -0,0 +1,506 @@ +{ + "schema": 1, + "contract": "native GBA music commands, fanfare waits and audio session ownership", + "profiles": [ + "firered", + "emerald" + ], + "native_command_revision": 20, + "native_party_revision": 1, + "native_digest": "43fe7ea27b438e0e", + "legacy_digest": "e33d251961f2a41d", + "reference_ref": "535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91", + "luajit_ref": "a4c064539ea931274d407e1249b4bb575424a0f4", + "runtimes": [ + { + "role": "feature", + "qualified_ref": "540dc5472deeec21f79ff66cba2d8f4056dcf592", + "src_tree": "fde61c049daa7ad9bd93331359c07643285ffc39", + "tests_tree": "1447de335cde7e9caa2477c9fd3ed0377e303bcf", + "engine_suites": { + "passed": 174, + "total": 183 + }, + "sdk_suites": { + "passed": 3, + "total": 3 + }, + "b30": true, + "full_contracts": { + "workers": 268, + "paired_observations": 178264, + "all_modes_backends_profiles_exactly_once": true, + "complete_typed_raw_observations_recompared": true, + "slowest_worker_seconds": 84.0181, + "audit_evidence": "native-bgm-feature-session-full-qa/independent-audit.json", + "audit_sha256": "871a13251a3f8b957aa3ddfb7178071caf4f8044809ce6518e1cd34f6d285b52", + "batch_reports": [ + { + "evidence": "native-bgm-feature-session-full-qa/batch-01.json", + "sha256": "94ef70f98196800b2837df23284a00fb8df4d31cdedbbb5fbcb0302953a0ee2d" + }, + { + "evidence": "native-bgm-feature-session-full-qa/batch-02.json", + "sha256": "86ffb3e246213179af8fbd69a42db852bde38eed1291cecf1c65e2ba2b47e9ec" + }, + { + "evidence": "native-bgm-feature-session-full-qa/batch-03.json", + "sha256": "e4041e8ad6412b7d9e2501d5b0c408a527c1e04ccbc9f58c9f06727edb0e96d8" + }, + { + "evidence": "native-bgm-feature-session-full-qa/batch-04.json", + "sha256": "99e7dfbe8a2d3d3459d1c094b977676101240da2424d6155c8fcdabdd25dbb1e" + }, + { + "evidence": "native-bgm-feature-session-full-qa/batch-05.json", + "sha256": "c44c814ec98687f67397dd8ff32dc6d3076b627802c87b7640ba278777c73e08" + }, + { + "evidence": "native-bgm-feature-session-full-qa/batch-06.json", + "sha256": "4994a0154694d766682b0ea4fe8b0209941d7d26a39db1e11a55e41b6a28fc2f" + }, + { + "evidence": "native-bgm-feature-session-full-qa/batch-07.json", + "sha256": "a1026e191e6d3ad15b165a884f6821566763b7ac68bd9d2a8de146fc678680b7" + }, + { + "evidence": "native-bgm-feature-session-full-qa/batch-08.json", + "sha256": "3c31ae864a312500f2d6502d5b8af0cc082227c62a1c5f18ae7cd83956d41f70" + }, + { + "evidence": "native-bgm-feature-session-full-qa/batch-09.json", + "sha256": "95c819b51f19a8ed059af1ab86ec231ae7018864569cd7661c80199dfb526732" + }, + { + "evidence": "native-bgm-feature-session-full-qa/batch-10.json", + "sha256": "acab079e52593febd48c22b9517e2410f225e361d0647524187e935bf1020199" + }, + { + "evidence": "native-bgm-feature-session-full-qa/batch-11.json", + "sha256": "9cb33b847a2d3748dc73a12e58ecede7c7b830fe6adf65624fbfc7270254a1c0" + } + ] + }, + "initialized_session": { + "workers": 8, + "paired_scenarios": 4, + "paired_snapshots": 36, + "individual_snapshots": 72, + "old_sources_and_listeners_retired": true, + "fresh_public_listener_and_fanfare_wait_work": true, + "full_raw_inventories_equal": false, + "audit_evidence": "native-game-audio-session-corrected-feature-qa/independent-audit.json", + "audit_sha256": "f3c378a1bc4fa1a93d2f1faba249b7e020ae8f28f316df8a8e3add38bca3385b" + }, + "full_quick_log_sha256": "80bc0ddfd72d01fdb954e090d5df9b57d93dffc04108eb6894caf1e28d07b473" + }, + { + "role": "combined", + "qualified_ref": "e436fba6228bd6e73a97497107e4a15f61c0d052", + "src_tree": "52e2e3ffc7e8f1145b0edffa16ca3713324bb8d2", + "tests_tree": "f033a6f7f66579d8322f01536ea16452233f0cf7", + "engine_suites": { + "passed": 292, + "total": 292 + }, + "sdk_suites": { + "passed": 3, + "total": 3 + }, + "b30": true, + "full_contracts": { + "workers": 268, + "paired_observations": 178264, + "all_modes_backends_profiles_exactly_once": true, + "complete_typed_raw_observations_recompared": true, + "slowest_worker_seconds": 71.7116, + "audit_evidence": "native-bgm-integration-session-full-qa/independent-audit.json", + "audit_sha256": "73b77b0b0c4ac907e571221bb5fbcfbc2e3b34ed9e25b118f4e55b8cd8403eaa", + "batch_reports": [ + { + "evidence": "native-bgm-integration-session-full-qa/batch-01.json", + "sha256": "ef8c3bc1cfffb422c1d5d2b0f75677a5f6e24720f7a1c574f6af333adce8bf26" + }, + { + "evidence": "native-bgm-integration-session-full-qa/batch-02.json", + "sha256": "79836e511402e43e7c174a745a762d7afbf289892f4b7a3e9f78e5a0b59e8f42" + }, + { + "evidence": "native-bgm-integration-session-full-qa/batch-03.json", + "sha256": "c58d2857ab143ba7a58293e242f6d89dbaf38a05c149eedca789525773fbd1ca" + }, + { + "evidence": "native-bgm-integration-session-full-qa/batch-04.json", + "sha256": "2fee961b40c2c6a184923169fc51805f18612c839d3ef692fe481f1a7ff0c3e2" + }, + { + "evidence": "native-bgm-integration-session-full-qa/batch-05.json", + "sha256": "107bc14e5016fde7d4a38583ef147cdbe82de29a5b29d9037a13e05f40c65a91" + }, + { + "evidence": "native-bgm-integration-session-full-qa/batch-06.json", + "sha256": "168002f2845c04f59d7e2c9303f319289e6313c23e69461313d226cf42cec778" + }, + { + "evidence": "native-bgm-integration-session-full-qa/batch-07.json", + "sha256": "5d839c42aced012d0b35cc45689f483a8ddf476e327126d61ad40ca7202a1b0b" + }, + { + "evidence": "native-bgm-integration-session-full-qa/batch-08.json", + "sha256": "a06a157f59a738b624e963e5bb632f813dc2d0594f644f401c3039a177baf002" + }, + { + "evidence": "native-bgm-integration-session-full-qa/batch-09.json", + "sha256": "0aeb92108aa3c0ab2e25de47354734967888d87da062a0f066a66bd1108a41f5" + }, + { + "evidence": "native-bgm-integration-session-full-qa/batch-10.json", + "sha256": "f542e98bf7fdc286da9654ef88be5dbf1d613572b2806f876444952640d6022f" + }, + { + "evidence": "native-bgm-integration-session-full-qa/batch-11.json", + "sha256": "5cd0b090c1a0d0bf1952a96fc661dfe26b2f96968b928ee64c60d6fa656f63a9" + } + ] + }, + "initialized_session": { + "workers": 8, + "paired_scenarios": 4, + "paired_snapshots": 36, + "individual_snapshots": 72, + "old_sources_and_listeners_retired": true, + "fresh_public_listener_and_fanfare_wait_work": true, + "full_raw_inventories_equal": false, + "audit_evidence": "native-game-audio-session-corrected-integration-qa/independent-queue-audit.json", + "audit_sha256": "8007a8970cfe86c4826ebe9318ba09b1dc19dea63918ddb31c8e607de349c1d6" + }, + "full_quick_log_sha256": "bcab5429b129a4d4b54867888c87e2bb056f55abe656bbc97615fa3a639d5f36" + } + ], + "baseline_regression": { + "parent_ref": "4994a41ca09fe50e4f186260ed8f5b7b1f233f4d", + "parent_engine_suites": { + "passed": 174, + "total": 183 + }, + "same_exact_fail_line_counter": true, + "inherited_fail_lines": 25, + "inherited_failing_suites": 9, + "all_expected_suites_executed_exactly_once": true, + "parent_log_sha256": "230a324c728fc63a6868e9ae4cf42ba729b775f1d76bc92408257d7884a4d003", + "audit_sha256": "30bf8400b109cf8d0e6f20d1ea7eccb3c1191fc08ed25bbc44cd6500c2d50976", + "protocol_checks": { + "feature": 175, + "integration": 179 + }, + "protocol_test_source_identical": true, + "party_codec_checks": 194540, + "cold_and_entry_checks": 69, + "audio_option_checks": 102 + }, + "combined_physical_audio": [ + { + "kind": "bgm-streams", + "workers": 4, + "paired_cases": 18, + "paired_frames": 1534, + "cold_userdata_admission": true, + "raw_pcm_retained": true, + "whole_waveform_parity": false, + "audit_sha256": "12e16a3fd886324d4bbefb2942bbd6ad824491003549f914b605a05c3f2cdf33", + "raw_workers": [ + { + "backend": "gen1", + "version": "firered", + "exit": 0, + "raw_sha256": "a5dfb5552b334d44e88c1015d594831d9bd890d52167008cf14d43d0f1d0aabd", + "raw_bytes": 81186860, + "cases": 9 + }, + { + "backend": "gen2", + "version": "firered", + "exit": 0, + "raw_sha256": "2fc4b04f04aa6f1f7264840411e39e38fd6ee99c740047c07ccb8fd9a64429f0", + "raw_bytes": 85172966, + "cases": 9 + }, + { + "backend": "gen1", + "version": "emerald", + "exit": 0, + "raw_sha256": "f31142f65bfa9e723c51f57a88abe8414c31c1e365cfb02291a9ee1f9266fecc", + "raw_bytes": 81186860, + "cases": 9 + }, + { + "backend": "gen2", + "version": "emerald", + "exit": 0, + "raw_sha256": "b12ce5cbcc761b753ae31a5fde5c0c364fcdcf02b57e4b0acbb7f82704ecdf41", + "raw_bytes": 85172966, + "cases": 9 + } + ] + }, + { + "kind": "fanfare-streams", + "workers": 4, + "paired_cases": 12, + "paired_frames": 156, + "cold_userdata_admission": true, + "raw_pcm_retained": true, + "whole_waveform_parity": false, + "audit_sha256": "ecaacf5d03a0d01071b10eadcc0b2ee1631c1474933fb5f7cb8838bd22442d9f", + "raw_workers": [ + { + "backend": "gen1", + "version": "firered", + "exit": 0, + "raw_sha256": "635b00cec6704aacd9a853fec2f0c44a5e5067e45e50ab0fba7dbb9a338f8f5d", + "raw_bytes": 32677339 + }, + { + "backend": "gen2", + "version": "firered", + "exit": 0, + "raw_sha256": "c423a940802a2404a7af3f0591a6c8abb4ca0afb43f5cbfbc4a0532ac5e677b4", + "raw_bytes": 34154025 + }, + { + "backend": "gen1", + "version": "emerald", + "exit": 0, + "raw_sha256": "d49ee363c4b111933c07b21a3fe858f1b6c78f7fc0e3708f96cdedc0bb47297d", + "raw_bytes": 32677350 + }, + { + "backend": "gen2", + "version": "emerald", + "exit": 0, + "raw_sha256": "46c2c15d4312f38f22d0b96411f3dffba869210b71729d2a8ab1228281af67aa", + "raw_bytes": 34154036 + } + ] + }, + { + "kind": "actual-imports", + "workers": 4, + "paired_cases": 8, + "paired_frames": 688, + "cold_userdata_admission": true, + "raw_pcm_retained": true, + "whole_waveform_parity": false, + "audit_sha256": "ef94e9642747e085303d8f9d258277bc0372a65f57171b022d90d492da72451e", + "raw_workers": [ + { + "version": "firered", + "backend": "gen1", + "exit": 0, + "log_sha256": "6e4ea4d321959e971cd307274e5ebaa9dc2de3d0860ced067b5930d0a06ea4a0", + "raw_bytes": 29923161 + }, + { + "version": "firered", + "backend": "gen2", + "exit": 0, + "log_sha256": "9f416b1410288dd4053c53d1ad83803c2e307575dc769259c7a0d172e84f80ac", + "raw_bytes": 21662006 + }, + { + "version": "emerald", + "backend": "gen1", + "exit": 0, + "log_sha256": "3f85f7b1ae202cde933730019f412973d6c93b1761e329c5487e75980d81dc73", + "raw_bytes": 29819715 + }, + { + "version": "emerald", + "backend": "gen2", + "exit": 0, + "log_sha256": "6551cd9254620de1708c06877063f57dee1339d9a4c301201fc101f08764277f", + "raw_bytes": 23295035 + } + ] + } + ], + "combined_service_and_protocol": { + "protocol_profiles": [ + { + "tokenChanges": 0, + "previous": "e33d251961f2a41d", + "version": "red", + "current": "e33d251961f2a41d" + }, + { + "tokenChanges": 1, + "previous": "22e453523676534e", + "version": "firered", + "current": "43fe7ea27b438e0e" + }, + { + "tokenChanges": 0, + "previous": "e33d251961f2a41d", + "version": "gold", + "current": "e33d251961f2a41d" + }, + { + "tokenChanges": 1, + "previous": "22e453523676534e", + "version": "emerald", + "current": "43fe7ea27b438e0e" + }, + { + "tokenChanges": 0, + "previous": "e33d251961f2a41d", + "version": "diamond", + "current": "e33d251961f2a41d" + }, + { + "tokenChanges": 0, + "previous": "e33d251961f2a41d", + "version": "black", + "current": "e33d251961f2a41d" + } + ], + "paired_audio_session_cases": 8, + "paired_snapshots": 72, + "all_old_sources_stopped": true, + "native_queue_allocation_difference_cases": 4, + "audit_sha256": "04245d3354feb3632e53b478194c178e30c7940a21d6e1fe704c11f06e321d9e" + }, + "negative_controls": { + "old_production_cold_checks": { + "passed": 59, + "total": 69, + "failures": 10, + "log_sha256": "ca7f4d509cc0d3fb68b95a6f24937ba7c771d6a540b1380a8caeb6e1c506288a", + "corrected_69_check_log_sha256": "10479cf1d551c909f8e3819090c626cb89dbbe69248e01bed929fcdafd79355c" + }, + "old_initialized_session_entry": { + "reference_passed": 4, + "host_failed": 4, + "report_sha256": "e9ef4e4f9938bf4122c5c1bad0b6648fc1fd1ec3eb10ea93bf2d6b05e8301287" + }, + "superseded_broad_runs": { + "deliberately_cancelled": true, + "accepted_as_passes": false, + "receipt_sha256": "f995c8ffd31ac4226b33f3a6c74501e764a28f2a794b52211cc090ad38fc5b65" + } + }, + "changed_file_sha256": { + "feature": { + ".github/workflows/enemy-action-contract.yml": "8cb6f43a60e2b3316c9916a15a09d169512e2cc9ab2ec750fbe39fc1d71d2df3", + "src/audio/AudioMix.lua": "d2df2bd4d9b23f63e11fd452aa8ccef96b8c95f147038759f42cd8051a6bc7ec", + "src/core/ChipAudio.lua": "3e12fdae76d3d1dde7b1d680451e93e348eec6ce0b72c2637fb6b25ab89dc6fa", + "src/core/Game.lua": "4985cbaa07d67f6efd0748c19ae56e45c4581c272fead923897238372bcbb98d", + "src/core/Gen3Fanfare.lua": "37be04861249b3628d58b92175e144c20ef8698a4e79d976817936364df0f964", + "src/core/Gen3Music.lua": "099f6c586b9b7eb0690068ff30c1cc6e7d7d4a725d4c93c3d66e32549ea51e82", + "src/core/Gen3MusicPolicy.lua": "bebd1d0e95d48a6fcb257a7ec21ef8f3ae3e46015575b5dbcedf3ae442dec6f3", + "src/core/Gen3SongIds.lua": "b5c2cd8e700a4bfeb63bcb6ed0296f7167a710aa1c88ea2c79c0b2d2543e9e3e", + "src/core/Music.lua": "7c3f2c4f03049e7ae337ed4f2e38234a658418b4d5415c11477f70fe113d6850", + "src/core/Sound.lua": "e9908dc1af854872a5fc1740c4a8874d2d4e04b120f44aaf52aa3cdb42114a73", + "src/core/chip_worker.lua": "7c09154146ea1f9de85b0f2bf7ab16316c215520d34ed68d4d05527b9bdeea4e", + "src/link/Fingerprint.lua": "2b5f6859349da5580796f5b96a7b798f9207f54b58182144495172095d5ea0e4", + "src/script/Gen3Commands.lua": "6dc3f25eef29045ee7a6dff47f6e25556d51681139cecf643d5ebab6234f687d", + "src/script/Gen3ScriptVM.lua": "6782ea393c57c8d4df444680b47c267052afbd4862bcb672f7c804932461029a", + "tests/enemy_actions/native_bgm_commands.lua": "7a33d49193ac1c793c81aa3b7ad634f2d7dc18b3469e1e99c5e96b5f055294f1", + "tests/enemy_actions/native_bgm_devices.lua": "53476ed69a985404e6655e45ea3b476e7a9fa5770a07a1d3c0bd92577cc8d3e5", + "tests/enemy_actions/native_bgm_hooks.lua": "f4c9d9394918cc8e682950da7b3c1dbf93085cca4f980aa4c07e36be06eb9ae5", + "tests/enemy_actions/native_bgm_options.lua": "cd4411750c77d4fc0a5732ebd4d7735c8a6fb0d1ea576458eb9784f6f7db4cb4", + "tests/enemy_actions/native_bgm_policy.lua": "f534775c922fa4b00c02bf0bc732783dd8e04e78a703c55bf9bf05122806f0ef", + "tests/enemy_actions/native_bgm_symbols.lua": "46d525eb14d21bbc350ce2820c0723700bcb102d7f280106ee4031f4eb20bc9c", + "tests/enemy_actions/native_fanfare_commands.lua": "aa33d95e22569bd647be83afe9d392b5cce7f51a1f23b4eb949263205e098f08", + "tests/engine/gen3_audio_cold.lua": "62514821fc9dc1a1d70af45022ccd44ec8e05854569a499eb7da7538b70ed563", + "tests/engine/gen3_audio_options.lua": "4cb4487ced2e64e2ab777e9ef53363b9219291c7bd3d3806393ddb6469547903", + "tests/engine/gen3_audio_recovery.lua": "d9b567a9de22d6247ce258ee926f4e43b88a21c2d5a51fc654062ca2557f224e", + "tests/engine/gen3_battle_protocol.lua": "4ede8f50b249db1c04e374bdc58add1bbe9ecd7a0de003a4a12fec68659f262e", + "tests/engine/gen3_bgm_commands.lua": "cafe816e212cc9afe8e5cac2d05a169502e2d62704c76d24641eb9e0f56c50e9", + "tests/engine/gen3_bgm_streams.lua": "da6dbdd14befd3204f177cb61a7eb3b43fa56a04821e35f187b1aaa7aee43ad2", + "tests/engine/gen3_music_hooks.lua": "eaebaca6e3136b21648ad52d1e7dfc2b987cbd8659aa07dac04ad292b6468ec0", + "tests/engine/gen3_native_fanfare.lua": "151c3b0b744804d566ceba405bf11213ee0c8ab217872c194b0fdf8f526de4b1", + "tests/native_bgm/fanfares.lua": "107d91d2c2dbd9486fb89f8c443495d95faed424212ae555c050d488b46afd46", + "tests/native_bgm/fixture.lua": "d3fdc59d7486d8aff32c07b3fd6e22ce73a1e37b53f55d22a2fb2a2233c7487a", + "tests/native_bgm/sounddata.lua": "614c7234468600700fd387b813016c1c9f5db1afdf1b38bd46a0773e456c456a", + "tests/native_bgm/streams.lua": "ff10171f0dd01890057cdf2c3012ceba3492984b5fa51d09ad4e7ceeca3f4d3b", + "tests/native_bgm/worker.lua": "fa31ad662d5eba68398966954862effb7551d84a9c9a55a66c72208ea3bfe725", + "tests/native_cry/fixture.lua": "607cfa7602c20cc66f4014bf3ad688253e93a47019804cf105e2bd659b2d96b0", + "tools/enemy-action-contract-check/check.py": "3ab116a1863c5d813a38c19f8f9d7f1cb16f6e28c10352c3e3a8acdb9ee1a0b2", + "tools/enemy-action-contract-check/imported-native-bgm-smoke.lua": "a5cc97e09c6b22f1469aef2cf37b43c0f5925fb86739560866aedc59a5255ce6", + "tools/enemy-action-contract-check/imported-native-fanfare-metadata.lua": "e8d035a574d0be4d95c30a44e64dfed016b2ac6fc0544dc9f11950f49d09684f", + "tools/enemy-action-contract-check/native-audio-session-smoke.lua": "da94961bb6dc60924fc633612cf92521771aa762a0e085a8ff37b6f9a334f457", + "tools/enemy-action-contract-check/native-bgm-stream-smoke.lua": "e2f0e3bd0fcc317ce3ee00106fd1717ed174c0ce0cdd31b5bfe2d291672a0f92", + "tools/enemy-action-contract-check/native-fanfare-stream-smoke.lua": "09cd54b8e1ac4b0b830b55e542770b49495e8a0668d12aab15dee279158b505e", + "tools/enemy-action-contract-check/native-game-audio-session-smoke.lua": "4d6b8d6b31bcec16f1cd7d383877c92eb6ccbfc641fa1f17ecbd802f60aed934", + "tools/enemy-action-contract-check/native-protocol-audit.lua": "927188bb1dfa1e341f4bab292cbed2aabc7d8564f3882510beee349870954a98", + "tools/enemy-action-contract-check/worker.lua": "9202766ff657bd2fa2e54e064a0cc8af27d1881b71dce159a1d34a13bf3418e0" + }, + "combined": { + ".github/workflows/enemy-action-contract.yml": "8cb6f43a60e2b3316c9916a15a09d169512e2cc9ab2ec750fbe39fc1d71d2df3", + "src/audio/AudioMix.lua": "d2df2bd4d9b23f63e11fd452aa8ccef96b8c95f147038759f42cd8051a6bc7ec", + "src/core/ChipAudio.lua": "3e12fdae76d3d1dde7b1d680451e93e348eec6ce0b72c2637fb6b25ab89dc6fa", + "src/core/Game.lua": "59d2d79bb34c3470b2e7510dc3333a5083c20865477192c8d0c7f46cd70eef0d", + "src/core/Gen3Fanfare.lua": "37be04861249b3628d58b92175e144c20ef8698a4e79d976817936364df0f964", + "src/core/Gen3Music.lua": "099f6c586b9b7eb0690068ff30c1cc6e7d7d4a725d4c93c3d66e32549ea51e82", + "src/core/Gen3MusicPolicy.lua": "bebd1d0e95d48a6fcb257a7ec21ef8f3ae3e46015575b5dbcedf3ae442dec6f3", + "src/core/Gen3SongIds.lua": "b5c2cd8e700a4bfeb63bcb6ed0296f7167a710aa1c88ea2c79c0b2d2543e9e3e", + "src/core/Music.lua": "7c3f2c4f03049e7ae337ed4f2e38234a658418b4d5415c11477f70fe113d6850", + "src/core/Sound.lua": "67ded2de63e5dd7cad3899532b05a974c68b4c76ec919e9860866e9de20c12d2", + "src/core/chip_worker.lua": "7c09154146ea1f9de85b0f2bf7ab16316c215520d34ed68d4d05527b9bdeea4e", + "src/link/Fingerprint.lua": "2b5f6859349da5580796f5b96a7b798f9207f54b58182144495172095d5ea0e4", + "src/script/Gen3Commands.lua": "6dc3f25eef29045ee7a6dff47f6e25556d51681139cecf643d5ebab6234f687d", + "src/script/Gen3ScriptVM.lua": "6782ea393c57c8d4df444680b47c267052afbd4862bcb672f7c804932461029a", + "tests/enemy_actions/native_bgm_commands.lua": "7a33d49193ac1c793c81aa3b7ad634f2d7dc18b3469e1e99c5e96b5f055294f1", + "tests/enemy_actions/native_bgm_devices.lua": "53476ed69a985404e6655e45ea3b476e7a9fa5770a07a1d3c0bd92577cc8d3e5", + "tests/enemy_actions/native_bgm_hooks.lua": "f4c9d9394918cc8e682950da7b3c1dbf93085cca4f980aa4c07e36be06eb9ae5", + "tests/enemy_actions/native_bgm_options.lua": "cd4411750c77d4fc0a5732ebd4d7735c8a6fb0d1ea576458eb9784f6f7db4cb4", + "tests/enemy_actions/native_bgm_policy.lua": "f534775c922fa4b00c02bf0bc732783dd8e04e78a703c55bf9bf05122806f0ef", + "tests/enemy_actions/native_bgm_symbols.lua": "46d525eb14d21bbc350ce2820c0723700bcb102d7f280106ee4031f4eb20bc9c", + "tests/enemy_actions/native_fanfare_commands.lua": "aa33d95e22569bd647be83afe9d392b5cce7f51a1f23b4eb949263205e098f08", + "tests/engine/gen3_audio_cold.lua": "62514821fc9dc1a1d70af45022ccd44ec8e05854569a499eb7da7538b70ed563", + "tests/engine/gen3_audio_options.lua": "4cb4487ced2e64e2ab777e9ef53363b9219291c7bd3d3806393ddb6469547903", + "tests/engine/gen3_audio_recovery.lua": "d9b567a9de22d6247ce258ee926f4e43b88a21c2d5a51fc654062ca2557f224e", + "tests/engine/gen3_battle_protocol.lua": "4ede8f50b249db1c04e374bdc58add1bbe9ecd7a0de003a4a12fec68659f262e", + "tests/engine/gen3_bgm_commands.lua": "cafe816e212cc9afe8e5cac2d05a169502e2d62704c76d24641eb9e0f56c50e9", + "tests/engine/gen3_bgm_streams.lua": "da6dbdd14befd3204f177cb61a7eb3b43fa56a04821e35f187b1aaa7aee43ad2", + "tests/engine/gen3_music_hooks.lua": "eaebaca6e3136b21648ad52d1e7dfc2b987cbd8659aa07dac04ad292b6468ec0", + "tests/engine/gen3_native_fanfare.lua": "151c3b0b744804d566ceba405bf11213ee0c8ab217872c194b0fdf8f526de4b1", + "tests/native_bgm/fanfares.lua": "107d91d2c2dbd9486fb89f8c443495d95faed424212ae555c050d488b46afd46", + "tests/native_bgm/fixture.lua": "d3fdc59d7486d8aff32c07b3fd6e22ce73a1e37b53f55d22a2fb2a2233c7487a", + "tests/native_bgm/sounddata.lua": "614c7234468600700fd387b813016c1c9f5db1afdf1b38bd46a0773e456c456a", + "tests/native_bgm/streams.lua": "ff10171f0dd01890057cdf2c3012ceba3492984b5fa51d09ad4e7ceeca3f4d3b", + "tests/native_bgm/worker.lua": "fa31ad662d5eba68398966954862effb7551d84a9c9a55a66c72208ea3bfe725", + "tests/native_cry/fixture.lua": "607cfa7602c20cc66f4014bf3ad688253e93a47019804cf105e2bd659b2d96b0", + "tools/enemy-action-contract-check/check.py": "3ab116a1863c5d813a38c19f8f9d7f1cb16f6e28c10352c3e3a8acdb9ee1a0b2", + "tools/enemy-action-contract-check/imported-native-bgm-smoke.lua": "a5cc97e09c6b22f1469aef2cf37b43c0f5925fb86739560866aedc59a5255ce6", + "tools/enemy-action-contract-check/imported-native-fanfare-metadata.lua": "e8d035a574d0be4d95c30a44e64dfed016b2ac6fc0544dc9f11950f49d09684f", + "tools/enemy-action-contract-check/native-audio-session-smoke.lua": "da94961bb6dc60924fc633612cf92521771aa762a0e085a8ff37b6f9a334f457", + "tools/enemy-action-contract-check/native-bgm-stream-smoke.lua": "e2f0e3bd0fcc317ce3ee00106fd1717ed174c0ce0cdd31b5bfe2d291672a0f92", + "tools/enemy-action-contract-check/native-fanfare-stream-smoke.lua": "09cd54b8e1ac4b0b830b55e542770b49495e8a0668d12aab15dee279158b505e", + "tools/enemy-action-contract-check/native-game-audio-session-smoke.lua": "4d6b8d6b31bcec16f1cd7d383877c92eb6ccbfc641fa1f17ecbd802f60aed934", + "tools/enemy-action-contract-check/native-protocol-audit.lua": "927188bb1dfa1e341f4bab292cbed2aabc7d8564f3882510beee349870954a98", + "tools/enemy-action-contract-check/worker.lua": "9202766ff657bd2fa2e54e064a0cc8af27d1881b71dce159a1d34a13bf3418e0" + } + }, + "scope": { + "local_cpu_qualification": true, + "reference_unmodified": true, + "actual_game_boot": false, + "gpu_or_audio_device": false, + "normal_save_or_import_writes": false, + "full_mod_or_playthrough_qualification": false, + "hosted_ci_execution_claim": false, + "known_differences": [ + "native synthesizer PCM and baseline gains", + "two shared GB lookahead cases", + "native queue allocation counts" + ], + "remaining": [ + "complete map/bike/surf transition coverage", + "other editions", + "real device recovery", + "full hot-reload screens", + "arbitrary full mods/playthroughs" + ] + } +} diff --git a/docs/qa/native-gba-cries.json b/docs/qa/native-gba-cries.json new file mode 100644 index 000000000..d78543303 --- /dev/null +++ b/docs/qa/native-gba-cries.json @@ -0,0 +1,1772 @@ +{ + "schema": 1, + "reference": "535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91", + "native_command_revision": 19, + "native_party_revision": 1, + "native_fixture_digest": "22e453523676534e", + "legacy_fixture_digest": "e33d251961f2a41d", + "runtimes": [ + { + "qualified_head": "1322ee81ed956b79d3f0da68e8bb3d5bc19d2d99", + "src_tree": "5e6df82f1a96f251cb39fc40092fa65cffec6e96", + "tests_tree": "cdd5c58cfb0e36907cc9a29d2cb70df42c52a566", + "workers": 240, + "complete_paired_rows": 176170, + "slowest_worker_seconds": 76.4408, + "all_modes_backends_profiles_exactly_once": true, + "all_complete_observations_recompared": true, + "json_boolean_types_preserved": true, + "fixture_sha256": { + "examples/portable_enemy_action/main.lua": "45f32dd07fe49b4b1d69a14b7685c9f92e5c5e11bc6a429ed48ae16e4931e35c", + "examples/portable_enemy_action/manifest.json": "44a5af16dc7e9980c54b1f01f880bb749b591ebe7161805ecc31d02bf4ead462", + "tests/post_hit_items/command-cases.json": "16255564717600d4ff54991d64055dc5211f0fbc4df049fb94a267264c240320", + "tests/enemy_actions/accuracy.lua": "77d77f61c391b2a919cd80874c0bfa407238321d203fbfa6b9943208dd713e71", + "tests/enemy_actions/arguments.lua": "1be797a88e4b16a94daa21b842abd7c3fc285a65c450bbb5da21ea7da1c8a331", + "tests/enemy_actions/assist.lua": "af4df57be823bf8a0c8136f68ee0ac9384b06ac2b97d1ae02eaccca57add6174", + "tests/enemy_actions/baton_pass.lua": "b7d34d8aa599f15f89c452bd6c3005f9944a273f564627b698e3263e7b015255", + "tests/enemy_actions/battle_effect_presentation.lua": "7e96da1c96ea935f343189563698bb4b839b38fc6ed47bc6118016ea9c3a2db6", + "tests/enemy_actions/beat_up.lua": "8785b017f6d5d3b25eee4d0660da923f7c2a02e3c4baf29b6f8900c4a3fc8ee2", + "tests/enemy_actions/cases.lua": "1e8e34b3f4d430215e88487af35d325b69652c836f3ae93a748c20ba189d169b", + "tests/enemy_actions/choice_band.lua": "1d63434a99aef6bd5cda2b146a3598419839c64392388ce9df202f0da0f1c514", + "tests/enemy_actions/choice_call_failure.lua": "26d4605fd6c36b2792c6e28e541753235208ad6befff86eb1febb2cdcd2e4c3b", + "tests/enemy_actions/choice_content.lua": "2a1fefad3a9dd74ed4e3aa8c1498977a964a0a8f01a1b95712a8af599a3062fc", + "tests/enemy_actions/choice_planning.lua": "0bc6f8c48d73dd4dac3df8b22e197388b27ea3557b66ff494c7fc29a50e97a7b", + "tests/enemy_actions/choice_selection.lua": "0bf5e872eb7776acd6544c5bc687b80102bda17fa1ca1596970961af430a7eaa", + "tests/enemy_actions/choice_wild.lua": "8c686058d633547156ba2503c85ff0bb529fbc0cc85f5c099c5ccf565674a174", + "tests/enemy_actions/constrained_party.lua": "90d8eb904acf611870066c3d96e3df97a6fb97c74fda6ad63fa20b74de722478", + "tests/enemy_actions/critical_kinds.lua": "e1f12a0512bf04ca81c6003bbf98de625aebefd580819873a64e5eb7470b56a5", + "tests/enemy_actions/disable.lua": "c72e0a46c08dc18c9fa4d242a64ad6be1d33a304e42ec36afcf1a7f8378cc5ed", + "tests/enemy_actions/encore.lua": "b1098ab50112d378d00daecec6bde3582e16aaf9893e4de2cdc74d6a8393e786", + "tests/enemy_actions/fixture.lua": "c9a47efd9a96bb16786ba0593ab4c659112adc30a5febda05ee516e36eb59a83", + "tests/enemy_actions/follow_me.lua": "73996c39ac7a6499c5036821a2c8d0c4b6e025eddea66df9adb1223dfaa056e8", + "tests/enemy_actions/helping_hand.lua": "80dfd210f629a81b06dea5227e0d2cd98daf1909ab7824cb0ce487305266745e", + "tests/enemy_actions/lock_on.lua": "e0a034cacbce4053da9fa71b7930cf2553d8e19bc742ab02c247da50692adaef", + "tests/enemy_actions/mirror_move.lua": "605ad320af100a0e821da104e37f914ca3f262d49ed5d632878dd9ae5f80cb1b", + "tests/enemy_actions/move_used_pp.lua": "a82587b98b62258476906c72bfaca7076228dbedf1df6ba4029df4dbb054b5a7", + "tests/enemy_actions/multi_hit.lua": "ce3f976363fc9698edbd2adad5cf0038dc63720b2247e65368793cd67b0d3faa", + "tests/enemy_actions/native_cry_commands.lua": "39613836177d1e0b53ee175948d5f8e7f48fd6c7ae0348de04c48b53c0345178", + "tests/enemy_actions/native_cry_lifecycle.lua": "e7c9fa11f9e2c39afa240cad2e580fe5ee2f0149bcd8a4463ec8d08b9c66d6b6", + "tests/enemy_actions/native_cry_pcm.lua": "f194ac72d6218a568be9cfe547db238ae09724ba4f7ada244edc5bfc14e571a9", + "tests/enemy_actions/native_sound_lifecycle.lua": "de0c2d661bf16b0eaf6077ae56ca9385798d591821e5d07d9469415eef89df08", + "tests/enemy_actions/native_sound_pan.lua": "0c123bbfaefe763f5af169e3e6bdcdc9a334bb008a2a33f77c768ff2459c9b3b", + "tests/enemy_actions/native_sound_script_waits.lua": "84addd72ead7b69af4c42155591d160d15dd4d391568927438aae2ec8765d3e4", + "tests/enemy_actions/nature_power.lua": "962bea0356b91bc08036ad6a2e5736dfd0a7676d6833e281ccfb689585967015", + "tests/enemy_actions/party_codec.lua": "b93a7ca419db9157ac6039ef6b0815714cd3f8702df2053125f9c00b2e847ab2", + "tests/enemy_actions/party_codec_hooks.lua": "03ee43ce8a3d0993b8fb4bc0f497b8950543d1f208c7915f8e817a98adcff8e5", + "tests/enemy_actions/post_hit_items.lua": "20c457c610b1b9e943d438a301c9e2e0b315273216a592a8a47b103d4d5d6262", + "tests/enemy_actions/rage.lua": "9ab04345ace6bae4f9cdeb03ca7817955a46c0a670cfb1810411cb020a5f702d", + "tests/enemy_actions/records.lua": "46a4e3212c8d17fbd10956ae2988f4fde08548f929dd0c617e664ab18537b592", + "tests/enemy_actions/secondary.lua": "c356415b82f5040b8baf7bf1b370e2726965534cf8421bc5a41db6aa941f3a13", + "tests/enemy_actions/sleep_talk.lua": "94cc5d33e71a968233eebe5e3db57fe842922006489c6e0ddd087b072189c4d0", + "tests/enemy_actions/spit_up.lua": "302a5510f0854b00f76d1566954b979eb9aacc6083014b69d1a4bfc137672750", + "tests/enemy_actions/substitute_fade.lua": "f56d807b858d882d04b061dea13f5e1488ec5492aab72236568d07ba159cafd5", + "tests/enemy_actions/substitute_presentation.lua": "830db8653efe84dcf117e305e1b5c5056fb424670d8eb20bba85c0d80af1e5cb", + "tests/enemy_actions/targets.lua": "487ba3e87c8f9796679afc6984a2f69f5ed25e8a7590509eb269f2682ffb2ce6", + "tests/enemy_actions/triple_kick.lua": "9ed45dff2ae14220672caa3d9458401b42d9ab26dbbb8f62204d9172b06301fa", + "tools/enemy-action-contract-check/worker.lua": "25e75cfa1e719c3b0a3538297a055f0d0bb8659bda1a9eff82f802e62e37c2ff", + "tests/native_sound/fixture.lua": "9990683d1b7fc4c5af6d9119d14fa2ee8bf9897ec3aeb7fab5029057f0e33292", + "tests/native_cry/fixture.lua": "ce350eff0f5e6a537c75d103a03e9f676d838ae7a8d833a745431bfffca894c8" + }, + "branch": "feature/native-cry-commands", + "raw_reports": [ + { + "sha256": "9bdad00634f6576298e80c1142777581bfc278ea3ecc2b10693698c30968c6dc", + "host_ref": "1322ee81ed956b79d3f0da68e8bb3d5bc19d2d99", + "original_passed": true, + "path": "tmp/native-cry-broad-qa/batch-01.json" + }, + { + "sha256": "618cac07821aaf2d1d5eb74d2a82feb8a06e21b4bf24e1f340554990f26ecc56", + "host_ref": "1322ee81ed956b79d3f0da68e8bb3d5bc19d2d99", + "original_passed": true, + "path": "tmp/native-cry-broad-qa/batch-02.json" + }, + { + "sha256": "3705cbb11241fdfef5c90eae05cd482fc4513a6cc1472109e3037a4d301f0499", + "host_ref": "1322ee81ed956b79d3f0da68e8bb3d5bc19d2d99", + "original_passed": false, + "path": "tmp/native-cry-broad-qa/batch-03.json" + }, + { + "sha256": "16a3c9711d5233b0ed8b4c39abc32fafd46b3729fd69fa3d60e1f0690bd6b9d0", + "host_ref": "1322ee81ed956b79d3f0da68e8bb3d5bc19d2d99", + "original_passed": true, + "path": "tmp/native-cry-broad-qa/batch-04.json" + }, + { + "sha256": "fa916bea3ea3f5dcb78e70c2698b25dbf7806551ca40befdd727024ff9e8328d", + "host_ref": "1322ee81ed956b79d3f0da68e8bb3d5bc19d2d99", + "original_passed": true, + "path": "tmp/native-cry-broad-qa/batch-05.json" + }, + { + "sha256": "4eed1aa342fc4048dd5c3bc8115e670290f20341978a1a93f44e71b61c83caba", + "host_ref": "1322ee81ed956b79d3f0da68e8bb3d5bc19d2d99", + "original_passed": true, + "path": "tmp/native-cry-broad-qa/batch-06.json" + }, + { + "sha256": "5be603d547ce3fe9d2046a381d691b0ea672d7ff34ced454b742a716e0ac7051", + "host_ref": "1322ee81ed956b79d3f0da68e8bb3d5bc19d2d99", + "original_passed": true, + "path": "tmp/native-cry-broad-qa/batch-07.json" + }, + { + "sha256": "2cb2fd4de017fe53b9279934eec3686506c5ba0d4be106a7ec80cae2da1ab25f", + "host_ref": "1322ee81ed956b79d3f0da68e8bb3d5bc19d2d99", + "original_passed": true, + "path": "tmp/native-cry-broad-qa/batch-08.json" + }, + { + "sha256": "582566eb2bc2713b3be978f49efb155642157094367e7cbf96e8a34310657707", + "host_ref": "1322ee81ed956b79d3f0da68e8bb3d5bc19d2d99", + "original_passed": true, + "path": "tmp/native-cry-broad-qa/batch-09.json" + }, + { + "sha256": "7037a113e254803ac1314fc8bd9591da0ae652088016948a13ca48d5c1b3f435", + "host_ref": "1322ee81ed956b79d3f0da68e8bb3d5bc19d2d99", + "original_passed": true, + "path": "tmp/native-cry-broad-qa/batch-10.json" + }, + { + "sha256": "98fbec63e99f99f9079e2e0d7a899655e44c89bd03ce3d210af0abe0fff78571", + "host_ref": "1322ee81ed956b79d3f0da68e8bb3d5bc19d2d99", + "bounded_retry": true, + "path": "tmp/native-cry-nature-power-retry-qa/bounded-retry.json" + } + ], + "retained_initial_timeouts": [ + { + "worker": [ + "gen2", + "firered", + 3, + "nature_power" + ], + "seconds": 60, + "sha256": "3705cbb11241fdfef5c90eae05cd482fc4513a6cc1472109e3037a4d301f0499", + "raw_report": "tmp/native-cry-broad-qa/batch-03.json" + } + ] + }, + { + "qualified_head": "0286d291ffe9588d31679dc543508501857d5e39", + "src_tree": "27c23e9133c28e1912fea9a393b5748708bdc3bf", + "tests_tree": "5c97db04e40ee96268d365824bae4841757257a5", + "workers": 240, + "complete_paired_rows": 176170, + "slowest_worker_seconds": 47.3421, + "all_modes_backends_profiles_exactly_once": true, + "all_complete_observations_recompared": true, + "json_boolean_types_preserved": true, + "fixture_sha256": { + "examples/portable_enemy_action/main.lua": "45f32dd07fe49b4b1d69a14b7685c9f92e5c5e11bc6a429ed48ae16e4931e35c", + "examples/portable_enemy_action/manifest.json": "44a5af16dc7e9980c54b1f01f880bb749b591ebe7161805ecc31d02bf4ead462", + "tests/post_hit_items/command-cases.json": "16255564717600d4ff54991d64055dc5211f0fbc4df049fb94a267264c240320", + "tests/enemy_actions/accuracy.lua": "77d77f61c391b2a919cd80874c0bfa407238321d203fbfa6b9943208dd713e71", + "tests/enemy_actions/arguments.lua": "1be797a88e4b16a94daa21b842abd7c3fc285a65c450bbb5da21ea7da1c8a331", + "tests/enemy_actions/assist.lua": "af4df57be823bf8a0c8136f68ee0ac9384b06ac2b97d1ae02eaccca57add6174", + "tests/enemy_actions/baton_pass.lua": "b7d34d8aa599f15f89c452bd6c3005f9944a273f564627b698e3263e7b015255", + "tests/enemy_actions/battle_effect_presentation.lua": "7e96da1c96ea935f343189563698bb4b839b38fc6ed47bc6118016ea9c3a2db6", + "tests/enemy_actions/beat_up.lua": "8785b017f6d5d3b25eee4d0660da923f7c2a02e3c4baf29b6f8900c4a3fc8ee2", + "tests/enemy_actions/cases.lua": "1e8e34b3f4d430215e88487af35d325b69652c836f3ae93a748c20ba189d169b", + "tests/enemy_actions/choice_band.lua": "1d63434a99aef6bd5cda2b146a3598419839c64392388ce9df202f0da0f1c514", + "tests/enemy_actions/choice_call_failure.lua": "26d4605fd6c36b2792c6e28e541753235208ad6befff86eb1febb2cdcd2e4c3b", + "tests/enemy_actions/choice_content.lua": "2a1fefad3a9dd74ed4e3aa8c1498977a964a0a8f01a1b95712a8af599a3062fc", + "tests/enemy_actions/choice_planning.lua": "0bc6f8c48d73dd4dac3df8b22e197388b27ea3557b66ff494c7fc29a50e97a7b", + "tests/enemy_actions/choice_selection.lua": "0bf5e872eb7776acd6544c5bc687b80102bda17fa1ca1596970961af430a7eaa", + "tests/enemy_actions/choice_wild.lua": "8c686058d633547156ba2503c85ff0bb529fbc0cc85f5c099c5ccf565674a174", + "tests/enemy_actions/constrained_party.lua": "90d8eb904acf611870066c3d96e3df97a6fb97c74fda6ad63fa20b74de722478", + "tests/enemy_actions/critical_kinds.lua": "e1f12a0512bf04ca81c6003bbf98de625aebefd580819873a64e5eb7470b56a5", + "tests/enemy_actions/disable.lua": "c72e0a46c08dc18c9fa4d242a64ad6be1d33a304e42ec36afcf1a7f8378cc5ed", + "tests/enemy_actions/encore.lua": "b1098ab50112d378d00daecec6bde3582e16aaf9893e4de2cdc74d6a8393e786", + "tests/enemy_actions/fixture.lua": "c9a47efd9a96bb16786ba0593ab4c659112adc30a5febda05ee516e36eb59a83", + "tests/enemy_actions/follow_me.lua": "73996c39ac7a6499c5036821a2c8d0c4b6e025eddea66df9adb1223dfaa056e8", + "tests/enemy_actions/helping_hand.lua": "80dfd210f629a81b06dea5227e0d2cd98daf1909ab7824cb0ce487305266745e", + "tests/enemy_actions/lock_on.lua": "e0a034cacbce4053da9fa71b7930cf2553d8e19bc742ab02c247da50692adaef", + "tests/enemy_actions/mirror_move.lua": "605ad320af100a0e821da104e37f914ca3f262d49ed5d632878dd9ae5f80cb1b", + "tests/enemy_actions/move_used_pp.lua": "a82587b98b62258476906c72bfaca7076228dbedf1df6ba4029df4dbb054b5a7", + "tests/enemy_actions/multi_hit.lua": "ce3f976363fc9698edbd2adad5cf0038dc63720b2247e65368793cd67b0d3faa", + "tests/enemy_actions/native_cry_commands.lua": "39613836177d1e0b53ee175948d5f8e7f48fd6c7ae0348de04c48b53c0345178", + "tests/enemy_actions/native_cry_lifecycle.lua": "e7c9fa11f9e2c39afa240cad2e580fe5ee2f0149bcd8a4463ec8d08b9c66d6b6", + "tests/enemy_actions/native_cry_pcm.lua": "f194ac72d6218a568be9cfe547db238ae09724ba4f7ada244edc5bfc14e571a9", + "tests/enemy_actions/native_sound_lifecycle.lua": "de0c2d661bf16b0eaf6077ae56ca9385798d591821e5d07d9469415eef89df08", + "tests/enemy_actions/native_sound_pan.lua": "0c123bbfaefe763f5af169e3e6bdcdc9a334bb008a2a33f77c768ff2459c9b3b", + "tests/enemy_actions/native_sound_script_waits.lua": "84addd72ead7b69af4c42155591d160d15dd4d391568927438aae2ec8765d3e4", + "tests/enemy_actions/nature_power.lua": "962bea0356b91bc08036ad6a2e5736dfd0a7676d6833e281ccfb689585967015", + "tests/enemy_actions/party_codec.lua": "b93a7ca419db9157ac6039ef6b0815714cd3f8702df2053125f9c00b2e847ab2", + "tests/enemy_actions/party_codec_hooks.lua": "03ee43ce8a3d0993b8fb4bc0f497b8950543d1f208c7915f8e817a98adcff8e5", + "tests/enemy_actions/post_hit_items.lua": "20c457c610b1b9e943d438a301c9e2e0b315273216a592a8a47b103d4d5d6262", + "tests/enemy_actions/rage.lua": "9ab04345ace6bae4f9cdeb03ca7817955a46c0a670cfb1810411cb020a5f702d", + "tests/enemy_actions/records.lua": "46a4e3212c8d17fbd10956ae2988f4fde08548f929dd0c617e664ab18537b592", + "tests/enemy_actions/secondary.lua": "c356415b82f5040b8baf7bf1b370e2726965534cf8421bc5a41db6aa941f3a13", + "tests/enemy_actions/sleep_talk.lua": "94cc5d33e71a968233eebe5e3db57fe842922006489c6e0ddd087b072189c4d0", + "tests/enemy_actions/spit_up.lua": "302a5510f0854b00f76d1566954b979eb9aacc6083014b69d1a4bfc137672750", + "tests/enemy_actions/substitute_fade.lua": "f56d807b858d882d04b061dea13f5e1488ec5492aab72236568d07ba159cafd5", + "tests/enemy_actions/substitute_presentation.lua": "830db8653efe84dcf117e305e1b5c5056fb424670d8eb20bba85c0d80af1e5cb", + "tests/enemy_actions/targets.lua": "487ba3e87c8f9796679afc6984a2f69f5ed25e8a7590509eb269f2682ffb2ce6", + "tests/enemy_actions/triple_kick.lua": "9ed45dff2ae14220672caa3d9458401b42d9ab26dbbb8f62204d9172b06301fa", + "tools/enemy-action-contract-check/worker.lua": "25e75cfa1e719c3b0a3538297a055f0d0bb8659bda1a9eff82f802e62e37c2ff", + "tests/native_sound/fixture.lua": "9990683d1b7fc4c5af6d9119d14fa2ee8bf9897ec3aeb7fab5029057f0e33292", + "tests/native_cry/fixture.lua": "ce350eff0f5e6a537c75d103a03e9f676d838ae7a8d833a745431bfffca894c8" + }, + "branch": "integration/native-cry-commands", + "raw_reports": [ + { + "sha256": "ad7600ab6f55870570f259e9a61618ae6407128512a6bb62b45b1288b51bfc80", + "host_ref": "0286d291ffe9588d31679dc543508501857d5e39", + "original_passed": true, + "path": "tmp/native-cry-broad-qa/batch-01.json" + }, + { + "sha256": "8d400cebf7ba0bcdc23ae5a245b9a071fd7d0a2dabbd3e19d7eb077eda10fc18", + "host_ref": "0286d291ffe9588d31679dc543508501857d5e39", + "original_passed": true, + "path": "tmp/native-cry-broad-qa/batch-02.json" + }, + { + "sha256": "4cc13dcad20adee8ab8fa74945f320d1bbcc2c5e23a3e666dbb03dd0b2c7c156", + "host_ref": "0286d291ffe9588d31679dc543508501857d5e39", + "original_passed": true, + "path": "tmp/native-cry-broad-qa/batch-03.json" + }, + { + "sha256": "5356d12f0b86400f56129cb211af44149a5101a0e50b962249202870535d6c2f", + "host_ref": "0286d291ffe9588d31679dc543508501857d5e39", + "original_passed": true, + "path": "tmp/native-cry-broad-qa/batch-04.json" + }, + { + "sha256": "42a0e27dc236d0b19506ba2e9a70685b525b1dc2ffee59f3256bd34b31e530a8", + "host_ref": "0286d291ffe9588d31679dc543508501857d5e39", + "original_passed": true, + "path": "tmp/native-cry-broad-qa/batch-05.json" + }, + { + "sha256": "15c4b4e83f0a7903d67fcf8b588dadc6406318a0688095cbccfd04f149288447", + "host_ref": "0286d291ffe9588d31679dc543508501857d5e39", + "original_passed": true, + "path": "tmp/native-cry-broad-qa/batch-06.json" + }, + { + "sha256": "2429a4200a5f6fd6ed0f98f84bfff9c15c13bc518a7b6975ee72373ab2da86d6", + "host_ref": "0286d291ffe9588d31679dc543508501857d5e39", + "original_passed": true, + "path": "tmp/native-cry-broad-qa/batch-07.json" + }, + { + "sha256": "5a3ffe80f15de341a9f1cd27c7159f20c01687acd4de5b46ae08c005350f08da", + "host_ref": "0286d291ffe9588d31679dc543508501857d5e39", + "original_passed": true, + "path": "tmp/native-cry-broad-qa/batch-08.json" + }, + { + "sha256": "dcafd6858b02036dce94c0bf22fd5d3865d04cb11392df5730860c664bba2836", + "host_ref": "0286d291ffe9588d31679dc543508501857d5e39", + "original_passed": true, + "path": "tmp/native-cry-broad-qa/batch-09.json" + }, + { + "sha256": "5f7923e4a05319a9550b1134817b4d4435361fd509068307d02d832e65f11e7a", + "host_ref": "0286d291ffe9588d31679dc543508501857d5e39", + "original_passed": true, + "path": "tmp/native-cry-broad-qa/batch-10.json" + } + ], + "retained_initial_timeouts": [] + } + ], + "full_regression": { + "feature": { + "engine": "167/176", + "sdk": "3/3", + "b30": true, + "log_sha256": "d183f46570a8fa823e85c8b63909aa4b556c3511c9b81603b19b9a790abf1c18" + }, + "fresh_parent": { + "engine": "166/175", + "sdk": "3/3", + "b30": true, + "log_sha256": "fabc6c9e19a4ccb0c7a9e2ddc2a0a8c7692be2331f8a9edf9f34ef6fd1a5a14d" + }, + "combined": { + "head": "0286d291ffe9588d31679dc543508501857d5e39", + "engine": "285/285", + "sdk": "3/3", + "b30": true, + "all_quick_tiers": true, + "anchored_FAIL_Counter": {}, + "sha256": "80ce2b06322a6a50f08db450541ad0010773fb539d9b47120f3661c3e61a3c76" + }, + "exact_feature_parent_FAIL_Counter": { + "FAIL hook:battle.status_hud_visible has no unit test naming it through the public mod API (add one, or add a DEBT entry saying which milestone owes it)": 1, + "FAIL event:intro.birch_speech.finished has no unit test naming it through the public mod API (add one, or add a DEBT entry saying which milestone owes it)": 1, + "FAIL event:intro.birch_speech.started has no unit test naming it through the public mod API (add one, or add a DEBT entry saying which milestone owes it)": 1, + "FAIL event:pokemon.egg_hatched has no unit test naming it through the public mod API (add one, or add a DEBT entry saying which milestone owes it)": 1, + "FAIL event:world.blocks_replaced has no unit test naming it through the public mod API (add one, or add a DEBT entry saying which milestone owes it)": 1, + "FAIL event:world.tick has no unit test naming it through the public mod API (add one, or add a DEBT entry saying which milestone owes it)": 1, + "FAIL tests/engine/gate_meta_coverage.lua": 1, + "FAIL the process exit runs only after love.quit declined to veto": 1, + "FAIL the process exit is gated on Android": 1, + "FAIL tests/engine/quit_thread_shutdown.lua": 1, + "FAIL and stays out of the live pad map until close (got x, want nil)": 1, + "FAIL tests/engine/rebind_capture_bug510.lua": 1, + "FAIL all eight actions still have exactly one pad button (got 12, want 8)": 1, + "FAIL tests/engine/rebind_swap_clear_bug589.lua": 1, + "FAIL tests/engine/soft_reset_bug563.lua": 1, + "FAIL tests/engine/tool_mod_hooks.lua": 1, + "FAIL releaseUrl points at the repo's latest release (got https://github.com/UNDERdecoded/Gen2Recomped/releases/latest, want https://github.com/bryanthaboi/gen1recomp/releases/latest)": 1, + "FAIL payload name derived from version (got Gen2Recomped-1.4.2.love, want gen1recomp-1.4.2.love)": 1, + "FAIL tests/engine/update_check_tests.lua": 1, + "FAIL parseRelease derives the payload name from the version (got Gen2Recomped-1.4.2.love, want gen1recomp-1.4.2.love)": 1, + "FAIL tests/engine/update_tests.lua": 1, + "FAIL a player-side frame lands on the player anchor (got 37, want 20)": 1, + "FAIL a player-side frame lands on the player baseline (got 7, want 8)": 1, + "FAIL an enemy-side frame lands on the enemy anchor (got 131, want 136)": 1, + "FAIL tests/engine/wide_battle_layout.lua": 1 + } + }, + "targeted_engine_checks": { + "native_cry": 10983, + "protocol": 175, + "se_player_lifecycle": 110, + "world_se_waits": 6306 + }, + "negative_audio_clock": { + "runtime_line_removed": "Game:update Sound.updateCry call", + "fail_counter": { + "FAIL Game:update advances the independent real audio clock after script completion": 6 + }, + "negative_checks": "10977/10983", + "positive_checks": "10983/10983", + "raw_log_sha256": "3e6db98a3770fed89d1ec35661ec9118bfadc4b734fb0dcf8670905333bfedd9" + }, + "tool_qualification": { + "imported_cry_scripts": { + "tool": "tools/enemy-action-contract-check/imported-native-cry-smoke.lua", + "tool_sha256": "8a14e5a9a449a990c37ec9fdd0341ee2fb6ee026dd4d92223cefe2c7164eb363", + "runs": [ + { + "host": "gen2recomped-native-cry-commands", + "backend": "gen1", + "version": "firered", + "exit": 0, + "log": "tmp/native-cry-published-import-qa/gen1-firered-commands-first.log", + "sha256": "06e1d77b9f49ebdfe68eb432e810e44b8d0b6ddecd2991f35644d025f24d95a1" + }, + { + "host": "gen2recomped-native-cry-commands", + "backend": "gen2", + "version": "firered", + "exit": 0, + "log": "tmp/native-cry-published-import-qa/gen2-firered-commands-first.log", + "sha256": "d5f58d85c918dd96766eb1e78c4397011b180b0731071cb86b4e13304ea32a8f" + }, + { + "host": "gen2recomped-native-cry-commands", + "backend": "gen1", + "version": "emerald", + "exit": 0, + "log": "tmp/native-cry-published-import-qa/gen1-emerald-commands-first.log", + "sha256": "8edf699dfc6461e6c8a835866416c89a39f6602e0548fe0f2dcd753502a29b04" + }, + { + "host": "gen2recomped-native-cry-commands", + "backend": "gen2", + "version": "emerald", + "exit": 0, + "log": "tmp/native-cry-published-import-qa/gen2-emerald-commands-first.log", + "sha256": "fbbfa30b39dda37c867e9c37af9ae6559afdfe967805331eb7f445535dbb1d30" + }, + { + "host": "gen2recomped-native-cry-commands-integration", + "backend": "gen1", + "version": "firered", + "exit": 0, + "log": "tmp/native-cry-published-import-qa/gen1-firered-commands-first.log", + "sha256": "2d317e11df5ff579f8e3b9f3c1448cc618ae52f848eeb709867df21fd0050eb4" + }, + { + "host": "gen2recomped-native-cry-commands-integration", + "backend": "gen2", + "version": "firered", + "exit": 0, + "log": "tmp/native-cry-published-import-qa/gen2-firered-commands-first.log", + "sha256": "3ff6e4e6f01f799002ab359ef8870debb5c52104e70fbbeec5d129c498be5c8d" + }, + { + "host": "gen2recomped-native-cry-commands-integration", + "backend": "gen1", + "version": "emerald", + "exit": 0, + "log": "tmp/native-cry-published-import-qa/gen1-emerald-commands-first.log", + "sha256": "37d1189e93fc214e13817cfefdfc5c1dd7b64958801cbba4e301911f950d8ae3" + }, + { + "host": "gen2recomped-native-cry-commands-integration", + "backend": "gen2", + "version": "emerald", + "exit": 0, + "log": "tmp/native-cry-published-import-qa/gen2-emerald-commands-first.log", + "sha256": "3b8c097cd28ae67a3e4c3e4dae2a42bba7d6cf0f559483a713dee300b2ee921b" + } + ], + "complete_pairs_across_two_runtimes": 36, + "comparison_details": [ + { + "version": "firered", + "equal": true, + "cases": 9, + "rows": [ + { + "species": 4, + "mode": 0, + "index": 3, + "completed": 41, + "frames": 221, + "samples": 29925 + }, + { + "species": 4, + "mode": 6, + "index": 3, + "completed": 40, + "frames": 221, + "samples": 29047 + }, + { + "species": 4, + "mode": 9, + "index": 3, + "completed": 21, + "frames": 221, + "samples": 15506 + }, + { + "species": 277, + "mode": 0, + "index": 273, + "completed": 33, + "frames": 221, + "samples": 24136 + }, + { + "species": 277, + "mode": 6, + "index": 273, + "completed": 32, + "frames": 221, + "samples": 23427 + }, + { + "species": 277, + "mode": 9, + "index": 273, + "completed": 21, + "frames": 221, + "samples": 15506 + }, + { + "species": 411, + "mode": 0, + "index": 387, + "completed": 29, + "frames": 221, + "samples": 20796 + }, + { + "species": 411, + "mode": 6, + "index": 387, + "completed": 28, + "frames": 221, + "samples": 20186 + }, + { + "species": 411, + "mode": 9, + "index": 387, + "completed": 21, + "frames": 221, + "samples": 15506 + } + ] + }, + { + "version": "emerald", + "equal": true, + "cases": 9, + "rows": [ + { + "species": 4, + "mode": 0, + "index": 3, + "completed": 41, + "frames": 221, + "samples": 29925 + }, + { + "species": 4, + "mode": 6, + "index": 3, + "completed": 40, + "frames": 221, + "samples": 29047 + }, + { + "species": 4, + "mode": 9, + "index": 3, + "completed": 21, + "frames": 221, + "samples": 15506 + }, + { + "species": 277, + "mode": 0, + "index": 273, + "completed": 33, + "frames": 221, + "samples": 24136 + }, + { + "species": 277, + "mode": 6, + "index": 273, + "completed": 32, + "frames": 221, + "samples": 23427 + }, + { + "species": 277, + "mode": 9, + "index": 273, + "completed": 21, + "frames": 221, + "samples": 15506 + }, + { + "species": 411, + "mode": 0, + "index": 387, + "completed": 29, + "frames": 221, + "samples": 20796 + }, + { + "species": 411, + "mode": 6, + "index": 387, + "completed": 28, + "frames": 221, + "samples": 20186 + }, + { + "species": 411, + "mode": 9, + "index": 387, + "completed": 21, + "frames": 221, + "samples": 15506 + } + ] + }, + { + "version": "firered", + "equal": true, + "cases": 9, + "rows": [ + { + "species": 4, + "mode": 0, + "index": 3, + "completed": 41, + "frames": 221, + "samples": 29925 + }, + { + "species": 4, + "mode": 6, + "index": 3, + "completed": 40, + "frames": 221, + "samples": 29047 + }, + { + "species": 4, + "mode": 9, + "index": 3, + "completed": 21, + "frames": 221, + "samples": 15506 + }, + { + "species": 277, + "mode": 0, + "index": 273, + "completed": 33, + "frames": 221, + "samples": 24136 + }, + { + "species": 277, + "mode": 6, + "index": 273, + "completed": 32, + "frames": 221, + "samples": 23427 + }, + { + "species": 277, + "mode": 9, + "index": 273, + "completed": 21, + "frames": 221, + "samples": 15506 + }, + { + "species": 411, + "mode": 0, + "index": 387, + "completed": 29, + "frames": 221, + "samples": 20796 + }, + { + "species": 411, + "mode": 6, + "index": 387, + "completed": 28, + "frames": 221, + "samples": 20186 + }, + { + "species": 411, + "mode": 9, + "index": 387, + "completed": 21, + "frames": 221, + "samples": 15506 + } + ] + }, + { + "version": "emerald", + "equal": true, + "cases": 9, + "rows": [ + { + "species": 4, + "mode": 0, + "index": 3, + "completed": 41, + "frames": 221, + "samples": 29925 + }, + { + "species": 4, + "mode": 6, + "index": 3, + "completed": 40, + "frames": 221, + "samples": 29047 + }, + { + "species": 4, + "mode": 9, + "index": 3, + "completed": 21, + "frames": 221, + "samples": 15506 + }, + { + "species": 277, + "mode": 0, + "index": 273, + "completed": 33, + "frames": 221, + "samples": 24136 + }, + { + "species": 277, + "mode": 6, + "index": 273, + "completed": 32, + "frames": 221, + "samples": 23427 + }, + { + "species": 277, + "mode": 9, + "index": 273, + "completed": 21, + "frames": 221, + "samples": 15506 + }, + { + "species": 411, + "mode": 0, + "index": 387, + "completed": 29, + "frames": 221, + "samples": 20796 + }, + { + "species": 411, + "mode": 6, + "index": 387, + "completed": 28, + "frames": 221, + "samples": 20186 + }, + { + "species": 411, + "mode": 9, + "index": 387, + "completed": 21, + "frames": 221, + "samples": 15506 + } + ] + } + ] + }, + "actual_cry_pcm": { + "tool": "tools/enemy-action-contract-check/imported-native-cry-render.lua", + "tool_sha256": "e0e5fbc309f17a807731f4515d5ae94b990d1d03ebfa7050a447f0ad4ee4000d", + "runs": [ + { + "host": "gen2recomped-native-cry-commands", + "backend": "gen1", + "version": "firered", + "exit": 0, + "log": "tmp/native-cry-published-render-qa/gen1-firered-render-frozen.log", + "sha256": "0377d9cf86558564cddc230ebd71f023587002b1eb279a729bca140f552765d7" + }, + { + "host": "gen2recomped-native-cry-commands", + "backend": "gen2", + "version": "firered", + "exit": 0, + "log": "tmp/native-cry-published-render-qa/gen2-firered-render-frozen.log", + "sha256": "b36615f72d185823ac73f8056a153c3f2c2c499c1c6da267ddb4714fddd6f1e9" + }, + { + "host": "gen2recomped-native-cry-commands", + "backend": "gen1", + "version": "emerald", + "exit": 0, + "log": "tmp/native-cry-published-render-qa/gen1-emerald-render-frozen.log", + "sha256": "7eb55912951dd3ba8d0c14cbe054aea750e711a17f5f0721dceb2972d96fdd06" + }, + { + "host": "gen2recomped-native-cry-commands", + "backend": "gen2", + "version": "emerald", + "exit": 0, + "log": "tmp/native-cry-published-render-qa/gen2-emerald-render-frozen.log", + "sha256": "80741b584a2e83043bde7f388ff708cbbf080b4132bbf37c8110ee8a2a785a35" + }, + { + "host": "gen2recomped-native-cry-commands-integration", + "backend": "gen1", + "version": "firered", + "exit": 0, + "log": "tmp/native-cry-published-render-qa/gen1-firered-render-frozen.log", + "sha256": "5a18759207421bbac2fe3bc2c2bbef7eec7f52b27e33b77c5a15080ac3cbb68e" + }, + { + "host": "gen2recomped-native-cry-commands-integration", + "backend": "gen2", + "version": "firered", + "exit": 0, + "log": "tmp/native-cry-published-render-qa/gen2-firered-render-frozen.log", + "sha256": "82a930fd492d31b4d530bede09bbe70712a89751435d2202319a8f6dbb471e67" + }, + { + "host": "gen2recomped-native-cry-commands-integration", + "backend": "gen1", + "version": "emerald", + "exit": 0, + "log": "tmp/native-cry-published-render-qa/gen1-emerald-render-frozen.log", + "sha256": "0fc6f9003f6eef02d698ed06082407779a6955330f829645bb44733370c5d3dd" + }, + { + "host": "gen2recomped-native-cry-commands-integration", + "backend": "gen2", + "version": "emerald", + "exit": 0, + "log": "tmp/native-cry-published-render-qa/gen2-emerald-render-frozen.log", + "sha256": "bcf5ecfae3866cec2bbb65447e49e87a62fa3c56a69716a068a4330d0ae52f2f" + } + ], + "complete_pairs_across_two_runtimes": 96, + "comparison_details": [ + { + "version": "firered", + "equal": true, + "cases": 24, + "rows": [ + { + "index": 0, + "mode": 0, + "frames": 47, + "samples": 34330, + "channels": 1, + "rate": 44100 + }, + { + "index": 0, + "mode": 1, + "frames": 47, + "samples": 34330, + "channels": 1, + "rate": 44100 + }, + { + "index": 0, + "mode": 4, + "frames": 30, + "samples": 22151, + "channels": 1, + "rate": 44100 + }, + { + "index": 0, + "mode": 6, + "frames": 46, + "samples": 33322, + "channels": 1, + "rate": 44100 + }, + { + "index": 0, + "mode": 9, + "frames": 21, + "samples": 15506, + "channels": 1, + "rate": 44100 + }, + { + "index": 0, + "mode": 12, + "frames": 50, + "samples": 36918, + "channels": 1, + "rate": 44100 + }, + { + "index": 3, + "mode": 0, + "frames": 41, + "samples": 29925, + "channels": 1, + "rate": 44100 + }, + { + "index": 3, + "mode": 1, + "frames": 41, + "samples": 29925, + "channels": 1, + "rate": 44100 + }, + { + "index": 3, + "mode": 4, + "frames": 30, + "samples": 22151, + "channels": 1, + "rate": 44100 + }, + { + "index": 3, + "mode": 6, + "frames": 40, + "samples": 29047, + "channels": 1, + "rate": 44100 + }, + { + "index": 3, + "mode": 9, + "frames": 21, + "samples": 15506, + "channels": 1, + "rate": 44100 + }, + { + "index": 3, + "mode": 12, + "frames": 46, + "samples": 33228, + "channels": 1, + "rate": 44100 + }, + { + "index": 273, + "mode": 0, + "frames": 33, + "samples": 24136, + "channels": 1, + "rate": 44100 + }, + { + "index": 273, + "mode": 1, + "frames": 33, + "samples": 24136, + "channels": 1, + "rate": 44100 + }, + { + "index": 273, + "mode": 4, + "frames": 30, + "samples": 22151, + "channels": 1, + "rate": 44100 + }, + { + "index": 273, + "mode": 6, + "frames": 32, + "samples": 23427, + "channels": 1, + "rate": 44100 + }, + { + "index": 273, + "mode": 9, + "frames": 21, + "samples": 15506, + "channels": 1, + "rate": 44100 + }, + { + "index": 273, + "mode": 12, + "frames": 37, + "samples": 26799, + "channels": 1, + "rate": 44100 + }, + { + "index": 387, + "mode": 0, + "frames": 29, + "samples": 20796, + "channels": 1, + "rate": 44100 + }, + { + "index": 387, + "mode": 1, + "frames": 29, + "samples": 20796, + "channels": 1, + "rate": 44100 + }, + { + "index": 387, + "mode": 4, + "frames": 27, + "samples": 19771, + "channels": 1, + "rate": 44100 + }, + { + "index": 387, + "mode": 6, + "frames": 28, + "samples": 20186, + "channels": 1, + "rate": 44100 + }, + { + "index": 387, + "mode": 9, + "frames": 21, + "samples": 15506, + "channels": 1, + "rate": 44100 + }, + { + "index": 387, + "mode": 12, + "frames": 32, + "samples": 23091, + "channels": 1, + "rate": 44100 + } + ] + }, + { + "version": "emerald", + "equal": true, + "cases": 24, + "rows": [ + { + "index": 0, + "mode": 0, + "frames": 47, + "samples": 34330, + "channels": 1, + "rate": 44100 + }, + { + "index": 0, + "mode": 1, + "frames": 47, + "samples": 34330, + "channels": 1, + "rate": 44100 + }, + { + "index": 0, + "mode": 4, + "frames": 30, + "samples": 22151, + "channels": 1, + "rate": 44100 + }, + { + "index": 0, + "mode": 6, + "frames": 46, + "samples": 33322, + "channels": 1, + "rate": 44100 + }, + { + "index": 0, + "mode": 9, + "frames": 21, + "samples": 15506, + "channels": 1, + "rate": 44100 + }, + { + "index": 0, + "mode": 12, + "frames": 50, + "samples": 36918, + "channels": 1, + "rate": 44100 + }, + { + "index": 3, + "mode": 0, + "frames": 41, + "samples": 29925, + "channels": 1, + "rate": 44100 + }, + { + "index": 3, + "mode": 1, + "frames": 41, + "samples": 29925, + "channels": 1, + "rate": 44100 + }, + { + "index": 3, + "mode": 4, + "frames": 30, + "samples": 22151, + "channels": 1, + "rate": 44100 + }, + { + "index": 3, + "mode": 6, + "frames": 40, + "samples": 29047, + "channels": 1, + "rate": 44100 + }, + { + "index": 3, + "mode": 9, + "frames": 21, + "samples": 15506, + "channels": 1, + "rate": 44100 + }, + { + "index": 3, + "mode": 12, + "frames": 46, + "samples": 33228, + "channels": 1, + "rate": 44100 + }, + { + "index": 273, + "mode": 0, + "frames": 33, + "samples": 24136, + "channels": 1, + "rate": 44100 + }, + { + "index": 273, + "mode": 1, + "frames": 33, + "samples": 24136, + "channels": 1, + "rate": 44100 + }, + { + "index": 273, + "mode": 4, + "frames": 30, + "samples": 22151, + "channels": 1, + "rate": 44100 + }, + { + "index": 273, + "mode": 6, + "frames": 32, + "samples": 23427, + "channels": 1, + "rate": 44100 + }, + { + "index": 273, + "mode": 9, + "frames": 21, + "samples": 15506, + "channels": 1, + "rate": 44100 + }, + { + "index": 273, + "mode": 12, + "frames": 37, + "samples": 26799, + "channels": 1, + "rate": 44100 + }, + { + "index": 387, + "mode": 0, + "frames": 29, + "samples": 20796, + "channels": 1, + "rate": 44100 + }, + { + "index": 387, + "mode": 1, + "frames": 29, + "samples": 20796, + "channels": 1, + "rate": 44100 + }, + { + "index": 387, + "mode": 4, + "frames": 27, + "samples": 19771, + "channels": 1, + "rate": 44100 + }, + { + "index": 387, + "mode": 6, + "frames": 28, + "samples": 20186, + "channels": 1, + "rate": 44100 + }, + { + "index": 387, + "mode": 9, + "frames": 21, + "samples": 15506, + "channels": 1, + "rate": 44100 + }, + { + "index": 387, + "mode": 12, + "frames": 32, + "samples": 23091, + "channels": 1, + "rate": 44100 + } + ] + }, + { + "version": "firered", + "equal": true, + "cases": 24, + "rows": [ + { + "index": 0, + "mode": 0, + "frames": 47, + "samples": 34330, + "channels": 1, + "rate": 44100 + }, + { + "index": 0, + "mode": 1, + "frames": 47, + "samples": 34330, + "channels": 1, + "rate": 44100 + }, + { + "index": 0, + "mode": 4, + "frames": 30, + "samples": 22151, + "channels": 1, + "rate": 44100 + }, + { + "index": 0, + "mode": 6, + "frames": 46, + "samples": 33322, + "channels": 1, + "rate": 44100 + }, + { + "index": 0, + "mode": 9, + "frames": 21, + "samples": 15506, + "channels": 1, + "rate": 44100 + }, + { + "index": 0, + "mode": 12, + "frames": 50, + "samples": 36918, + "channels": 1, + "rate": 44100 + }, + { + "index": 3, + "mode": 0, + "frames": 41, + "samples": 29925, + "channels": 1, + "rate": 44100 + }, + { + "index": 3, + "mode": 1, + "frames": 41, + "samples": 29925, + "channels": 1, + "rate": 44100 + }, + { + "index": 3, + "mode": 4, + "frames": 30, + "samples": 22151, + "channels": 1, + "rate": 44100 + }, + { + "index": 3, + "mode": 6, + "frames": 40, + "samples": 29047, + "channels": 1, + "rate": 44100 + }, + { + "index": 3, + "mode": 9, + "frames": 21, + "samples": 15506, + "channels": 1, + "rate": 44100 + }, + { + "index": 3, + "mode": 12, + "frames": 46, + "samples": 33228, + "channels": 1, + "rate": 44100 + }, + { + "index": 273, + "mode": 0, + "frames": 33, + "samples": 24136, + "channels": 1, + "rate": 44100 + }, + { + "index": 273, + "mode": 1, + "frames": 33, + "samples": 24136, + "channels": 1, + "rate": 44100 + }, + { + "index": 273, + "mode": 4, + "frames": 30, + "samples": 22151, + "channels": 1, + "rate": 44100 + }, + { + "index": 273, + "mode": 6, + "frames": 32, + "samples": 23427, + "channels": 1, + "rate": 44100 + }, + { + "index": 273, + "mode": 9, + "frames": 21, + "samples": 15506, + "channels": 1, + "rate": 44100 + }, + { + "index": 273, + "mode": 12, + "frames": 37, + "samples": 26799, + "channels": 1, + "rate": 44100 + }, + { + "index": 387, + "mode": 0, + "frames": 29, + "samples": 20796, + "channels": 1, + "rate": 44100 + }, + { + "index": 387, + "mode": 1, + "frames": 29, + "samples": 20796, + "channels": 1, + "rate": 44100 + }, + { + "index": 387, + "mode": 4, + "frames": 27, + "samples": 19771, + "channels": 1, + "rate": 44100 + }, + { + "index": 387, + "mode": 6, + "frames": 28, + "samples": 20186, + "channels": 1, + "rate": 44100 + }, + { + "index": 387, + "mode": 9, + "frames": 21, + "samples": 15506, + "channels": 1, + "rate": 44100 + }, + { + "index": 387, + "mode": 12, + "frames": 32, + "samples": 23091, + "channels": 1, + "rate": 44100 + } + ] + }, + { + "version": "emerald", + "equal": true, + "cases": 24, + "rows": [ + { + "index": 0, + "mode": 0, + "frames": 47, + "samples": 34330, + "channels": 1, + "rate": 44100 + }, + { + "index": 0, + "mode": 1, + "frames": 47, + "samples": 34330, + "channels": 1, + "rate": 44100 + }, + { + "index": 0, + "mode": 4, + "frames": 30, + "samples": 22151, + "channels": 1, + "rate": 44100 + }, + { + "index": 0, + "mode": 6, + "frames": 46, + "samples": 33322, + "channels": 1, + "rate": 44100 + }, + { + "index": 0, + "mode": 9, + "frames": 21, + "samples": 15506, + "channels": 1, + "rate": 44100 + }, + { + "index": 0, + "mode": 12, + "frames": 50, + "samples": 36918, + "channels": 1, + "rate": 44100 + }, + { + "index": 3, + "mode": 0, + "frames": 41, + "samples": 29925, + "channels": 1, + "rate": 44100 + }, + { + "index": 3, + "mode": 1, + "frames": 41, + "samples": 29925, + "channels": 1, + "rate": 44100 + }, + { + "index": 3, + "mode": 4, + "frames": 30, + "samples": 22151, + "channels": 1, + "rate": 44100 + }, + { + "index": 3, + "mode": 6, + "frames": 40, + "samples": 29047, + "channels": 1, + "rate": 44100 + }, + { + "index": 3, + "mode": 9, + "frames": 21, + "samples": 15506, + "channels": 1, + "rate": 44100 + }, + { + "index": 3, + "mode": 12, + "frames": 46, + "samples": 33228, + "channels": 1, + "rate": 44100 + }, + { + "index": 273, + "mode": 0, + "frames": 33, + "samples": 24136, + "channels": 1, + "rate": 44100 + }, + { + "index": 273, + "mode": 1, + "frames": 33, + "samples": 24136, + "channels": 1, + "rate": 44100 + }, + { + "index": 273, + "mode": 4, + "frames": 30, + "samples": 22151, + "channels": 1, + "rate": 44100 + }, + { + "index": 273, + "mode": 6, + "frames": 32, + "samples": 23427, + "channels": 1, + "rate": 44100 + }, + { + "index": 273, + "mode": 9, + "frames": 21, + "samples": 15506, + "channels": 1, + "rate": 44100 + }, + { + "index": 273, + "mode": 12, + "frames": 37, + "samples": 26799, + "channels": 1, + "rate": 44100 + }, + { + "index": 387, + "mode": 0, + "frames": 29, + "samples": 20796, + "channels": 1, + "rate": 44100 + }, + { + "index": 387, + "mode": 1, + "frames": 29, + "samples": 20796, + "channels": 1, + "rate": 44100 + }, + { + "index": 387, + "mode": 4, + "frames": 27, + "samples": 19771, + "channels": 1, + "rate": 44100 + }, + { + "index": 387, + "mode": 6, + "frames": 28, + "samples": 20186, + "channels": 1, + "rate": 44100 + }, + { + "index": 387, + "mode": 9, + "frames": 21, + "samples": 15506, + "channels": 1, + "rate": 44100 + }, + { + "index": 387, + "mode": 12, + "frames": 32, + "samples": 23091, + "channels": 1, + "rate": 44100 + } + ] + } + ] + }, + "nonfading_bgm_sources": { + "tool": "tools/enemy-action-contract-check/native-cry-bgm-smoke.lua", + "tool_sha256": "c674da396a8e98797dce9697b346a74c5cec97669f11e59a21622703204ac1ac", + "runs": [ + { + "host": "gen2recomped-native-cry-commands", + "backend": "gen1", + "version": "firered", + "exit": 0, + "log": "tmp/native-cry-published-bgm-qa/gen1-firered-first.log", + "sha256": "f30393d6d8ae9f8a15481455368ed967adaff63d14b00666322609e454ac7b25" + }, + { + "host": "gen2recomped-native-cry-commands", + "backend": "gen1", + "version": "emerald", + "exit": 0, + "log": "tmp/native-cry-published-bgm-qa/gen1-emerald-first.log", + "sha256": "94a69a09fd783947a8398a1780cb4872c3595d1b22e0274eef14c473a7350631" + }, + { + "host": "gen2recomped-native-cry-commands", + "backend": "gen2", + "version": "firered", + "exit": 0, + "log": "tmp/native-cry-published-bgm-qa/gen2-firered-first.log", + "sha256": "729364a79180eddc4a7bc0766e287d84a2cebfc56301a7275376e0d6ae8f7321" + }, + { + "host": "gen2recomped-native-cry-commands", + "backend": "gen2", + "version": "emerald", + "exit": 0, + "log": "tmp/native-cry-published-bgm-qa/gen2-emerald-first.log", + "sha256": "aab3390ec8cb90fe6f023509858d1f2abe71966a83e0ec1ea7e2670dfb6bb3fa" + }, + { + "host": "gen2recomped-native-cry-commands-integration", + "backend": "gen1", + "version": "firered", + "exit": 0, + "log": "tmp/native-cry-published-bgm-qa/gen1-firered-first.log", + "sha256": "75ab79ac817b5cf38998e35c3ea81126f95eb3122fa966ac58ac7bfd7ebd84c2" + }, + { + "host": "gen2recomped-native-cry-commands-integration", + "backend": "gen1", + "version": "emerald", + "exit": 0, + "log": "tmp/native-cry-published-bgm-qa/gen1-emerald-first.log", + "sha256": "0cbbc191f07cad5428cbb81bfadd6bd1c94c1512badb844dd98d8b02bde5ea8c" + }, + { + "host": "gen2recomped-native-cry-commands-integration", + "backend": "gen2", + "version": "firered", + "exit": 0, + "log": "tmp/native-cry-published-bgm-qa/gen2-firered-first.log", + "sha256": "c31f6f0f9afc85c8bbfe8149cbd2263ded138c8255016dd9285432f1933163aa" + }, + { + "host": "gen2recomped-native-cry-commands-integration", + "backend": "gen2", + "version": "emerald", + "exit": 0, + "log": "tmp/native-cry-published-bgm-qa/gen2-emerald-first.log", + "sha256": "0b6c8dcb6113346916cb2a0696d5f2fc10f64e8b75f60857cee003615b180d2b" + } + ], + "scope": "Actual nonfading BGM source attenuation relative to each backend baseline gain; raw gains retained; no full BGM synthesizer or fade equivalence claim" + } + }, + "published_checker": { + "worker_timeout_for_nature_power": 180, + "sha256": "e9273be2d423153f0cb09381a258e51a73ba511d1bab6743b5a9e257b4f8dec7", + "runtimes": [ + { + "head": "729445bebfb715696c71ff5ec7beb4028debc1b1", + "report": "tmp/native-cry-qa/published-nature-power.json", + "sha256": "966eda8e62a67e3bf523281706b88bdc888fbf9c42ad47ad166d83779234ef28", + "workers": 4, + "passed": true + }, + { + "head": "5b50e039d71a88469678a798a8ddf31cb8f295f4", + "report": "tmp/native-cry-qa/published-nature-power.json", + "sha256": "3e8474847e0ace773c038bf9c6389b57d4812691e54106e17c3ecb0ada87e8eb", + "workers": 4, + "passed": true + } + ] + }, + "native_species_control_map": { + "module_sha256": "a2cfb7b11aba09e5f6735e4ccf0a8169c9b9f2b6f712e8d2caa0fe7b0bdb89a9", + "profiles": [ + { + "version": "firered", + "rom_sha1": "41cb23d8dccc8ebd7c649cd8fbb58eeace6e2fdc", + "offset": 2439636, + "control_table_sha256": "ce1f862369df5567ee685755d5ec34760f2e43b921cd581d46f1ffd82ccf741c", + "entries": 135, + "all_runtime_ids_equal": true + }, + { + "version": "emerald", + "rom_sha1": "f3ae088181bf583e55daf962a92bb46f4f1d07b7", + "offset": 3274268, + "control_table_sha256": "ce1f862369df5567ee685755d5ec34760f2e43b921cd581d46f1ffd82ccf741c", + "entries": 135, + "all_runtime_ids_equal": true + } + ] + }, + "existing_import_regressions": { + "comparisons": [ + { + "tool": "imported-native-sound-smoke.lua", + "cases": 10, + "strict_complete_equal": true + }, + { + "tool": "imported-mirror-smoke.lua", + "cases": 24, + "strict_complete_equal": true + } + ], + "logs": [ + { + "tool": "imported-native-sound-smoke.lua", + "host": "gen2recomped-native-cry-commands", + "sha256": "5a2552ce876b7262164656d139a73d05606452c38811defd1597137fdf24dfa0" + }, + { + "tool": "imported-native-sound-smoke.lua", + "host": "gen2recomped-native-cry-commands-integration", + "sha256": "7fdc8d18f697e08d0db1fee9ba7787de3b6e657e283067ce21cac6469045090a" + }, + { + "tool": "imported-mirror-smoke.lua", + "host": "gen2recomped-native-cry-commands", + "sha256": "c7b62727a1d4f7ac4445c1ea9b544e1aa14d04135009257cfe9fdbe4d213427f" + }, + { + "tool": "imported-mirror-smoke.lua", + "host": "gen2recomped-native-cry-commands-integration", + "sha256": "55c78fe99c2f4f0b9edd9fe3c3af14455e8f0c853c503bb177e3809e979346de" + } + ] + }, + "scope": "Local CPU validation on admitted FireRed/Emerald native contracts. No GPU/device/playthrough or full public-audio timing claim. Nonfading BGM checks validate attenuation relative to retained per-backend baseline gains.", + "next_verified_gap": { + "command": "fadeoutbgm 4", + "native_stop_tick": 64, + "host_stop_tick": 0, + "profiles": [ + "firered", + "emerald" + ], + "scope": "Actual world VMs over owned running BGM-source state; kept separate from cry playback." + }, + "evidence_audit_sha256": { + "native-cry-final-contract-audit.json": "6d0c4e3981f3cac8cc118d11dbf77f3f24e9c37c746a8bd6018cddceed2d8907", + "native-cry-feature-regression-audit.json": "32622dfc3df0bbc479af39096005a01e9b1ce2b4dace63385b55d80211c5da54", + "native-cry-integration-regression-audit.json": "083ea8cc78d76285d2e7960e167c896395397b88da14caf3b45fcc378a5963f7", + "native-cry-negative-clock-audit.json": "4c7320d47f59caf91208d8fab906598c62a393e0e66571020f7ef550c37f507a", + "native-cry-species-map-final-audit.json": "95e1555a49d4efba60f3851424ec88dc27f4cb1d3c036bc0cea8b900519e0cae", + "native-cry-frozen-protocol-audit.log": "aee7f827e1e2ffda60100b46afbeaca0174a8596a92ab022abc81018ca1ecab5" + } +} diff --git a/docs/qa/native-gen3-assist.json b/docs/qa/native-gen3-assist.json new file mode 100644 index 000000000..638c81758 --- /dev/null +++ b/docs/qa/native-gen3-assist.json @@ -0,0 +1,93 @@ +{ + "feature": { + "runtime_ref": "dc7e13c22eb297fa345b4e92f7a9d5a5a01b8ecd", + "engine": "157/166", + "fail_counter_lines": 25, + "sdk": "3/3", + "oracle_workers": 188, + "oracle_rows": 160724, + "selected_paired_workers": 196, + "selected_paired_rows": 169676, + "new_assist_rows": { + "assist": 13440, + "assist_content": 13680, + "assist_held": 11520, + "assist_failed_callers": 72 + }, + "strict_contracts": { + "held-item": { + "workers": 40, + "rows": 1914 + }, + "charge-required": { + "workers": 64, + "rows": 1020 + } + }, + "imported_scenarios": { + "imported-assist": 80, + "imported-assist-nested": 80 + }, + "oracle_sha256": "b9959b60be923020b9ed42baef3f19dc8af48039fe6abed8da2b94b39629ccab", + "full_gate_sha256": "91a20b9ad104feb74c60ffaefa913803644b3691cb67245a5863bdfa68d901f9" + }, + "combined": { + "runtime_ref": "5e384409322e98dfd292716b9c97d2bdcdf9936e", + "engine": "275/275", + "fail_counter_lines": 0, + "sdk": "3/3", + "oracle_workers": 188, + "oracle_rows": 160724, + "selected_paired_workers": 196, + "selected_paired_rows": 169676, + "new_assist_rows": { + "assist": 13440, + "assist_content": 13680, + "assist_held": 11520, + "assist_failed_callers": 72 + }, + "strict_contracts": { + "held-item": { + "workers": 40, + "rows": 1914 + }, + "charge-required": { + "workers": 64, + "rows": 1020 + } + }, + "imported_scenarios": { + "imported-assist": 80, + "imported-assist-nested": 80 + }, + "oracle_sha256": "08131511b25504d4371855de52d314610c1cc5020df4ed252cedb9279ef306f4", + "full_gate_sha256": "b2119bb37065b4d59eda6181e005903bd6edb0eb7702223e4ba20a577b2cf29d" + }, + "reference": "535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91", + "prerequisite_ref": "812ec1f2354d4f98a5f4ebff15876120743ea7a9", + "native_command_revision": 15, + "party_codec_revision": 1, + "native_digest": "662d7e5ee823ce82", + "legacy_digest": "e33d251961f2a41d", + "old_parent_negative": { + "native_rows": 6480, + "native_different": 4860, + "failed_caller_rows": 36, + "failed_history_different": 36 + }, + "old_public_registration": "unsupported: effect has no native handler: ASSIST", + "native_linked_checks": 120, + "assist_direct_checks": 60846, + "assist_public_checks": 27482, + "assist_failed_caller_checks": 506, + "independent_hash_audit_sha256": "8925ad25ff57739b2ddd91486ff95e1c9542922902cf642586af91a8411bbbbc", + "frozen_parent_serializer_sha256": "688baad34c546f3c6fb77727e6f2e10ba1a72fb30067323d75f6a3307b93a048", + "raw_failed_and_superseded_evidence_retained": true, + "scope": "Selected native GBA Assist/party/PP/history/targeting contracts and API-3 registration; owned native-shaped definitions plus separate actual-import CPU menu scenarios; no whole-catalog, graphical, full-mod or full-playthrough acceptance", + "negative_evidence_sha256": { + "animations-and-failed.json": "2139a0a918383cb089e18958b90038b5ef1b38a477253667025cf845e2b9cd8a", + "parent-native-negative.log": "7960e5db4b9061f3bc5b48fe9cf4ee45f463272d9dc92b6a60e8e7efc469fd4e", + "parent-failed-callers-negative.log": "04241920b057c3ce7684704915b72764e73179ee4f75f042f4e28dcf8f0ba8e1", + "parent-public-negative.log": "5225ec67f4ceefb099a0423f18432e30395d2e8baf278bde33f65844ce1dd4eb" + } +} diff --git a/docs/qa/native-gen3-mirror-move.json b/docs/qa/native-gen3-mirror-move.json new file mode 100644 index 000000000..27cb9b7a1 --- /dev/null +++ b/docs/qa/native-gen3-mirror-move.json @@ -0,0 +1,338 @@ +{ + "schema_version": 1, + "reference_commit": "535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91", + "reference_read_only": true, + "lua_jit_commit": "a4c064539ea931274d407e1249b4bb575424a0f4", + "feature_qualified_head": "eab96604b54ee06a9a3dc17ffd34af6443c8403f", + "feature_runtime_regression_commit": "f9016f1c23d0d97fd21b761d2f76d15c3f38884d", + "feature_runtime_src_tree": "d44e9c6082e15d606664ea22936fd5464c09c09c", + "feature_tests_tree": "c7b3779282cc4cc529ddee33a37f23552736a995", + "combined_qualified_head": "513bb62e3d14cf3525b8c96a5a826f760125fce1", + "combined_full_regression_commit": "01315149fd67cb7262c9ac7d786294409033fcbb", + "combined_src_tree": "4bcf6c817e867f54596b259d2c8d9a088d6519d2", + "combined_tests_tree": "6f4e04d1ba79f0a318bab3723f397ad551cdae03", + "native_command_revision": 17, + "party_codec_revision": 1, + "native_digest": "351a5becf7f236a0", + "legacy_digest": "e33d251961f2a41d", + "full_regression": { + "candidate_head": "01315149fd67cb7262c9ac7d786294409033fcbb", + "baseline_head": "66ec5c141f0d49a9d4c163f842837d295668139d", + "exact_counter_equal": true, + "baseline_fail_lines": 0, + "candidate_fail_lines": 0, + "new": {}, + "removed": {}, + "engine": [ + "engine: 282/282 suites passed" + ], + "baseline_engine": [ + "engine: 275/275 suites passed" + ], + "sdk": [ + "modkit: 3/3 suites passed" + ], + "baseline_sdk": [ + "modkit: 3/3 suites passed" + ], + "b30": [ + "PASS Dramatic Shape B30 profile" + ], + "baseline_b30": [ + "PASS Dramatic Shape B30 profile" + ], + "candidate_log_sha256": "8bf366cde1ba96343a7a941a2e1d6488e3b0ddcb3987f66f8213d18b23a7c605", + "baseline_log_sha256": "9f1170c2bf359898924836adc67743c587387541e64fa8baef61f4df3634573b" + }, + "feature_regression": { + "engine": "163/172", + "sdk": "3/3", + "b30": true, + "baseline_engine": "157/166", + "exact_anchored_FAIL_Counter_equal": true, + "inherited_fail_lines": 25, + "inherited_failing_suites": 9, + "later_added_received_history_checks": 994 + }, + "contracts": [ + { + "host": "feature", + "qualified_workers": 220, + "qualified_comparisons": 110, + "paired_rows": 175540, + "reports": [ + { + "path": "tmp/mirror-history-qa/frozen-command-matrix.json", + "sha256": "cd5979084c65525e7d0b1dcf1a7fc1154d89cf474d20872fe4ca4fd915c0a2c1", + "host_ref": "74602df7a96205fbc92d563e4466ada399842756" + }, + { + "path": "tmp/mirror-broad-qa/batch-01.json", + "sha256": "f3fba3e360addddf9f1e4c29a10fc20fbcc58fbb93ef19b4445c3a79ac0b9de7", + "host_ref": "a215dc3db1432989c8daedef0d09056096806c43" + }, + { + "path": "tmp/mirror-broad-qa/batch-02.json", + "sha256": "4b0c2fe2b1f005f823f992145173db2c768a6a44802fce6996561f2fb6b84ce3", + "host_ref": "a215dc3db1432989c8daedef0d09056096806c43" + }, + { + "path": "tmp/mirror-broad-qa/batch-03.json", + "sha256": "3e6c169305488ffe4123a5eb8f6d3029a3c456fa69c8a73dd15159cf799f6bfb", + "host_ref": "a215dc3db1432989c8daedef0d09056096806c43" + }, + { + "path": "tmp/mirror-broad-qa/batch-04.json", + "sha256": "90245b4dbb94de41cd3057d894d0055c8bbf596e2c0dcc6601a8da30a8c5bd75", + "host_ref": "a215dc3db1432989c8daedef0d09056096806c43" + }, + { + "path": "tmp/mirror-broad-qa/batch-05.json", + "sha256": "b943e5daff0eac373952467c66a5b18e2b7f605f532024539f24a6160fe1650c", + "host_ref": "a215dc3db1432989c8daedef0d09056096806c43" + }, + { + "path": "tmp/mirror-broad-qa/batch-06.json", + "sha256": "5c9d2c3ccb80c290b1178749e1df313dc63310992073b13fc18e950301dcf5f5", + "host_ref": "a215dc3db1432989c8daedef0d09056096806c43" + }, + { + "path": "tmp/mirror-broad-qa/batch-07.json", + "sha256": "624b530df5b65052b342ead8f48482b488c78e552f994a2a0c9cb4e244752744", + "host_ref": "a215dc3db1432989c8daedef0d09056096806c43" + }, + { + "path": "tmp/mirror-broad-qa/batch-08.json", + "sha256": "5439a87845f82ee54da0962c6ea10b4faa257f3624910edb6d1b1a65572a98a2", + "host_ref": "a215dc3db1432989c8daedef0d09056096806c43" + } + ], + "retained_failed_attempts": [], + "rerun_timings": [] + }, + { + "host": "combined", + "qualified_workers": 220, + "qualified_comparisons": 110, + "paired_rows": 175540, + "reports": [ + { + "path": "tmp/mirror-integration-qa/frozen-command-matrix.json", + "sha256": "d26ae49f0446937926fe8a6b91ec3d4fb472a3088508c239e61fb6edc1c5fdc6", + "host_ref": "01315149fd67cb7262c9ac7d786294409033fcbb" + }, + { + "path": "tmp/mirror-broad-qa/batch-01.json", + "sha256": "ec11710dd39d13ee430b48c199f1260cd3ad0c184f1a5983b7b057b8b4a77ca3", + "host_ref": "01315149fd67cb7262c9ac7d786294409033fcbb" + }, + { + "path": "tmp/mirror-broad-qa/batch-02.json", + "sha256": "0d6304503132d055d2ebf839ee9a3a2ec21cc05cd311f041c76f505c3357676d", + "host_ref": "01315149fd67cb7262c9ac7d786294409033fcbb" + }, + { + "path": "tmp/mirror-broad-qa/batch-03.json", + "sha256": "7704e2bcc58c8a018f6aa097db10910c40f3b6744d8af248d3b11d3344b8e949", + "host_ref": "01315149fd67cb7262c9ac7d786294409033fcbb" + }, + { + "path": "tmp/mirror-broad-qa/batch-04.json", + "sha256": "ef443c280bb1996ad43128068f98000f72e256dccd5892345ef1aba52a8f2a2c", + "host_ref": "01315149fd67cb7262c9ac7d786294409033fcbb" + }, + { + "path": "tmp/mirror-broad-qa/batch-05.json", + "sha256": "41ee847562998b9f9e26537aec9e212a5f8b50fe81e59373ddbedd3588450294", + "host_ref": "01315149fd67cb7262c9ac7d786294409033fcbb" + }, + { + "path": "tmp/mirror-broad-qa/batch-06.json", + "sha256": "4c6f6f1eaaff09bbb7457a6d70f58f1816572ac3441b6a994b19fb2fd97a08af", + "host_ref": "01315149fd67cb7262c9ac7d786294409033fcbb" + }, + { + "path": "tmp/mirror-broad-qa/batch-07.json", + "sha256": "735963641e138bf781679c54434a7c1df8dc9b9a93c548aad32dc20344fe5c6c", + "host_ref": "01315149fd67cb7262c9ac7d786294409033fcbb" + }, + { + "path": "tmp/mirror-broad-qa/batch-08.json", + "sha256": "a4eb32eb688c705891c2f505d8d718d73e2be04996f605cb6a3668c623d12886", + "host_ref": "01315149fd67cb7262c9ac7d786294409033fcbb" + }, + { + "path": "tmp/mirror-broad-qa/bounded-assist-rerun.json", + "sha256": "0e18b4084e836f0973a353434b0a4a1c9f42d063501a4af5c3b2bff88ce1f25b", + "host_ref": "513bb62e3d14cf3525b8c96a5a826f760125fce1" + } + ], + "retained_failed_attempts": [ + { + "key": [ + "gen2", + "firered", + 3, + "assist" + ], + "exit": 124, + "timeout": { + "seconds": 60, + "stdout_hex": null, + "stderr_hex": null + }, + "vm": [] + }, + { + "key": [ + "gen2", + "emerald", + 3, + "assist_held" + ], + "exit": 124, + "timeout": { + "seconds": 60, + "stdout_hex": null, + "stderr_hex": null + }, + "vm": [] + } + ], + "rerun_timings": [ + { + "key": [ + "gen1", + "firered", + 3, + "assist" + ], + "seconds": 14.1076 + }, + { + "key": [ + "gen2", + "firered", + 3, + "assist" + ], + "seconds": 34.8894 + }, + { + "key": [ + "gen1", + "firered", + 3, + "assist_held" + ], + "seconds": 12.1253 + }, + { + "key": [ + "gen2", + "firered", + 3, + "assist_held" + ], + "seconds": 36.2911 + }, + { + "key": [ + "gen1", + "emerald", + 3, + "assist" + ], + "seconds": 10.2617 + }, + { + "key": [ + "gen2", + "emerald", + 3, + "assist" + ], + "seconds": 30.6749 + }, + { + "key": [ + "gen1", + "emerald", + 3, + "assist_held" + ], + "seconds": 11.3661 + }, + { + "key": [ + "gen2", + "emerald", + 3, + "assist_held" + ], + "seconds": 30.4373 + } + ] + } + ], + "controller_ticks": { + "swap_inputs": 360, + "swap_ticks": 5240, + "poison_focus_inputs": 384, + "poison_focus_ticks": 11112, + "substitute_fade_inputs": 288, + "substitute_fade_ticks": 23488, + "common_pcm_inputs": 56 + }, + "units": { + "received_history": 994, + "linked_roles": 108, + "protocol": 154, + "effect_controller": 13778, + "fade_producer_controller": 104512, + "art_import_draw": 70, + "native_M4A_sound": 165455, + "import_locator": 8 + }, + "real_imports": { + "per_runtime_scenarios": 24, + "turns": 48, + "move_used_events": 120, + "profiles": [ + "firered", + "emerald" + ], + "native_and_registered": true, + "incoming_moves": [ + "TACKLE", + "SCRATCH", + "GROWL" + ], + "held_items": [ + "CHOICE_BAND", + "SHELL_BELL" + ], + "ordinary_cache_save_writes": false + }, + "actual_doll_imports": { + "compared_RGBA_pixels": 16384, + "different_pixels": 0, + "transparent_RGB_compared": true, + "cartridge_assets_committed": false + }, + "late_nonbehavior_changes": { + "Sound.lua_comment_edit": "Identical stripped LuaJIT bytecode before/after; feature13949 bytes, combined14383 bytes. All other runtime sources unchanged from their full-test commits.", + "checker_budget": "Exhaustive Assist/Assist-content/Assist-held use180s bounded workers; all othersretain60s exceptexisting180spost-hit execution. wall_seconds records actual worker elapsed time before observation parsing.", + "retained_timeouts": "Two original candidate workers terminated at60s(exit124). Eight fresh Assist/Assist-held workers all exit0 under180s; every earlier completed observation is unchanged. Largest fresh worker36.2911s." + }, + "limitations": [ + "FireRed/Emerald qualified; Ruby/Sapphire/LeafGreen support prerequisites are separate", + "CPU native state/audio controls/draw commands; final GPU scene pixels not established", + "Common-carrier PCM equality is not full-synthesizer waveform parity", + "All public audio-event timing and native M4A player lifecycle are not covered by this foundation", + "Separate lifecycle negative control:20pairedcases,16differences;follow-up branch feature/native-sound-player-lifecycle", + "Arbitrary complete mods and a full playthrough not established" + ], + "raw_failed_and_superseded_reports_retained": true, + "ordinary_game_gpu_or_audio_device_launched": false +} diff --git a/docs/qa/native-gen3-nature-power.json b/docs/qa/native-gen3-nature-power.json new file mode 100644 index 000000000..71511620f --- /dev/null +++ b/docs/qa/native-gen3-nature-power.json @@ -0,0 +1,68 @@ +{ + "feature": { + "runtime_ref": "0536521ea23e41af8c206961a266db96f5ff6eb1", + "engine": "153/162", + "fail_counter_lines": 25, + "sdk": "3/3", + "oracle_workers": 172, + "oracle_rows": 122012, + "selected_paired_workers": 180, + "selected_paired_rows": 130964, + "new_nature_rows": { + "nature_power": 6048, + "nature_power_content": 6048, + "nature_power_chained": 6048 + }, + "imported_scenarios": { + "imported-nature-power": 80, + "imported-nature-power-chained": 80 + }, + "oracle_sha256": "1d4dd0098213dd91b5dd31c5b6db8541342a8b8b77b713c30a0f6e7dd193f7ba", + "full_gate_sha256": "ba8b46bf7a65667f4399737518b06c75dcf61d8e6698265e146db0d6f3f40557" + }, + "combined": { + "runtime_ref": "0ed0602be9019102a06f4df3ee22480bca1d800f", + "engine": "271/271", + "fail_counter_lines": 0, + "sdk": "3/3", + "oracle_workers": 172, + "oracle_rows": 122012, + "selected_paired_workers": 180, + "selected_paired_rows": 130964, + "new_nature_rows": { + "nature_power": 6048, + "nature_power_content": 6048, + "nature_power_chained": 6048 + }, + "imported_scenarios": { + "imported-nature-power": 80, + "imported-nature-power-chained": 80 + }, + "oracle_sha256": "7a06e058137eb17884e4fd3d5087022d66e2e992fa13a8233a58e06f8174130c", + "full_gate_sha256": "e95d0f5030fc4221b410f8a6238300c71aeab3e7153055c10cdb75554dafd3ec" + }, + "reference": "535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91", + "prerequisite_ref": "1ea0f875ea03cc1ca7b22f88191d9bb15b51bac0", + "native_command_revision": 14, + "party_codec_revision": 1, + "native_digest": "cb314093f24370e7", + "legacy_digest": "e33d251961f2a41d", + "strict_held": { + "workers": 40, + "rows": 1914 + }, + "charge": { + "workers": 64, + "rows": 1020, + "strict_native_policy": "combined" + }, + "native_linked_checks": 108, + "nature_direct_checks": 28926, + "nature_public_checks": 17474, + "nature_chained_checks": 15458, + "old_prerequisite_different_rows": 5376, + "before_chain_fix_different_rows": 4032, + "old_public_registration": "unsupported: effect has no native handler: NATURE_POWER", + "raw_failed_and_interrupted_evidence_retained": true, + "scope": "Selected native GBA caller/PP/history and public API-3 registration; owned native-shaped matrix definitions and separate actual-import CPU menu scenarios; no full-catalog, graphical or full-mod acceptance" +} diff --git a/docs/qa/native-gen3-post-hit-items.json b/docs/qa/native-gen3-post-hit-items.json new file mode 100644 index 000000000..fe58f3309 --- /dev/null +++ b/docs/qa/native-gen3-post-hit-items.json @@ -0,0 +1,63 @@ +{ + "feature": { + "full_gate_ref": "d88be1fc42df80756dfc7daa918b7d5c77e80004", + "oracle_ref": "80c8bd8e4d2e403b94679f6e9283d616d77f5007", + "engine_suites": "149/158", + "fail_counter_lines": 25, + "oracle_workers": 160, + "oracle_rows": 103868, + "post_hit_command_rows": 7488, + "post_hit_execution_rows": 15840, + "oracle_sha256": "cf5b7057e2a4aa0f5de258e199459499865ebdf12022ced188e8fa8ec3ef4255", + "full_log_sha256": "3caa779b45c625ed14f0408786b152976d74c63a1621029b465bbd4aeb3f063a" + }, + "combined": { + "full_gate_ref": "d9cf080d676eccefa861b8ecc93de621782dd5a6", + "oracle_ref": "f829462500c6dc2035fd06739f2e2cc95495df2f", + "engine_suites": "267/267", + "fail_counter_lines": 0, + "oracle_workers": 160, + "oracle_rows": 103868, + "post_hit_command_rows": 7488, + "post_hit_execution_rows": 15840, + "oracle_sha256": "d7bd74e730b482c95b709a291d6f261a16b0bfbd09d3cb610404c1b51b492b4d", + "full_log_sha256": "1d8cdba4cadf8e8c5f2043a1e3c24399cabdaf656c5f3c0b3594316c9b21433b" + }, + "reference": "535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91", + "native_command_revision": 13, + "party_codec_revision": 1, + "native_digest": "beacb71804a6af34", + "legacy_digest": "e33d251961f2a41d", + "sdk_suites": "3/3", + "b30": "passed", + "additional_damage_rows": { + "held_command": 2040, + "ordinary_damage": 6912 + }, + "strict_held": { + "workers": 40, + "rows": 1914 + }, + "charge": { + "workers": 64, + "rows": 1020, + "strict_native_policy": "combined" + }, + "old_execution_different_rows": 12032, + "old_spectator_failed_checks": 14, + "post_hit_link_checks": 172, + "actual_import_controlled_scenarios": 32, + "sleep_talk_phase_reads": { + "success": 10, + "awake": 2, + "empty": 2, + "blocked": 4 + }, + "failed_observations_retained": [ + "initial mismatched fixture flags", + "initial spectator turn overlap", + "superseded sixty-second checker timeout" + ], + "timeout_negative": "exit 124 remains failed; partial text and exact bytes retained; later comparisons complete", + "scope": "Selected FireRed/Emerald native command and script contracts; no full-mod, graphical-playthrough, full-catalog or Ruby/Sapphire per-hit acceptance" +} diff --git a/docs/qa/native-sound-player-lifecycle.json b/docs/qa/native-sound-player-lifecycle.json new file mode 100644 index 000000000..c5a378813 --- /dev/null +++ b/docs/qa/native-sound-player-lifecycle.json @@ -0,0 +1,703 @@ +{ + "schema_version": 1, + "reference_commit": "535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91", + "reference_read_only": true, + "lua_jit_commit": "a4c064539ea931274d407e1249b4bb575424a0f4", + "feature_frozen_head": "e70129d1311ca81a57dc67cc56967dac45975000", + "feature_src_tree": "fca83427924ade60e5f65df71790ad4a93ddcda7", + "feature_tests_tree": "e044ca1c05d8d24bd30f7ec8d570cb23e6157d32", + "combined_frozen_head": "7607388218b2d08dd5835369dec104f894ecd942", + "combined_src_tree": "f3284d939aba866975579e4ea0f85dff6bdac543", + "combined_tests_tree": "9cd1bb00ab3758dc1becdf7191d49832425a3a8d", + "native_command_revision": 18, + "party_codec_revision": 1, + "independent_native_digest": "c82a14f754706103", + "unchanged_legacy_digest": "e33d251961f2a41d", + "fingerprint_audit": { + "path": "tmp/native-sound-lifecycle-qa/hash-audit.log", + "sha256": "ad34bf815771fa5934809bab5599cc3ab719b3f0af0096dae5743f6db981cca7" + }, + "feature_regression": { + "candidate": { + "log": "tmp/native-sound-lifecycle-qa/frozen-full.log", + "sha256": "75e4c2b6d80fef6020d3944adb9432166b3651a0cd6c4acca46478109ee17f79", + "fail_counter": { + "FAIL hook:battle.status_hud_visible has no unit test naming it through the public mod API (add one, or add a DEBT entry saying which milestone owes it)": 1, + "FAIL event:intro.birch_speech.finished has no unit test naming it through the public mod API (add one, or add a DEBT entry saying which milestone owes it)": 1, + "FAIL event:intro.birch_speech.started has no unit test naming it through the public mod API (add one, or add a DEBT entry saying which milestone owes it)": 1, + "FAIL event:pokemon.egg_hatched has no unit test naming it through the public mod API (add one, or add a DEBT entry saying which milestone owes it)": 1, + "FAIL event:world.blocks_replaced has no unit test naming it through the public mod API (add one, or add a DEBT entry saying which milestone owes it)": 1, + "FAIL event:world.tick has no unit test naming it through the public mod API (add one, or add a DEBT entry saying which milestone owes it)": 1, + "FAIL tests/engine/gate_meta_coverage.lua": 1, + "FAIL the process exit runs only after love.quit declined to veto": 1, + "FAIL the process exit is gated on Android": 1, + "FAIL tests/engine/quit_thread_shutdown.lua": 1, + "FAIL and stays out of the live pad map until close (got x, want nil)": 1, + "FAIL tests/engine/rebind_capture_bug510.lua": 1, + "FAIL all eight actions still have exactly one pad button (got 12, want 8)": 1, + "FAIL tests/engine/rebind_swap_clear_bug589.lua": 1, + "FAIL tests/engine/soft_reset_bug563.lua": 1, + "FAIL tests/engine/tool_mod_hooks.lua": 1, + "FAIL releaseUrl points at the repo's latest release (got https://github.com/UNDERdecoded/Gen2Recomped/releases/latest, want https://github.com/bryanthaboi/gen1recomp/releases/latest)": 1, + "FAIL payload name derived from version (got Gen2Recomped-1.4.2.love, want gen1recomp-1.4.2.love)": 1, + "FAIL tests/engine/update_check_tests.lua": 1, + "FAIL parseRelease derives the payload name from the version (got Gen2Recomped-1.4.2.love, want gen1recomp-1.4.2.love)": 1, + "FAIL tests/engine/update_tests.lua": 1, + "FAIL a player-side frame lands on the player anchor (got 37, want 20)": 1, + "FAIL a player-side frame lands on the player baseline (got 7, want 8)": 1, + "FAIL an enemy-side frame lands on the enemy anchor (got 131, want 136)": 1, + "FAIL tests/engine/wide_battle_layout.lua": 1 + }, + "engine": [ + "engine: 165/174 suites passed" + ], + "sdk": [ + "modkit: 3/3 suites passed" + ], + "b30": true, + "tier_summary": [ + "", + "==============================================================", + " 1 TIER(S) FAILED", + " - T1/T2 engine invariants + parity gates", + "==============================================================" + ] + }, + "baseline": { + "log": "fresh-full.log", + "sha256": "4f3553e3a478ca2b7b67e0b885975f4c5c577e2c15cd2c31eb3fee34dfaa09b5", + "fail_counter": { + "FAIL hook:battle.status_hud_visible has no unit test naming it through the public mod API (add one, or add a DEBT entry saying which milestone owes it)": 1, + "FAIL event:intro.birch_speech.finished has no unit test naming it through the public mod API (add one, or add a DEBT entry saying which milestone owes it)": 1, + "FAIL event:intro.birch_speech.started has no unit test naming it through the public mod API (add one, or add a DEBT entry saying which milestone owes it)": 1, + "FAIL event:pokemon.egg_hatched has no unit test naming it through the public mod API (add one, or add a DEBT entry saying which milestone owes it)": 1, + "FAIL event:world.blocks_replaced has no unit test naming it through the public mod API (add one, or add a DEBT entry saying which milestone owes it)": 1, + "FAIL event:world.tick has no unit test naming it through the public mod API (add one, or add a DEBT entry saying which milestone owes it)": 1, + "FAIL tests/engine/gate_meta_coverage.lua": 1, + "FAIL the process exit runs only after love.quit declined to veto": 1, + "FAIL the process exit is gated on Android": 1, + "FAIL tests/engine/quit_thread_shutdown.lua": 1, + "FAIL and stays out of the live pad map until close (got x, want nil)": 1, + "FAIL tests/engine/rebind_capture_bug510.lua": 1, + "FAIL all eight actions still have exactly one pad button (got 12, want 8)": 1, + "FAIL tests/engine/rebind_swap_clear_bug589.lua": 1, + "FAIL tests/engine/soft_reset_bug563.lua": 1, + "FAIL tests/engine/tool_mod_hooks.lua": 1, + "FAIL releaseUrl points at the repo's latest release (got https://github.com/UNDERdecoded/Gen2Recomped/releases/latest, want https://github.com/bryanthaboi/gen1recomp/releases/latest)": 1, + "FAIL payload name derived from version (got Gen2Recomped-1.4.2.love, want gen1recomp-1.4.2.love)": 1, + "FAIL tests/engine/update_check_tests.lua": 1, + "FAIL parseRelease derives the payload name from the version (got Gen2Recomped-1.4.2.love, want gen1recomp-1.4.2.love)": 1, + "FAIL tests/engine/update_tests.lua": 1, + "FAIL a player-side frame lands on the player anchor (got 37, want 20)": 1, + "FAIL a player-side frame lands on the player baseline (got 7, want 8)": 1, + "FAIL an enemy-side frame lands on the enemy anchor (got 131, want 136)": 1, + "FAIL tests/engine/wide_battle_layout.lua": 1 + }, + "engine": [ + "engine: 164/173 suites passed" + ], + "sdk": [ + "modkit: 3/3 suites passed" + ], + "b30": true, + "tier_summary": [ + "", + "==============================================================", + " 1 TIER(S) FAILED", + " - T1/T2 engine invariants + parity gates", + "==============================================================" + ] + }, + "exact_anchored_FAIL_Counter_equal": true, + "inherited_fail_lines": 25, + "candidate_exit": 1, + "baseline_exit": 1 + }, + "combined_regression": { + "candidate": { + "log": "tmp/native-sound-integration-qa/frozen-full.log", + "sha256": "0b4918de8241acdb4f35a0ea06ec8b1108afa0c9d16b3d475d7f11fa3f0706e4", + "fail_counter": {}, + "engine": [ + "engine: 283/283 suites passed" + ], + "sdk": [ + "modkit: 3/3 suites passed" + ], + "b30": true, + "tier_summary": [ + " import a ROM to run them; the modded-link cases ran in T4)", + "", + "==============================================================", + " ALL TIERS PASSED", + "==============================================================" + ] + }, + "baseline": { + "log": "frozen-full.log", + "sha256": "8bf366cde1ba96343a7a941a2e1d6488e3b0ddcb3987f66f8213d18b23a7c605", + "fail_counter": {}, + "engine": [ + "engine: 282/282 suites passed" + ], + "sdk": [ + "modkit: 3/3 suites passed" + ], + "b30": true, + "tier_summary": [ + " import a ROM to run them; the modded-link cases ran in T4)", + "", + "==============================================================", + " ALL TIERS PASSED", + "==============================================================" + ] + }, + "exact_anchored_FAIL_Counter_equal": true, + "candidate_exit": 0, + "baseline_exit": 0 + }, + "contracts": [ + { + "host": "feature", + "head": "e70129d1311ca81a57dc67cc56967dac45975000", + "src_tree": "fca83427924ade60e5f65df71790ad4a93ddcda7", + "tests_tree": "e044ca1c05d8d24bd30f7ec8d570cb23e6157d32", + "qualified_workers": 224, + "qualified_comparisons": 112, + "paired_rows": 175580, + "reports": [ + { + "path": "tmp/native-sound-lifecycle-qa/frozen-command-matrix.json", + "sha256": "a3f859b053b19da04644ad210e656879302d7a773e1e517019a37abf10f41a8c", + "host_ref": "e70129d1311ca81a57dc67cc56967dac45975000" + }, + { + "path": "tmp/native-sound-broad-qa/batch-01.json", + "sha256": "146d2724621f27a09faa8998e3cde6d904555a29d156e620e635afb08b70957c", + "host_ref": "e70129d1311ca81a57dc67cc56967dac45975000" + }, + { + "path": "tmp/native-sound-broad-qa/batch-02.json", + "sha256": "3e7dca577e9f05368118548c2577c591ce366b230be587a618c84a8aa9c7e978", + "host_ref": "e70129d1311ca81a57dc67cc56967dac45975000" + }, + { + "path": "tmp/native-sound-broad-qa/batch-03.json", + "sha256": "2ba0532893f96ba147c631ad10455d073c908a3607ffc11f1dbab49481f9bfd6", + "host_ref": "e70129d1311ca81a57dc67cc56967dac45975000" + }, + { + "path": "tmp/native-sound-broad-qa/batch-04.json", + "sha256": "c523cba2c7e2b3e532900d0e0c7767619cd8e60c778c5b4816e1ec8ed2bb8667", + "host_ref": "e70129d1311ca81a57dc67cc56967dac45975000" + }, + { + "path": "tmp/native-sound-broad-qa/batch-05.json", + "sha256": "1062dfc53c4c2180c6db6dcbdf3ee4fc4906fda0abcb953682208dc6304cddcf", + "host_ref": "e70129d1311ca81a57dc67cc56967dac45975000" + }, + { + "path": "tmp/native-sound-broad-qa/batch-06.json", + "sha256": "7fc8d01683a687ce339c58c63f3c46ad9a11648bc99f1bec38db7a4b0fa2df33", + "host_ref": "e70129d1311ca81a57dc67cc56967dac45975000" + }, + { + "path": "tmp/native-sound-broad-qa/batch-07.json", + "sha256": "c57c273eb8e81f3143bb98d1031826914cc905fe014b582d8b526bb99a6a3564", + "host_ref": "e70129d1311ca81a57dc67cc56967dac45975000" + }, + { + "path": "tmp/native-sound-broad-qa/batch-08.json", + "sha256": "220ed516f2bb993b156c08f69efd0d8fbd2e926ec9de8e5ff7877a1d287a5bb4", + "host_ref": "e70129d1311ca81a57dc67cc56967dac45975000" + } + ], + "slowest_worker_seconds": 104.494, + "complete_observations_recompared": true, + "json_boolean_types_preserved": true + }, + { + "host": "combined", + "head": "7607388218b2d08dd5835369dec104f894ecd942", + "src_tree": "f3284d939aba866975579e4ea0f85dff6bdac543", + "tests_tree": "9cd1bb00ab3758dc1becdf7191d49832425a3a8d", + "qualified_workers": 224, + "qualified_comparisons": 112, + "paired_rows": 175580, + "reports": [ + { + "path": "tmp/native-sound-integration-qa/frozen-command-matrix.json", + "sha256": "86fae4068ac03d4046c4338babc5cc3418d36e8a307f042ad869b32aa46c5a13", + "host_ref": "7607388218b2d08dd5835369dec104f894ecd942" + }, + { + "path": "tmp/native-sound-broad-qa/batch-01.json", + "sha256": "c540a3cf9722f43ed3ec07b430ea0657bd5696740fda6d5978acac662907a419", + "host_ref": "7607388218b2d08dd5835369dec104f894ecd942" + }, + { + "path": "tmp/native-sound-broad-qa/batch-02.json", + "sha256": "a0ccaa7725bd4ce9d538ec1167ef06d37e2a78fe34aa85af8337afad05642beb", + "host_ref": "7607388218b2d08dd5835369dec104f894ecd942" + }, + { + "path": "tmp/native-sound-broad-qa/batch-03.json", + "sha256": "138df3ba88dafe071896d18bfb2e3f543ece4a4848e7340fe538854f5cc69388", + "host_ref": "7607388218b2d08dd5835369dec104f894ecd942" + }, + { + "path": "tmp/native-sound-broad-qa/batch-04.json", + "sha256": "8c65e60187a075de9b9fc2fa9972e6d6b086f2ed62f7dd70cca1f05a30813222", + "host_ref": "7607388218b2d08dd5835369dec104f894ecd942" + }, + { + "path": "tmp/native-sound-broad-qa/batch-05.json", + "sha256": "cd802ef4b40a3c3da239b5e30fdce34dc6b37f734a824e3d65a690c3de6e5a2c", + "host_ref": "7607388218b2d08dd5835369dec104f894ecd942" + }, + { + "path": "tmp/native-sound-broad-qa/batch-06.json", + "sha256": "fe291fb49b74957e69530e6024e6f913df8032807c1247c6811bd9472dae27c2", + "host_ref": "7607388218b2d08dd5835369dec104f894ecd942" + }, + { + "path": "tmp/native-sound-broad-qa/batch-07.json", + "sha256": "625bb2183228779d2d1653c7c7b99ddeebde9917bf603a6e342ebe16b75aa008", + "host_ref": "7607388218b2d08dd5835369dec104f894ecd942" + }, + { + "path": "tmp/native-sound-broad-qa/batch-08.json", + "sha256": "d2e3e092b1646500bd924cc4f520d5008d52a3ba7d87130fac928b865c6169de", + "host_ref": "7607388218b2d08dd5835369dec104f894ecd942" + } + ], + "slowest_worker_seconds": 68.7168, + "complete_observations_recompared": true, + "json_boolean_types_preserved": true + } + ], + "actual_host_regression": { + "checks": 110, + "path": "tmp/native-sound-lifecycle-qa/unit-entrypoints.log", + "sha256": "daaa1426d65b576814f511af554c27a5d8532aafb6c9719c89be9a39e0b856d5" + }, + "protocol_regression": { + "checks": 154, + "path": "tmp/native-sound-lifecycle-qa/protocol18.log", + "sha256": "8bed4f864b85fdaefc464e830592f1ef28ecb2c245987b24440b65b4aac691e2" + }, + "negative_controls": { + "owned_cases": { + "paired_cases": 20, + "different_cases": 16, + "all_four_workers_exit_zero": true, + "host_ref": "6fcea3bbfd3461b4c48f01f3aa21484f38ee771e", + "path": "tmp/native-sound-lifecycle-qa/roles-cloning-negative.json", + "sha256": "31f59bd41f2f2b95c365fb3b62b44c403419c873bbefa14c7c0fe03c62879ccb" + }, + "actual_import": { + "exit": 1, + "reason": "actual same-player source must be replaced", + "sha256": "fa25f8d059d2c2b9b784e1b2892abbbbfeea8c5f9e3eb0b9c49c325cceb1a410", + "path": "tmp/native-lifecycle-baseline-qa/actual-audio-negative.log", + "host_ref": "62d1e451fad9dd992b6efbd912c09b6e873efba9" + } + }, + "actual_imported_audio": [ + { + "host": "feature", + "log": "tmp/native-sound-lifecycle-qa/actual-audio-import-mounted.log", + "sha256": "c33a11cd37f58d7e509c1e5a21b73e994635b3e0dc2e38838c651a4f14f46d0c", + "source_count": 10, + "rows": [ + { + "id": 141, + "player": 1, + "samples": 17641, + "channels": 2, + "version": "firered", + "active": [ + 141 + ] + }, + { + "id": 164, + "player": 1, + "samples": 59468, + "channels": 2, + "version": "firered", + "active": [ + 164 + ] + }, + { + "id": 5, + "player": 2, + "samples": 16906, + "channels": 2, + "version": "firered", + "active": [ + 5, + 164 + ] + }, + { + "id": 141, + "player": 1, + "samples": 17641, + "channels": 1, + "version": "firered", + "active": [ + 5, + 141 + ] + }, + { + "id": 74, + "player": 3, + "samples": 47041, + "channels": 2, + "version": "firered", + "active": [ + 5, + 74, + 141 + ] + }, + { + "id": 141, + "player": 1, + "samples": 24256, + "channels": 2, + "version": "emerald", + "active": [ + 141 + ] + }, + { + "id": 164, + "player": 1, + "samples": 92597, + "channels": 2, + "version": "emerald", + "active": [ + 164 + ] + }, + { + "id": 5, + "player": 2, + "samples": 16906, + "channels": 2, + "version": "emerald", + "active": [ + 5, + 164 + ] + }, + { + "id": 141, + "player": 1, + "samples": 24256, + "channels": 1, + "version": "emerald", + "active": [ + 5, + 141 + ] + }, + { + "id": 81, + "player": 3, + "samples": 47041, + "channels": 2, + "version": "emerald", + "active": [ + 5, + 81, + 141 + ] + } + ] + }, + { + "host": "combined", + "log": "tmp/native-sound-integration-qa/actual-audio-import.log", + "sha256": "cfbe951d651ebd84c424f7ff7a7c66d2a6ba1c114e9fb43d5daacebf18fc6516", + "source_count": 10, + "rows": [ + { + "id": 141, + "player": 1, + "samples": 17641, + "channels": 2, + "active": [ + 141 + ], + "version": "firered" + }, + { + "id": 164, + "player": 1, + "samples": 59468, + "channels": 2, + "active": [ + 164 + ], + "version": "firered" + }, + { + "id": 5, + "player": 2, + "samples": 16906, + "channels": 2, + "active": [ + 5, + 164 + ], + "version": "firered" + }, + { + "id": 141, + "player": 1, + "samples": 17641, + "channels": 1, + "active": [ + 5, + 141 + ], + "version": "firered" + }, + { + "id": 74, + "player": 3, + "samples": 47041, + "channels": 2, + "active": [ + 5, + 74, + 141 + ], + "version": "firered" + }, + { + "id": 141, + "player": 1, + "samples": 24256, + "channels": 2, + "active": [ + 141 + ], + "version": "emerald" + }, + { + "id": 164, + "player": 1, + "samples": 92597, + "channels": 2, + "active": [ + 164 + ], + "version": "emerald" + }, + { + "id": 5, + "player": 2, + "samples": 16906, + "channels": 2, + "active": [ + 5, + 164 + ], + "version": "emerald" + }, + { + "id": 141, + "player": 1, + "samples": 24256, + "channels": 1, + "active": [ + 5, + 141 + ], + "version": "emerald" + }, + { + "id": 81, + "player": 3, + "samples": 47041, + "channels": 2, + "active": [ + 5, + 81, + 141 + ], + "version": "emerald" + } + ] + } + ], + "actual_imported_public_mirror": [ + { + "host": "feature", + "log": "tmp/native-sound-lifecycle-qa/frozen-imported.log", + "scenario_count": 24, + "turn_count": 48, + "move_used_events": 120, + "log_sha256": "9a5e3321dce8d754568814c7e69abd42c8062dcbfb6bda9facee6fcc1e5380b4" + }, + { + "host": "combined", + "log": "tmp/native-sound-integration-qa/frozen-imported.log", + "scenario_count": 24, + "turn_count": 48, + "move_used_events": 120, + "log_sha256": "def532a4bae1b79e88e86e8d4009ef39bbc17069b2823ed8b37eaf67dfb2ac14" + } + ], + "metadata_audit": { + "all_equal": true, + "rows": [ + { + "version": "firered", + "id": 5, + "player": 2, + "header": 7034656, + "rom_sha1": "41cb23d8dccc8ebd7c649cd8fbb58eeace6e2fdc" + }, + { + "version": "firered", + "id": 141, + "player": 1, + "header": 7046236, + "rom_sha1": "41cb23d8dccc8ebd7c649cd8fbb58eeace6e2fdc" + }, + { + "version": "firered", + "id": 148, + "player": 1, + "header": 7047012, + "rom_sha1": "41cb23d8dccc8ebd7c649cd8fbb58eeace6e2fdc" + }, + { + "version": "firered", + "id": 164, + "player": 1, + "header": 7049052, + "rom_sha1": "41cb23d8dccc8ebd7c649cd8fbb58eeace6e2fdc" + }, + { + "version": "firered", + "id": 171, + "player": 1, + "header": 7050084, + "rom_sha1": "41cb23d8dccc8ebd7c649cd8fbb58eeace6e2fdc" + }, + { + "version": "firered", + "id": 74, + "player": 3, + "header": 7039116, + "rom_sha1": "41cb23d8dccc8ebd7c649cd8fbb58eeace6e2fdc" + }, + { + "version": "firered", + "id": 75, + "player": 3, + "header": 7039180, + "rom_sha1": "41cb23d8dccc8ebd7c649cd8fbb58eeace6e2fdc" + }, + { + "version": "firered", + "id": 76, + "player": 3, + "header": 7039240, + "rom_sha1": "41cb23d8dccc8ebd7c649cd8fbb58eeace6e2fdc" + }, + { + "version": "emerald", + "id": 5, + "player": 2, + "header": 9421084, + "rom_sha1": "f3ae088181bf583e55daf962a92bb46f4f1d07b7" + }, + { + "version": "emerald", + "id": 141, + "player": 1, + "header": 9432084, + "rom_sha1": "f3ae088181bf583e55daf962a92bb46f4f1d07b7" + }, + { + "version": "emerald", + "id": 148, + "player": 1, + "header": 9432952, + "rom_sha1": "f3ae088181bf583e55daf962a92bb46f4f1d07b7" + }, + { + "version": "emerald", + "id": 164, + "player": 1, + "header": 9435004, + "rom_sha1": "f3ae088181bf583e55daf962a92bb46f4f1d07b7" + }, + { + "version": "emerald", + "id": 171, + "player": 1, + "header": 9435768, + "rom_sha1": "f3ae088181bf583e55daf962a92bb46f4f1d07b7" + }, + { + "version": "emerald", + "id": 81, + "player": 3, + "header": 9425832, + "rom_sha1": "f3ae088181bf583e55daf962a92bb46f4f1d07b7" + }, + { + "version": "emerald", + "id": 82, + "player": 3, + "header": 9425896, + "rom_sha1": "f3ae088181bf583e55daf962a92bb46f4f1d07b7" + }, + { + "version": "emerald", + "id": 83, + "player": 3, + "header": 9425956, + "rom_sha1": "f3ae088181bf583e55daf962a92bb46f4f1d07b7" + } + ] + }, + "retained_superseded_evidence": [ + { + "path": "tmp/native-sound-lifecycle-qa/parent-negative.json", + "sha256": "2ce11cc0c361235b92588c882e685913d3d485ac7ff21feab3d0e9dfc8982407" + }, + { + "path": "tmp/native-sound-lifecycle-qa/complete-inventory-negative.json", + "sha256": "0e879b34a0d49560acb5a86d50ca693a1298531d8f369336a6ba283caacc15a7" + }, + { + "path": "tmp/native-sound-lifecycle-qa/unit-first.log", + "sha256": "138abb08dea0a91300764b2cbb8f0ccc38dce6437983395136e44011ad6b827c" + }, + { + "path": "tmp/native-sound-lifecycle-qa/actual-audio-import-first.log", + "sha256": "90f6ccb98a587129e0e64b10ebdc6816caeafa9c0bcd5acbe678d1993bfb9619" + } + ], + "limits": [ + "FireRed and Emerald player ownership; only admitted native profiles", + "Owned audio-controller cases do not advance playback time or assert synthesizer waveform equality", + "Actual M4A buffers are synthesized from existing private imports; speaker hardware is owned", + "No qualification of every public sound-event timing or complete GPU scenes", + "No arbitrary whole-mod or full-playthrough claim", + "No ROM/audio assets, normal cache writes or normal save writes", + "Feature keeps nine inherited failing suites visible; combined fork is fully passing", + "Hosted CI status must be checked separately after publication" + ] +} diff --git a/docs/qa/native-sound-script-wait-clocks.json b/docs/qa/native-sound-script-wait-clocks.json new file mode 100644 index 000000000..d5302b352 --- /dev/null +++ b/docs/qa/native-sound-script-wait-clocks.json @@ -0,0 +1,71 @@ +{ + "schema_version": 1, + "source_ref": "535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91", + "reference_functions_unchanged": true, + "raw_log": "workspace/tmp/native-sound-script-wait-clock-oracle.log", + "raw_log_sha256": "68f89c3fcf2a204ecd996b3b32ca1c911bfae891602a52d7b18d7c8b4cae24f9", + "command_exit": 0, + "cases": [ + { + "version": "firered", + "case": "logic-only", + "completion_logic_tick": 181, + "recorded_frames": 182 + }, + { + "version": "firered", + "case": "normal-audio-after-logic", + "completion_logic_tick": 180, + "recorded_frames": 181 + }, + { + "version": "firered", + "case": "two-audio-ticks-per-logic", + "completion_logic_tick": 90, + "recorded_frames": 91 + }, + { + "version": "firered", + "case": "one-audio-tick-per-two-logic", + "completion_logic_tick": 181, + "recorded_frames": 182 + }, + { + "version": "firered", + "case": "latched-silence-before-replacement", + "completion_logic_tick": 2, + "recorded_frames": 3 + }, + { + "version": "emerald", + "case": "logic-only", + "completion_logic_tick": 181, + "recorded_frames": 182 + }, + { + "version": "emerald", + "case": "normal-audio-after-logic", + "completion_logic_tick": 180, + "recorded_frames": 181 + }, + { + "version": "emerald", + "case": "two-audio-ticks-per-logic", + "completion_logic_tick": 90, + "recorded_frames": 91 + }, + { + "version": "emerald", + "case": "one-audio-tick-per-two-logic", + "completion_logic_tick": 181, + "recorded_frames": 182 + }, + { + "version": "emerald", + "case": "latched-silence-before-replacement", + "completion_logic_tick": 2, + "recorded_frames": 3 + } + ], + "scope": "Actual native VM, native audio entrypoints, Audio.update and callback list; own speaker/carrier/initial state. Reproduces logic-before-audio owner order; no full game/GPU scene claim." +} diff --git a/docs/qa/native-sound-script-waits-negative.json b/docs/qa/native-sound-script-waits-negative.json new file mode 100644 index 000000000..f1a1fade3 --- /dev/null +++ b/docs/qa/native-sound-script-waits-negative.json @@ -0,0 +1,50 @@ +{ + "schema_version": 1, + "source_ref": "535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91", + "host_ref": "aaf44f4eed3bb49a910b8a77d11e73c2075d8396", + "paired_cases": 12, + "different_cases": [ + { + "version": "firered", + "case": "native-player-one" + }, + { + "version": "firered", + "case": "native-player-two" + }, + { + "version": "firered", + "case": "special-player-three" + }, + { + "version": "firered", + "case": "native-and-file-override" + }, + { + "version": "emerald", + "case": "native-player-one" + }, + { + "version": "emerald", + "case": "native-player-two" + }, + { + "version": "emerald", + "case": "native-and-file-override" + } + ], + "workers": 4, + "all_workers_exit_zero": true, + "voices_and_queries_equal": true, + "raw_negative_path": "tmp/native-sound-script-waits-qa/frozen-negative.json", + "raw_negative_sha256": "cab9d8bbf9ca0086842223cc25c23b45f660806bd9ed24f130e56a4b5bc32f9f", + "runtime_unchanged_from": "e70129d1311ca81a57dc67cc56967dac45975000", + "status": "reproduced; implementation and qualification pending", + "next_required": [ + "Preserve immediate completion for silence/override-only and Emerald player three", + "Preserve polling of current players after stops/replacements", + "Qualify the native 181-poll fallback and real Audio.update callback timing before choosing a timeout implementation", + "Cover imported-script lowering, actual runner, native script hooks and gameplay return path", + "Do not alter or publish the separate frozen sound-player ownership change until its existing gates finish" + ] +} diff --git a/docs/qa/native-sound-script-waits.json b/docs/qa/native-sound-script-waits.json new file mode 100644 index 000000000..0138d2a17 --- /dev/null +++ b/docs/qa/native-sound-script-waits.json @@ -0,0 +1,528 @@ +{ + "schema_version": 1, + "reference_commit": "535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91", + "reference_read_only": true, + "lua_jit_commit": "a4c064539ea931274d407e1249b4bb575424a0f4", + "prerequisite_pr": "https://github.com/UNDERdecoded/Gen2Recomped/pull/277", + "feature_frozen_head": "974ec078b51ad07690c183c224db293738080b19", + "feature_src_tree": "99122e59f7e2d599ba96ca80f2c0763ee4a6a3ae", + "feature_tests_tree": "5f847cdabb17bcd77d31d9d4d770d6ed63ad2fa9", + "combined_frozen_head": "f04fdf95164b2f3cc6d2da9563931ff53722ec15", + "combined_src_tree": "90688af7c38d19843369b895f87bc83a5697d753", + "combined_tests_tree": "c4fd68af51840dc517e20379e1e8c5e95c11a9ec", + "native_battle_revision_unchanged": 18, + "party_codec_revision_unchanged": 1, + "native_digest_unchanged": "c82a14f754706103", + "legacy_digest_unchanged": "e33d251961f2a41d", + "feature_regression": { + "candidate": { + "log": "frozen-full.log", + "sha256": "55d524fee921418ffc0111aa2c7003ec9c3fb44ac05ce7fa339d3c004e507fbb", + "fail_counter": { + "FAIL hook:battle.status_hud_visible has no unit test naming it through the public mod API (add one, or add a DEBT entry saying which milestone owes it)": 1, + "FAIL event:intro.birch_speech.finished has no unit test naming it through the public mod API (add one, or add a DEBT entry saying which milestone owes it)": 1, + "FAIL event:intro.birch_speech.started has no unit test naming it through the public mod API (add one, or add a DEBT entry saying which milestone owes it)": 1, + "FAIL event:pokemon.egg_hatched has no unit test naming it through the public mod API (add one, or add a DEBT entry saying which milestone owes it)": 1, + "FAIL event:world.blocks_replaced has no unit test naming it through the public mod API (add one, or add a DEBT entry saying which milestone owes it)": 1, + "FAIL event:world.tick has no unit test naming it through the public mod API (add one, or add a DEBT entry saying which milestone owes it)": 1, + "FAIL tests/engine/gate_meta_coverage.lua": 1, + "FAIL the process exit runs only after love.quit declined to veto": 1, + "FAIL the process exit is gated on Android": 1, + "FAIL tests/engine/quit_thread_shutdown.lua": 1, + "FAIL and stays out of the live pad map until close (got x, want nil)": 1, + "FAIL tests/engine/rebind_capture_bug510.lua": 1, + "FAIL all eight actions still have exactly one pad button (got 12, want 8)": 1, + "FAIL tests/engine/rebind_swap_clear_bug589.lua": 1, + "FAIL tests/engine/soft_reset_bug563.lua": 1, + "FAIL tests/engine/tool_mod_hooks.lua": 1, + "FAIL releaseUrl points at the repo's latest release (got https://github.com/UNDERdecoded/Gen2Recomped/releases/latest, want https://github.com/bryanthaboi/gen1recomp/releases/latest)": 1, + "FAIL payload name derived from version (got Gen2Recomped-1.4.2.love, want gen1recomp-1.4.2.love)": 1, + "FAIL tests/engine/update_check_tests.lua": 1, + "FAIL parseRelease derives the payload name from the version (got Gen2Recomped-1.4.2.love, want gen1recomp-1.4.2.love)": 1, + "FAIL tests/engine/update_tests.lua": 1, + "FAIL a player-side frame lands on the player anchor (got 37, want 20)": 1, + "FAIL a player-side frame lands on the player baseline (got 7, want 8)": 1, + "FAIL an enemy-side frame lands on the enemy anchor (got 131, want 136)": 1, + "FAIL tests/engine/wide_battle_layout.lua": 1 + }, + "engine": [ + "engine: 166/175 suites passed" + ], + "sdk": [ + "modkit: 3/3 suites passed" + ], + "b30": true + }, + "baseline": { + "log": "fresh-full.log", + "sha256": "65e7e2b8f3021ac8d3a80887bcb002f485eb51e3e8c4889c927f1e0bf71ba075", + "fail_counter": { + "FAIL hook:battle.status_hud_visible has no unit test naming it through the public mod API (add one, or add a DEBT entry saying which milestone owes it)": 1, + "FAIL event:intro.birch_speech.finished has no unit test naming it through the public mod API (add one, or add a DEBT entry saying which milestone owes it)": 1, + "FAIL event:intro.birch_speech.started has no unit test naming it through the public mod API (add one, or add a DEBT entry saying which milestone owes it)": 1, + "FAIL event:pokemon.egg_hatched has no unit test naming it through the public mod API (add one, or add a DEBT entry saying which milestone owes it)": 1, + "FAIL event:world.blocks_replaced has no unit test naming it through the public mod API (add one, or add a DEBT entry saying which milestone owes it)": 1, + "FAIL event:world.tick has no unit test naming it through the public mod API (add one, or add a DEBT entry saying which milestone owes it)": 1, + "FAIL tests/engine/gate_meta_coverage.lua": 1, + "FAIL the process exit runs only after love.quit declined to veto": 1, + "FAIL the process exit is gated on Android": 1, + "FAIL tests/engine/quit_thread_shutdown.lua": 1, + "FAIL and stays out of the live pad map until close (got x, want nil)": 1, + "FAIL tests/engine/rebind_capture_bug510.lua": 1, + "FAIL all eight actions still have exactly one pad button (got 12, want 8)": 1, + "FAIL tests/engine/rebind_swap_clear_bug589.lua": 1, + "FAIL tests/engine/soft_reset_bug563.lua": 1, + "FAIL tests/engine/tool_mod_hooks.lua": 1, + "FAIL releaseUrl points at the repo's latest release (got https://github.com/UNDERdecoded/Gen2Recomped/releases/latest, want https://github.com/bryanthaboi/gen1recomp/releases/latest)": 1, + "FAIL payload name derived from version (got Gen2Recomped-1.4.2.love, want gen1recomp-1.4.2.love)": 1, + "FAIL tests/engine/update_check_tests.lua": 1, + "FAIL parseRelease derives the payload name from the version (got Gen2Recomped-1.4.2.love, want gen1recomp-1.4.2.love)": 1, + "FAIL tests/engine/update_tests.lua": 1, + "FAIL a player-side frame lands on the player anchor (got 37, want 20)": 1, + "FAIL a player-side frame lands on the player baseline (got 7, want 8)": 1, + "FAIL an enemy-side frame lands on the enemy anchor (got 131, want 136)": 1, + "FAIL tests/engine/wide_battle_layout.lua": 1 + }, + "engine": [ + "engine: 165/174 suites passed" + ], + "sdk": [ + "modkit: 3/3 suites passed" + ], + "b30": true + }, + "exact_anchored_FAIL_Counter_equal": true, + "inherited_fail_lines": 25, + "candidate_exit": 1, + "baseline_exit": 1 + }, + "combined_regression": { + "candidate": { + "log": "frozen-full.log", + "sha256": "697eb9a34cdcbedcf72358e692b405dc0e8fc857e5e5a36f0822557700a33441", + "fail_counter": {}, + "engine": [ + "engine: 284/284 suites passed" + ], + "sdk": [ + "modkit: 3/3 suites passed" + ], + "b30": true + }, + "baseline": { + "log": "frozen-full.log", + "sha256": "0b4918de8241acdb4f35a0ea06ec8b1108afa0c9d16b3d475d7f11fa3f0706e4", + "fail_counter": {}, + "engine": [ + "engine: 283/283 suites passed" + ], + "sdk": [ + "modkit: 3/3 suites passed" + ], + "b30": true + }, + "exact_anchored_FAIL_Counter_equal": true, + "candidate_exit": 0, + "baseline_exit": 0 + }, + "paired_native_wait_cases": 36, + "actual_host_checks": { + "checks": 6306, + "path": "tmp/native-sound-script-waits-qa/actual-game-field.log", + "sha256": "83a96e41c855b89980508feebfcfe5c407d81de5143bb907e1d15970b65d6866" + }, + "contracts": [ + { + "host": "feature", + "head": "974ec078b51ad07690c183c224db293738080b19", + "src_tree": "99122e59f7e2d599ba96ca80f2c0763ee4a6a3ae", + "tests_tree": "5f847cdabb17bcd77d31d9d4d770d6ed63ad2fa9", + "qualified_workers": 228, + "qualified_comparisons": 114, + "paired_rows": 175616, + "reports": [ + { + "path": "tmp/native-sound-script-waits-qa/frozen-command-matrix.json", + "sha256": "cba654e5926c44a85a21f7b3e944c8a0e11843f83430706b43fe907cd9aa9e75", + "host_ref": "974ec078b51ad07690c183c224db293738080b19" + }, + { + "path": "tmp/native-world-wait-broad-qa/batch-01.json", + "sha256": "aec85be7eb623385d00fa88fcd315bcdfbbe33ddf4943eabe315cc6823dc84b6", + "host_ref": "974ec078b51ad07690c183c224db293738080b19" + }, + { + "path": "tmp/native-world-wait-broad-qa/batch-02.json", + "sha256": "0808cff3700f38632111949549ae7bc2546ba72b933c19884b427c87ffa56d30", + "host_ref": "974ec078b51ad07690c183c224db293738080b19" + }, + { + "path": "tmp/native-world-wait-broad-qa/batch-03.json", + "sha256": "d8bbc7efea607c1292c30f61e42a6323f95bcd298a393ad307c58ab7b4872617", + "host_ref": "974ec078b51ad07690c183c224db293738080b19" + }, + { + "path": "tmp/native-world-wait-broad-qa/batch-04.json", + "sha256": "bf2131557ba855a1127ad3e80cabad52123b300ef35e85d183613f3c531dc29a", + "host_ref": "974ec078b51ad07690c183c224db293738080b19" + }, + { + "path": "tmp/native-world-wait-broad-qa/batch-05.json", + "sha256": "9724f762953ac72f86c5907ad1aec86f2fd959dfffae7cd642b628ca7889fc56", + "host_ref": "974ec078b51ad07690c183c224db293738080b19" + }, + { + "path": "tmp/native-world-wait-broad-qa/batch-06.json", + "sha256": "020c1e70440e6bf8220de49aa70a90357cd83936f30b9b02144fd9ce62004dbe", + "host_ref": "974ec078b51ad07690c183c224db293738080b19" + }, + { + "path": "tmp/native-world-wait-broad-qa/batch-07.json", + "sha256": "97566f44edf4992a8f33c2db4ea9a3b0a08c4fddd3fd4091f2d26b28cc6d05e7", + "host_ref": "974ec078b51ad07690c183c224db293738080b19" + }, + { + "path": "tmp/native-world-wait-broad-qa/batch-08.json", + "sha256": "cc8a93ba45b39824a938f79cbad1b38371c6104b57104b47de9ade5a0b4c4a9c", + "host_ref": "974ec078b51ad07690c183c224db293738080b19" + } + ], + "slowest_worker_seconds": 124.7249, + "complete_observations_recompared": true, + "json_boolean_types_preserved": true + }, + { + "host": "combined", + "head": "f04fdf95164b2f3cc6d2da9563931ff53722ec15", + "src_tree": "90688af7c38d19843369b895f87bc83a5697d753", + "tests_tree": "c4fd68af51840dc517e20379e1e8c5e95c11a9ec", + "qualified_workers": 228, + "qualified_comparisons": 114, + "paired_rows": 175616, + "reports": [ + { + "path": "tmp/native-world-wait-integration-qa/frozen-command-matrix.json", + "sha256": "83a3e325089b4c6c375d1180e41538704c7cd757f53e7d565175e24b03bd01a0", + "host_ref": "f04fdf95164b2f3cc6d2da9563931ff53722ec15" + }, + { + "path": "tmp/native-world-wait-broad-qa/batch-01.json", + "sha256": "f6ecc5095ee907b3c7b771a799c44e6512d635b323ea737c4c4e25c17fa81597", + "host_ref": "f04fdf95164b2f3cc6d2da9563931ff53722ec15" + }, + { + "path": "tmp/native-world-wait-broad-qa/batch-02.json", + "sha256": "09a0e35259e8fec5a5c58b64061822b9b13afc05fc98cb5b1203c2113d248011", + "host_ref": "f04fdf95164b2f3cc6d2da9563931ff53722ec15" + }, + { + "path": "tmp/native-world-wait-broad-qa/batch-03.json", + "sha256": "0bd083b2775a173015238a5bafcb776d39ba9919a7bd48c9113cc32537e7510d", + "host_ref": "f04fdf95164b2f3cc6d2da9563931ff53722ec15" + }, + { + "path": "tmp/native-world-wait-broad-qa/batch-04.json", + "sha256": "15bd28c7a9f73013b03b583cdcf7b19cbed436d7cdf0c3ac3b86285e76a35f80", + "host_ref": "f04fdf95164b2f3cc6d2da9563931ff53722ec15" + }, + { + "path": "tmp/native-world-wait-broad-qa/batch-05.json", + "sha256": "0bf29f721413c4abbabacc040b221dc37a36aed3d07c390a124d273e4bd9ef65", + "host_ref": "f04fdf95164b2f3cc6d2da9563931ff53722ec15" + }, + { + "path": "tmp/native-world-wait-broad-qa/batch-06.json", + "sha256": "be106097bd1966c2a504fcf65aa1b2d7437745e94b8fddbf5da7e77f5141054f", + "host_ref": "f04fdf95164b2f3cc6d2da9563931ff53722ec15" + }, + { + "path": "tmp/native-world-wait-broad-qa/batch-07.json", + "sha256": "6434e159d3360455119d93397c179734757fde7253c36deafcfd62cf60431d2d", + "host_ref": "f04fdf95164b2f3cc6d2da9563931ff53722ec15" + }, + { + "path": "tmp/native-world-wait-broad-qa/batch-08.json", + "sha256": "a158959f6f9cbcc84c06a29a2a8cc6641a8e9648bab7548b56f7b92d1e828c90", + "host_ref": "f04fdf95164b2f3cc6d2da9563931ff53722ec15" + } + ], + "slowest_worker_seconds": 52.5738, + "complete_observations_recompared": true, + "json_boolean_types_preserved": true + } + ], + "existing_actual_imports": [ + { + "host": "feature", + "reports": [ + { + "log": "tmp/native-sound-script-waits-qa/actual-audio-import.log", + "sha256": "5a2552ce876b7262164656d139a73d05606452c38811defd1597137fdf24dfa0", + "scenario_or_source_count": 10, + "turns": null, + "move_used_events": null + }, + { + "log": "tmp/native-sound-script-waits-qa/frozen-imported.log", + "sha256": "b702a5fc22d4ff4ea84667499d0584d6a3ee5eb5349d2eca26bf4da52c045221", + "scenario_or_source_count": 24, + "turns": 48, + "move_used_events": 120 + } + ] + }, + { + "host": "combined", + "reports": [ + { + "log": "tmp/native-world-wait-integration-qa/actual-audio-import.log", + "sha256": "8e79583c1f53bb02395d13d4f244d6c6ae1fc298cc4904e5c4c3bd5552d9fd98", + "scenario_or_source_count": 10, + "turns": null, + "move_used_events": null + }, + { + "log": "tmp/native-world-wait-integration-qa/frozen-imported.log", + "sha256": "ebb6eb96734f571a4882dd004703e1f57db4853dfd1fda7cc07ed92f7a6e9e5d", + "scenario_or_source_count": 24, + "turns": 48, + "move_used_events": 120 + } + ] + } + ], + "actual_native_field_imports": [ + { + "host": "feature", + "log": "tmp/native-sound-script-waits-qa/actual-field-import-first.log", + "sha256": "be3b170be6e9e5850861fb184eac0ee3ee88a3e89c032fd0a32b7b211123ad7a", + "strict_complete_outputs_equal": true, + "profiles": [ + { + "version": "firered", + "completed": 25, + "samples": 17641, + "rate": 44100, + "channels": 2 + }, + { + "version": "emerald", + "completed": 34, + "samples": 24256, + "rate": 44100, + "channels": 2 + } + ], + "recorded_frames_per_profile": 100 + }, + { + "host": "combined", + "log": "tmp/native-world-wait-integration-qa/actual-field-import.log", + "sha256": "170ed946c6bdba99745af7cee6316ac9b692b206ea5ea49d35fc8d6eb51530a8", + "strict_complete_outputs_equal": true, + "profiles": [ + { + "version": "firered", + "completed": 25, + "samples": 17641, + "rate": 44100, + "channels": 2 + }, + { + "version": "emerald", + "completed": 34, + "samples": 24256, + "rate": 44100, + "channels": 2 + } + ], + "recorded_frames_per_profile": 100 + } + ], + "private_import_inputs": { + "inputs_read_only": true, + "files": [ + { + "version": "firered", + "path": "data/generated/audio.lua", + "bytes": 184817, + "sha256": "fca8c548ee8cf47c638a824794b80f708431e765bf9eae9157274189b4b46e69" + }, + { + "version": "firered", + "path": "assets/generated/audio/m4a.bin", + "bytes": 2563861, + "sha256": "c01203f0323711b00781a7ce25ad7306b71d5791a0f07fac2d6c0e128e5d0940" + }, + { + "version": "emerald", + "path": "data/generated/audio.lua", + "bytes": 276284, + "sha256": "5825679875f18a4d726db9fd9609a5d20f8365682d02f89d5eb92e7fe2954d4a" + }, + { + "version": "emerald", + "path": "assets/generated/audio/m4a.bin", + "bytes": 3330877, + "sha256": "ab8621d57ce4c43b09343ebf1a0af168b59992b6a84ade4e10459696431625ab" + } + ] + }, + "negative_controls": { + "original_vm_wait": { + "schema_version": 1, + "source_ref": "535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91", + "host_ref": "aaf44f4eed3bb49a910b8a77d11e73c2075d8396", + "paired_cases": 12, + "different_cases": [ + { + "version": "firered", + "case": "native-player-one" + }, + { + "version": "firered", + "case": "native-player-two" + }, + { + "version": "firered", + "case": "special-player-three" + }, + { + "version": "firered", + "case": "native-and-file-override" + }, + { + "version": "emerald", + "case": "native-player-one" + }, + { + "version": "emerald", + "case": "native-player-two" + }, + { + "version": "emerald", + "case": "native-and-file-override" + } + ], + "workers": 4, + "all_workers_exit_zero": true, + "voices_and_queries_equal": true, + "raw_negative_path": "tmp/native-sound-script-waits-qa/frozen-negative.json", + "raw_negative_sha256": "cab9d8bbf9ca0086842223cc25c23b45f660806bd9ed24f130e56a4b5bc32f9f", + "runtime_unchanged_from": "e70129d1311ca81a57dc67cc56967dac45975000", + "status": "reproduced; implementation and qualification pending", + "next_required": [ + "Preserve immediate completion for silence/override-only and Emerald player three", + "Preserve polling of current players after stops/replacements", + "Qualify the native 181-poll fallback and real Audio.update callback timing before choosing a timeout implementation", + "Cover imported-script lowering, actual runner, native script hooks and gameplay return path", + "Do not alter or publish the separate frozen sound-player ownership change until its existing gates finish" + ] + }, + "audio_service_wire": { + "base": "974ec078b51ad07690c183c224db293738080b19", + "change": "Only remove Sound.update() from Game.update audio loop", + "exit": 1, + "checks_passed": 6304, + "checks_total": 6306, + "exact_failures": [ + "actual Game:update services audio after logic independently of fast-forward (got 181, want 180)", + "actual Game:update services audio after logic independently of fast-forward (got 181, want 180)" + ], + "path": "tmp/native-sound-no-audio-qa/actual-game-negative.log", + "sha256": "32b26dada2eac3b8c75a98c1d09feb1c1c0dd4b295751840a3f5b9c2e690d847" + }, + "actual_parent_field_script": { + "head": "8e06021a19dca8a150fbc27a6af20cd7c8ea4f2f", + "exit": 1, + "reason": "actual native wait must end with decoded PCM: got 0, want 25", + "path": "tmp/native-world-wait-baseline-qa/actual-field-negative.log", + "sha256": "b59a6717518d56abb4a1453ea54bff540eadba9fb60a90fceddb708080a15454" + } + }, + "native_clock_oracle": { + "schema_version": 1, + "source_ref": "535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91", + "reference_functions_unchanged": true, + "raw_log": "workspace/tmp/native-sound-script-wait-clock-oracle.log", + "raw_log_sha256": "68f89c3fcf2a204ecd996b3b32ca1c911bfae891602a52d7b18d7c8b4cae24f9", + "command_exit": 0, + "cases": [ + { + "version": "firered", + "case": "logic-only", + "completion_logic_tick": 181, + "recorded_frames": 182 + }, + { + "version": "firered", + "case": "normal-audio-after-logic", + "completion_logic_tick": 180, + "recorded_frames": 181 + }, + { + "version": "firered", + "case": "two-audio-ticks-per-logic", + "completion_logic_tick": 90, + "recorded_frames": 91 + }, + { + "version": "firered", + "case": "one-audio-tick-per-two-logic", + "completion_logic_tick": 181, + "recorded_frames": 182 + }, + { + "version": "firered", + "case": "latched-silence-before-replacement", + "completion_logic_tick": 2, + "recorded_frames": 3 + }, + { + "version": "emerald", + "case": "logic-only", + "completion_logic_tick": 181, + "recorded_frames": 182 + }, + { + "version": "emerald", + "case": "normal-audio-after-logic", + "completion_logic_tick": 180, + "recorded_frames": 181 + }, + { + "version": "emerald", + "case": "two-audio-ticks-per-logic", + "completion_logic_tick": 90, + "recorded_frames": 91 + }, + { + "version": "emerald", + "case": "one-audio-tick-per-two-logic", + "completion_logic_tick": 181, + "recorded_frames": 182 + }, + { + "version": "emerald", + "case": "latched-silence-before-replacement", + "completion_logic_tick": 2, + "recorded_frames": 3 + } + ], + "scope": "Actual native VM, native audio entrypoints, Audio.update and callback list; own speaker/carrier/initial state. Reproduces logic-before-audio owner order; no full game/GPU scene claim." + }, + "retained_first_invocation": { + "path": "tmp/native-sound-script-waits-qa/actual-game-first.log", + "sha256": "7fb583a193a9ba3c38fd390c3b6707c9964642020271acd62a928a073dde9402" + }, + "limits": [ + "Only admitted FireRed and Emerald native world-controller behavior is qualified", + "Native audio/script/runner functions are unchanged in the reference; input state, waveform hardware and operation definitions are owned", + "Actual-import field speaker completion uses decoded PCM sample duration; no physical audio-device timing claim", + "No complete mobile audio-session, all public sound-event timing, waveform equality, GPU scene or unregistered profile claim", + "No arbitrary whole-mod or full-playthrough claim", + "No ROM/audio assets, normal cache/save writes or automatic game/audio-device/GPU launch", + "Feature keeps nine inherited failing suites visible; combined fork passes every quick tier", + "Hosted CI must be inspected separately after publication" + ] +} diff --git a/docs/ui-actions-api.md b/docs/ui-actions-api.md new file mode 100644 index 000000000..caa7c4d36 --- /dev/null +++ b/docs/ui-actions-api.md @@ -0,0 +1,128 @@ +# Public UI actions + +`mod.ui:snapshot()` and `mod.ui:submit(intent)` drive the **currently open** +native party, bag or dialogue screen. They are additive for API 1 and 2, with +no manifest opt-in. Existing `mod.ui.insertBefore`, `insertAfter`, step-list +helpers and their dot-call signatures remain available. Each mod receives its +own facade. If the optional capability-query feature is installed, +`mod:supports("ui", "snapshot")` and `mod:supports("ui", "submit")` return true. +This describes the interface, not whether the current screen accepts an action. + +The battle API can open the native party/bag menu. This API then selects its +rows and follows the native target, item, confirmation and message flow. It +does not open a menu from arbitrary world/script states. + +## Snapshot and intents + +A snapshot returns a detached table, or `nil, reason` if the top screen is +absent or unsupported. It never searches through a covering screen. Reading +early does not bootstrap Game, load a screen, poll input or render graphics. + +| Field | Meaning | +| --- | --- | +| `revision` | Positive per-facade context revision; obtain it from this facade | +| `kind` | `party`, `party_actions`, `bag`, `bag_actions`, `text` or `choice` | +| `rows` | Ordered options, including disabled placeholders; empty for text | +| `index` | Native selected row, where applicable | +| `locked` | Animation, typing, controller or script currently owns input | +| `canBack`, `canAdvance`, `canPocket` | Which non-selection intents are offered | +| `pocket` | Optional native pocket identifier; diagnostic, not portable | +| `page` | DS battle bag stage: `menu`, `pocket` or `use` | + +Every row has `index` and `enabled`. Depending on the native screen it can also +carry a string `label`/`action`, portable `item`/`species` ID, item `count`, or +`partyIndex`, `hp`, `maxHp`. Missing fields remain absent. The current dataset +resolves IDs, including numeric DS IDs and ROM-hack aliases. Labels can be +localized; native action strings are diagnostic, not a common semantic action +enumeration. There are no callbacks, native objects or future dialogue pages +in the public record. + +`select.index` is **1-based in the returned rows**. It is not always a party +slot or item number: party confirmation/cancel rows, DS list headers, GBA +blank action cells, and DS battle-bag navigation buttons retain their native +positions. Disabled rows cannot be submitted. In field bags the rows cover the +current pocket in native order; HGSS/BW scroll/page cursors are positioned for +the selected row. DS battle bags expose only their current page's six item +slots plus previous/next/back buttons. + +Intents must be plain tables with exactly the fields below: + +```lua +{ id = 1, revision = snapshot.revision, kind = "select", index = 2 } +{ id = 2, revision = snapshot.revision, kind = "back" } +{ id = 3, revision = snapshot.revision, kind = "advance" } +{ id = 4, revision = snapshot.revision, kind = "pocket", direction = 1 } +``` + +`id` is a strictly increasing positive safe integer **per UI facade**; +`revision` must come from a fresh snapshot returned by that facade. Pocket +`direction` is exactly `-1` or `1`. Unknown fields/kinds, malformed integers, +missing/disabled rows, stale contexts and unavailable actions return +`nil, reason` before native dispatch. Failed admission does not consume the ID. +Keep the ID counter across menu/Game changes. + +```lua +local nextId = 0 +local function chooseRow(index) + local snapshot, why = mod.ui:snapshot() + if not snapshot then return nil, why end + local row = snapshot.rows[index] + if snapshot.locked or not row or not row.enabled then + return nil, "row is not currently available" + end + nextId = nextId + 1 + return mod.ui:submit({ + id = nextId, revision = snapshot.revision, kind = "select", index = index, + }) +end +``` + +Take a new snapshot after each accepted action or native input/update. Revisions +track native screen/option/callback identity, live party/inventory and relevant +selection/control state. A private per-Game guard/epoch serializes UI and battle +submissions, rejects callback reentry and expires other mods' old and in-flight +snapshots. A snapshot obtained during dispatch reports `locked = true`. + +## Native behavior and limits + +- Party lists/pickers, swap/field/item submenus, forced replacement and team + selection retain their native eligibility, callback and cancellation paths. + `back` uses that screen's B behavior; it may clear a pending swap or submenu + instead of closing. The underlying battle decides how a cancelled forced + replacement is reopened. GB/GBA/DS each keep their own return semantics. +- Bags preserve native pocket order, item-use eligibility, target pickers and + consumption. The classic bag's own action menu and GBA item action grid are + supported; unrelated generic menus/lists are not inferred from their shape. + DS battle-bag presses complete on subsequent native updates, including their + page/category return paths. `true` does not mean an item has already run. +- `advance` handles a ready manual page/line break or final text, a bag's + result message, or a DS party notice. Typing and page/scroll holds are never + skipped. Multi-page question/auto/stay text can advance its intermediate + manual pages; the final script-owned page stays with its controller. +- TextBox's native choice creates the separate YES/NO screen on the next + native update. `select` chooses YES/NO; `back` selects NO. The native answer + hold and delayed callback are preserved, so the answer cannot be replayed + while pending. +- Scripts, no-input/tutorial states, healing fills, pending answer/DS battle + button animations, item sorting and quantity entry are locked. QuantityBox, + summary screens, PC/shop-specific auxiliary screens, TM cases/berry pouches, + arbitrary custom menus and custom screen subclasses have no adapter here; + they return `nil, reason`. This is not a generic UI automation API. + +`true` means a native action was admitted and entered. It may open another +screen, display a native refusal or schedule work. IDs are consumed **before** +native code. An error after partial native effects returns +`nil, "UI action failed; intent consumed: ..."`; the guard is released, but no +rollback or automatic retry occurs. Inspect a fresh snapshot before continuing. +The read/validation boundary is not a security sandbox for mods that directly +replace native engine methods or mutate Game themselves. + +## Reproducible tests + +Run `luajit tests/engine/ui_actions.lua` and `LUA=luajit bash scripts/test.sh`. +The focused suite uses real Loader/native menus, Pokémon, item effects and text +updates with the ROM-free fixture data across all 15 supported version IDs. +It checks native A/B parity, copied reads, full-graph immutability on refusal, +identity/revision/replay checks, callbacks and recovery after partial errors. +DS UI fixtures include native pocket/answer/press delays. Imported-ROM gameplay +and rendered cartridge scenes require a separate ROM-backed run. diff --git a/docs/unified-mod-callbacks.md b/docs/unified-mod-callbacks.md new file mode 100644 index 000000000..a79661ca6 --- /dev/null +++ b/docs/unified-mod-callbacks.md @@ -0,0 +1,177 @@ +# Unified hook and event records + +This is an additive, explicit Mod API 2 option inspired by Gen1Recomp's +unified callback boundary. It adapts Gen2Recomped's existing call sites across +generations 1–5. It does not introduce Gen1Recomp API 3 hooks or events. + +```json +{ + "id": "portable_hooks", + "name": "Portable hooks", + "version": "1.0.0", + "api": 2, + "entry": "main.lua", + "callback_format": "unified" +} +``` + +Absent/`"native"` keeps the original API, including object identities and +custom hook names. Unknown format values and unified callbacks on API 1 fail +manifest validation. `mod.callbackFormat` exposes the selected format. The +option is independent of `content_format`; either can be adopted first. +There is no global conversion of saves, registry data or battle state. + +```lua +return function(mod) + mod.events:on("pokemon.level_up", function(event) + mod.log:info("%s reached %d", event.mon.species, event.level) + end) + + mod.hooks:wrap("battle.damage", function(next, ctx) + local damage, info = next() + if ctx.user.mon.species == "PIKACHU" then damage = damage + 1 end + return damage, info + end) + + mod.events:on("pokemon.before_give", function(gift) + if gift.species == "EEVEE" then gift.nickname = "Buddy" end + end) +end +``` + +## Records + +IDs are resolved through this boot's registries. Pokémon, move and item IDs +are symbolic strings, including on numeric-ID DS hosts. Imported numeric +records use their names (e.g. `PIKACHU`); named/custom keys retain their IDs. +No National Dex lookup or hard-coded item-number conversion is used. Unknown +or ambiguous replacement IDs fail validation. An unchanged unknown native +ID can round-trip without being guessed or rewritten. Native aliases remain +one identity even after registry merging creates separate row tables. + +| Record | Common fields | +| --- | --- | +| Live Pokémon | `species`, `item`, `nickname`, `name`, `level`, `hp`, `maxHp`, `status`, `isEgg`, `stats`, `moves`, `native` | +| Stats | `hp`, `attack`, `defense`, `speed`, `special`, `specialAttack`, `specialDefense` | +| Live move slot | `{ id, pp, maxPp, ppUps }` | +| Battler | `{ mon, id, side, native }`; `side` is `"player"`/`"enemy"` when known | +| Trainer party slot | `species`, `level`, optional `item`, `moves` as symbolic ID strings, `native`; other native metadata is read-only | +| Encounter / fishing slot | `species`, `level` or `min`/`max`; native slot metadata retained | +| Move definition | `id`, `name`, `type`, `power`, `accuracy`, `pp`, `priority`, `category`, symbolic `effect` when available, `native` | +| Species definition | `id`, `name`, `baseStats`, `types`, `baseExp`, `catchRate`, `growthRate`, `native` | +| Item definition | `id`, `name`, `price`, `description`, `consumable`, `native` | + +Optional fields remain absent if the native data does not provide them. +`maxHp` maps to native `mon.stats.hp`. Special stats map to `spatk`/`spdef`; +Gen 1's single special stat is exposed through all three special fields. +Editing one keeps its aliases consistent; contradictory edits are rejected. +Type aliases match unified content IDs (`ELECTR` → `ELECTRIC`, `FIGHT` → +`FIGHTING`, `PSYCHIC`/`PSYCHC` → `PSYCHIC_TYPE`, `MYSTERY` → `CURSE_TYPE`). +Status values are `sleep`, `poison`, `burn`, `freeze`, `paralyze`, `toxic`, or +`nil`; clearing uses `nil` or `false`. Unknown existing statuses are retained +until explicitly changed. `isEgg` is the native boolean flag. + +Definitions and `native` snapshots are detached, read-only diagnostics. Use +content registration during mod initialization to change definitions. The +callback view intentionally exposes a limited common definition shape; +additional generation-specific fields are available under `native`. +Battler metadata is read-only. Change its live Pokémon via `battler.mon`. +Shared native records share one public view within a call, so `user.mon` and +`target.mon` remain the same object when they reference the same Pokémon. + +## Adapted hooks + +| Hook | Adapted boundary | +| --- | --- | +| `battle.accuracy`, `battle.damage`, `battle.crit`, `exp.gain` | Context records: battlers, Pokémon, move/species definitions, known ID fields | +| `battle.run`, `battle.catch_exp`, `battle.low_health_alarm` | Context shell; native battle handle | +| `battle.turn_order` | Both battler/move pairs; bound battler result preserves native identity | +| `battle.enemy_action` | Second argument battler; move-action `id` in result | +| `battle.exp_award` | `alive` Pokémon list and `applyShare` helper | +| `catch.rate` | Ball ID, Pokémon, species definition, `opts.ballDef` item definition | +| `trainer.party` | Third argument and result use trainer slots, not live saved Pokémon | +| `evolution.check` | Second argument live Pokémon, evolution/trigger context known IDs | +| `fieldmove.eligibility` | Move ID and bound live Pokémon result | +| `encounter.fishing` | Rod ID, candidate list and encounter result | +| `encounter.roll` | Context and encounter result; imported encounter-table argument stays native | +| `encounter.species` | Encounter argument/result and context | +| `pokemon.sprite`, `pokemon.icon` | Context Pokémon and species ID; path result stays native | +| `ui.party.submenu` | Third argument live Pokémon; menu rows and nested callbacks stay native | + +The remaining native hooks pass their arguments/results through. In +particular, UI callbacks, renderers, input, script arguments, maps, movement, +saves, intro speech and link fingerprint data retain their engine contracts. +Registering an unknown engine callback in unified mode fails immediately. +The source-catalog test forces an explicit boundary decision for new seams; +registration is not a promise that a given game executes that seam. Where +available, use `mod:supports("hooks"/"events", name)` to query the host. + +`battle.exp_award`'s `ctx.applyShare(mon, divisor, announce)` accepts a bound +public Pokémon, commits pending validated edits, calls the native helper, +and refreshes the public views. It retains native argument/result behavior, +including errors. Retained helpers and `next` cannot be used after the hook +callback returns. Live party membership/order cannot be changed through this +view. New encounter, enemy-action and trainer-party records may be returned; +live Pokémon/battler replacements must be records bound during that call. + +## Mutation and failure behavior + +Edits to represented live Pokémon fields, trainer slots, encounters and +context fields are validated before being written. Levels must be 1–100; +stats/max HP positive integers; HP must fit the maximum. Move lists contain +at most four entries, PP must fit the selected move and PP Ups, and `maxPp` +is read-only. Trainer parties contain at most six slots. Unsupported fields, +read-only changes and invalid IDs reject the transaction before any staged +record is changed. Hidden native metadata (IVs, EVs, personality, slot fields) +is retained. Native Pokémon, stats tables, move lists and existing move slots +keep their identities, including battle aliases into those tables. + +Before `next()`, validated edits are committed together. After downstream +returns, public views refresh from native state. A successful callback's +later edits commit together too. Throwing before `next()` skips the wrapper; +throwing/validation failure after `next()` keeps downstream results and +changes. Already committed edits and native helper effects are not undone. +Calling `next()` again returns the cached result (including nil holes), or +rethrows the cached error; it never repeats downstream gameplay. Native +errors preserve their identity and are not converted into mod failures. + +Only the represented record edits participate in this transaction. Game, +battle, save, world, map, data and RNG handles stay native, as do unclassified +engine-specific fields; direct changes through such handles have ordinary +native side effects. Changing a species/level does not automatically rebuild +battle definitions, recalculate stats, grant moves or emit evolution events. +Use the engine's gameplay operations when that bookkeeping is required. + +## Events + +Ordinary engine notifications copy their outer context and adapt known +record fields/IDs: battle Pokémon/battlers/moves, level-up learnable move IDs, +move actions, caught/received/evolved Pokémon and trade records. Edits to +these snapshots do not write back. Engine handles remain native, including +`side`, `battle`, `game`, interaction targets and script contexts. A status +source such as `SYNCHRONIZE` remains an engine label rather than a guessed +move ID. + +`pokemon.before_give` is the explicit mutable exception: `species`, `level` +and optional `nickname` are validated and committed atomically. Its script +`ctx` stays native. A failure changes none of those gift fields, and later +listeners continue through the original event bus. + +Save lifecycle/repair events, screen and intro-speech events, `game.ready`, +`mods.loaded`, and mod option events pass through natively. This preserves +save repair semantics, forwarding metatables and nested callback identities. +Namespaced `mod..` events also pass through unchanged. +Mods may listen to other mods' events and emit only their own namespace. +Priorities, unsubscribe, once semantics, owner attribution and failed-entry +rollback are provided by the original event/hook buses. + +## Verification + +`tests/engine/unified_mod_callbacks.lua` runs through a ROM-free in-memory +filesystem and the real loader/event/hook buses across every supported host. +It covers string/numeric IDs and aliases, transactional writeback, native +identity, mixed mod chains, `next`/helper failures, event isolation, mutable +gifts, priorities, ownership rollback and the native default. The catalog +checks compare against actual source call sites, rather than another list. +Run it with the same LuaJIT runtime as the engine, then run `scripts/test.sh`. +ROM-dependent gameplay checks still require an imported legal ROM. diff --git a/docs/unified-mod-content.md b/docs/unified-mod-content.md new file mode 100644 index 000000000..6108608a9 --- /dev/null +++ b/docs/unified-mod-content.md @@ -0,0 +1,187 @@ +# Unified definition records (opt-in) + +A mod can use the same core content records when modifying different game +versions. Enable the format explicitly in an API-2 manifest: + +```json +{ + "id": "portable_balance", + "name": "Portable Balance", + "version": "1.0.0", + "api": 2, + "content_format": "unified", + "generations": [1, 2, 3, 4, 5], + "entry": "main.lua" +} +``` + +```lua +return function(mod) + -- Required when distributing to older engine builds: those builds do not + -- understand content_format and would otherwise supply native records. + assert(mod.contentFormat == "unified", "This mod needs unified content support") + if mod.content.moves:has("TACKLE") then + local ok, reason = mod.content.moves:patch("TACKLE", { power = 45 }) + assert(ok, reason) + end +end +``` + +Omitting the field (or using `"native"`) preserves existing mod behavior. +API 1 cannot opt in. Mod API stays at **2**: this is the content-record part of +portability, not Gen1Recomp's complete API 3. Callback payloads, saved Pokémon, +world/battle services, scripts and other registries retain their native forms. +A mod still needs testing on every game it claims. + +## Available records + +The five handles retain `register`, `override`, `patch`, `remove`, `get` and +`each`, and add `has`, `supports()` and `available`. Other content handles pass +through unchanged. `mod.assets` retains its existing content-handle aliases. +On engines that provide `mod:supports`, content queries follow the selected +format's availability; other capability surfaces retain their existing answers. + +| Handle | Unified fields | Supported versions | +| --- | --- | --- | +| `pokemon` | `id`, `name`, `dex`, `types`, `baseStats`, `catchRate`, `baseExp`, `growthRate`, `level1Moves`, `learnset`, `evolutions`, `tmhm`; optional breeding/art fields | Existing definitions, Gen 1–5 | +| `moves` | `id`, `name`, `type`, `power`, `accuracy`, `pp`, `effect`, `secondaryChance`, `category`, `priority`, `description` | Existing definitions, Gen 1–5 | +| `items` | `id`, `name`, `price`, `machine`, `effect`, `ball`, `tossable`, `needsTarget`, `description` | Existing definitions, Gen 1–5 | +| `trainers` | `id`, `name`, `pic`, `baseMoney`, `battleTheme`, `partyNames`, `parties` | Existing definitions, Gen 1–5 | +| `encounters` | `id`, `grass`, `water` | Gen 1–3 grass/water tables | + +Reads and iteration return detached records. Editing a returned table does +nothing until a write method is called. Each record also contains a detached, +**read-only** `native` snapshot for diagnostics. Unexposed native fields are +preserved by edits; changing that snapshot is refused. `override` requires a +complete unified record and preserves omitted optional/native extension fields; +use `mod.DELETE` to clear a represented optional field. For normal +edits use `patch`; this keeps earlier and later mods' independent fields in the +registry's normal operation order. Writes return `true` on success. + +`register` supports string-key definitions on Gen 1–3, except new Gen 3 species: +their EV/ability/storage metadata has no canonical constructor here. Numeric +DS allocations are refused. This layer does not invent storage slots or +metadata. Use native content for those additions. Existing records can be +patched, overridden and removed across all five generations. + +### IDs and references + +Species, move and item IDs are uppercase symbolic strings, such as `PIKACHU`, +`TACKLE` and `POTION`. Resolution uses the selected dataset's native keys and +names, not fixed cartridge numbers. `FARFETCHD`, `MR_MIME`, `NIDORAN_F` and +`NIDORAN_M` retain distinct names. `TM01`/`HM01` names normalize to +`TM_01`/`HM_01`. Types use Gen 1 spellings, including `PSYCHIC_TYPE`; the native GBA abbreviations `PSYCHC`, +`FIGHT` and `ELECTR` map to their common names. + +A numeric table or a generated key such as `SPECIES_025` is mapped through its +name in that ROM. Renaming a definition through a mod does not change its ID +within that load. Numeric/string aliases of one record are read once and +written or removed together. Multiple distinct records with one canonical +name are ambiguous: the adapter refuses to choose one. Missing or ambiguous +`get` returns `nil, reason`; `has` returns false; ambiguous iteration raises. + +Trainer and encounter IDs remain game-specific; no cross-game equivalence +between a trainer class, a numbered individual or two maps is implied. +Numeric trainer IDs are exposed as decimal strings. References must resolve +when a write is made: define a referenced record first or declare a mod +dependency that provides it. Missing references return `nil, reason` before +an operation is appended. Removing a referenced definition retains the +loader's post-merge, owner-attributed reference diagnostics, including DS +numeric targets and Gen 2 time tables. + +### Pokémon and trainers + +`baseStats` uses `hp`, `attack`, `defense`, `speed`, `special`, with optional +`specialAttack` and `specialDefense` for the later split. A patch to `special` +sets both halves on Gen 2–5; explicit split fields can change them separately. +Conflicting values are rejected. Gen 1 refuses unequal Special stats. + +`level1Moves` is a list of move IDs. `learnset` is +`{ { level = 9, move = "TACKLE" } }`. Evolutions use +`{ method = "LEVEL", level = 20, species = "RAICHU" }`, or `ITEM`, `TRADE` +and `FRIENDSHIP` with `item`, `time` (`ANYTIME`, `MORNDAY`, `NITE`) or +`comparison` (`ATK_GT_DEF`, `ATK_LT_DEF`, `ATK_EQ_DEF`) where representable. +Held trade items and friendship/stat conditions are translated for Gen 2–3. +DS level evolutions are translated to their numeric target and parameter. +Other DS evolution writes and unknown method changes are refused; unchanged +native methods survive a detached read/modify/write. Gen 1 refuses eggs, +breeding fields, held items and later evolution conditions. + +Trainer `parties` always has the form +`{ { { species = "PIKACHU", level = 18, item = "POTION", moves = { "TACKLE" } } } }`. +Items and moves are optional, and held items require Gen 2 or later. Native +per-slot IVs, flags and AI metadata are retained. A DS trainer's single `party` +and `parties[1]` aliases stay synchronized. + +Move accuracy is percent, as in the engine's existing normalized definitions. +Gen1–3 move effects use canonical IDs, resolved to the host's actual native +battle handlers before an operation is journaled. For example +`SPECIAL_UP1_EFFECT` selects `SP_ATK_UP1_EFFECT` on Gen2/3; the Gen1 Special +stage does not leak into split Special Attack/Defense. `HIT` and `SLEEP` are +accepted aliases. Registered custom native effects retain their own IDs. + +`secondaryChance` is an optional whole percentage (0–100) on Gen2/3. Explicit +odds convert to the nearest native fraction out of 256; detached unmodified +records retain their exact original byte. Explicit zero is distinguished from +a legacy native zero byte whose controller uses a fallback. Changing a +canonical effect applies its reference default chance unless the caller +supplies odds. Authored aliases survive later odds edits. + +Unknown native selectors are opaque `native:generation:selector` tokens on +Gen2/3. They roundtrip only with their original record; they cannot assign an +unknown handler to another move. Gen4/5 retain the previous diagnostic-only +numeric selector behavior and refuse effect/chance changes. Item behavior +and changing a DS move type remain unavailable without their native adapters. + +The data-only binding catalog is reproduced from pinned Gen1Recomp contract +maps plus the host importer's native names by +`tools/move-effect-contract/generate.py --gen1 CLEAN_REFERENCE --lua LUAJIT +--check`. It does not execute imported content. A binding still requires a +real registered host handler: FOLLOW_ME, HELPING_HAND, MAGIC_COAT, SNATCH, +IMPRISON, ASSIST, NATURE_POWER and SECRET_POWER are refused on the current +host. Identity-dependent native Rest/SonicBoom variants are not assigned to +an incompatible move. This adapter selects native behavior; it does not add +missing battle mechanics or promise cross-engine RNG/pixel equivalence. + +### Encounters + +An area is `{ rate, slots = { { species = "PIKACHU", level = 5 } } }`. +A range uses `minLevel` and `maxLevel` instead of `level`. Rates are fractions +out of **256**, including fractional values needed to represent GBA rates +exactly. Conversion refuses a rate that cannot be represented in native units; +it never silently rounds. Slot edits retain the native count and probability +buckets. Changing slot counts or weights requires native content. + +Gen 2 also exposes `grass.times.MORN`, `DAY` and `NITE` in the same area shape. +A shared rate/slot patch applies to every native time. An explicitly changed +time can override that patch. Unchanged inherited times in a detached record +do not undo a shared edit. + +DS encounter tables carry daily, seasonal, swarm and other replacements. +This adapter reports `available = false` and `supports()` returns `false, +reason` for them; reads/writes return `nil, reason` and iteration is empty. +The native encounter API remains available to mods using the native format. + +### Lists, failures and lifecycle + +Lists replace; `__append` and `__prepend` extend them. `{}` and `mod.DELETE` +clear represented lists, including inherited learnsets and TM lists. Required +scalar fields, base stats and species types cannot be deleted. Other optional +fields support `mod.DELETE` where their native representation permits it. + +Malformed records raise schema errors. Non-representable operations return +`nil, reason` before recording anything. Payloads, returned records and native +snapshots never share writable tables with the operation log. Frozen content +still rejects writes. An entry failure rolls back every operation owned by +that mod, including all aliases. No Game, save, graphics or imported ROM is +needed to query or construct the content facade. + +## Validation + +Run `luajit tests/engine/unified_content.lua`. The tests load synthetic datasets +through the real loader, exercise all registered host versions and compare +native and unified round trips. Behavioral checks use native Pokémon/trainer +construction, encounter selection, DS move parsing and evolution dispatch. +This proves the exercised formats and consumers, not real-ROM playthroughs or +visual correctness. The complete local suite and its base comparison are +reported in the pull request. diff --git a/docs/world-actions-api.md b/docs/world-actions-api.md new file mode 100644 index 000000000..230b7737e --- /dev/null +++ b/docs/world-actions-api.md @@ -0,0 +1,135 @@ +# Public world actions + +`mod.world` adds six methods without a manifest opt-in or Mod API bump: +`canReorderParty`, `reorderParty`, `availableFieldActions`, `useFieldAction`, +`canFly`, and `flyTo`. The new actions validate free-roam state before using +existing native gameplay entry points. Existing world methods, party menus +and script commands keep their contracts. + +```lua +local ok, reason = mod.world:reorderParty(1, 2) +if not ok then mod.log:info("%s", reason) end + +local actions = mod.world:availableFieldActions() +for _, action in ipairs(actions) do + -- { id = "surf", label = "SURF" }, for example. + -- Show a companion UI; revalidate when the player actually chooses it. +end + +-- Later, from an explicit player choice: +local started, why = mod.world:useFieldAction("surf") +local flying, flyWhy = mod.world:flyTo("A_NATIVE_DESTINATION_MAP_ID") +``` + +## Free-roam boundary + +The save, overworld map, player, native script runner and authoritative stack +top must be present and ready. Methods refuse while another screen owns the +stack (including battles/dialogue), a script is running/queued, scripted +movement is pending, a transition or field animation is active, the player +is moving/locked, a mod script lock is held, a decoration mode owns input, +or a link session is live. Native engine errors propagate; an action that +throws is never retried by this facade. + +`canReorderParty()` and `canFly()` return booleans. Mutations return `true` +when accepted, or `nil, reason` when refused. `availableFieldActions()` gives +an array of detached `{ id, label }` records, or an empty array and a reason +when the world is unavailable/busy. Queries use native eligibility checks, +including existing mod hooks; the facade does not itself perform an action +while probing. A mod's own hook can still have ordinary native side effects. +Recursive mutations from those eligibility hooks are refused, and a changed +source context invalidates the pending request. + +A previous successful probe/list is not a reservation. Every action checks +state again when invoked. Typical reasons are `no save`, `no overworld`, +`world is busy`, invalid arguments, unavailable destination or unavailable +field action. Availability of the interface does not promise every action +exists in every generation. + +## Party ordering + +`reorderParty(fromSlot, toSlot)` swaps two existing party references, matching +the native party menus. It does not insert/remove Pokémon, recalculate them +or copy their data. Slots are integer, one-based indices from 1 to 6; both +must exist. Fainted Pokémon and eggs can be reordered. A same-slot request is +a successful no-op. Invalid slots refuse before changing the party. + +`canReorderParty()` is true only when the world is ready and there are at +least two party members. A retained facade reads the current save when used. +No menu selection or battle party order is changed behind a screen's back. + +## Flight + +`canFly()` additionally requires a real non-egg party carrier, the native +badge/location checks and at least one currently unlocked, valid destination. +`flyTo(mapId)` accepts a native map ID from that game's destination set. It +is not a general warp: unknown, locked, unvisited or invalid landing points +are refused before departure state changes. A caller-provided coordinate, +spawn-table index or arbitrary destination object is not accepted. + +| Host | Eligibility and destinations | Native departure | +| --- | --- | --- | +| Gen 1/2 | `partyKnows("FLY")`, outside-map rules, visited `field.flyOrder` towns with `flyWarps` | `OverworldState:flyTo` | +| Gen 3 | Native carrier/badge check, `Gen3FieldRules`, visited town set | Native field-move cut-in, then `flyTo` | +| Platinum / HGSS | `Gen4FieldMoves`, `Gen4Fly.check/destinations`, partner/Safari/badge checks and HGSS costume refusal | `gen4FlyTo` | +| Black / White | Native numeric move lookup, `Gen5Fly.allowed/destinations/destination`; no invented badge gate | `gen4FlyTo` | + +The native handlers retain departure timing, player input lock, bike/surf +state changes and exact landing coordinates. The direct map-ID call does +not open a destination picker. Gen 3 keeps its native Pokémon cut-in, falling +back directly when there is no picture to display. + +## Verified contextual field actions + +This initial adapter deliberately exposes only these native paths: + +| Action ID | Gen 1 | Gen 2 | Gen 3 | Gen 4/5 | +| --- | --- | --- | --- | --- | +| `cut` | Native cut eligibility + `tryCut` | Same, reaching native Gen 2 Cut | Refused: object-script adapter required | Refused: native script adapter required | +| `surf` | Native eligibility + `trySurf` | Same | Same, with native field-move presentation | Refused: native script adapter required | +| `dig` | Eligible dungeon → native last-heal departure | Eligible cave/dungeon → recorded entrance | `Gen3FieldRules` → recorded entrance | Refused: native script adapter required | +| `teleport` | Outside map → last heal | Outside map → last heal | Native travel rules → last heal | Refused: native script adapter required | + +Native carrier lookup selects a current non-egg party member. Badge checks +and target/location gates are retained; fainted carriers remain eligible +where the native engine permits them. Surf starts mounting; there is no +invented generic dismount action. Dig and Teleport keep distinct native +escape/last-heal semantics. Cut uses live target eligibility rather than +matching a global block number. + +`useFieldAction(id, opts)` accepts no options or an empty plain options table. +Unsupported options, action IDs and generation adapters return a reason. +This version does not expose bicycle/fishing, Strength/Flash, healing moves, +custom source selection, Gen 3 object-script actions or DS field-script +activation. These require separate adapters with their native checks; callers +must use the returned action list rather than assuming a fixed menu. Fly is +separate because it needs a destination. + +## Deferred completion and cancellation + +A `true` result means the native request was accepted, not that the animation, +dialogue or warp has finished. Native gameplay owns those asynchronous flows. +A Gen 3 cut-in blocks competing public mutations until it finishes. Its +effect is guarded by the source save/data/map/player, position/facing, version, +party membership and fresh eligibility. Changing that context cancels the +effect. A repeated completion callback cannot replay it. Abandoning the +presentation does not leave a permanent facade lock; a stale callback cannot +start a later effect. Already executed native gameplay is not rolled back. + +## Capabilities and tests + +With the optional capability-query feature, each of these six method names +returns true from `mod:supports("world", name)` without constructing Game or +the world. Other capability answers are delegated unchanged. Use +`canFly`/`availableFieldActions` and the mutation's return value for current +state and action-level availability. + +`tests/engine/world_actions_api.lua` uses the real loader across all 15 host +version IDs. It exercises refusal parity for world/player/script/link locks, +party identities, slots, destination gates, eggs, partner/costume checks, +contextual dispatch and reentrant/deferred/error paths. It also runs actual +native carrier/badge lookup, Fly/DS Fly departure methods and Gen 1–3 +Dig/Teleport departure state changes without ROM assets or graphics. A cut-in +test double drives completion/cancellation deterministically; it is not a +rendered playthrough. ROM-dependent map scripts and graphics still need an +imported legal ROM. diff --git a/docs/world-overview.md b/docs/world-overview.md new file mode 100644 index 000000000..e3c494197 --- /dev/null +++ b/docs/world-overview.md @@ -0,0 +1,105 @@ +# World overview and encounter queries + +These additive API-1/API-2 readers use the Gen1Recomp `mapOverview` and +`effectiveEncounters` result shapes. They return detached tables and do not +load maps, consume RNG, run mod callbacks, refresh objects, or change the save. +They are available through the normal lazy `mod.world` facade. Hosts with +`mod:supports` report both names on the `world` surface before Game exists. +This does not opt a mod into API 3 or change existing actions/encounter rolls. + +```lua +local overview, reason = mod.world:mapOverview() +if overview then + -- Cells and marker coordinates start at zero; Lua row arrays start at one. + local terrain = overview.rows[y + 1]:sub(x + 1, x + 1) +end + +local encounters, reason = mod.world:effectiveEncounters(mapId, "grass", { + daytime = "NITE", -- optional; MORN/MORNING, DAY, NITE/NIGHT +}) +if encounters then + local total = 0 + for species, weight in pairs(encounters.dist) do total = total + weight end + -- Conditional species share = weight / total (when total > 0). +end +``` + +## `mapOverview()` + +Requires an active overworld; menus/battles above it do not prevent reads. +At the title screen, returns `nil, "no overworld"`. + +| Field | Meaning | +| --- | --- | +| `mapId`, `width`, `height` | Active map ID and size in 16px walk cells | +| `rows` | `height` strings, each exactly `width` ASCII characters | +| `markers` | Array of detached `{kind, x, y}` records | +| `tileRows`, `tileWidth`, `tileHeight` | Optional visual shade grid at twice the cell resolution | +| `tileDetailRows`, `tileDetailWidth`, `tileDetailHeight` | Optional visual shade grid at four times the cell resolution | + +Terrain characters are `+` for a native warp tile, `~` for water, `.` for +walkable ground, and a space for a wall, in that order of precedence. This +summarizes terrain, not directional movement permissions, elevation crossings, +NPC occupancy, or a pathfinding result. Runtime block/collision patches win. +Packed GBA blocks are decoded privately; a cold map remains cold afterwards. + +Markers identify in-bounds `warp` events, `item` objects actually in the active +NPC list (at their current cells, excluding hidden objects and fruit trees), +and untaken `hidden` item spots. Hidden-item signs also honor their event flags +and their distinct native taken keys. Neighbour-map objects are not included. +The snapshot reflects the loaded object list; it does not run visibility or +spawn filters again. Hidden markers deliberately reveal item locations to the +requesting mod, as in Gen1Recomp. + +GB visual grids use the resolved, mod-overridable atlas's pixel luminance: +`"0"` is lightest, `"3"` darkest. `tileRows` averages each 8x8 tile; +`tileDetailRows` preserves each 4x4 quadrant. Missing/invalid art is omitted. +The private visual cache is invalidated with the engine's asset cache. +GBA metatile compositions and DS 3D art have no GB atlas, so visual grids +are omitted there. Terrain grids support GB/GBA maps and resident DS interior +permission grids. DS maps without a resident behaviour grid return +`nil, "map overview unavailable for streamed terrain"`; reading streamed +renderer chunks would load/cache terrain during a read query. + +## `effectiveEncounters(mapId, terrain, opts?)` + +Takes an explicit, known map ID, including at the title screen. `terrain` is +`"grass"`, `"water"`, or `"indoor"` (the grass table used by cave floors). +Returns `{chance = number, dist = {[publicSpeciesId] = integerWeight}}`. +Unknown maps, invalid terrain/options/time, unloaded encounter data and invalid +bucket/rate metadata return `nil, reason`. A known map without that table +returns `{chance = 0, dist = {}}`. + +`chance` is the table's independent integer rate gate, clamped to its native +denominator (256 by default, 100 on DS adapter tables). It is **not** the final +probability of a battle on the player's next step. `dist` aggregates consecutive +cumulative bucket differences for duplicate species. Weights are not percentages: +use their sum when computing conditional shares. Missing slots/uncovered bucket +space remain empty outcomes, just as in the native chooser. Zero-rate tables +return an empty distribution. Species IDs use the same dataset-derived public +names as the other portable readers, including numeric DS species and aliases. +Loader identity metadata is captured before presentation-name patches. + +Gen2 land tables use the current clock period, or the last effective cached +period when a `world.tod` hook is installed. `opts.daytime` overrides this +without dispatching the hook or updating the world. Active Gold/Silver/Crystal +swarm tables are selected by the native resolver. DS previews use the native +map-to-table indirection, day/night substitutions, active daily slots/outbreaks +and BW season selection. DS `daytime` selects a representative hour (6/12/22). +Water uses its own table. + +This is a **table preview**: it excludes per-step ability/radio/held-item rate +changes, movement/grace gates, Repel, roamers, lead-based slot filtering, +fishing, Radar, phenomena and dark-grass overrides. It never samples or executes +`encounter.roll`, `encounter.species`, or `world.tod`. Gen2Recomped has no native +`encounter.table` hook; this PR does not introduce one or pretend to preview +arbitrary mod callbacks. A future shared table-transform seam can extend both +the native roll and preview together. + +HGSS's position-dependent Safari Zone returns +`nil, "position-dependent Safari encounters unavailable"`; its native resolver +consults the global player's position and can initialize save state. Emerald's +Battle Pike wild room and Battle Pyramid floors return +`nil, "facility encounters unavailable"`; these use separate facility tables. +These refusals keep an explicit-map query from reporting another map's table +or changing gameplay state. diff --git a/docs/world-read-api.md b/docs/world-read-api.md new file mode 100644 index 000000000..01b2a60d0 --- /dev/null +++ b/docs/world-read-api.md @@ -0,0 +1,151 @@ +# Read-only world and party queries + +Native mods can read `mod.world:party()`, `activeBlockAt(mapId, bx, by)` and +`liveMaps()` across generations 1–5. These are additive methods: no manifest +format switch or Mod API bump is required. Their records use the common live +Pokémon shape from unified callbacks, independently of the mod's selected +content/callback format. Existing world actions and `loadedMaps()` retain +their previous behavior. + +The methods read the current save/runtime map when called. They do not load +maps, decode whole packed grids, create Pokémon, emit events, write state or +advance random generators. Returned tables are detached plain data; editing +or retaining them never changes the game. Native engine handles remain +available through the existing APIs, but these three methods return none. + +```lua +return function(mod) + mod.events:on("world.stepped", function() + local here = mod.world:current() + if not here then return end + + local party, reason = mod.world:party() + if not party then mod.log:warn("%s", reason); return end + + local maps = mod.world:liveMaps() + local geometry = maps and maps[1].block + if geometry then + -- current().x/y use walk cells; activeBlockAt uses map blocks. + local cellsPerBlock = geometry.tileSize / geometry.cellSize + local block, info = mod.world:activeBlockAt(here.mapId, + math.floor(here.x / cellsPerBlock), math.floor(here.y / cellsPerBlock)) + if block ~= nil then mod.log:info("%s block %d", info.kind, block) end + end + end) +end +``` + +## Party snapshots + +`party()` returns an array in native party order. A save with an empty or +absent party gives `{}`; no save gives `nil, "no save"`. Reading a saved party +does not require an active overworld, so menus and a title-screen save are +valid contexts. A retained facade observes a replacement of `game.save` on +its next call; previously returned records remain snapshots. + +Each Pokémon has the following common fields (optional native values may +be absent): + +| Field | Meaning | +| --- | --- | +| `species`, `item` | Symbolic IDs; an absent/zero held item gives `nil` | +| `nickname`, `name` | Native nickname and optional native name | +| `level`, `hp`, `maxHp` | Current values; `maxHp` is native `stats.hp` | +| `status` | `sleep`, `poison`, `burn`, `freeze`, `paralyze`, `toxic`, or absent; unknown native status labels are retained | +| `isEgg` | Native egg flag as a boolean | +| `stats` | `hp`, `attack`, `defense`, `speed`, `special`, `specialAttack`, `specialDefense` | +| `moves` | Array of `{ id, pp, maxPp, ppUps }`; absent definitions leave `maxPp` absent | +| `native` | Detached data-only diagnostics, including IVs/EVs and custom save fields | + +IDs use this boot's actual registries, including custom records and numeric +DS IDs, rather than hard-coded National Dex numbers. Numeric IDs resolve via +imported names; existing named keys retain their names. Standard aliases use +the shared unified-ID resolver. Identity/name metadata is captured before +mod merging, so a display-name patch does not rename a canonical ID. Records +registered later during mod initialization remain visible. Unknown IDs are +preserved as strings; this is a read-only view, not an allocation or reference +validation API. The native diagnostics retain the underlying numeric IDs. + +Gen 1's single special stat fills both special-attack and special-defense +fields; later split stats read native `spatk` and `spdef`. PP maxima use native +PP-Up rounding (`base + ppUps * floor(base / 5)`). Reads never recalculate +stats, learn moves, infer shininess or consume RNG. Functions, userdata, +threads, metatables and table-valued keys are excluded from diagnostics; +cycles in data are copied without retaining native tables. + +## Active block lookup + +`activeBlockAt(mapId, bx, by)` returns `blockId, metadata`, or `nil, reason`. +The expected map ID is required: a request prepared before a warp cannot +silently inspect the replacement map. Only the active runtime map is read. +Coordinates are finite, nonnegative integers in **zero-based block units**; +off-map coordinates are refused rather than returning the map's border fill. +A block ID of zero is a valid result: check `blockId ~= nil`. + +Runtime `blockPatch` entries take precedence, including zero-valued changes. +Table grids, packed little-endian 16-bit grids and an existing decoded cache +follow the runtime Map representation. A cold packed read extracts only the +requested word and its low ten ID bits; it does not populate `_blockArray`. +Missing/truncated data, negative/invalid IDs and inconsistent geometry give +`nil, reason`. No call into map loading, rendering or collision is made. + +Metadata is a fresh record: + +| Field | Units / meaning | +| --- | --- | +| `tileSize` | Pixels per map block, from runtime `blockTiles * 8` | +| `cellSize` | 16 pixels per walk cell | +| `coordinateBase` | `0` | +| `width`, `height` | Map dimensions in blocks | +| `kind` | `block`, `metatile`, or `runtime_cell` | + +Ordinary Gen 1/2 blocks are 32 pixels; Gen 3 metatiles are 16 pixels. Geometry +comes from the actual runtime tileset so custom maps need not match their +selected generation's usual size. Gen 4/5 imported maps use a 16-pixel proxy +grid in the shared runtime; `runtime_cell` explicitly identifies that grid. +Its values are synthetic runtime/behavior values, **not DS mesh/model IDs or +a public collision/elevation encoding**. Use the existing terrain/elevation +services for those questions. A DS map must carry the runtime grid; this +method does not generate one from meshes or lazily load shared layouts. + +## Loaded map snapshots + +`liveMaps()` returns only maps already held by the active overworld, with the +active map first and neighbors in native order. It never enumerates all ROM +maps or loads missing neighbors. No overworld gives `nil, "no overworld"`. +Each row contains: + +```lua +{ + mapId = "MAP_ID", active = true, + x = 0, y = 0, -- placement relative to active map, walk cells + width = 20, height = 18, -- dimensions in walk cells + block = { -- absent if runtime geometry is unavailable + tileSize = 32, cellSize = 16, coordinateBase = 0, + width = 10, height = 9, kind = "block" -- dimensions in blocks + } +} +``` + +Neighbor offsets may be negative. Exact duplicate placements collapse; +distinct placements of one map at different offsets remain separate rows. +Each row carries its own geometry, including mixed-size neighborhoods. +The legacy `loadedMaps()` method keeps its original shape, duplicates and +order for existing consumers. + +## Availability and tests + +When the optional `mod:supports` capability-query feature is installed, +`mod:supports("world", "party")`, `activeBlockAt` and `liveMaps` report true. +Probes do not construct Game or a world. Availability describes the method; +a call can still return `no save`, `no overworld`, `map is not active`, invalid +coordinates, unavailable geometry or unavailable data. Other capability +answers retain their existing behavior. + +`tests/engine/world_read_api.lua` exercises the real loader and Map objects +without an imported ROM or a graphics context, across all supported version +IDs. It checks detached nested data, ID aliases and late registrations, +current save/map replacement, block sizes/bounds, cold/warm packed data, +runtime patches, map placements, native `loadedMaps()` parity and RNG/gameplay +tripwires. When the unified-callback feature is present, it also compares the +party shape against an actual event's Pokémon record. diff --git a/examples/portable_charge_required/main.lua b/examples/portable_charge_required/main.lua new file mode 100644 index 000000000..ac7ea76c2 --- /dev/null +++ b/examples/portable_charge_required/main.lua @@ -0,0 +1,32 @@ +local mod = ... +local state = { calls = 0, mode = "native" } +mod.exports.api, mod.exports.state = mod, state +local stop +if not mod.supports or mod:supports("hooks", "battle.charge_required") then + stop = mod.hooks:wrap("battle.charge_required", function(next, ctx) + state.calls = state.calls + 1 + state.charge, state.isCalled = ctx.charge, ctx.isCalled + state.move = ctx.move and ctx.move.id + state.battle, state.user, state.target = ctx.battle, ctx.user, ctx.target + if state.mode == "bypass" then + return false + end + if state.mode == "nil" then + return nil + end + if state.mode == "truthy" then + return 0 + end + if state.mode == "edit" then + ctx.charge = false + end + local required = next(ctx) + state.nextResult = required + return required + end) +end +mod.exports.stop = function() + if stop then + stop() + end +end diff --git a/examples/portable_charge_required/manifest.json b/examples/portable_charge_required/manifest.json new file mode 100644 index 000000000..5ec52692b --- /dev/null +++ b/examples/portable_charge_required/manifest.json @@ -0,0 +1 @@ +{"id":"portable_charge_required","name":"Portable charge-turn control","version":"1.0.0","api":3,"games":["gen1","gen2","gen3"],"entry":"main.lua","affects_link":true} diff --git a/examples/portable_critical_order/main.lua b/examples/portable_critical_order/main.lua new file mode 100644 index 000000000..3e285e680 --- /dev/null +++ b/examples/portable_critical_order/main.lua @@ -0,0 +1,10 @@ +local mod = ... +local state = { critical = true, calls = 0 } +mod.exports.state = state +mod.exports.available = mod:supports("hooks", "battle.crit") +if mod.exports.available then + mod.hooks:wrap("battle.crit", function() + state.calls = state.calls + 1 + return state.critical + end) +end diff --git a/examples/portable_critical_order/manifest.json b/examples/portable_critical_order/manifest.json new file mode 100644 index 000000000..c9c880f34 --- /dev/null +++ b/examples/portable_critical_order/manifest.json @@ -0,0 +1,8 @@ +{ + "id": "portable_critical_order", + "name": "Portable critical-hit probe", + "version": "1.0.0", + "api": 3, + "entry": "main.lua", + "games": ["gen3"] +} diff --git a/examples/portable_enemy_action/main.lua b/examples/portable_enemy_action/main.lua new file mode 100644 index 000000000..a5301a4f9 --- /dev/null +++ b/examples/portable_enemy_action/main.lua @@ -0,0 +1,13 @@ +return function(mod) + local choice + mod.hooks:wrap("battle.enemy_action", function(nextAction, ...) + if choice then + return choice(nextAction, ...) + end + return nextAction(...) + end) + mod.exports.choose = function(callback) + choice = callback + end + mod.exports.api = mod +end diff --git a/examples/portable_enemy_action/manifest.json b/examples/portable_enemy_action/manifest.json new file mode 100644 index 000000000..74fff025a --- /dev/null +++ b/examples/portable_enemy_action/manifest.json @@ -0,0 +1,11 @@ +{ + "id": "portable_enemy_action", + "name": "Portable enemy action", + "version": "1.0.0", + "api": 3, + "entry": "main.lua", + "games": [ + "gen3" + ], + "affects_link": true +} diff --git a/examples/portable_held_item/main.lua b/examples/portable_held_item/main.lua new file mode 100644 index 000000000..3166d4d8f --- /dev/null +++ b/examples/portable_held_item/main.lua @@ -0,0 +1,26 @@ +local mod = ... +local state = { mode = "delegate", calls = 0, seen = {}, views = {}, requiresItem = true } +mod.exports.api, mod.exports.state = mod, state +mod.exports.stop = mod.hooks:wrap("held_item.trigger", function(next, context) + state.calls = state.calls + 1 + state.last = context + state.seen[context.trigger] = (state.seen[context.trigger] or 0) + 1 + state.views[context.trigger] = context + if + (not state.trigger or state.trigger == context.trigger) + and (not state.holder or state.holder == context.mon.nickname) + and (not state.requiresItem or context.def ~= nil) + then + state.selected = context + if state.mode == "suppress" then + return nil + end + if state.mode == "override" then + return state.effect, state.parameter + end + if state.mode == "mutate" then + context.effect, context.parameter = state.effect, state.parameter + end + end + return next(context) +end) diff --git a/examples/portable_held_item/manifest.json b/examples/portable_held_item/manifest.json new file mode 100644 index 000000000..a1016b81d --- /dev/null +++ b/examples/portable_held_item/manifest.json @@ -0,0 +1,8 @@ +{ + "id": "portable_held_item", + "name": "Portable Held Item Decisions", + "version": "1.0.0", + "api": 3, + "games": ["gen2", "gen3"], + "entry": "main.lua" +} diff --git a/examples/portable_pokemon_rolls/main.lua b/examples/portable_pokemon_rolls/main.lua new file mode 100644 index 000000000..e25a78092 --- /dev/null +++ b/examples/portable_pokemon_rolls/main.lua @@ -0,0 +1,116 @@ +local mod = ... +local e = mod.exports +e.api, e.mode, e.calls, e.observations = mod, mod:read("case.txt") or "force", {}, {} +local supported = mod.supports and mod:supports("hooks", "shiny.roll") + or (not mod.supports and (mod.generation == 2 or mod.generation == 3)) +e.available = supported == true +if not supported then + return +end +local function observe(name, ctx) + e.calls[name] = (e.calls[name] or 0) + 1 + e.observations[name] = { + species = ctx.species, + defId = ctx.def and ctx.def.id, + level = ctx.level, + ratio = ctx.ratio, + personality = ctx.personality, + ivs = ctx.ivs and { + hp = true, + atk = ctx.ivs.atk ~= nil, + def = ctx.ivs.def ~= nil, + spe = ctx.ivs.spe ~= nil, + spa = ctx.ivs.spa ~= nil, + spd = ctx.ivs.spd ~= nil, + }, + dvs = ctx.dvs and { + attack = ctx.dvs.attack, + defense = ctx.dvs.defense, + speed = ctx.dvs.speed, + special = ctx.dvs.special, + }, + } +end +local function wrap(name) + return mod.hooks:wrap(name, function(next, ctx) + observe(name, ctx) + local shiny = name == "shiny.roll" + if e.mode == "force" then + if shiny then + return true + else + return "female" + end + end + if e.mode == "suppress" then + if shiny then + return false + else + return "male" + end + end + if e.mode == "nil" then + return nil + end + if e.mode == "invalid" then + if shiny then + return 0 + else + return "invalid" + end + end + if e.mode == "error_before" then + error("intentional roll failure before next") + end + if e.mode == "invalid_threshold" and not shiny then + ctx.def.genderRatio = 254 + return "invalid" + end + if e.mode == "ratio_only" then + ctx.ratio = 254 + end + if e.mode == "new_threshold" and not shiny then + local replaced = {} + for k, v in pairs(ctx) do + replaced[k] = v + end + replaced.def = { genderRatio = 254 } + return next(replaced) + end + if e.mode == "iv_edit" and ctx.ivs then + ctx.ivs.hp = 31 + end + if e.mode == "replacement" then + local replaced = {} + for k, v in pairs(ctx) do + replaced[k] = v + end + if ctx.dvs then + replaced.dvs = { attack = 2, defense = 10, speed = 10, special = 10 } + else + replaced.personality = 0 + if shiny then + replaced.otId, replaced.otSecretId = 0, 0 + end + end + return next(replaced) + end + if e.mode == "steer" then + if ctx.dvs then + ctx.dvs.attack, ctx.dvs.defense, ctx.dvs.speed, ctx.dvs.special = 2, 10, 10, 10 + elseif shiny then + ctx.personality, ctx.otId, ctx.otSecretId = 0, 0, 0 + else + ctx.personality = 0 + end + elseif e.mode == "threshold" and not shiny then + ctx.def.genderRatio = 254 + end + local result = next(ctx) + if e.mode == "error_after" then + error("intentional roll failure after next") + end + return result + end) +end +e.stopShiny, e.stopGender = wrap("shiny.roll"), wrap("gender.roll") diff --git a/examples/portable_pokemon_rolls/manifest.json b/examples/portable_pokemon_rolls/manifest.json new file mode 100644 index 000000000..5ee8b754e --- /dev/null +++ b/examples/portable_pokemon_rolls/manifest.json @@ -0,0 +1 @@ +{"id":"portable_pokemon_rolls","name":"Portable Pokemon identity rolls","version":"1.0.0","api":3,"games":["gen2","gen3"],"entry":"main.lua"} diff --git a/src/audio/AudioMix.lua b/src/audio/AudioMix.lua new file mode 100644 index 000000000..92b2a2fee --- /dev/null +++ b/src/audio/AudioMix.lua @@ -0,0 +1,23 @@ +-- Apply platform mixing policy only when the audio backend admits it. +local AudioMix = {} +local sent +function AudioMix.set(value) + value = value and true or false + if sent == value then + return false + end + if not (love and love.audio and love.audio.setMixWithSystem) then + return false + end + if pcall(love.audio.setMixWithSystem, value) then + sent = value + end + return true +end +function AudioMix.current() + return sent +end +function AudioMix._resetForTest() + sent = nil +end +return AudioMix diff --git a/src/battle/BattleCommands.lua b/src/battle/BattleCommands.lua new file mode 100644 index 000000000..c766b522f --- /dev/null +++ b/src/battle/BattleCommands.lua @@ -0,0 +1,400 @@ +-- 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] + local nativeReason = require("src.battle.Gen3MoveSelection").manualReason(self, chooser, mv, self.moveIndex) + if require("src.core.GameVersion").isGen3() and not nativeReason then + if Targeting.needsChoice(self, self:choosingBattler(), mvDef) then + self.pendingMove = mv + self.targetChoices = Targeting.choices(self, self:menuBattler()) + self.targetIndex, self.phase = 1, "targetSelect" + else + self:chooseAction(mv) + end + return true + elseif nativeReason then + local message + if nativeReason == "choice" then + local locked + for _, def in pairs(self.data.moves) do + if require("src.battle.Gen3ChoiceBand").identity(self, def) == chooser.choicedMove then locked = def; break end + end + local item = require("src.battle.HoldItems").defOf(chooser) + message = Strings("%s allows use of\nonly %s!", item and item.name or "CHOICE BAND", + locked and locked.name or tostring(chooser.choicedMove)) + elseif nativeReason == "pp" then + message = self:romText("_MoveNoPPText", "No PP left for\nthis move!") + elseif nativeReason == "disabled" then + message = self:romText("_MoveDisabledText", "The move is\ndisabled!") + elseif nativeReason == "taunt" then + message = Strings("%s can't use\n%s after the\ntaunt!", chooser.name, mvDef and mvDef.name or mv.id) + elseif nativeReason == "torment" then + message = Strings("%s can't use the\nsame move twice in\na row due to the\nTORMENT!", chooser.name) + elseif nativeReason == "imprison" then + message = Strings("%s can't use the\nsealed %s!", chooser.name, mvDef and mvDef.name or mv.id) + else + message = Strings("That move can't\nbe selected!") + end + self:say(message) + self.phase, self.afterQueue = "messages", "moveSelect" + return true + end + if require("src.battle.Gen3Disable").blocked(chooser, mv, self.moveIndex) then + self:say(self:romText("_MoveDisabledText", "The move is\ndisabled!")) + self.phase = "messages" + self.afterQueue = "menu" + -- TAUNT and TORMENT refuse the move at SELECTION rather than after the + -- fact, the way DISABLE already does, so the player is never told to + -- pick again for something the game was never going to allow. + elseif self.bw and require("src.battle.Gen5MoveRules").moveBlocked(self, chooser, mvDef, true) then + -- BLACK AND WHITE: the Choice lock, Imprison, Gravity, Heal Block + self:say(require("src.battle.Gen5MoveRules").moveBlocked(self, chooser, mvDef, true)) + self.phase = "messages" + self.afterQueue = "menu" + elseif self:tauntBlocks(chooser, mvDef) then + self:say(Strings("%s can't use\n%s after the\ntaunt!", chooser.name, mvDef and mvDef.name or mv.id)) + self.phase = "messages" + self.afterQueue = "menu" + elseif self:tormentBlocks(chooser, mvDef) then + self:say(Strings("%s can't use the\nsame move twice in\na row due to the\nTORMENT!", chooser.name)) + self.phase = "messages" + self.afterQueue = "menu" + elseif mv.pp <= 0 then + self:say(self:romText("_MoveNoPPText", "No PP left for\nthis move!")) + self.phase = "messages" + self.afterQueue = "menu" + elseif Targeting.needsChoice(self, self:choosingBattler(), mvDef) then + -- WHICH OF THE TWO, when the move takes one and there are two. + -- + -- Emerald's selector cycles over the legal slots and names the + -- candidate; nothing else in the menu changes. A single battle + -- never gets here, because needsChoice answers false whenever there + -- is only one thing to aim at -- and on the Game Boy cartridges the + -- moves carry no target byte at all, so it answers false for every + -- move in the game. + self.pendingMove = mv + self.targetChoices = Targeting.choices(self, self:menuBattler()) + self.targetIndex = 1 + self.phase = "targetSelect" + else + self:chooseAction(mv) + end + return true +end + +function Methods.chooseTarget(self, index) + local list = self.targetChoices or {} + self.targetIndex = index + local mv = self.pendingMove + local pick = list[self.targetIndex] + self.pendingMove, self.targetChoices = nil, nil + self.chosenTargets = self.chosenTargets or {} + local who = self:menuBattler() + if who and who.position then + self.chosenTargets[who.position] = pick + end + self:chooseAction(mv) + return true +end + +function Methods.chooseMimic(self, index) + local moves = self.mimicMoves + self.mimicIndex = index + local pick = moves[self.mimicIndex] + local ctx = self.mimicCtx + self.mimicMoves, self.mimicCtx = nil, nil + self.phase = "messages" + self.nextInsert = 0 -- the copy's anim + text go to the queue head + self:applyMimic(ctx.user, ctx.target, ctx.moveInst, pick.slot) + return true +end + +local SAFARI_INDEX = { ball = 1, bait = 2, rock = 3, run = 4 } +function Methods.chooseSafari(self, action) + self.menuIndex = SAFARI_INDEX[action] + self:safariAction(action) + return true +end + +local function backToFirst(self) + if not self:isDouble() or self:choosingSlotNow() ~= 2 then + return nil + end + local left = self:battlerAt(0) + local pending = self.pendingActions and self.pendingActions[0] + if + left + and not left.ally + and (left.mon and left.mon.hp or 0) > 0 + and pending + and not self:menuLockedAction(left) + then + return left + end +end +function Methods.cancelMove(self) + if self.phase == "moveSelect" then + self.moveSwapIndex, self.phase = nil, "menu" + elseif self.phase == "targetSelect" then + self.pendingMove, self.targetChoices = nil, nil + self.phase = "moveSelect" + elseif self.phase == "menu" and backToFirst(self) then + self.pendingActions[0] = nil + if self.chosenTargets then + self.chosenTargets[0] = nil + end + self.choosingSlot, self.menuIndex, self.moveIndex = 0, 1, 1 + pcall(function() + require("src.core.Sound").play(self.game.data, "SEQ_SE_DP_DECIDE") + end) + else + return nil, "nothing to cancel" + end + return true +end + +local function moveReason(b, actor, slot, move) + if not move or not b.data.moves[move.id] then + return "invalid move slot" + end + if require("src.core.GameVersion").isGen3() then + local why = require("src.battle.Gen3MoveSelection").manualReason(b, actor, move, slot) + if why == "disabled" then return "move is disabled" end + if why == "pp" then return "move has no PP" end + if why == "taunt" then return "move is blocked by Taunt" end + if why == "torment" then return "move is blocked by Torment" end + return why and ("move is blocked by native GBA rules: " .. why) or nil + end + if require("src.battle.Gen3Disable").blocked(actor, move, slot) then + return "move is disabled" + end + if (move.pp or 0) <= 0 then + return "move has no PP" + end + local def = b.data.moves[move.id] + if b.bw and require("src.battle.Gen5MoveRules").moveBlocked(b, actor, def, true) then + return "move is blocked by native BW rules" + end + if b:tauntBlocks(actor, def) then + return "move is blocked by Taunt" + end + if b:tormentBlocks(actor, def) then + return "move is blocked by Torment" + end +end + +-- Admission checks are read-only. Native refusal messages still belong to +-- chooseMove when driven by ordinary input; invalid API requests do not act. +function Commands.reason(b, intent) + if b.kind == "link" or b.spectating or b.demo or b.dudeDemo or b.pokedudeDemo or b.palace or b.arena then + return "battle controller owns input" + end + if b.result or b.dead or b.waitingUI then + return "battle is not ready" + end + if intent.kind == "safari" then + if b.phase ~= "menu" or not b.safari then + return "safari menu is not active" + end + if (b.safari.balls or 0) <= 0 then + return "no safari balls remain" + end + return + end + if b.safari then + return "safari controller owns input" + end + local actor = intent.kind == "mimic" and b.mimicCtx and b.mimicCtx.user or b:menuBattler() + if not (actor and actor.mon) or actor.ally or (actor.mon.hp or 0) <= 0 then + return "player battler is not ready" + end + if + intent.kind ~= "mimic" + and ( + b:menuLockedAction(actor) + or actor.mustRecharge + or actor.charging + or actor.rageMove + or (actor.thrashTurns and actor.thrashTurns > 0) + ) + then + return "player battler is locked" + end + if intent.kind == "menu" then + if b.phase ~= "menu" then + return "battle menu is not active" + end + local acting = b.player or b:activeOn(1) + if not acting or (acting.mon.hp or 0) <= 0 or b:menuLockedAction(acting) then + return "battle menu is not ready" + end + elseif intent.kind == "move" then + if b.phase ~= "moveSelect" then + return "move menu is not active" + end + if b.moveSwapIndex then + return "move reorder is active" + end + return moveReason(b, actor, intent.slot, (actor.curMoves or {})[intent.slot]) + elseif intent.kind == "target" then + if b.phase ~= "targetSelect" then + return "target menu is not active" + end + local move, slot = b.pendingMove, nil + for index, mv in ipairs(actor.curMoves or {}) do + if mv == move then + slot = index + break + end + end + if not slot then + return "pending move is no longer current" + end + local reason = moveReason(b, actor, slot, move) + if reason then + return reason + end + if not Targeting.needsChoice(b, actor, b.data.moves[move.id]) then + return "target choice is no longer needed" + end + local pick = b:battlerAt(intent.target) + if not (pick and pick.mon and (pick.mon.hp or 0) > 0) then + return "target is not on the field" + end + local offered = false + for _, target in ipairs(b.targetChoices or {}) do + if target == pick then + offered = true + break + end + end + if not offered then + return "target is not in the current selector" + end + for _, target in ipairs(Targeting.choices(b, actor)) do + if target == pick then + return + end + end + return "target is no longer eligible" + elseif intent.kind == "back" then + if b.phase == "moveSelect" or b.phase == "targetSelect" or (b.phase == "menu" and backToFirst(b)) then + return + end + return "nothing to cancel" + elseif intent.kind == "mimic" then + if b.phase ~= "mimicSelect" then + return "mimic menu is not active" + end + local ctx, pick = b.mimicCtx, (b.mimicMoves or {})[intent.index] + if not ctx or not pick or ctx.user ~= actor or not ctx.target or not ctx.moveInst then + return "invalid mimic context" + end + local targetPresent = false + for pos = 0, 3 do + if b:battlerAt(pos) == ctx.target then + targetPresent = true + end + end + if not targetPresent or #(actor.curMoves or {}) == 0 then + return "invalid mimic context" + end + local onField = false + for _, pos in ipairs({ 0, 2 }) do + if b:battlerAt(pos) == actor then + onField = true + end + end + if not onField then + return "mimic user is no longer on the field" + end + local source = (ctx.target.curMoves or {})[pick.slot] + if not source or source.id ~= pick.id then + return "mimic option is no longer current" + end + else + return "unknown battle intent" + end +end + +local DISPATCH = { + menu = "chooseMenu", + move = "chooseMove", + target = "chooseTarget", + back = "cancelMove", + safari = "chooseSafari", + mimic = "chooseMimic", +} +function Commands.dispatch(b, intent) + -- Ordinary input already performs this at its menu boundary. + if b.phase == "menu" and not b.safari then + b:clearTurnFlinches() + end + if intent.kind == "target" then + local pick = b:battlerAt(intent.target) + for index, target in ipairs(b.targetChoices or {}) do + if target == pick then + return b:chooseTarget(index) + end + end + return nil, "target is not in the current selector" + end + local value = intent.choice or intent.slot or intent.action or intent.index + return b[DISPATCH[intent.kind]](b, value) +end +return Commands diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index 9688aa08f..c1024f07b 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -38,6 +38,9 @@ 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") +local EnemyMoveChoice = require("src.battle.EnemyMoveChoice") +local EnemyActionContext = require("src.battle.EnemyActionContext") -- 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 +56,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 @@ -972,7 +979,7 @@ end -- construction -- --------------------------------------------------------------------- -local function makeBattler(data, mon, isPlayer, save) +local function makeBattler(data, mon, isPlayer, save, battle) local def = require('src.pokemon.Gen4Forms').definition(data,mon) local badgeBoosts = data.constants and data.constants.badgeBoosts local badges = nil @@ -984,7 +991,7 @@ local function makeBattler(data, mon, isPlayer, save) if save.inventory[row.badge] then badges[row.badge] = true end end end - return { + local battler = { mon = mon, def = def, name = mon.nickname or def.name, @@ -1013,6 +1020,7 @@ local function makeBattler(data, mon, isPlayer, save) -- while the tilemap still shows the prior condition until the next -- post-action HUD refresh (core.asm after Execute*Move) shownStatus = mon.status, + toxicCounter = GameVersion.isGen3() and mon.status == "PSN" and mon.toxic == true and 1 or nil, -- a sleep put on outside battle with its own count (the Battle Pike's -- Kirlia: STATUS1_SLEEP, all three bits); spent, it is gone sleepTurns = (mon.status == "SLP" and mon.sleepTurns) or nil, @@ -1041,6 +1049,7 @@ local function makeBattler(data, mon, isPlayer, save) monAnim = (not isPlayer) and require("src.pokemon.MonAnim").new(data, mon.species) or nil, } + return require("src.battle.HeldItemRead").bind(battler, battle) end -- LinkBattle builds clamped copies with save=nil (no badge boosts); wild @@ -1362,7 +1371,7 @@ function BattleState.newWild(game, species, level, opts) Logger.warn("wild battle with no healthy party; skipping") self.dead = true else - self.player = makeBattler(game.data, playerMon, true, game.save) + self.player = makeBattler(game.data, playerMon, true, game.save, self) end local form=opts and opts.form if form==nil and (game.data.constants or {}).gen==4 then @@ -1376,7 +1385,7 @@ function BattleState.newWild(game, species, level, opts) and game.data.pokemon[species] and game.data.pokemon[species].forms then form=require("src.pokemon.PolishedUnown").choose(game.save) end - local wild = Pokemon.new(game.data, species, level, nil, form) + local wild = Pokemon.new(game.data, species, level, nil, form, opts) -- SYNCHRONIZE'S NATURE AND CUTE CHARM'S GENDER, decided with the slot (see -- src/world/Gen4WildLead.lua) and applied the cartridge's way: personality -- re-drawn until it carries them. @@ -1410,6 +1419,11 @@ function BattleState.newWild(game, species, level, opts) -- kind of their own, because everything else about the fight -- the music, -- the intro line, the wild-mon held item roll -- is an ordinary wild one. self.noRun = opts and opts.noRun or nil + if GameVersion.isGen3() then + self.firstBattle = opts and opts.firstBattle and true or nil + self.firstBattleKind = opts and opts.firstBattleKind + or (self.firstBattle and (GameVersion.get() == "emerald" and "birch" or "oak")) + end self.canLose = opts and opts.canLose or nil -- ...AND A ROAMER IS THE SAME INDIVIDUAL EVERY TIME. The cartridge keeps -- its personality and IVs and rebuilds from them, which is what makes a @@ -1419,7 +1433,7 @@ function BattleState.newWild(game, species, level, opts) if opts and opts.roamerSeed then Pokemon.applySeed(game.data, wild, opts.roamerSeed) end - self.enemy = makeBattler(game.data, wild, false) + self.enemy = makeBattler(game.data, wild, false, nil, self) require("src.battle.Gen2Wild").initialize(self, opts) if self.roamer then local kept = math.floor(tonumber(opts.roamerHP) or 0) @@ -1453,11 +1467,11 @@ end local POKEDUDE_DEMOS = { battle = { player = { - { species="RATTATA", level=15, nature="LONELY", - moves={"TACKLE","TAIL_WHIP","HYPER_FANG","QUICK_ATTACK"} }, + { species=19, level=15, nature=1, gender="male", + moves={33,39,158,98} }, }, - enemy = { species="PIDGEY", level=18, nature="NAUGHTY", - moves={"TACKLE","SAND_ATTACK","GUST","QUICK_ATTACK"} }, + enemy = { species=16, level=18, nature=4, gender="male", + moves={33,28,16,98} }, turns = { { action="fight", playerMove=3, enemyMove=3, voices={1} }, { action="fight", playerMove=3, enemyMove=3, voices={2,3}, repeatTurn=true }, @@ -1466,11 +1480,11 @@ local POKEDUDE_DEMOS = { }, status = { player = { - { species="RATTATA", level=15, nature="LONELY", - moves={"TACKLE","TAIL_WHIP","HYPER_FANG","QUICK_ATTACK"} }, + { species=19, level=15, nature=1, gender="male", + moves={33,39,158,98} }, }, - enemy = { species="ODDISH", level=14, nature="RASH", - moves={"ABSORB","SWEET_SCENT","POISONPOWDER"} }, + enemy = { species=43, level=14, nature=19, gender="male", + moves={71,230,77} }, turns = { { action="fight", playerMove=3, enemyMove=3 }, { action="item", item="ANTIDOTE", enemyMove=1, voices={1,2} }, @@ -1480,13 +1494,13 @@ local POKEDUDE_DEMOS = { }, matchups = { player = { - { species="POLIWAG", level=15, nature="RASH", - moves={"WATER_GUN","HYPNOSIS","BUBBLE"} }, - { species="BUTTERFREE", level=15, nature="RASH", - moves={"CONFUSION","POISONPOWDER","STUN_SPORE","SLEEP_POWDER"} }, + { species=60, level=15, nature=19, gender="male", + moves={55,95,145} }, + { species=12, level=15, nature=19, gender="male", + moves={93,77,78,79} }, }, - enemy = { species="ODDISH", level=14, nature="RASH", - moves={"ABSORB","SWEET_SCENT","POISONPOWDER"} }, + enemy = { species=43, level=14, nature=19, gender="male", + moves={71,230,77} }, turns = { { action="fight", playerMove=3, enemyMove=1 }, { action="switch", switchTo=2, enemyMove=1, voices={1,2,3} }, @@ -1496,11 +1510,11 @@ local POKEDUDE_DEMOS = { }, catch = { player = { - { species="BUTTERFREE", level=15, nature="RASH", - moves={"CONFUSION","POISONPOWDER","SLEEP_POWDER","STUN_SPORE"} }, + { species=12, level=15, nature=19, gender="male", + moves={93,77,79,78} }, }, - enemy = { species="JIGGLYPUFF", level=11, nature="CAREFUL", - moves={"SING","DEFENSE_CURL","POUND"} }, + enemy = { species=39, level=11, nature=23, gender="male", + moves={47,111,1} }, turns = { { action="fight", playerMove=1, enemyMove=3, voices={1} }, { action="fight", playerMove=3, enemyMove=3, voices={2,3} }, @@ -1510,42 +1524,23 @@ local POKEDUDE_DEMOS = { }, } -local function pokedudeMon(game, spec) - -- Fixed mid-range IVs keep the scripted demonstrations deterministic while - -- still using the ordinary Gen-3 stat path. Retail's creator also forces - -- the requested nature/gender rather than using the player's Pokémon. - local mon = Pokemon.new(game.data, spec.species, spec.level, - function(a, b) return math.floor((a + b) / 2) end) - mon.nature = spec.nature - mon.gender = "male" - mon.stats = require("src.pokemon.Stats").calc(game.data.pokemon[spec.species], - spec.level, mon.ivs, mon.evs, - mon.nature) - mon.hp = mon.stats.hp - mon.moves = {} - for _, id in ipairs(spec.moves) do - local def = game.data.moves[id] - mon.moves[#mon.moves + 1] = { id=id, pp=def and def.pp or 0, - maxPp=def and def.pp or 0 } - end - return mon -end - -function BattleState.newPokedudeDemo(game, lesson) +function BattleState.newPokedudeDemo(game, lesson, rng) + local Preset = require("src.pokemon.Gen3ConstrainedMon") local def = POKEDUDE_DEMOS[lesson] assert(def, "unknown POKé DUDE lesson " .. tostring(lesson)) local savedParty = game.save.party local party = {} - for _, spec in ipairs(def.player) do party[#party + 1] = pokedudeMon(game, spec) end + for _, spec in ipairs(def.player) do party[#party + 1] = Preset.new(game.data, spec, rng) end + local enemyParty = { Preset.new(game.data, def.enemy, rng) } require("src.pokemon.PartyAside").hold(game.save, savedParty) -- kept in the save too game.save.party = party local self = newBattle(game) self.kind = "wild" self.noRun = true - self.player = makeBattler(game.data, party[1], true, game.save) - self.enemyParty = { pokedudeMon(game, def.enemy) } - self.enemy = makeBattler(game.data, self.enemyParty[1], false) + self.player = makeBattler(game.data, party[1], true, game.save, self) + self.enemyParty = enemyParty + self.enemy = makeBattler(game.data, self.enemyParty[1], false, nil, self) self.introText = self:romText("_WildMonAppearedText", "Wild %s\nappeared!", self.enemy.name) self.demoName = "POKé DUDE" @@ -1553,10 +1548,9 @@ function BattleState.newPokedudeDemo(game, lesson) lesson=lesson, def=def, turn=1, timer=0, savedParty=savedParty, voiceDone={}, } - -- The retail tutorial is a prerecorded demonstration, not a battle whose - -- damage/accuracy swings with the player's global RNG state. A midpoint - -- generator keeps the scripted turns stable while still running through the - -- ordinary Gen-3 battle formulas and status checks. + -- The existing scripted-turn midpoint policy is separate from native + -- identity creation above. Whole-game RNG scheduling and retail demo + -- playback timing still need their own controller-level comparison. self.rng = function(a, b) if a ~= nil and b ~= nil then return math.floor((a + b) / 2) end if a ~= nil then return math.max(1, math.floor((a + 1) / 2)) end @@ -1733,9 +1727,9 @@ function BattleState.newTrainer(game, oppClass, partyIndex) Logger.warn("trainer battle with no healthy party; skipping") self.dead = true else - self.player = makeBattler(game.data, playerMon, true, game.save) + self.player = makeBattler(game.data, playerMon, true, game.save, self) end - self.enemy = makeBattler(game.data, self.enemyParty[1], false) + self.enemy = makeBattler(game.data, self.enemyParty[1], false, nil, self) self.aiUses = self:aiUsesFor() -- wAICount, reset per enemy mon markSeen(game, self.enemyParty[1].species, self.enemyParty[1]) @@ -1827,7 +1821,7 @@ function BattleState:sendOutSecondPair() if self:battlerAt(BattleState.POS.PLAYER_RIGHT) == nil and self.ally then local first = Party.firstHealthy(self.ally.party or {}) if first then - local b = makeBattler(game.data, first, true, nil) + local b = makeBattler(game.data, first, true, nil, self) b.ally = true self:placeBattler(BattleState.POS.PLAYER_RIGHT, b) end @@ -1838,7 +1832,7 @@ function BattleState:sendOutSecondPair() local second = Party.firstHealthy(game.save.party, lead) if second then self:placeBattler(BattleState.POS.PLAYER_RIGHT, - makeBattler(game.data, second, true, game.save)) + makeBattler(game.data, second, true, game.save, self)) else -- one usable mon and a double battle is a state the cartridge refuses -- to enter (special 64 answers non-zero and the approach is called @@ -1854,7 +1848,7 @@ function BattleState:sendOutSecondPair() if party and party[2] then self.enemyIndexRight = 2 self:placeBattler(BattleState.POS.OPPONENT_RIGHT, - makeBattler(game.data, party[2], false)) + makeBattler(game.data, party[2], false, nil, self)) markSeen(game, party[2].species, party[2]) else -- a "double" trainer with one Pokemon fights alone on their side, @@ -1912,7 +1906,7 @@ function BattleState:addOpponentTrainer(oppClass) -- the right-hand opponent is the SECOND trainer's lead, replacing -- whatever the first trainer's second Pokemon had been put there self:placeBattler(BattleState.POS.OPPONENT_RIGHT, - makeBattler(self.game.data, self.enemyPartyB[1], false)) + makeBattler(self.game.data, self.enemyPartyB[1], false, nil, self)) markSeen(self.game, self.enemyPartyB[1].species, self.enemyPartyB[1]) end return true @@ -2131,6 +2125,7 @@ end -- script under "PROF.OAK" (pokeyellow core.asm .profOakName), so the -- displayed thrower name is a parameter. function BattleState:makeOldManDemo(name) + self.wallyTutorial = nil self.demo = true self.demoName = name or "OLD MAN" -- LoadPlayerBackPic and DisplayBattleMenu split on the same wBattleType: @@ -2147,7 +2142,7 @@ function BattleState:makeOldManDemo(name) if not self.player then self.dead = false self.player = makeBattler(self.game.data, - Pokemon.new(self.game.data, self.enemy.mon.species, 5), true) + Pokemon.new(self.game.data, self.enemy.mon.species, 5), true, nil, self) end end @@ -2178,6 +2173,7 @@ end function BattleState:makeDudeDemo(name) self:makeOldManDemo(name or "DUDE") self.dudeDemo = true + self.wallyTutorial = GameVersion.get() == "emerald" and true or nil self.demoBallCount = "x1" end @@ -2589,6 +2585,10 @@ function BattleState:updateQueue() if self.game.stack:top() ~= self then return true end self.waitingUI = nil end + if self._gbaSubstituteTransition + and require("src.battle.Gen3SubstitutePresentation").update(self) then return true end + if self._gbaEffectPresentation + and require("src.battle.Gen3EffectPresentation").update(self) then return true end -- HOENN'S PARTICLES TICK BEFORE THE QUEUE'S OWN HOLD, and the whole -- reason the animations looked broken is that they used to tick after it. -- @@ -2708,7 +2708,20 @@ function BattleState:updateQueue() end if not self.current then local item = table.remove(self.queue, 1) - if not item then return false end + while item and (item.gbaSubstituteSpecial or item.gbaNativeEffect) do + local controller = item.gbaSubstituteSpecial and "Gen3SubstitutePresentation" or "Gen3EffectPresentation" + if require("src.battle." .. controller).start(self, item) then return true end + item = table.remove(self.queue, 1) + end + if not item then + if self._gbaEffectReconcile then + require("src.battle.Gen3EffectPresentation").reconcile(self) + end + if self._gbaSubstitutePresentation then + require("src.battle.Gen3SubstitutePresentation").reconcile(self) + end + return false + end if item.fn then self.nextInsert = 0 -- sayNext inserts right after this item item.fn() @@ -3411,8 +3424,14 @@ end -- caller already means. function BattleState:placeBattler(pos, battler) local side, flank = slotOf(pos) + if GameVersion.isGen3() then + require("src.battle.Gen3LockOn").replacing(self, pos, self.sides[side].battlers[flank], battler) + end self.sides[side].battlers[flank] = battler - if battler then battler.position = pos end + if battler then + battler.position = pos + require("src.battle.HeldItemRead").bind(battler, self) + end if flank == 1 then if side == 1 then self.player = battler else self.enemy = battler end end @@ -3517,6 +3536,11 @@ end function BattleState:activeBattlers() return activeBattlers(self) end function BattleState:syncSides() + if GameVersion.isGen3() then + local lock = require("src.battle.Gen3LockOn") + lock.replacing(self, 0, self.sides[1].battlers[1], self.player) + lock.replacing(self, 1, self.sides[2].battlers[1], self.enemy) + end self.sides[1].battlers[1] = self.player self.sides[2].battlers[1] = self.enemy if self.player then self.player.position = BattleState.POS.PLAYER_LEFT end @@ -3532,6 +3556,9 @@ function BattleState:syncSides() if pr then pr.position = BattleState.POS.PLAYER_RIGHT end if orr then orr.position = BattleState.POS.OPPONENT_RIGHT end end + for _, battler in activeBattlers(self) do + require("src.battle.HeldItemRead").bind(battler, self) + end end -- IS THIS SIDE STILL ARRIVING? @@ -3980,6 +4007,7 @@ function BattleState:fightLockedAction(battler) end -- ENCORE: the same move again, whether the Pokemon wants to or not. Only -- if it still has the move -- an ENCOREd move that ran out of PP frees it. + if GameVersion.isGen3() then return require("src.battle.Gen3Encore").menu(battler) end if battler.encoreTurns and battler.encoreMove then for _, m in ipairs(battler.curMoves or battler.mon.moves or {}) do if m.id == battler.encoreMove and (m.pp or 0) > 0 then @@ -4030,8 +4058,14 @@ end function BattleState:playerHasPP(who) who = who or self:menuBattler() if not (who and who.curMoves) then return false end + if GameVersion.isGen3() then + for slot = 1, 4 do + if not require("src.battle.Gen3MoveSelection").manualReason(self, who, who.curMoves[slot], slot) then return true end + end + return false + end for i, mv in ipairs(who.curMoves) do - if mv.pp > 0 and who.disabledSlot ~= i then return true end + if mv.pp > 0 and not require("src.battle.Gen3Disable").blocked(who, mv, i) then return true end end return false end @@ -4340,6 +4374,8 @@ function BattleState:update(dt) if not self:updateQueue() then if self.afterQueue == "menu" then self.phase = "menu" + elseif self.afterQueue == "moveSelect" then + self.phase = "moveSelect" elseif self.afterQueue == "finish" then self:finish() end @@ -4406,7 +4442,7 @@ function BattleState:update(dt) Gen4Battle.actionMove(self.menuIndex, dir, self.gen4MenuColumn) end if input:wasPressed("a") then - self:safariAction(({ "ball", "bait", "rock", "run" })[self.menuIndex]) + self:chooseSafari(({ "ball", "bait", "rock", "run" })[self.menuIndex]) end return end @@ -4432,7 +4468,7 @@ function BattleState:update(dt) end self.menuIndex = row * 2 + col + 1 if input:wasPressed("a") then - self:safariAction(({ "ball", "bait", "rock", "run" })[self.menuIndex]) + self:chooseSafari(({ "ball", "bait", "rock", "run" })[self.menuIndex]) end return end @@ -4512,60 +4548,10 @@ function BattleState:update(dt) -- asked (fainted, or locked into a move it cannot choose). if input:wasPressed("b") and self:isDouble() and self:choosingSlotNow() == BattleState.POS.PLAYER_RIGHT then - local left = self:battlerAt(BattleState.POS.PLAYER_LEFT) - local pend = self.pendingActions and self.pendingActions[BattleState.POS.PLAYER_LEFT] - if left and not left.ally and (left.mon and left.mon.hp or 0) > 0 and pend - and not self:menuLockedAction(left) then - self.pendingActions[BattleState.POS.PLAYER_LEFT] = nil - if self.chosenTargets then self.chosenTargets[BattleState.POS.PLAYER_LEFT] = nil end - self.choosingSlot = BattleState.POS.PLAYER_LEFT - self.menuIndex, self.moveIndex = 1, 1 - pcall(function() require("src.core.Sound").play(self.game.data, "SEQ_SE_DP_DECIDE") end) - return - end + if self:cancelMove() then return end end if input:wasPressed("a") then - local choice = self:menuActions()[self.menuIndex] - -- BattleMenu_Pack `.contest` (core.asm:4990): `ld a, PARK_BALL / - -- ld [wCurItem], a / call DoItemEffect`. No pack, no submenu -- the - -- third slot IS the ball, so the item action becomes the throw. - if choice == "item" and self.bugContest then - self:bugContestBall() - return - end - if choice == "fight" and self.ghost then - self:say(Strings("%s is too\nscared to move!", self.player.name)) - self.phase = "messages" - self.afterQueue = "menu" - self:act(function() - self:executeAction(self.enemy, self.player, self:enemyAction()) - end) - self:act(function() self:endOfTurn() end) - elseif choice == "fight" then - -- After the menu: own trapping/Bide or foe Wrap skips the move - -- list and forces the locked action (core.asm:320-329) - local fightLock = self:fightLockedAction(self.player) - if fightLock then - self:resolveTurn(fightLock) - return - end - local who = self:menuBattler() - if not self:playerHasPP(who) then - -- _NoMovesLeftText, then Struggle engages - self:say(Strings("%s has no\nmoves left!", who.name)) - self:chooseAction({ id = "STRUGGLE", pp = 1, struggle = true }) - return - end - self.phase = "moveSelect" - self.moveIndex = math.min(self.moveIndex, #who.curMoves) - self.moveSwapIndex = nil - elseif choice == "run" then - self:tryRun() - elseif choice == "item" then - self:openItems() - else - self:openParty() - end + self:chooseMenu(self:menuActions()[self.menuIndex]) end return end @@ -4591,58 +4577,14 @@ function BattleState:update(dt) self.moveSwapIndex = self.moveIndex end elseif input:wasPressed("b") then - self.moveSwapIndex = nil - self.phase = "menu" + self:cancelMove() elseif input:wasPressed("a") then if self.moveSwapIndex then self:swapMoves(self.moveSwapIndex, self.moveIndex) self.moveSwapIndex = nil return end - local mv = moves[self.moveIndex] - local mvDef = mv and self.data.moves[mv.id] - if chooser.disabledSlot == self.moveIndex then - self:say(self:romText("_MoveDisabledText", "The move is\ndisabled!")) - self.phase = "messages" - self.afterQueue = "menu" - -- TAUNT and TORMENT refuse the move at SELECTION rather than after the - -- fact, the way DISABLE already does, so the player is never told to - -- pick again for something the game was never going to allow. - elseif self.bw and require("src.battle.Gen5MoveRules").moveBlocked(self, chooser, mvDef, true) then - -- BLACK AND WHITE: the Choice lock, Imprison, Gravity, Heal Block - self:say(require("src.battle.Gen5MoveRules").moveBlocked(self, chooser, mvDef, true)) - self.phase = "messages" - self.afterQueue = "menu" - elseif self:tauntBlocks(chooser, mvDef) then - self:say(Strings("%s can't use\n%s after the\ntaunt!", - chooser.name, mvDef and mvDef.name or mv.id)) - self.phase = "messages" - self.afterQueue = "menu" - elseif self:tormentBlocks(chooser, mvDef) then - self:say(Strings("%s can't use the\nsame move twice in\na row due to the\nTORMENT!", - chooser.name)) - self.phase = "messages" - self.afterQueue = "menu" - elseif mv.pp <= 0 then - self:say(self:romText("_MoveNoPPText", "No PP left for\nthis move!")) - self.phase = "messages" - self.afterQueue = "menu" - elseif Targeting.needsChoice(self, self:choosingBattler(), mvDef) then - -- WHICH OF THE TWO, when the move takes one and there are two. - -- - -- Emerald's selector cycles over the legal slots and names the - -- candidate; nothing else in the menu changes. A single battle - -- never gets here, because needsChoice answers false whenever there - -- is only one thing to aim at -- and on the Game Boy cartridges the - -- moves carry no target byte at all, so it answers false for every - -- move in the game. - self.pendingMove = mv - self.targetChoices = Targeting.choices(self, self:menuBattler()) - self.targetIndex = 1 - self.phase = "targetSelect" - else - self:chooseAction(mv) - end + self:chooseMove(self.moveIndex) end return end @@ -4660,16 +4602,9 @@ function BattleState:update(dt) elseif input:wasPressed("right") or input:wasPressed("down") then self.targetIndex = self.targetIndex < #list and self.targetIndex + 1 or 1 elseif input:wasPressed("b") then - self.pendingMove, self.targetChoices = nil, nil - self.phase = "moveSelect" + self:cancelMove() elseif input:wasPressed("a") then - local mv = self.pendingMove - local pick = list[self.targetIndex] - self.pendingMove, self.targetChoices = nil, nil - self.chosenTargets = self.chosenTargets or {} - local who = self:menuBattler() - if who and who.position then self.chosenTargets[who.position] = pick end - self:chooseAction(mv) + self:chooseTarget(self.targetIndex) end return end @@ -4690,12 +4625,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 @@ -4759,7 +4689,7 @@ function BattleState:applyMimic(user, target, moveInst, slot) if not (src and src.id) then return end local mySlot for i, m in ipairs(user.curMoves) do - if m == moveInst then mySlot = i break end + if m == EnemyMoveChoice.slot(moveInst) then mySlot = i break end end if not mySlot then -- a called Mimic (Metronome) isn't in the list. For the player, @@ -5224,8 +5154,23 @@ function BattleState:effectRecord(effect) return require("src.battle.Gen5MoveEffects").record(self, effect) end local effects = self.data.move_effects - if effects then return effects[effect] end - return MoveEffects.RECORDS[effect] + local record = effects and effects[effect] or not effects and MoveEffects.RECORDS[effect] + if GameVersion.isGen3() and not effects then + record = require("src.battle.Gen3Builtins").record(effect, record) + end + if GameVersion.isGen3() and effect == "DISABLE_EFFECT" then + return require("src.battle.Gen3Disable").record(record, MoveEffects.RECORDS.DISABLE_EFFECT) + end + if GameVersion.isGen3() and effect == "OHKO_EFFECT" then + return require("src.battle.Gen3OHKO").record(record, MoveEffects.RECORDS.OHKO_EFFECT) + end + if GameVersion.isGen3() and effect == "BATON_PASS_EFFECT" then + return require("src.battle.Gen3BatonPass").record(record, MoveEffects.RECORDS.BATON_PASS_EFFECT) + end + if GameVersion.isGen3() and effect == "ENCORE_EFFECT" then + return require("src.battle.Gen3Encore").record(record, MoveEffects.RECORDS.ENCORE_EFFECT) + end + return record end -- the merged ball record (Catching.attempt handles the unknown-id default) @@ -5276,6 +5221,11 @@ end -- sun and rain, and `50 percent + 1` is 128 rather than the 127 a percentage -- would round to. function BattleState:accuracyRoll(move, user, target, accuracyRaw) + if GameVersion.isGen3() then + local hit, reason = require("src.battle.Gen3Accuracy").check(self, move, user, target, accuracyRaw) + if not hit and self._gbaMoveFrame then self._gbaMoveFrame.missed = true end + return hit, reason + end -- LOCK-ON / MIND READER: the next move cannot miss, and the aim is spent -- whether it was needed or not. if user.lockedOn == target then @@ -5317,15 +5267,17 @@ function BattleState:pokedudeUseAntidote(step) .use(self.data, self.game.save, "ANTIDOTE", self.player.mon, self) for _, message in ipairs(messages or {}) do self:say(message) end self:syncShownStatus() - self:act(function() - self:executeAction(self.enemy, self.player, self:enemyAction()) - end) + self:act(self:nonMoveAction()) self:act(function() self:endOfTurn() end) end -- Damage.compute, hooked as battle.damage; the ctx table is only built -- when a chain is installed, so the no-mod path allocates nothing function BattleState:computeDamage(user, target, move, opts) + if GameVersion.isGen3() then + opts = opts or {} + opts.battle = self + end -- Damage's Gen II held-item path needs the imported item records alongside -- the ordinary formula options. Keep Gen I's no-mod fast path allocation-free. if self.data and self.data.constants and self.data.constants.generation == 2 then @@ -5365,7 +5317,9 @@ function BattleState:computeDamage(user, target, move, opts) local n = 0 for flank = 1, 2 do local b = self.sides[isPlayer and 1 or 2].battlers[flank] - if b and b.mon and (b.mon.hp or 0) > 0 then n = n + 1 end + -- Native GBA counts occupied slots. A fainted target stays present + -- until its queued removal, including the rest of a spread script. + if b and b.mon and (GameVersion.isGen3() or (b.mon.hp or 0) > 0) then n = n + 1 end end return n end @@ -5412,7 +5366,7 @@ function BattleState:computeDamage(user, target, move, opts) opts = opts or {} opts.bwBattle = self end - if Runtime.wantsHook("battle.damage") then + if Runtime.wantsHook("battle.damage") and not (opts and opts.gbaSetDamage) then return Runtime.call("battle.damage", function(c) return Damage.compute(c.ruleset, c.user, c.target, c.move, c.opts) end, { battle = self, ruleset = self.ruleset, user = user, @@ -5490,6 +5444,13 @@ end -- "menuLockedAction: attempt to index local 'battler' (a nil value)" after -- killing their first Pokemon. A slot with nobody in it has no action. function BattleState:enemyAction(battler) + local requestedBattler = battler + if GameVersion.isGen3() then + local who = battler or self.enemy + if who and who.mon and (who.mon.hp or 0) > 0 then + require("src.battle.Gen3AIOrder").prepare(self, self._nativeOrderSelectionTurn or self.turnCount or 0) + end + end battler = battler or self.enemy if not battler then return nil end -- The POKé DUDE opponent does not run the AI at all. Its controller feeds @@ -5500,6 +5461,18 @@ function BattleState:enemyAction(battler) if move then return move end end if Runtime.wantsHook("battle.enemy_action") then + if GameVersion.generation() == 3 then + return EnemyActionContext.call(self, requestedBattler, function() + local delegated, captured + local result = Runtime.call("battle.enemy_action", function(battle, who) + delegated, captured = EnemyMoveChoice.delegate(battle, who or battle.enemy, battle:vanillaEnemyAction(who)) + return delegated + end, self, battler) + return EnemyMoveChoice.normalize(self.data, battler, result, delegated, captured) + or EnemyMoveChoice.normalize(self.data, battler, delegated, delegated, captured) + or self:vanillaEnemyAction(battler) + end) + end return Runtime.call("battle.enemy_action", function(battle, who) return battle:vanillaEnemyAction(who) end, self, battler) @@ -5511,7 +5484,10 @@ function BattleState:vanillaEnemyAction(battler) battler = battler or self.enemy if not battler then return nil end local locked = self:lockedAction(battler) - if locked then return locked end + local encoreOnly = GameVersion.isGen3() and not battler.charging and not battler.rageMove + and not (battler.thrashTurns and battler.thrashTurns > 0) + and locked == require("src.battle.Gen3Encore").menu(battler) + if locked and not encoreOnly then return locked end if self.palace then return require("src.battle.Gen3Palace").choose(self, battler) end -- an ai_classes brain (or one on the trainer record) supersedes the -- class action and move scoring entirely @@ -5591,10 +5567,50 @@ function BattleState:choosingBattler() end function BattleState:resolveTurn(playerAction) + if GameVersion.isGen3() then + self._nativeOrderSelectionTurn = (self.turnCount or 0) + 1 + require("src.battle.Gen3AIOrder").prepare(self, self._nativeOrderSelectionTurn) + end -- nil in a double battle whose left-hand foe has fainted with nothing on -- the bench to replace it; `actionFor` asks each surviving slot for its own - local enemyAction = self:enemyAction(self.enemy) + local enemyAction = self:enemyAction(self:isDouble() and self.enemy or nil) + local enemyActions + if GameVersion.isGen3() and self:isDouble() then + enemyActions = { [1] = enemyAction } + local right = self:battlerAt(3) + if right and right.mon and (right.mon.hp or 0) > 0 then + enemyActions[3] = self:enemyAction(right) + end + end + self._nativeOrderSelectionTurn = nil + local selectedPlayerAction, selectedEnemyAction = playerAction, enemyAction + local nativeActions + if GameVersion.isGen3() then + local Choice = require("src.battle.Gen3ChoiceBand") + local Rage = require("src.battle.Gen3Rage") + if self:isDouble() then + nativeActions = {} + for pos = 0, 3 do + local b = self:battlerAt(pos) + if b and b.mon and b.mon.hp > 0 then + local action + if enemyActions and not b.isPlayer and not b.ally then action = enemyActions[pos] + else action = self:actionFor(b, playerAction, enemyAction) end + action = Choice.plan(self, b, action) + nativeActions[pos] = action + Rage.selected(self, b, action) + end + end + playerAction, enemyAction = nativeActions[0], nativeActions[1] + else + playerAction = Choice.plan(self, self.player, playerAction) + enemyAction = Choice.plan(self, self.enemy, enemyAction) + Rage.selected(self, self.player, playerAction) + Rage.selected(self, self.enemy, enemyAction) + end + end self.turnCount = (self.turnCount or 0) + 1 + if GameVersion.isGen3() then require("src.battle.Gen3FollowMe").clear(self) end -- BATTLE ARENA: arenaTurnCounter if self.arena then require("src.battle.Gen3Arena").turnStarted(self) end -- TURN-SCOPED FLAGS, cleared before anybody moves. REVENGE reads @@ -5605,22 +5621,25 @@ function BattleState:resolveTurn(playerAction) b.hurtThisTurn = nil b.specialDamageTaken, b.physicalDamageTaken = nil, nil b.protecting, b.enduring = nil, nil + if GameVersion.isGen3() then b._gen3HelpingHand = nil end b.turnsOut = (b.turnsOut or 0) + 1 end end Runtime.emit("battle.turn_started", { battle = self, turn = self.turnCount, - playerAction = playerAction, enemyAction = enemyAction, + playerAction = selectedPlayerAction, enemyAction = selectedEnemyAction, }) local pMove = orderMove(playerAction, self.data) local eMove = orderMove(enemyAction, self.data) - -- QUICK CLAW, before the speed comparison and before priority is even - -- read: AI_TryToFaint's caller rolls Random() % 100 against the item's own - -- parameter for each side and a winner simply goes first. Both sides - -- rolling it is not a draw -- the cartridge checks the player's first, so - -- a double claw is the player's turn. - local pClaw = self:quickClawWins(self.player) - local eClaw = not pClaw and self:quickClawWins(self.enemy) + -- GBA Quick Claw uses the shared turn word through the native comparator; + -- the earlier-generation path retains its separate activation rolls. + local function gbaMeta(action) + local special = action and action.special + return GameVersion.isGen3() and (special == "aiItem" or special == "aiSwitch" or special == "playerSwitch") + end + local singleMeta = not self:isDouble() and (gbaMeta(playerAction) or gbaMeta(enemyAction)) + local pClaw = not GameVersion.isGen3() and self:quickClawWins(self.player) + local eClaw = not GameVersion.isGen3() and not pClaw and self:quickClawWins(self.enemy) local pFirst -- EITHER LEFT SLOT CAN BE EMPTY IN A DOUBLE. A fainted battler is lifted -- off the field at once and only replaced when the turn ends, so between @@ -5628,7 +5647,11 @@ function BattleState:resolveTurn(playerAction) -- flank fights on. The pairwise comparison below is the SINGLE battle's -- ordering and has nothing to compare; the double branch sorts all four -- entries with the same comparator a few lines down and does not read it. - if not (self.player and self.enemy) then + if GameVersion.isGen3() and self:isDouble() then + pFirst = true -- native double planning compares only the real pairs below + elseif singleMeta then + pFirst = gbaMeta(playerAction) + elseif not (self.player and self.enemy) then pFirst = true elseif pClaw or eClaw then pFirst = pClaw == true @@ -5652,21 +5675,29 @@ function BattleState:resolveTurn(playerAction) if self:isDouble() then -- FOUR ENTRIES, SORTED WITH THE SAME COMPARATOR. -- - -- SetActionsAndBattlersTurnOrder builds the list in position order and - -- bubbles it with GetWhoStrikesFirst, which is firstMover -- so this is - -- the same rule applied to four rather than a second rule for doubles. - -- Built in position order 0,1,2,3 because that is the order the - -- cartridge fills its own array in, and a bubble is stable, so - -- equal-speed entries fall out the same way it does. + -- GBA SetActionsAndBattlersTurnOrder starts in position order, keeps + -- item/switch choices in that order, and compares every remaining i,j + -- pair. Equal-speed comparisons consume one tie draw each. local entries = {} for pos = 0, 3 do local b = self:battlerAt(pos) if b and b.mon and (b.mon.hp or 0) > 0 then - local action = self:actionFor(b, playerAction, enemyAction) + -- Native choices were resolved once, before turn-start observers. + local action + if nativeActions then action = nativeActions[pos] + else action = self:actionFor(b, playerAction, enemyAction) end entries[#entries + 1] = { battler = b, move = orderMove(action, self.data), action = action } end end + local metadata = {} + if GameVersion.isGen3() then + local moves = {} + for _, e in ipairs(entries) do + if gbaMeta(e.action) then metadata[#metadata + 1] = e else moves[#moves + 1] = e end + end + entries = moves + end TurnOrder.order(entries, self.rng, self) -- and a QUICK CLAW winner is simply hoisted to the front if pClaw or eClaw then @@ -5679,6 +5710,12 @@ function BattleState:resolveTurn(playerAction) end end end + if #metadata > 0 then + local merged = {} + for _, e in ipairs(metadata) do merged[#merged + 1] = e end + for _, e in ipairs(entries) do merged[#merged + 1] = e end + entries = merged + end -- ...AND EVERY SWITCH HAPPENS BEFORE EVERY MOVE. -- -- SetActionsAndBattlersTurnOrder runs the switch actions out of the array @@ -5694,7 +5731,7 @@ function BattleState:resolveTurn(playerAction) moves[#moves + 1] = e end end - if #switches > 0 then + if not GameVersion.isGen3() and #switches > 0 then entries = switches for _, e in ipairs(moves) do entries[#entries + 1] = e end end @@ -5714,10 +5751,22 @@ function BattleState:resolveTurn(playerAction) -- BLACK AND WHITE: who chose what, Quick Claw / Custap, Pursuit before a -- switch (src/battle/Gen5MoveRules.lua) + if GameVersion.isGen3() then require("src.battle.Gen3AIOrder").release(self) end if self.bw then order = require("src.battle.Gen5MoveRules").arrange(self, order) end + if GameVersion.isGen3() then + self._gbaResidualPriority = {} + for _, entry in ipairs(order) do + local move = entry[3] and not entry[3].special and self:moveDef(entry[3]) + if move then self._gbaResidualPriority[entry[1].position] = move.priority or 0 end + end + end self.phase = "messages" self.afterQueue = "menu" + if GameVersion.generation() == 3 then + self._actionTargetOrder = {} + for _, entry in ipairs(order) do self._actionTargetOrder[#self._actionTargetOrder + 1] = entry[1].position end + end for _, entry in ipairs(order) do self:act(function() @@ -5747,7 +5796,7 @@ function BattleState:resolveTurn(playerAction) end self:act(function() -- the collected choices belong to the turn that just ran, not the next - self.pendingActions, self.chosenTargets = nil, nil + self.pendingActions, self.chosenTargets, self._actionTargetOrder = nil, nil, nil self:endOfTurn() end) end @@ -5795,9 +5844,7 @@ function BattleState:resolveSwitch(newMon) if self.bw and require("src.battle.Gen5MoveRules").playerSwitch(self, newMon) then return end self:act(function() self:switchPlayerInto(BattleState.POS.PLAYER_LEFT, newMon) end) - self:act(function() - self:executeAction(self.enemy, self.player, self:enemyAction()) - end) + self:act(self:nonMoveAction()) self:act(function() self:endOfTurn() end) end @@ -5829,7 +5876,7 @@ function BattleState:switchPlayerInto(pos, newMon) end local previous = self:battlerAt(pos) self:restoreMimicked(previous) -- the battle copy leaves with it - local incoming = makeBattler(self.data, newMon, true, self.game.save) + local incoming = makeBattler(self.data, newMon, true, self.game.save, self) self:placeBattler(pos, incoming) -- BATON PASS: the stat stages and the volatiles go with the switch, which -- is the whole move. Nothing else survives a switch, so a pass that was @@ -5931,7 +5978,10 @@ function BattleState:abilitySwitchIn(battler) -- An eighth of maximum HP, and a FLYING or LEVITATing Pokemon never lands -- on them at all. local side = self.sideOf and self:sideOf(battler) - if side and side.spikes then + local layers = side and side.spikes and (tonumber(side.spikes) or 1) or 0 + if layers ~= layers then layers = 0 end + layers = GameVersion.isGen3() and math.max(0, math.min(3, math.floor(layers))) or 1 + if side and side.spikes and layers > 0 then local grounded = Abilities.of(battler) ~= "LEVITATE" for _, t in ipairs(battler.curTypes or {}) do if t == "FLYING" then grounded = false end @@ -5939,7 +5989,8 @@ function BattleState:abilitySwitchIn(battler) if grounded then local max = (battler.mon.stats and battler.mon.stats.hp) or battler.mon.hp self:sayNext(Strings("%s is hurt\nby SPIKES!", displayName(battler))) - self:applyDamage(battler, math.max(1, math.floor(max / 8))) + local divisor = ({ 8, 6, 4 })[math.max(1, layers)] + self:applyDamage(battler, math.max(1, math.floor(max / divisor))) self:drainNext() if battler.mon.hp <= 0 then self:onFaint(battler) @@ -5957,8 +6008,8 @@ function BattleState:abilitySwitchIn(battler) -- anything else on the field gets to speak. self:abilityForecast(battler) local act = Abilities.onSwitchIn(battler) - if not act then return end - self:abilitySwitchInEffect(battler, act) + if act then self:abilitySwitchInEffect(battler, act) end + if GameVersion.isGen3() and not self._gen3BatonSwitching then require("src.battle.GbaItemSchedule").afterAction(self) end end -- The visible half of a switch-in ability, split out so TRACE can run the @@ -6239,11 +6290,22 @@ end -- true when the band held and the Pokemon is standing on one HP. function BattleState:focusBandHolds(battler, incoming) if not (battler and battler.mon) then return false end + if GameVersion.isGen2() then + return HeldItems.limitDirectDamage(self.data, battler, incoming, self.rng) < incoming + end -- BLACK AND WHITE: Focus Sash / Focus Band (src/battle/Gen5HoldItems.lua) if self.bw and require("src.battle.Gen5HoldItems").owns(battler) then if battler.mon.hp <= 0 or incoming < battler.mon.hp then return false end return require("src.battle.Gen5HoldItems").focusBand(self, battler, incoming) end + if GameVersion.isGen3() then + if battler.mon.hp <= 0 then return false end + local effect, chance = HoldItems.effectOf(battler, "endure") + if effect ~= "FOCUS_BAND" then return false end + if (self.rng or love.math.random)(0, 99) >= chance then return false end + require("src.battle.Gen3AIItemKnowledge").record(self, battler, 39) + return incoming >= battler.mon.hp + end -- only a hit that would actually finish it, and only from full-ish health -- is NOT a condition: the cartridge lets a band save a Pokemon on 1 HP too if battler.mon.hp <= 0 or incoming < battler.mon.hp then return false end @@ -6302,11 +6364,15 @@ function BattleState:holdItemTrigger(battler, when) local label = (def and def.name) or "the item" local rng = self.rng or love.math.random - if act.kind == "heal" then + if act.kind == "prize" then + if GameVersion.get() ~= "emerald" or battler.isPlayer then self.amuletCoin = true end + + elseif act.kind == "heal" then local max = (battler.mon.stats and battler.mon.stats.hp) or battler.mon.hp battler.mon.hp = math.min(max, battler.mon.hp + act.amount) self:drainNext(battler, battler.mon.hp) if act.effect == "LEFTOVERS" then + require("src.battle.Gen3AIItemKnowledge").record(self, battler, 43) self:sayNext(Strings("%s restored a little\nHP using its\n%s!", who, label)) else @@ -6339,7 +6405,8 @@ function BattleState:holdItemTrigger(battler, when) local moves = battler.curMoves or battler.mon.moves local slot = moves and moves[act.slot] if not slot then return false end - local cap = slot.maxPp or slot.pp_max or act.amount + local move = GameVersion.isGen3() and self:moveDef(slot) + local cap = slot.maxPp or slot.pp_max or (move and move.pp) or act.amount slot.pp = math.min(cap, (slot.pp or 0) + act.amount) self:sayNext(Strings("%s's %s\nrestored %s's PP!", who, label, tostring(slot.id or "its move"))) @@ -6355,7 +6422,8 @@ function BattleState:holdItemTrigger(battler, when) if not stat then -- STARF BERRY picks one of the five at random local list = HoldItems.RANDOM_STATS - stat = list[rng(1, #list)] + repeat stat = list[rng(1, #list)] + until not GameVersion.isGen3() or ((battler.stages or {})[stat] or 0) < 6 end self:sayNext(Strings("%s used its\n%s!", who, label)) for _, m in ipairs(MoveEffects.changeStage(self, battler, stat, @@ -6364,7 +6432,8 @@ function BattleState:holdItemTrigger(battler, when) end elseif act.kind == "critUp" then - battler.critStage = (battler.critStage or 0) + act.delta + if GameVersion.isGen3() then battler.focusEnergy = true + else battler.critStage = (battler.critStage or 0) + act.delta end self:sayNext(Strings("%s used its\n%s!", who, label)) self:sayNext(Strings("%s is getting\npumped!", who)) @@ -6377,6 +6446,7 @@ function BattleState:holdItemTrigger(battler, when) end function BattleState:endOfTurn() + if GameVersion.isGen3() then require("src.battle.Gen3AIOrder").release(self) end -- the same ret: a decided battle never reaches HandlePoisonBurnLeechSeed -- or CheckNumAttacksLeft (core.asm:417-421, 456-460), so the residual -- sweep and the trapping-counter release are skipped on the turn a @@ -6386,6 +6456,9 @@ function BattleState:endOfTurn() Runtime.emit("battle.turn_ended", { battle = self, turn = self.turnCount or 0 }) return end + if GameVersion.isGen3() then + require("src.battle.GbaItemSchedule").residuals(self) + else -- sideToxic mirrors w*ToxicCounter: it advances only while the -- battler's badly-poisoned flag (toxicCounter) is set, an item/AI -- cure clears the flag but NOT the side counter, and a fresh Toxic @@ -6476,6 +6549,7 @@ function BattleState:endOfTurn() self:holdItemTrigger(self.enemy, "turn") end self:gen3CountersTick() + end self:tickTokens() self:roamerFlees() -- BATTLE ARENA: the third turn is over and both are standing -- judgment @@ -6505,77 +6579,100 @@ end -- THE COUNTERS HOENN'S NEW MOVES SET, ticked once per turn in the order -- HandleBetweenTurnEffects walks them. Each one is a plain countdown; the -- interesting part is what happens when it runs out. -function BattleState:gen3CountersTick() - for _, b in activeBattlers(self) do - if b and b.mon and b.mon.hp > 0 then - -- YAWN: the sleep lands at the END of the turn AFTER the one it was - -- used on, which is the whole reason it is worth using at all. - if b.drowsyTurns then - b.drowsyTurns = b.drowsyTurns - 1 - if b.drowsyTurns <= 0 then - b.drowsyTurns = nil - for _, m in ipairs(StatusRegistry.inflict(self, b, "SLP", {})) do - self:sayNext(m) - end - end - end - if b.tauntTurns then - b.tauntTurns = b.tauntTurns - 1 - if b.tauntTurns <= 0 then - b.tauntTurns = nil - self:sayNext(Strings("%s's taunt\nwore off!", displayName(b))) - end +function BattleState:gen3WishTick(b) + if not (b and b.mon and b.mon.hp > 0) then return end + -- WISH: half of maximum HP, arriving at the end of the turn AFTER the + -- one it was made on -- which is what makes it a switch-in heal. + if b.wishTurns then + b.wishTurns = b.wishTurns - 1 + if b.wishTurns <= 0 then + local heal = b.wishHeal or math.max(1, math.floor(maxHpOf(b) / 2)) + b.wishTurns, b.wishHeal = nil, nil + local max = maxHpOf(b) + if b.mon.hp < max then + b.mon.hp = math.min(max, b.mon.hp + heal) + self:drainNext(b, b.mon.hp) + self:sayNext(Strings("%s's wish\ncame true!", displayName(b))) end - if b.encoreTurns then - b.encoreTurns = b.encoreTurns - 1 - if b.encoreTurns <= 0 then - b.encoreTurns, b.encoreMove = nil, nil - self:sayNext(Strings("%s's ENCORE\nended!", displayName(b))) + end + end +end + +function BattleState:gen3IngrainTick(b) + if not (b and b.mon and b.mon.hp > 0) then return end + -- INGRAIN: a sixteenth back every turn for as long as it stands there + if b.ingrained and b.mon.hp < maxHpOf(b) then + b.mon.hp = math.min(maxHpOf(b), + b.mon.hp + math.max(1, math.floor(maxHpOf(b) / 16))) + self:drainNext(b, b.mon.hp) + self:sayNext(Strings("%s absorbed\nnutrients with its\nroots!", + displayName(b))) + end +end + +function BattleState:gen3CountersTick() + for _, b in activeBattlers(self) do self:gen3BattlerCountersTick(b) end + self:gen3DelayedCountersTick() +end + +function BattleState:gen3BattlerCountersTick(b, scheduled) + if b and b.mon and b.mon.hp > 0 then + if GameVersion.isGen3() then + require("src.battle.Gen3Disable").tick(self, b) + require("src.battle.Gen3Encore").tick(self, b) + require("src.battle.Gen3LockOn").tick(self, b) + end + -- YAWN: the sleep lands at the END of the turn AFTER the one it was + -- used on, which is the whole reason it is worth using at all. + if b.drowsyTurns then + b.drowsyTurns = b.drowsyTurns - 1 + if b.drowsyTurns <= 0 then + b.drowsyTurns = nil + for _, m in ipairs(StatusRegistry.inflict(self, b, "SLP", {})) do + self:sayNext(m) end end - if b.safeguardTurns then - b.safeguardTurns = b.safeguardTurns - 1 - if b.safeguardTurns <= 0 then - b.safeguardTurns = nil - self:sayNext(Strings("%s's party is no\nlonger protected!", - displayName(b))) - end + end + if b.tauntTurns then + b.tauntTurns = b.tauntTurns - 1 + if b.tauntTurns <= 0 then + b.tauntTurns = nil + self:sayNext(Strings("%s's taunt\nwore off!", displayName(b))) end - -- WISH: half of maximum HP, arriving at the end of the turn AFTER the - -- one it was made on -- which is what makes it a switch-in heal. - if b.wishTurns then - b.wishTurns = b.wishTurns - 1 - if b.wishTurns <= 0 then - local heal = b.wishHeal or math.max(1, math.floor(maxHpOf(b) / 2)) - b.wishTurns, b.wishHeal = nil, nil - local max = maxHpOf(b) - if b.mon.hp < max then - b.mon.hp = math.min(max, b.mon.hp + heal) - self:drainNext(b, b.mon.hp) - self:sayNext(Strings("%s's wish\ncame true!", displayName(b))) - end - end + end + if not GameVersion.isGen3() and b.encoreTurns then + b.encoreTurns = b.encoreTurns - 1 + if b.encoreTurns <= 0 then + b.encoreTurns, b.encoreMove = nil, nil + self:sayNext(Strings("%s's ENCORE\nended!", displayName(b))) end - -- INGRAIN: a sixteenth back every turn for as long as it stands there - if b.ingrained and b.mon.hp < maxHpOf(b) then - b.mon.hp = math.min(maxHpOf(b), - b.mon.hp + math.max(1, math.floor(maxHpOf(b) / 16))) - self:drainNext(b, b.mon.hp) - self:sayNext(Strings("%s absorbed\nnutrients with its\nroots!", + end + if b.safeguardTurns then + b.safeguardTurns = b.safeguardTurns - 1 + if b.safeguardTurns <= 0 then + b.safeguardTurns = nil + self:sayNext(Strings("%s's party is no\nlonger protected!", displayName(b))) end - -- UPROAR: three turns of noise, and nobody sleeps through it - if b.uproarTurns then - b.uproarTurns = b.uproarTurns - 1 - if b.uproarTurns <= 0 then - b.uproarTurns = nil - self:sayNext(Strings("%s calmed down.", displayName(b))) - else - self:sayNext(Strings("%s is making\nan UPROAR!", displayName(b))) - end + end + if not scheduled then + self:gen3WishTick(b) + self:gen3IngrainTick(b) + end + -- UPROAR: three turns of noise, and nobody sleeps through it + if b.uproarTurns then + b.uproarTurns = b.uproarTurns - 1 + if b.uproarTurns <= 0 then + b.uproarTurns = nil + self:sayNext(Strings("%s calmed down.", displayName(b))) + else + self:sayNext(Strings("%s is making\nan UPROAR!", displayName(b))) end end end +end + +function BattleState:gen3DelayedCountersTick() -- FUTURE SIGHT and DOOM DESIRE, whose damage was rolled two turns ago and -- lands on whoever is standing there now. for index, side in ipairs(self.sides or {}) do @@ -7576,8 +7673,37 @@ function BattleState:executeAction(user, target, action) -- faint cases are already covered by the HP guard below (#441) if self.result then return end if not (user and user.mon) then return end + if GameVersion.isGen3() then + if user.position ~= nil and self:battlerAt(user.position) ~= user then return end + if target and target.position ~= nil and self:battlerAt(target.position) ~= target then + target = self:battlerAt(target.position) or self:defaultTargetFor(user) + end + end if user.mon.hp <= 0 then return end if not action then return end + if GameVersion.isGen3() and action.id and not action.special then + require("src.battle.Gen3PostHitItems").markMoved(self, user) + end + if GameVersion.isGen3() then action = require("src.battle.Gen3ChoiceBand").execution(user, action) end + local explicitTarget = false + if GameVersion.generation() == 3 and self:isDouble() and not action.special then + local selected + selected, explicitTarget = Targeting.fromAction(self, action) + if explicitTarget then + target = Targeting.redirect(self, user, nil, selected)[1] or self:defaultTargetFor(user) + end + end + local encoreTargets + if GameVersion.isGen3() then + local chosen = self:moveDef(action) + local changed + action, changed = require("src.battle.Gen3Encore").forced(user, action) + if changed and self:isDouble() then + explicitTarget = false + encoreTargets = require("src.battle.Gen3Encore").targets(self,user,self:moveDef(action),chosen) + target = encoreTargets[1] or self:defaultTargetFor(user) + end + end -- A SWITCH NEEDS NO TARGET. Everything else does, and with four on the -- field the one it was aimed at can already be off it -- the entry's -- target is resolved as the entry runs, and there may be nobody left to @@ -7647,7 +7773,7 @@ function BattleState:executeAction(user, target, action) local previous = self.enemy local oldName = self.enemy.name self.enemyIndex = action.index - self.enemy = makeBattler(self.data, self.enemyParty[action.index], false) + self.enemy = makeBattler(self.data, self.enemyParty[action.index], false, nil, self) -- EnemySendOutFirstMon (core.asm:1314-1315): clears player's trap clearTrapping(self.player) self:syncSides() @@ -7707,12 +7833,13 @@ function BattleState:executeAction(user, target, action) return end if action.special == "bide" then - if self:statusInterrupt(user, target) then return end + local move = GameVersion.isGen3() and require("src.battle.Gen3Disable").bideMove(self, user) or nil + if self:statusInterrupt(user, target, move) then return end self:continueBide(user, target) return end - if self:statusInterrupt(user, target) then return end + if self:statusInterrupt(user, target, action) then return end -- ONE MOVE, POSSIBLY SEVERAL TARGETS. -- -- The cartridge prints "X used SURF!" once and then resolves the move @@ -7734,8 +7861,11 @@ function BattleState:executeAction(user, target, action) local targetKind = tonumber(def.target) spreadAnimation = targetKind == Targeting.BOTH or targetKind == Targeting.FOES_AND_ALLY - local chosen = (self.chosenTargets or {})[user.position] or target - local got = Targeting.resolve(self, user, def, chosen) + local chosen = explicitTarget and target or (self.chosenTargets or {})[user.position] or target + local nativeSpread = GameVersion.isGen3() + and (targetKind == Targeting.BOTH or targetKind == Targeting.FOES_AND_ALLY) + local got = encoreTargets or (explicitTarget or nativeSpread) and Targeting.actionTargets(self, user, def, chosen) + or Targeting.resolve(self, user, def, chosen) if #got > 0 then list = got end end end @@ -7744,7 +7874,8 @@ function BattleState:executeAction(user, target, action) if one and one.mon and (one.mon.hp or 0) > 0 then -- only the first announces; the rest are the same move landing again self:performMove(user, one, action, i > 1, - spreadAnimation and animationPlayed) + spreadAnimation and animationPlayed, GameVersion.generation() == 3 and i > 1) + if self._gbaActionFrame and self._gbaActionFrame.ppBlocked then break end -- FireRed's Cmd_attackanimation increments animTargetsHit only when an -- animation actually survives the no-effect/failure path. If the -- first target cancels it, a later spread target is still allowed to @@ -7753,7 +7884,17 @@ function BattleState:executeAction(user, target, action) end end end + local parent = self._gbaActionFrame + local frame = GameVersion.isGen3() and {} or nil + if frame then self._gbaActionFrame = frame end run() + if frame then + self._gbaActionFrame = parent + local Schedule = require("src.battle.GbaItemSchedule") + if action.id and not frame.moved then Schedule.moveEnd(self) end + -- Switch-in abilities are queued; their completion performs the sweep. + if not isSwitch and not self._gen3BatonPending then Schedule.afterAction(self) end + end -- after announce/anim/effect text (pokered DrawHUDsAndHPBars) self:actNext(function() self:syncShownStatus() end) end @@ -7850,7 +7991,7 @@ end -- Runs Status.beforeMove plus the shared interruption bookkeeping; -- returns true when the user's action is interrupted. -function BattleState:statusInterrupt(user, target) +function BattleState:statusInterrupt(user, target, move) -- TRUANT, before the conditions. SLAKING and SLAKOTH move every OTHER -- turn: the cartridge sets a flag after a move and reads it before the -- next one, which is why this is a toggle rather than "every second turn @@ -7864,8 +8005,13 @@ function BattleState:statusInterrupt(user, target) end user.truantLoafing = true end - local canMove, msgs, selfHit = Status.beforeMove(user, self.rng, self) + local canMove, msgs, selfHit, reason = Status.beforeMove(user, self.rng, self, move) for _, m in ipairs(msgs) do self:sayStatusMsg(user, m) end + if reason == "gen3_sleep" or reason == "gen3_flinch" then user.lastMove = nil end + if reason == "gen3_disabled" then + require("src.battle.Gen3Disable").cancel(user) + return true + end if selfHit then -- confusion self-hit (core.asm:3428-3434): clears everything in -- status1 except CONFUSED, then HandleSelfConfusionDamage deals a @@ -7875,10 +8021,13 @@ function BattleState:statusInterrupt(user, target) local dmg = self:computeDamage(user, user, { id = "CONFUSED", power = 40, type = "NORMAL", accuracy = 100 }, { rng = self.rng, forceCrit = false, typeless = true, - screens = target }) + screens = GameVersion.isGen3() and user or target }) + if GameVersion.isGen3() then + dmg = math.max(1, math.floor(dmg * self.rng(85, 100) / 100)) + end self:sayNext(self:romText("_HurtItselfText", "It hurt itself in\nits confusion!")) self:clearVolatiles(user, true) - self:applyDamage(user, dmg) + self:applyDamage(user, dmg, false, GameVersion.isGen3() and { confusion = true } or nil) if user.mon.hp <= 0 then self:onFaint(user) end return true end @@ -7896,7 +8045,7 @@ function BattleState:statusInterrupt(user, target) -- while one is going on, on either side. if (self.player and self.player.uproarTurns) or (self.enemy and self.enemy.uproarTurns) then - if user.mon.status == "SLP" then + if user.mon.status == "SLP" and (not GameVersion.isGen3() or Abilities.of(user) ~= "SOUNDPROOF") then user.mon.status = nil self:sayNext(Strings("%s woke up in\nthe UPROAR!", displayName(user))) end @@ -8026,7 +8175,8 @@ local WEATHER_BALL_ANIM = { SUN = 1, RAIN = 2, SANDSTORM = 3, HAIL = 4, } -function BattleState:performMove(user, target, moveInst, isCalled, suppressAnimation) +local function performMove(battle, user, target, moveInst, isCalled, suppressAnimation, spreadContinuation) + local self = battle local move = self:moveDef(moveInst) if not move then Logger.warn("unknown move instance %s", tostring(moveInst.id)) @@ -8039,6 +8189,11 @@ function BattleState:performMove(user, target, moveInst, isCalled, suppressAnima -- charge release? local releasing = user.charging == moveInst and user.chargeReady + if not releasing and GameVersion.isGen3() and user.charging and user.chargeReady then + -- Native planning may wrap the learned action; release follows the + -- locked move identity rather than the action table's identity. + releasing = user.charging.id == moveInst.id + end if releasing then user.charging, user.chargeReady, user.invulnerable = nil, nil, nil user.hiddenAs = nil @@ -8094,41 +8249,88 @@ function BattleState:performMove(user, target, moveInst, isCalled, suppressAnima or moveInst == user.rageMove local enemyUnlimited = not user.isPlayer and self.kind ~= "link" and self.ruleset and self.ruleset.enemyUnlimitedPP - if not isContinuation and not moveInst.struggle and not isCalled + local nativePP = GameVersion.isGen3() + and require("src.battle.Gen3PPCommand").prepare(self, user, target, moveInst, isContinuation, isCalled) + if nativePP and self._gbaMoveFrame then self._gbaMoveFrame.ppIndex = nativePP.index end + if not nativePP and not isContinuation and not moveInst.struggle and not isCalled + and not enemyUnlimited and EnemyMoveChoice.exhausted(moveInst) then + self:sayNext(self:romText("_MoveNoPPText", "No PP left for\nthis move!")) + return + end + if not nativePP and not isContinuation and not moveInst.struggle and not isCalled and not enemyUnlimited then -- PRESSURE costs the ATTACKER an extra point for every move aimed at its -- holder. Two per use, not double the total -- which is why it is -- subtracted here rather than multiplying the cost. - local cost = 1 + Abilities.extraPP(target) + local cost = GameVersion.generation() == 3 and Targeting.gen3PPCost(self, user, target, move) + or 1 + Abilities.extraPP(target) moveInst.pp = math.max(0, moveInst.pp - cost) end + local function resolveCharge() + local required = record and record.charge ~= nil and not releasing and not skipCharge + if required and GameVersion.generation() <= 3 and Runtime.wantsHook("battle.charge_required") then + required = Runtime.call("battle.charge_required", function(c) return c.charge end, { + battle = self, user = user, target = target, move = move, + charge = true, isCalled = isCalled or false, + }) ~= false + if not required then animContext.moveTurn = 1 end + end + return required + end + local chargeRequired + if nativePP and not nativePP.blocked then + chargeRequired = resolveCharge() + -- Native charge setup/bypass/sunlight consumes PP before any attack + -- string. A retained first charge has no move-used event at all. + if record and record.charge and not releasing then moveInst = nativePP.reduce() end + end + + if record and record.gbaSleepTalk and user.mon.status == "SLP" then self:statusOnomatopoeia(user, "sleep") end self.moveAnimRow = nil if not (user.thrashTurns and moveInst == user.thrashMove and user.thrashAnnounced) then - self:sayNext(self:romText("_ItemUseText001", "%s\nused %s!", displayName(user), move.name)) + if not chargeRequired then + self:sayNext(self:romText("_ItemUseText001", "%s\nused %s!", displayName(user), move.name)) + end -- the move's animation plays right after the announcement; the -- damage path attaches the target's hit blink to this row so the -- blink follows the animation (pokered's order). Mimic is the -- exception (announceAnim = false): PlayCurrentMoveAnimation runs -- only after a successful copy, never on a miss -- applyMimic queues it - if not suppressAnimation and not (record and record.announceAnim == false) then + if not (nativePP and nativePP.blocked) and not suppressAnimation and not (record and record.announceAnim == false) then self.nextInsert = (self.nextInsert or 0) + 1 self.moveAnimRow = { anim = move.id, attackerIsPlayer = user.isPlayer, attackerPosition = user.position, targetPosition = target and target.position, animContext = animContext } + require("src.battle.Gen3SubstitutePresentation").mark(self, self.moveAnimRow, user) table.insert(self.queue, self.nextInsert, self.moveAnimRow) end end - Runtime.emit("battle.move_used", { - battle = self, user = user, target = target, move = move, - isCalled = isCalled or false, - }) + if not spreadContinuation and not chargeRequired then + Runtime.emit("battle.move_used", { + battle = self, user = user, target = target, move = move, + isCalled = isCalled or false, + }) + end + if nativePP then + if nativePP.blocked then + if self._gbaMoveFrame then self._gbaMoveFrame.finished = true end + if self._gbaActionFrame then self._gbaActionFrame.ppBlocked = true end + self:sayNext(self:romText("_MoveNoPPText", "No PP left for\nthis move!")) + return + end + if record and record.gbaAssist and self._gbaMoveFrame then + self._gbaMoveFrame.assistPool = require("src.battle.Gen3Assist").pool(self, user) + end + if not (record and record.gbaNaturePower) then moveInst = nativePP.reduce() end + end -- PROTECT stops a status move too, and it is the only check that has to -- happen before the effect record is consulted at all. if target.protecting and move.power == 0 and move.protectAffected ~= false and record and record.kind == "primary" then + if GameVersion.isGen3() and self._gbaMoveFrame then self._gbaMoveFrame.missed = true end self:cancelMoveAnim() self:sayNext(Strings("%s\nprotected itself!", displayName(target))) return @@ -8140,13 +8342,22 @@ function BattleState:performMove(user, target, moveInst, isCalled, suppressAnima -- already said its failure text if record and record.callsMove then local pick = record.callsMove(ctx) + if not pick and record.gbaNativeChoiceCaller == record.callsMove then + local frame = self._gbaMoveFrame + if frame then frame.finished, frame.nativeNoHistory = true, true end + end -- Mirror Move never plays its own anim (MetronomePickMove does; -- MirrorMoveCopyMove only reloads the copied move or prints fail) if move.id == "MIRROR_MOVE" or not pick then self:cancelMoveAnim() end if pick then - self:performMove(user, target, { id = pick, pp = 1 }, true) + if record.gbaAssist and GameVersion.isGen3() then require("src.battle.Gen3Assist").call(ctx, pick) + elseif record.gbaNaturePower and GameVersion.isGen3() then require("src.battle.Gen3NaturePower").call(ctx, pick) + elseif record.gbaSleepTalk then require("src.battle.Gen3SleepTalk").call(ctx, pick) + elseif record.gbaNativeChoiceCaller and record.gbaNativeChoiceCaller == record.callsMove then + require("src.battle.Gen3CalledMove").call(ctx, pick) + else self:performMove(user, target, { id = pick, pp = 1 }, true) end end return end @@ -8161,7 +8372,7 @@ function BattleState:performMove(user, target, moveInst, isCalled, suppressAnima if user.rampMove and user.rampMove ~= move.id then user.rampRun, user.rampMove = nil, nil end - user.lastMove = move.id + if not (record and record.gbaBatonPass or GameVersion.isGen3() and move.gen3Effect == 127) then user.lastMove = move.id end -- charge moves: first turn just charges; the text comes from the move -- record (chargeText) and the invulnerability from semiInvulnerable, @@ -8170,7 +8381,8 @@ function BattleState:performMove(user, target, moveInst, isCalled, suppressAnima -- BattleCommand_SkipSunCharge (effect_commands.asm:6535): Solar Beam jumps -- straight past `charge` while the sun is up and fires on the turn it was -- selected. Every other charge move ignores the weather. - if record and record.charge and not releasing and not skipCharge then + if not nativePP then chargeRequired = resolveCharge() end + if chargeRequired then local branchAware = GameVersion.get() == "firered" and hasMoveTurnVariant(move.anim) if not branchAware then self:cancelMoveAnim() end @@ -8219,9 +8431,13 @@ function BattleState:performMove(user, target, moveInst, isCalled, suppressAnima -- 100%-accuracy early-out (even Thunder Wave misses on the 255 -- roll) and misses outright against a mid-Fly/Dig target; the -- never-miss paths (X ACCURACY) live inside Damage.accuracyRoll - if record.accuracyChecked - and (target.invulnerable - or not self:accuracyRoll(move, user, target)) then + local hit, reason = true, nil + if record.accuracyChecked then + if target.invulnerable and not GameVersion.isGen3() then hit = false + else hit, reason = self:accuracyRoll(move, user, target) end + end + if not hit then + if reason == "absorbed" then self:cancelMoveAnim(); return end -- SleepEffect/PoisonEffect/... call PlayCurrentMoveAnimation only -- after the effect lands; a miss skips it self:cancelMoveAnim() @@ -8237,7 +8453,8 @@ function BattleState:performMove(user, target, moveInst, isCalled, suppressAnima local picked = user.callsMoveId user.callsMoveId = nil for _, m in ipairs(msgs) do self:sayNext(m) end - self:performMove(user, target, { id = picked, pp = 1 }, true) + if ctx.gbaSleepTalk then require("src.battle.Gen3SleepTalk").call(ctx, picked) + else self:performMove(user, target, { id = picked, pp = 1 }, true) end return end -- Gen 1 status/stat effects animate only when they take effect @@ -8272,6 +8489,41 @@ function BattleState:performMove(user, target, moveInst, isCalled, suppressAnima end end +function BattleState:performMove(user, target, moveInst, isCalled, suppressAnimation, spreadContinuation) + if not GameVersion.isGen3() then + return performMove(self, user, target, moveInst, isCalled, suppressAnimation, spreadContinuation) + end + if not isCalled then + local chosen = self:moveDef(moveInst) + local changed + moveInst, changed = require("src.battle.Gen3Encore").forced(user, moveInst) + if changed and self:isDouble() then + target = require("src.battle.Gen3Encore").targets(self,user,self:moveDef(moveInst),chosen)[1] or target + end + end + local Schedule = require("src.battle.GbaItemSchedule") + local parent = self._gbaMoveFrame + if not isCalled then require("src.battle.Gen3PostHitItems").markMoved(self, user) end + local caller = self._gbaNextCaller + self._gbaNextCaller = nil + local frame = { parent = parent, user = user, target = target, move = self:moveDef(moveInst), moveId = moveInst.id, + calledBy = isCalled and caller and caller.parent == parent and caller.move or nil, + calledPPSlot = isCalled and caller and caller.parent == parent and caller.ppSlot or nil } + if isCalled and caller and caller.parent == parent then caller.frame = frame end + if parent then parent.called = true end + self._gbaMoveFrame = frame + if self._gbaActionFrame then self._gbaActionFrame.moved = true end + performMove(self, user, target, moveInst, isCalled, suppressAnimation, spreadContinuation) + require("src.battle.Gen3SubstitutePresentation").finish(self, frame) + if not frame.finished and not frame.nativeCalled and not frame.suspended then Schedule.moveEnd(self) end + if frame.called and not frame.nativeCalled and not frame.suspended and frame.move + and tonumber(frame.move.gen3Effect) ~= 127 and frame.move.effect ~= "BATON_PASS_EFFECT" then + frame.user.lastMove = frame.moveId + end + self._gbaMoveFrame = parent + if not isCalled and not parent and not self._gbaActionFrame and not frame.suspended then Schedule.afterAction(self) end +end + function BattleState:continueTrapping(user, target) self:sayNext(self:romText("_AttackContinuesText", "%s's\nattack continues!", displayName(user))) -- .MultiturnMoveCheck (core.asm:3554-3566) prints AttackContinuesText @@ -8326,29 +8578,50 @@ end -- caller that cares about: the band saves a Pokemon from an attack and not -- from a sandstorm, its own recoil or a Substitute's upkeep, and applyDamage -- is the one door all of those come through. -function BattleState:applyDamage(target, dmg, fromMove) - if target.substituteHP then +function BattleState:applyDamage(target, dmg, fromMove, adjustment) + -- GBA adjusts every direct hit before Substitute/Endure, including a + -- successful Focus Band roll on a nonlethal hit and a zero-percent item. + local gba = GameVersion.isGen3() + adjustment = type(adjustment) == "table" and adjustment or nil + local prepared = gba and adjustment and adjustment.bandRolled == true + local selfHit = gba and adjustment and adjustment.confusion == true + local gbaBand = not prepared and gba and (fromMove == true or selfHit) and self:focusBandHolds(target, dmg) + if target.substituteHP and not selfHit then + local absorbed = math.min(math.max(0, dmg), target.substituteHP) target.substituteHP = target.substituteHP - dmg if target.substituteHP <= 0 then + require("src.battle.Gen3SubstitutePresentation").broken(self, target) target.substituteHP = nil self:sayNext(self:romText("_SubstituteBrokeText", "%s's\nSUBSTITUTE broke!", displayName(target))) else self:sayNext(self:romText("_SubstituteTookDamageText", "The SUBSTITUTE\ntook damage for\n%s!", displayName(target))) end - return dmg + if prepared and adjustment.endured then + self:sayNext(Strings("%s endured\nthe hit!", displayName(target))) + elseif prepared and adjustment.hung then + require("src.battle.Gen3EffectPresentation").queue(self, target, "FOCUS_BAND") + self:sayNext(Strings("%s hung on\nusing its FOCUS BAND!", displayName(target))) + end + return gba and absorbed or dmg end -- FOCUS BAND, asked before the HP is taken: a one-in-ten hold leaves the -- Pokemon standing on a single HP instead of fainting. Asked here rather -- than at the faint check because the cartridge's band changes the DAMAGE, -- and everything downstream -- Bide's tally, Rage, the exp award -- reads - -- what was actually dealt. +-- what was actually dealt. -- ENDURE first: it is a certainty where the band is a roll, so a Pokemon -- that used ENDURE this turn never needs the band at all. - local endured = fromMove == true and target.enduring == true + local mayEndure = fromMove == true or selfHit + if selfHit and (target.substituteHP or 0) > 0 then mayEndure, gbaBand = false, false end + local endured = mayEndure and target.enduring == true and target.mon.hp > 0 and dmg >= target.mon.hp - local band = not endured and fromMove == true - and self:focusBandHolds(target, dmg) - if endured or band then dmg = math.max(0, target.mon.hp - 1) end + local band = gba and gbaBand or (not gba and not endured and fromMove == true + and self:focusBandHolds(target, dmg)) + if prepared then + endured, band = adjustment.endured, adjustment.hung + elseif endured or band then + dmg = math.max(0, target.mon.hp - 1) + end local dealt = math.min(dmg, target.mon.hp) if fromMove and dealt > 0 then target.hurtThisTurn = true end target.mon.hp = target.mon.hp - dealt @@ -8356,6 +8629,7 @@ function BattleState:applyDamage(target, dmg, fromMove) if endured then self:sayNext(Strings("%s endured\nthe hit!", displayName(target))) elseif band then + require("src.battle.Gen3EffectPresentation").queue(self, target, "FOCUS_BAND") self:sayNext(require("src.battle.Gen5HoldItems").hungOnLine(self, target) or Strings("%s hung on\nusing its FOCUS BAND!", displayName(target))) end @@ -8366,7 +8640,8 @@ function BattleState:applyDamage(target, dmg, fromMove) target.stages.attack = math.min(6, (target.stages.attack or 0) + 1) self:sayNext(self:romText("_BuildingRageText", "%s's\nRAGE is building!", displayName(target))) end - return dealt + -- Native multi-hit continuation also needs the actual Endure decision. + return dealt, endured end -- --------------------------------------------------------------------- @@ -8632,7 +8907,7 @@ function BattleState:fillEmptySlots() if pick then local allySlot = isPlayer and self.ally and pos == BattleState.POS.PLAYER_RIGHT self:placeBattler(pos, makeBattler(self.data, pick, isPlayer, - (isPlayer and not allySlot) and self.game.save or nil)) + (isPlayer and not allySlot) and self.game.save or nil, self)) self:syncSides() if not isPlayer then markSeen(self.game, pick.species, pick) end local who = self:battlerAt(pos) @@ -8674,7 +8949,7 @@ function BattleState:promptDoubleReplacement(pos, rest) end end done = true - self:placeBattler(pos, makeBattler(self.data, mon, true, game.save)) + self:placeBattler(pos, makeBattler(self.data, mon, true, game.save, self)) self:syncSides() local who = self:battlerAt(pos) self.nextInsert = 0 @@ -8971,7 +9246,7 @@ function BattleState:enemyMonFainted() end self:act(function() local previous = self.enemy - self.enemy = makeBattler(self.data, self.enemyParty[self.enemyIndex], false) + self.enemy = makeBattler(self.data, self.enemyParty[self.enemyIndex], false, nil, self) -- EnemySendOutFirstMon (core.asm:1314-1315): clears player's trap clearTrapping(self.player) self:syncSides() @@ -9009,7 +9284,7 @@ function BattleState:enemyMonFainted() local mon = shiftSwitchMon if not mon then return end local previous = self.player - self.player = makeBattler(self.data, mon, true, self.game.save) + self.player = makeBattler(self.data, mon, true, self.game.save, self) clearTrapping(self.enemy) self:syncSides() Runtime.emit("battle.battler_switched", { @@ -9382,7 +9657,7 @@ function BattleState:openReplacementMenu() end self:restoreMimicked(self.player) local previous = self.player - self.player = makeBattler(self.data, mon, true, game.save) + self.player = makeBattler(self.data, mon, true, game.save, self) clearTrapping(self.enemy) -- SendOutMon clears foe trap self:syncSides() Runtime.emit("battle.battler_switched", { @@ -9715,9 +9990,7 @@ function BattleState:tryRun() -- lucky roll can never break the scene. if self.noRun then self:say(self:romText("_CantEscapeText", "Can't escape!")) - self:act(function() - self:executeAction(self.enemy, self.player, self:enemyAction()) - end) + self:act(self:nonMoveAction()) self:act(function() self:endOfTurn() end) return end @@ -9771,6 +10044,7 @@ function BattleState:tryRun() TurnOrder.effectiveSpeed(self.enemy)) end if escaped then + if GameVersion.isGen3() then self:nonMoveAction() end require("src.core.Sound").play(self.data, "Run") -- BLACK AND WHITE: "X fled using its Smoke Ball!" (bank 14 #1004) local smoke = self.bw and require("src.battle.Gen5HoldItems").smokeBallLine(self, self.player) @@ -9779,9 +10053,7 @@ function BattleState:tryRun() self.afterQueue = "finish" else self:say(self:romText("_CantEscapeText", "Can't escape!")) - self:act(function() - self:executeAction(self.enemy, self.player, self:enemyAction()) - end) + self:act(self:nonMoveAction()) self:act(function() self:endOfTurn() end) end end @@ -9806,6 +10078,23 @@ function BattleState:gen4ItemTargetScreen() return Gen4Battle.partyScreenId(self) end +-- A committed non-move action consumes the turn. Native enemy selection +-- precedes Rage cleanup; older generations retain their delayed selection. +function BattleState:nonMoveAction() + local native, action = GameVersion.isGen3() + if native then + action = self:enemyAction() + action = require("src.battle.Gen3ChoiceBand").plan(self, self.enemy, action) + local Rage = require("src.battle.Gen3Rage") + Rage.selected(self, self.player, nil) + Rage.selected(self, self.enemy, action) + end + return function() + if not native then action = self:enemyAction() end + self:executeAction(self.enemy, self.player, action) + end +end + -- called by BagMenu after an item is used in battle (consumes the turn) function BattleState:itemUsed(messages) -- bag cures clear mon.status before the message UI; refresh the HUD @@ -9813,9 +10102,7 @@ function BattleState:itemUsed(messages) self:syncShownStatus() for _, m in ipairs(messages or {}) do self:say(m) end table.insert(self.queue, { drain = true }) -- potions animate the bar - self:act(function() - self:executeAction(self.enemy, self.player, self:enemyAction()) - end) + self:act(self:nonMoveAction()) self:act(function() self:endOfTurn() end) end @@ -10322,6 +10609,7 @@ end -- called by BagMenu when a ball is thrown function BattleState:throwBall(ball) + local reply = self:nonMoveAction() -- ItemUseBall branches to ThrowBallAtTrainerMon on wIsInBattle != 1 -- (item_effects.asm:109-113) BEFORE it reaches `ld hl, ItemUseText00 / -- call PrintText` (:146-147), so a trainer battle never shows the @@ -10354,9 +10642,7 @@ function BattleState:throwBall(ball) or self:romText("_ThrowBallAtTrainerMonText1", "The trainer\nblocked the BALL!")) self:sayNext(t._ThrowBallAtTrainerMonText2 or self:romText("_ThrowBallAtTrainerMonText2", "Don't be a thief!")) - self:act(function() - self:executeAction(self.enemy, self.player, self:enemyAction()) - end) + self:act(reply) self:act(function() self:endOfTurn() end) return end @@ -10372,9 +10658,7 @@ function BattleState:throwBall(ball) self:animNext(self:tossAnimFor(ball), true, nil, ball) self:sayNext(Strings("It dodged the\nthrown BALL!")) self:sayNext(Strings("This POKéMON\ncan't be caught!")) - self:act(function() - self:executeAction(self.enemy, self.player, self:enemyAction()) - end) + self:act(reply) self:act(function() self:endOfTurn() end) return end @@ -10398,9 +10682,7 @@ function BattleState:throwBall(ball) self:act(function() self:storeCaughtMon() end) else self:sayNext(self:ballMissMessage(shakes)) - self:act(function() - self:executeAction(self.enemy, self.player, self:enemyAction()) - end) + self:act(reply) self:act(function() self:endOfTurn() end) end end) @@ -10452,9 +10734,7 @@ function BattleState:bugContestBall() else self:sayNext(self:ballMissMessage(shakes)) -- the ball is the player's turn, so the foe still moves - self:act(function() - self:executeAction(self.enemy, self.player, self:enemyAction()) - end) + self:act(self:nonMoveAction()) self:act(function() self:endOfTurn() end) end end) @@ -10870,6 +11150,7 @@ end -- (32,72) player. local substDoll function BattleState:drawSubstituteDoll(battler) + if GameVersion.isGen3() and require("src.battle.Gen3SubstituteArt").draw(self, battler) then return end if substDoll == nil then local ok, img = pcall(love.graphics.newImage, "assets/generated/sprites/monster.png") @@ -11139,6 +11420,10 @@ end -- to happen where the mon is drawn rather than as a particle laid over it -- -- which is why it is here and not in Gen3MoveAnim's own draw. function BattleState:gen3AnimShake(battler) + local substituteX = GameVersion.isGen3() and battler + and require("src.battle.Gen3SubstitutePresentation").offset(self, battler) or 0 + local effect = self._gbaEffectPresentation + if effect and effect.user == battler then return substituteX + effect.x, effect.y end -- SINNOH'S ANIMATOR SPEAKS THE SAME THREE METHODS, which is why -- it plugs in here rather than bringing a second draw path: a -- second one would have none of the grow-in scale, hit shake or @@ -11150,11 +11435,18 @@ function BattleState:gen3AnimShake(battler) return 0, 0 end if not (self.gen3AnimPlaying and self.gen3Anim and battler) then - return 0, 0 + return substituteX, 0 end local ok, dx, dy = pcall(self.gen3Anim.monOffset, self.gen3Anim, battler) - if not ok then return 0, 0 end - return dx or 0, dy or 0 + if not ok then return substituteX, 0 end + return (dx or 0) + substituteX, dy or 0 +end + +function BattleState:substituteVisible(battler) + if GameVersion.isGen3() then + return require("src.battle.Gen3SubstitutePresentation").showing(self, battler) + end + return battler and battler.substituteHP end -- ...AND WHICH HBLANK ROW TRANSFORM ITS COPIED BG IS USING. @@ -11176,6 +11468,10 @@ end -- draws reproduce exactly: the sprite dimmed by (1 - c), and the colour added -- back at c through the sprite's own alpha. function BattleState:gen3AnimTint(battler) + local effect = self._gbaEffectPresentation + if effect and effect.user == battler and effect.coeff > 0 then + return effect.color[1], effect.color[2], effect.color[3], effect.coeff + end -- SINNOH'S ANIMATOR SPEAKS THE SAME THREE METHODS, which is why -- it plugs in here rather than bringing a second draw path: a -- second one would have none of the grow-in scale, hit shake or @@ -11204,6 +11500,8 @@ function BattleState:gen3AnimSpecialMonState(battler) tint = 0, r = 1, g = 1, b = 1, alpha = 1, brightness = 1, mosaic = 1, } + local effect = self._gbaEffectPresentation + if effect and effect.user == battler and effect.alpha then state.alpha = effect.alpha end -- Sinnoh: alpha / mosaic / brightness / white from Gen4MoveAnimExtras. if self.gen4AnimPlaying and self.gen4Anim and self.gen4Anim.monSpecial then local ok4, sp = pcall(self.gen4Anim.monSpecial, self.gen4Anim, battler == self.player, battler) @@ -11474,7 +11772,7 @@ function BattleState:drawBattlerPicAt(battler, x, y, scale) -- copies rather than being hidden behind them local ghosts = self:gen3AnimGhosts(battler) if ghosts and not battler.fainted and not self:fxFaintActive(battler) - and not battler.substituteHP then + and not self:substituteVisible(battler) then local img = self:battlerPic(battler) if img then local cr, cg, cb, ca = love.graphics.getColor() @@ -11488,7 +11786,7 @@ function BattleState:drawBattlerPicAt(battler, x, y, scale) end end local img = self:battlerPic(battler) - if battler.substituteHP and not self:fxFaintActive(battler) + if self:substituteVisible(battler) and not self:fxFaintActive(battler) and not battler.fainted then self:drawSubstituteDoll(battler) return @@ -12635,7 +12933,7 @@ function BattleState:drawTextAreaInner() local sel = chooser.curMoves[self.moveIndex] if sel then local def = self.data.moves[sel.id] - if chooser.disabledSlot == self.moveIndex then + if require("src.battle.Gen3Disable").blocked(chooser, sel, self.moveIndex) then Font.draw(Strings("disabled!"), 8, 80) elseif def then Font.draw(Strings("TYPE/"), 8, 72) diff --git a/src/battle/Damage.lua b/src/battle/Damage.lua index 2664e4b17..ad9be84c3 100644 --- a/src/battle/Damage.lua +++ b/src/battle/Damage.lua @@ -82,7 +82,7 @@ Damage.stageOf = stageOf -- ruleset that sets critStages cannot just tune the Gen 1 numbers. local CRIT_STAGE_DEN = { [0] = 16, 8, 4, 3, 2 } -function Damage.critRoll(ruleset, attacker, moveId, rng, highCrit, data) +function Damage.critRoll(ruleset, attacker, moveId, rng, highCrit, data, battle) rng = rng or love.math.random if highCrit == nil then highCrit = HIGH_CRIT[moveId] end @@ -102,14 +102,9 @@ function Damage.critRoll(ruleset, attacker, moveId, rng, highCrit, data) end if ruleset.critStages then - local stage = 0 - if highCrit then stage = stage + 1 end - if attacker.focusEnergy then stage = stage + 2 end - if attacker.critStage then stage = stage + attacker.critStage end - -- SCOPE LENS is +1 for anybody; LUCKY PUNCH and STICK are +2 and are - -- dead weight on anything that is not a CHANSEY or a FARFETCH'D - stage = stage + HoldItems.critStages(attacker) - local den = CRIT_STAGE_DEN[math.max(0, math.min(4, stage))] or 16 + local stage = require("src.battle.Gen3Critical").stage(battle, attacker, highCrit) + local den = CRIT_STAGE_DEN[stage] or 16 + if battle and require("src.core.GameVersion").isGen3() then return rng(0, den - 1) == 0 end return rng(1, den) == 1 end local function shl(x) return math.min(255, x * 2) end @@ -180,7 +175,7 @@ function Damage.accuracyRoll(ruleset, move, attacker, defender, rng, -- one, otherwise a 1/256 hit chance is invented. A Gen 3 dataset has no -- such attribute and this answers zero. acc = math.max(0, acc - HeldItems.accuracyPenalty(data, defender)) - if not ruleset.oneIn256Miss and basePct >= 100 and not deepFog + if not ruleset.oneIn256Miss and basePct >= 100 and acc >= 255 and not deepFog and (attacker.stages.accuracy or 0) >= (defender.stages.evasion or 0) then return true end @@ -266,25 +261,33 @@ function Damage.compute(ruleset, attacker, defender, move, opts) crit = false end if crit == nil then + local high = opts.highCrit + if high == nil then high = move.highCrit end if Runtime.wantsHook("battle.crit") then + local gba = opts.battle and require("src.core.GameVersion").isGen3() + local stageHigh = high + if stageHigh == nil then stageHigh = HIGH_CRIT[move.id] end crit = Runtime.call("battle.crit", function(c) - return Damage.critRoll(c.ruleset, c.attacker, c.moveId, c.rng, c.highCrit, c.data) - end, { ruleset = ruleset, attacker = attacker, moveId = move.id, + return require("src.battle.Gen3Critical").roll(c.battle, Damage.critRoll, + c.ruleset, c.attacker, c.moveId, c.rng, c.highCrit, c.data) + end, { battle = opts.battle, target = defender, ruleset = ruleset, attacker = attacker, moveId = move.id, rng = rng, - highCrit = opts.highCrit ~= nil and opts.highCrit - or move.highCrit, + highCrit = high, + moveNum = gba and tonumber(move.index or move.numId) or nil, + stage = gba and require("src.battle.Gen3Critical").stage(opts.battle, attacker, stageHigh) or nil, data = opts.data }) else -- the record's highCrit (BLAZE KICK, POISON TAIL, the Hoenn moves whose -- crit rate is part of the EFFECT rather than a move-record field) -- wins over the move's own, which in turn wins over the id table - local high = move.highCrit - if opts.highCrit ~= nil then high = opts.highCrit end - crit = Damage.critRoll(ruleset, attacker, move.id, rng, high, opts.data) + crit = require("src.battle.Gen3Critical").roll(opts.battle, Damage.critRoll, + ruleset, attacker, move.id, rng, high, opts.data) end end local special = categoryOf(move) == "special" + local gbaBase = ruleset.baseDamageStyle == "gba" + local burnAfterBase, gbaScreen -- Gen 2 kept the same type-based physical/special split but gave the -- attacker Sp.Atk and the defender Sp.Def. A Gen 1 stat block has -- neither key, so it stays on the single `special`. @@ -292,15 +295,51 @@ function Damage.compute(ruleset, attacker, defender, move, opts) local atkStat = special and (split and "spatk" or "special") or "attack" local defStat = special and (split and "spdef" or "special") or "defense" + -- The GBA base command resolves both held effects once. Its stat modifiers + -- precede stages for ordinary attacks and scripted set-damage alike. + local rawAtk, rawDef = attacker.curStats[atkStat], defender.curStats[defStat] + if gbaBase then + local ability = Abilities.of(attacker) + if not special and (ability == "HUGE_POWER" or ability == "PURE_POWER") then rawAtk = rawAtk * 2 end + rawAtk, rawDef = require("src.battle.Gen3DamageStats").held( + attacker, defender, move, special, rawAtk, rawDef, opts.battle) + local kind = Abilities.normalizeType(move.type) + if special and Abilities.of(defender) == "THICK_FAT" and (kind == "FIRE" or kind == "ICE") then + rawAtk = math.floor(rawAtk / 2) + end + if special and (ability == "PLUS" or ability == "MINUS") and opts.battle then + local wanted = ability == "PLUS" and "MINUS" or "PLUS" + for _, other in opts.battle:activeBattlers() do + if Abilities.of(other) == wanted then rawAtk = math.floor(rawAtk * 3 / 2); break end + end + end + if not special and (ability == "HUSTLE" or ability == "GUTS" and attacker.mon.status) then + rawAtk = math.floor(rawAtk * 3 / 2) + end + if not special and Abilities.of(defender) == "MARVEL_SCALE" and defender.mon.status then + rawDef = math.floor(rawDef * 3 / 2) + end + end + + local applyStage = Stats.applyStage + if gbaBase then + applyStage = function(value, stage) + stage = math.max(-6, math.min(6, stage or 0)) + if stage >= 0 then return math.floor(value * (2 + stage) / 2) end + return math.floor(value * 2 / (2 - stage)) + end + end local atk, dfn if crit and ruleset.critIgnoresStages then atk = attacker.curStats[atkStat] dfn = defender.curStats[defStat] else - atk = Stats.applyStage(attacker.curStats[atkStat], - stageOf(attacker, atkStat)) - dfn = Stats.applyStage(defender.curStats[defStat], - stageOf(defender, defStat)) + local attackStage, defenseStage = stageOf(attacker, atkStat), stageOf(defender, defStat) + if gbaBase and crit then + attackStage, defenseStage = math.max(0, attackStage), math.min(0, defenseStage) + end + atk = applyStage(rawAtk, attackStage) + dfn = applyStage(rawDef, defenseStage) -- badge boosts (x9/8), engine/battle/core.asm ApplyBadgeStatBoosts: -- Boulder -> attack, Thunder -> defense, Soul -> speed (TurnOrder), -- Volcano -> special @@ -323,7 +362,11 @@ function Damage.compute(ruleset, attacker, defender, move, opts) -- penalty into a bonus: it ignores the drop AND hits half again as hard if penalty and penalty.stat == atkStat and not attacker.hazeStatReset and not Abilities.ignoresBurnDrop(attacker) then - atk = math.max(1, math.floor(atk / penalty.div)) + if gbaBase and record.id == "BRN" then + burnAfterBase = penalty.div + else + atk = math.max(1, math.floor(atk / penalty.div)) + end end -- screens double the effective defense (crits bypass them). The -- confusion self-hit is the quirk case: HandleSelfConfusionDamage @@ -337,7 +380,9 @@ function Damage.compute(ruleset, attacker, defender, move, opts) if screens then local up = (special and screens.lightScreen) or (not special and screens.reflect) - if up then + if up and gbaBase then + gbaScreen = true + elseif up then -- A SCREEN IS WEAKER WHEN TWO ARE STANDING BEHIND IT. -- -- 0806_9B48 (REFLECT) and 0806_9C88 (LIGHT SCREEN) check @@ -372,7 +417,7 @@ function Damage.compute(ruleset, attacker, defender, move, opts) -- the defence and THICK FAT halves what a FIRE or ICE move gets to work -- with. A Gen 1 or Gen 2 Pokemon has no ability at all and every one of -- these answers 1/1, so nothing below this line changes for them. - do + if not gbaBase then local an, ad = Abilities.attackMultiplier(attacker, move, special) if an ~= 1 or ad ~= 1 then atk = math.max(1, math.floor(atk * an / ad)) end local dn, dd = Abilities.defenceMultiplier(defender, move, special) @@ -383,7 +428,7 @@ function Damage.compute(ruleset, attacker, defender, move, opts) -- species-locked doublings -- THICK CLUB, LIGHT BALL, METAL POWDER, the -- two DEEP SEA halves and SOUL DEW. A battler with no `items` view (every -- Gen 1 and Gen 2 one) answers 1/1 here too. - do + if not gbaBase then local an, ad = HoldItems.attackMultiplier(attacker, move, special) if an ~= 1 or ad ~= 1 then atk = math.max(1, math.floor(atk * an / ad)) end local dn, dd = HoldItems.defenceMultiplier(defender, move, special) @@ -391,7 +436,7 @@ function Damage.compute(ruleset, attacker, defender, move, opts) end -- GetDamageVars .scaleStats: when either stat no longer fits a byte, -- BOTH are quartered (losing low bits), each bumped to at least 1 - if atk > 255 or dfn > 255 then + if not gbaBase and (atk > 255 or dfn > 255) then atk = math.max(1, math.floor(atk / 4)) dfn = math.max(1, math.floor(dfn / 4)) end @@ -414,12 +459,28 @@ function Damage.compute(ruleset, attacker, defender, move, opts) -- FURY CUTTER and ROLLOUT doubling each turn they land -- hand the -- pipeline a multiplier and it arrives here. Floored at 1 so a scaled -- move never becomes a status move by arithmetic. - local power = move.power + local power = gbaBase and opts.power or move.power if opts.powerMultiplier and opts.powerMultiplier ~= 1 then power = math.max(1, math.floor(power * opts.powerMultiplier)) end + if gbaBase then + local kind = Abilities.normalizeType(move.type) + -- Spit Up's set-damage opcode does not forward the field sport flags. + for _, muted in pairs(not opts.gbaSetDamage and opts.sports or {}) do + if muted == kind then power = math.floor(power / 2) end + end + local pn, pd = Abilities.damageMultiplier(attacker, move) + power = math.floor(power * pn / pd) + end d = math.floor(math.floor(d * power * atk / math.max(1, dfn)) / 50) + if gbaBase then + if burnAfterBase then d = math.floor(d / burnAfterBase) end + if gbaScreen then + d = opts.doublesScreens and 2 * math.floor(d / 3) or math.floor(d / 2) + end + end + -- A MOVE THAT HITS BOTH OF THEM DOES HALF TO EACH. -- -- The cartridge applies this inside CalculateBaseDamage -- after the burn @@ -432,13 +493,36 @@ function Damage.compute(ruleset, attacker, defender, move, opts) d = math.floor(d * ruleset.spreadNum / ruleset.spreadDen) end - d = math.min(d, 997) + 2 + if gbaBase then + -- Physical CalculateBaseDamage raises a zero base to one. Special + -- damage has no equivalent minimum before the final +2. + if not special then d = math.max(1, d) end + if special and not opts.typeless and opts.weather then + -- GBA applies each weather step to the base without Gen-2's minimum + -- one/clamp. Solar Beam's penalty follows the type-weather step. + local modifier = Weather.modifier(opts.weather, move.type) + if modifier then d = math.floor(d * modifier / 10) end + if (opts.weather == "RAIN" or opts.weather == "SAND" or opts.weather == "SANDSTORM" or opts.weather == "HAIL") + and (move.gen3Effect == 151 or move.effect == "SOLARBEAM_EFFECT") then + d = math.floor(d / 2) + end + end + if special then + local fn, fd = Abilities.flashFireBoost(attacker, move) + d = math.floor(d * fn / fd) + end + d = d + 2 + else + d = math.min(d, 997) + 2 + end + + if opts.gbaSetDamage then d = d * opts.gbaSetDamage end -- Held type boosters live inside BattleCommand_DamageCalc, before weather, -- STAB and effectiveness. This placement matters because every stage floors -- independently. HeldItems applies the recomp's intentional Gen II cleanup -- for the retail Dragon Fang/Dragon Scale held-effect data bug. - if not opts.typeless then + if not opts.typeless and not opts.gbaSetDamage then d = HeldItems.applyTypeBoost(opts.data, attacker, move.type, d) end @@ -447,12 +531,26 @@ function Damage.compute(ruleset, attacker, defender, move, opts) -- as damage * modifier / 10. The confusion self-hit reaches -- CalculateDamage directly and never runs `stab`, so the typeless path -- skips weather along with STAB and the type chart. - if not opts.typeless and opts.weather then + if not gbaBase and not opts.typeless and opts.weather then d = Weather.applyModifier(d, opts.weather, move.type, move.effect) end + if crit and ruleset.critMultiplier and ruleset.critBeforeType then + d = math.floor(d * ruleset.critMultiplier) + end + if gbaBase and not opts.gbaSetDamage and attacker.charged + and Abilities.normalizeType(move.type) == "ELECTRIC" then d = d * 2 end + + -- Native Helping Hand scales the calculated base after the critical + -- multiplier, before STAB/type rounding. Fixed damage and confusion's + -- typeless base path do not pass through this modifier. + if not opts.typeless then d = require("src.battle.Gen3HelpingHand").damage(attacker, d) end + local mult = 10 - if not opts.typeless then + -- Native GBA Struggle skips only STAB/type calculation. It still uses + -- the ordinary base, critical and random-damage commands. + local gbaStruggle = gbaBase and (move.id == "STRUGGLE" or tonumber(move.index or move.numId) == 165) + if not opts.typeless and not gbaStruggle then -- STAB local stab = false for _, t in ipairs(attacker.curTypes) do @@ -490,7 +588,7 @@ function Damage.compute(ruleset, attacker, defender, move, opts) -- WONDER GUARD, which is the one immunity that needs the chart's answer -- first: anything that is not super-effective does nothing at all. It -- is SHEDINJA's whole reason to exist. - if Abilities.blocks(defender, move, mult) + if not opts.gbaSetDamage and Abilities.blocks(defender, move, mult) and not (bwLevitate and Abilities.of(defender) == "LEVITATE") then return 0, { crit = false, typeMult = 0, ability = Abilities.of(defender) } @@ -498,9 +596,12 @@ function Damage.compute(ruleset, attacker, defender, move, opts) for _, m in ipairs(TypeChart.rows(move.type, defender.curTypes)) do -- the identified GHOST's zero row is the one skipped; every other -- row a dual-typed Pokemon carries still applies - if not (foresight and m == 0) then d = math.floor(d * m / 10) end + if not (foresight and m == 0) then + d = math.floor(d * m / 10) + if opts.gbaSetDamage and m > 0 and d == 0 then d = 1 end + end end - if d == 0 then + if d == 0 and not opts.gbaSetDamage then -- a 2-3 damage hit at 0.25x floors to zero: the original flags -- the move as missed rather than dealing a minimum 1 return 0, { crit = false, typeMult = mult, missed = true } @@ -509,54 +610,59 @@ function Damage.compute(ruleset, attacker, defender, move, opts) -- a Pokemon's own type hits half again as hard. Applied here because -- the cartridge applies it to the finished damage rather than the stat, -- which is not the same number once the floors are counted. - local pn, pd = Abilities.damageMultiplier(attacker, move) - if pn ~= 1 or pd ~= 1 then d = math.floor(d * pn / pd) end - -- FLASH FIRE, once something has lit it: every FIRE move this Pokemon - -- makes is half again as strong for the rest of the battle. The flag - -- rides the battler, so it leaves when the Pokemon does. - local fn, fd = Abilities.flashFireBoost(attacker, move) - if fn ~= 1 or fd ~= 1 then d = math.floor(d * fn / fd) end - -- ...and the sixteen type trinkets, each by its own parameter: 10 - -- percent for a CHARCOAL or a MAGNET, 5 for the two INCENSEs. Reading - -- the number off the item rather than assuming ten is the difference - -- between a SEA INCENSE that works and one that is a MYSTIC WATER. - local tn, td = HoldItems.damageMultiplier(attacker, move) - if tn ~= 1 or td ~= 1 then d = math.floor(d * tn / td) end - -- BLACK AND WHITE's held items on the finished damage: type items, - -- Life Orb, Expert Belt, gems, resist berries (src/battle/Gen5HoldItems.lua) - if attacker.items or defender.items then - d = require("src.battle.Gen5HoldItems").modifyDamage(attacker, defender, move, mult, d, special) - end - -- CHARGE: the user's NEXT Electric move is worth double. The flag is - -- spent here and cleared by the turn loop once the move has resolved, - -- so it survives a miss the way the cartridge's does. - local moveType = Abilities.normalizeType(move.type) - if attacker.charged and moveType == "ELECTRIC" then - d = d * 2 - end - -- MUD SPORT and WATER SPORT halve one type for the rest of the battle, - -- for BOTH sides -- they are a field effect, not a buff. opts.sports - -- carries them because Damage.compute never sees the field. - for _, muted in pairs(opts.sports or {}) do - if muted == moveType then d = math.floor(d / 2) end + if not gbaBase then + local pn, pd = Abilities.damageMultiplier(attacker, move) + if pn ~= 1 or pd ~= 1 then d = math.floor(d * pn / pd) end + -- FLASH FIRE, once something has lit it: every FIRE move this Pokemon + -- makes is half again as strong for the rest of the battle. The flag + -- rides the battler, so it leaves when the Pokemon does. + local fn, fd = Abilities.flashFireBoost(attacker, move) + if fn ~= 1 or fd ~= 1 then d = math.floor(d * fn / fd) end + -- ...and the sixteen type trinkets, each by its own parameter: 10 + -- percent for a CHARCOAL or a MAGNET, 5 for the two INCENSEs. Reading + -- the number off the item rather than assuming ten is the difference + -- between a SEA INCENSE that works and one that is a MYSTIC WATER. + local tn, td = HoldItems.damageMultiplier(attacker, move) + if tn ~= 1 or td ~= 1 then d = math.floor(d * tn / td) end + -- BLACK AND WHITE's held items on the finished damage: type items, + -- Life Orb, Expert Belt, gems, resist berries (src/battle/Gen5HoldItems.lua) + if attacker.items or defender.items then + d = require("src.battle.Gen5HoldItems").modifyDamage(attacker, defender, move, mult, d, special) + end + -- CHARGE: the user's NEXT Electric move is worth double. The flag is + -- spent here and cleared by the turn loop once the move has resolved, + -- so it survives a miss the way the cartridge's does. + local moveType = Abilities.normalizeType(move.type) + if attacker.charged and moveType == "ELECTRIC" then + d = d * 2 + end + -- MUD SPORT and WATER SPORT halve one type for the rest of the battle, + -- for BOTH sides -- they are a field effect, not a buff. opts.sports + -- carries them because Damage.compute never sees the field. + for _, muted in pairs(opts.sports or {}) do + if muted == moveType then d = math.floor(d / 2) end + end end end -- random factor; the typeless confusion self-hit skips RandomizeDamage -- along with AdjustDamageForMoveType (HandleSelfConfusionDamage calls -- CalculateDamage directly), so it is fully deterministic - -- Gen 3's critical multiplier lands here, after STAB and the type chart - -- and before the random factor, which is where pokeemerald applies it. - if crit and ruleset.critMultiplier then + -- Profiles without the Gen-3 ordering flag retain their existing final + -- multiplier. Gen 3 has already applied it before STAB/type rounding. + if crit and ruleset.critMultiplier and not ruleset.critBeforeType then d = math.floor(d * ruleset.critMultiplier) end - if d > 1 and not opts.typeless then + if d > 1 and not opts.typeless and not opts.gbaSetDamage then local r = rng(ruleset.randMin, ruleset.randMax) -- randDiv defaults to 255 because that is Gen 1 and Gen 2's denominator -- (r runs 217..255). Gen 3 rolls 85..100 out of 100; dividing that by -- 255 would cut every hit to roughly a third. d = math.floor(d * r / (ruleset.randDiv or 255)) end + if opts.gbaSetDamage then + return math.max(d, 0), { crit = false, typeMult = mult, eventTypeMult = 10, gbaSetDamage = true } + end return math.max(d, 1), { crit = crit, typeMult = mult } end @@ -808,4 +914,4 @@ function Damage.rollHit(accuracy, accuracyStage, evasionStage, random) return roll < value end -return Damage \ No newline at end of file +return Damage diff --git a/src/battle/EffectRegistry.lua b/src/battle/EffectRegistry.lua index 0f731f0aa..924dc6a8e 100644 --- a/src/battle/EffectRegistry.lua +++ b/src/battle/EffectRegistry.lua @@ -76,6 +76,10 @@ function EffectRegistry.makeCtx(battle, user, target, move, moveInst, isCalled) return battle:accuracyRoll(move, user, target) end, callMove = function(moveId) + if require("src.core.GameVersion").isGen3() then + moveId = require("src.battle.EnemyMoveChoice").resolveMove(battle.data, moveId) + if not moveId then return nil end + end return battle:performMove(user, target, { id = moveId, pp = 1 }, true) end, side = function(who) return battle:sideOf(who) end, @@ -96,14 +100,29 @@ local function hitCount(ctx, record) return dist[r + 1] end +local function ordinaryDamage(battle, ctx, record) + return battle:computeDamage(ctx.user, ctx.target, ctx.move, + { rng = battle.rng, explode = (record and record.explode) or nil, + powerMultiplier = record and record.powerMultiplier and record.powerMultiplier(ctx) or nil, + highCrit = record and record.highCrit or nil, + power = record and record.gbaTripleKick and 10 * (ctx.gbaHit or 1) or nil }) +end + -- The damaging pipeline, extracted from the performMove monolith: every --- stage keeps the original's exact check order and rng consumption --- (invulnerability -> gate -> hit count -> pre-accuracy -> accuracy -> --- damage choice -> hits -> messages -> after-damage -> secondary run). +-- stage keeps its generation's check order and RNG consumption. GBA draws +-- hit count after accuracy and computes ordinary damage separately per hit; +-- older generations retain their pre-accuracy count and shared damage roll. function EffectRegistry.runDamaging(battle, ctx, record) local user, target = ctx.user, ctx.target local move, moveInst = ctx.move, ctx.moveInst local neverMiss = record and record.neverMiss + local gba = require("src.core.GameVersion").isGen3() + local ordinaryMulti = gba and require("src.battle.Gen3MultiHit").ordinary(move) + local tripleKick = gba and record and record.gbaTripleKick + local nativeMulti = ordinaryMulti or tripleKick + if gba and type(target.substituteHP) == "number" and target.substituteHP <= 0 then + target.substituteHP = nil + end -- alwaysHits is neverMiss's weaker sibling and the distinction is CheckHit's -- own ordering: .FlyDigMoves is tested BEFORE .ThunderRain -- (effect_commands.asm:1559-1563), so Thunder in rain skips the accuracy @@ -115,13 +134,19 @@ function EffectRegistry.runDamaging(battle, ctx, record) -- accuracyRaw replaces the move's accuracy BYTE for this turn -- (BattleCommand_ThunderAccuracy), so it is a 0-255 threshold, not a percent local accuracyRaw = record and record.accuracyRaw and record.accuracyRaw(ctx) + local nativeAccuracy = gba and (not neverMiss and not alwaysHits + or move.gen3Effect == 17 or move.gen3Effect == 78 or move.gen3Effect == 152) -- PROTECT and DETECT, before anything else happens. Only a move the -- cartridge marks PROTECT-affected is stopped, which is the second use the -- extracted flags byte is put to; a move with no flags recorded (every Gen -- 1 and Gen 2 one) is treated as affected, which is that generation's rule. - if target.protecting and move.protectAffected ~= false then + local lockedOHKO = record and record.gbaOHKO + and require("src.battle.Gen3LockOn").active(battle, user, target) + if target.protecting and (not gba or target ~= user) and move.protectAffected ~= false and not lockedOHKO then + if gba and battle._gbaMoveFrame then battle._gbaMoveFrame.missed = true end battle:cancelMoveAnim() + if record and record.onProtect and record.onProtect(ctx) then return end battle:sayNext(Strings("%s\nprotected itself!", displayName(target))) return end @@ -130,8 +155,8 @@ function EffectRegistry.runDamaging(battle, ctx, record) -- SWIFT_EFFECT before the INVULNERABLE check), and so does SKY UPPERCUT, -- which reaches a target in the middle of FLY without being a never-miss -- move in any other respect -- hence the second, narrower field. - if target.invulnerable and not neverMiss - and not (record and record.neverMissInvulnerable) then + if target.invulnerable and not nativeAccuracy and not neverMiss + and not (record and record.neverMissInvulnerable) and not lockedOHKO then -- Explosion/Selfdestruct still animate on a miss (HandleIfPlayerMoveMissed) if not (record and record.explode) then battle:cancelMoveAnim() end missBeat(battle, record) @@ -151,16 +176,23 @@ function EffectRegistry.runDamaging(battle, ctx, record) if not ok then battle:cancelMoveAnim() if failMsg then battle:sayNext(failMsg) end + if ctx.gbaOHKOMiss and record.onMiss then record.onMiss(ctx, "accuracy") end return end end - local hits = hitCount(ctx, record) + local hits = not gba and hitCount(ctx, record) or 1 if record and record.beforeAccuracy then record.beforeAccuracy(ctx) end - if not neverMiss and not alwaysHits then - if not battle:accuracyRoll(move, user, target, accuracyRaw) then + if nativeAccuracy or not neverMiss and not alwaysHits then + local hit, reason = battle:accuracyRoll(move, user, target, accuracyRaw) + if not hit then + if reason == "absorbed" then + battle:cancelMoveAnim() + if record and record.onMiss then record.onMiss(ctx, "absorbed") end + return + end -- Explosion/Selfdestruct still animate on a miss (HandleIfPlayerMoveMissed) if not (record and record.explode) then battle:cancelMoveAnim() end missBeat(battle, record) @@ -172,6 +204,14 @@ function EffectRegistry.runDamaging(battle, ctx, record) end end + if gba and record and record.gbaAfterAccuracy then record.gbaAfterAccuracy(ctx) end + if gba then hits = hitCount(ctx, record) end + if gba and user.mon.hp <= 0 and not (record and record.explode) then + ctx.gbaNoEffect = true + if battle._gbaMoveFrame then battle._gbaMoveFrame.noEffect = true end + return + end + -- damage per hit local dmg, info if move.id == "COUNTER" then @@ -208,13 +248,7 @@ function EffectRegistry.runDamaging(battle, ctx, record) end dmg, info = chosen, extra or { crit = false, typeMult = 10 } else - dmg, info = battle:computeDamage(user, target, move, - { rng = battle.rng, explode = (record and record.explode) or nil, - -- a record whose EFFECT decides the power or the crit rate this - -- turn rather than the move's own byte - powerMultiplier = record and record.powerMultiplier - and record.powerMultiplier(ctx) or nil, - highCrit = record and record.highCrit or nil }) + dmg, info = ordinaryDamage(battle, ctx, record) end if battle.arenaOutcome then @@ -224,6 +258,7 @@ function EffectRegistry.runDamaging(battle, ctx, record) if info.typeMult == 0 and info.ability then battle.arenaOutcome.ability = true end end if info.typeMult == 0 then + if gba and battle._gbaMoveFrame then battle._gbaMoveFrame.noEffect = true end -- type immunity zeros damage and sets wMoveMissed in Gen 1, so no anim if not (record and record.explode) then battle:cancelMoveAnim() end missBeat(battle, record) @@ -241,6 +276,7 @@ function EffectRegistry.runDamaging(battle, ctx, record) return end if info.missed then + if gba and battle._gbaMoveFrame then battle._gbaMoveFrame.missed = true end -- 0.25x floored the damage to zero: the original registers a miss if not (record and record.explode) then battle:cancelMoveAnim() end missBeat(battle, record) @@ -248,9 +284,9 @@ function EffectRegistry.runDamaging(battle, ctx, record) if record and record.onMiss then record.onMiss(ctx, "floored") end return end - -- Focus Band belongs to the direct-damage seam below. It is evaluated per - -- damaging strike so a later hit of a multi-hit move can save a battler once - -- that particular strike becomes lethal; Substitute never consumes its RNG. + -- Focus Band belongs to the damage-adjustment seam below. GBA rolls on + -- every direct strike, including nonlethal/Substitute hits; older rules + -- keep their own eligibility. OHKO supplies its already evaluated result. battle.lastDamage = dmg -- wDamage (shared by both sides, read by Counter) -- the hit blink + damage sound ride each animation row, placed BEFORE @@ -277,6 +313,36 @@ function EffectRegistry.runDamaging(battle, ctx, record) local landed, brokeSub = 0, false for h = 1, hits do if target.mon.hp <= 0 then break end + if nativeMulti and (user.mon.hp <= 0 or h > 1 and user.mon.status == "SLP" + and not require("src.battle.Gen3MultiHit").sleepTalk(battle)) then break end + -- Unlike ordinary multi-hit moves, each reached native kick checks + -- accuracy again before its critical/damage opcodes. A later miss ends + -- the script quietly; already landed kicks still reach move-end (Rage). + if tripleKick and h > 1 and (nativeAccuracy or not neverMiss and not alwaysHits) then + local hit = battle:accuracyRoll(move, user, target, accuracyRaw) + if not hit then break end + end + ctx.gbaHit = h + if record and record.perHitDamage then + local nextDamage, nextInfo = record.perHitDamage(ctx, h, dmg, info) + if nextDamage == nil then break end + dmg, info = nextDamage, nextInfo or info + elseif nativeMulti and h > 1 + and not (record and record.chooseDamage) then + dmg, info = ordinaryDamage(battle, ctx, record) + if info.typeMult == 0 or info.missed then + ctx.gbaNoEffect = true + if info.typeMult == 0 then + if not (info.ability and battle.abilityAbsorb and battle:abilityAbsorb(target, move)) then + battle:sayNext(Strings("It doesn't affect\n%s!", displayName(target))) + end + else + battle:sayNext(Strings("%s's\nattack missed!", displayName(user))) + end + if record and record.onMiss then record.onMiss(ctx, info.typeMult == 0 and "immune" or "floored") end + break + end + end local hitRow if h == 1 then hitRow = battle.moveAnimRow @@ -305,6 +371,10 @@ function EffectRegistry.runDamaging(battle, ctx, record) animContext = hitCtx } table.insert(battle.queue, battle.nextInsert, hitRow) end + if gba then + require("src.battle.Gen3SubstitutePresentation").mark(battle, hitRow, user, + tripleKick and 4 - h or nativeMulti and hits - h + 1 or 0) + end local hadSub = target.substituteHP ~= nil local hitDamage = dmg if not hadSub then @@ -316,12 +386,22 @@ function EffectRegistry.runDamaging(battle, ctx, record) battle.lastDamage = hitDamage -- ...and the one applyDamage a Gen 3 FOCUS BAND answers: a move landing -- on someone - local dealt = battle:applyDamage(target, hitDamage, true) + local substituteBefore = target.substituteHP + local dealt, endured = battle:applyDamage(target, hitDamage, true, info.damageAdjustment) + if (info.gbaSetDamage or info.gbaBeatUp) and substituteBefore then dealt = math.min(dealt, substituteBefore) end + if gba then require("src.battle.Gen3PostHitItems").damage(battle, hitDamage, dealt, hadSub) end totalDealt = totalDealt + dealt + if gba and not hadSub and require("src.battle.Damage").isSpecial(move.type) then + ctx.gbaSpecialHit = true + end -- MIRROR COAT is COUNTER's special twin and needs the SPECIAL half of -- the damage kept separately; battle.lastDamage is shared by both and -- cannot answer for it. Cleared at the head of every turn. - if dealt > 0 then + if info.gbaBeatUp then + -- Scripted Beat Up records each strike, including Endure's zero HP + -- loss; Substitute strikes leave the previous counter memory alone. + if not hadSub then target.specialDamageTaken = dealt end + elseif dealt > 0 and not (hadSub and require("src.core.GameVersion").isGen3()) then if require("src.battle.Damage").isSpecial(move.type) then target.specialDamageTaken = (target.specialDamageTaken or 0) + dealt else @@ -348,20 +428,39 @@ function EffectRegistry.runDamaging(battle, ctx, record) elseif info.typeMult < 10 then battle:sayNext(Strings("It's not very\neffective...")) end - if Runtime.wants("battle.damage_dealt") then + -- GBA's zero-power default script never runs the HP-damage command. + -- Keep explicit custom/fixed damage and the earlier generations' events. + if Runtime.wants("battle.damage_dealt") and (dealt > 0 or move.power ~= 0 + or (record and record.chooseDamage) or not require("src.core.GameVersion").isGen3()) then + local eventCritical = info.crit + if info.gbaBeatUp then eventCritical = false end + local gba = require("src.core.GameVersion").isGen3() + local eventKind + if info.gbaBeatUp then eventKind = "special" + elseif gba then + local physical = info.physical + if physical == nil then physical = not require("src.battle.Damage").isSpecial(move.type) end + eventKind = physical and "physical" or "special" + end Runtime.emit("battle.damage_dealt", { battle = battle, user = user, target = target, move = move, - damage = dealt, crit = info.crit, typeMult = info.typeMult, + damage = dealt, crit = eventCritical, typeMult = info.eventTypeMult or info.typeMult, + substitute = (gba or info.gbaSetDamage or info.gbaBeatUp) and hadSub or nil, + kind = eventKind, }) end - if hadSub and not target.substituteHP then - -- AttackSubstitute: breaking the substitute ends a multi-hit move + if battle.firstBattle then + require("src.battle.Gen3Critical").noteHit(battle, user, target, dealt, hadSub) + end + if hadSub and not target.substituteHP and not info.gbaBeatUp then + -- Older AttackSubstitute ends the move; GBA continues against real HP. brokeSub = true - break + if not gba then break end end + if nativeMulti and endured then break end end hits = landed > 0 and landed or hits - if hits > 1 then + if hits > 1 and not info.gbaBeatUp then -- player: _MultiHitText; enemy: _HitXTimesText (always plural) if user.isPlayer then battle:sayNext(Strings("Hit the enemy\n%d times!", hits)) @@ -382,8 +481,11 @@ function EffectRegistry.runDamaging(battle, ctx, record) battle:applyDamage(user, recoil) end - -- secondary side effects (blocked by fainting) - if record and record.run and record.kind ~= "primary" + -- Native chance runs before effect-level faint/Substitute/ability guards. + local nativeSecondary = require("src.core.GameVersion").isGen3() + and require("src.battle.Gen3Secondary").run(ctx, record) + -- Other/custom records retain their existing post-damage handler. + if not nativeSecondary and record and record.run and record.kind ~= "primary" and target.mon.hp > 0 and totalDealt > 0 then for _, m in ipairs(record.run(ctx)) do battle:sayNext(m) @@ -393,6 +495,14 @@ function EffectRegistry.runDamaging(battle, ctx, record) MoveEffects.warnUnknown(move.effect) end + if gba then require("src.battle.Gen3Rage").moveEnd(battle, ctx, landed) end + + if gba and ctx.gbaSpecialHit and target ~= user and target.mon.hp > 0 + and target.mon.status == "FRZ" and require("src.battle.Abilities").normalizeType(move.type) == "FIRE" then + target.mon.status = nil + battle:sayNext(Strings("Fire defrosted\n%s!", displayName(target))) + end + -- King's Rock is an explicit command in selected Gen II move scripts. It -- rolls once after the completed attack (not once per hit) and cannot pass a -- Substitute. Some native-flinch scripts (notably Sky Attack and Snore) @@ -428,7 +538,13 @@ function EffectRegistry.runDamaging(battle, ctx, record) -- KING'S ROCK flinches the target, SHELL BELL feeds the attacker, and a -- SITRUS BERRY the hit dropped below half goes off immediately rather -- than waiting for the end of the turn. - if totalDealt > 0 and battle.shellBellDrain then + local gba = require("src.core.GameVersion").isGen3() + if gba then + local frame = battle._gbaMoveFrame + if frame then frame.noEffect = ctx.gbaNoEffect end + require("src.battle.GbaItemSchedule").moveEnd(battle) + end + if not gba and totalDealt > 0 and battle.shellBellDrain then local flinch = HoldItems.flinchChance(user, move) if flinch > 0 and target.mon.hp > 0 and not target.substituteHP and (battle.rng or love.math.random)(0, 99) < flinch @@ -436,7 +552,7 @@ function EffectRegistry.runDamaging(battle, ctx, record) target.flinched = true end battle:shellBellDrain(user, totalDealt) - if target.mon.hp > 0 then battle:holdItemTrigger(target, "hit") end + if not gba and target.mon.hp > 0 then battle:holdItemTrigger(target, "hit") end end if target.mon.hp <= 0 then diff --git a/src/battle/EnemyActionContext.lua b/src/battle/EnemyActionContext.lua new file mode 100644 index 000000000..1f9cc2643 --- /dev/null +++ b/src/battle/EnemyActionContext.lua @@ -0,0 +1,53 @@ +-- Keep the caller's omitted/explicit selector without changing native hook +-- arguments. Each synchronous hook/next call owns a frame, including reentry. +local C = {} +local frames = setmetatable({}, { __mode = "k" }) +local unpack = table.unpack or unpack +local function pack(...) + return { n = select("#", ...), ... } +end +function C.call(battle, selector, fn, ...) + local previous = frames[battle] + frames[battle] = { explicit = selector ~= nil, default = battle.enemy } + local result = pack(pcall(fn, ...)) + frames[battle] = previous + if not result[1] then + error(result[2], 0) + end + return unpack(result, 2, result.n) +end +local function actorAt(battle, id) + if id == 0 then + return battle.player + end + if id == 1 then + return battle.enemy + end + return battle:battlerAt(id) +end +function C.publicId(battle, actor) + local frame = frames[battle] + if actor == nil or (frame and not frame.explicit and actor == frame.default) then + return nil + end + local id = actor == battle.player and 0 or actor == battle.enemy and 1 or actor.position + assert( + type(id) == "number" and id % 1 == 0 and id >= 0 and id <= 3, + "enemy action requires a positional battler" + ) + assert(actorAt(battle, id) == actor, "enemy action battler is no longer active") + return id +end +function C.nativeActor(battle, id) + if id == nil then + return battle.enemy + end + assert( + type(id) == "number" and id % 1 == 0 and id >= 0 and id <= 3, + "enemy action battler ID must be an integer from 0 to 3" + ) + local actor = actorAt(battle, id) + assert(actor, "enemy action battler is not active") + return actor +end +return C diff --git a/src/battle/EnemyMoveChoice.lua b/src/battle/EnemyMoveChoice.lua new file mode 100644 index 000000000..0e6660f5b --- /dev/null +++ b/src/battle/EnemyMoveChoice.lua @@ -0,0 +1,253 @@ +-- Public GBA move choices are action records, not the learned move slot. +-- A separate action can change its move/target without rewriting party data; +-- its PP still belongs to the selected native slot (including Pressure). +local Context = require("src.battle.EnemyActionContext") +local M = {} +local nativeViews = setmetatable({}, { __mode = "k" }) +local publicReferences = setmetatable({}, { __mode = "k" }) +local slots = setmetatable({}, { __mode = "k" }) +local pendingSelections = setmetatable({}, { __mode = "k" }) +local portableViews = setmetatable({}, { __mode = "k" }) +local function shallow(row) + local out = {} + for k, v in pairs(row) do + out[k] = v + end + return out +end +local function same(a, b) + for k, v in pairs(a) do + if b[k] ~= v then + return false + end + end + for k, v in pairs(b) do + if a[k] ~= v then + return false + end + end + return true +end +local function keyName(value) + return type(value) == "string" and value:upper():gsub("[%s%-]+", "_") or nil +end +local function resolve(data, ref) + if type(ref) ~= "string" and type(ref) ~= "number" then + return nil + end + local direct = data.moves[ref] + if type(direct) == "table" then + return ref, direct + end + local number = tonumber(ref) + if number and (number ~= number or number % 1 ~= 0 or number < 1 or number > 65535) then + return nil + end + local name, found, row = keyName(ref) + for id, def in pairs(data.moves) do + if type(def) == "table" then + local canonical = type(id) == "string" and not id:match("^MOVE_%d+$") and id or def.name + if + (number and (def.index == number or def.numId == number)) + or (name and keyName(canonical) == name) + then + if row and row ~= def then + return nil + end -- ambiguous names never select by iteration order + if found == nil or (def.id == id) or tostring(id) < tostring(found) then + found = id + end + row = def + end + end + end + return found, row +end +M.resolveMove = resolve +local function bind(action, slot) + slots[action] = slot + action.pp = nil + return setmetatable(action, { + __index = function(_, k) + if k == "pp" then + return slot.pp + end + end, + __newindex = function(t, k, v) + if k == "pp" then + slot.pp = v + else + rawset(t, k, v) + end + end, + }) +end +function M.slot(action) + return slots[action] or action +end +function M.withMove(action, id, forcedSlot, forcedIndex) + local copy = shallow(action) + copy.id = id + if nativeViews[action] then + nativeViews[copy] = shallow(nativeViews[action]) + if forcedIndex then + nativeViews[copy].slot = forcedIndex + end + end + if forcedIndex then + copy.slot = forcedIndex + end + publicReferences[copy] = publicReferences[action] + return bind(copy, forcedSlot or M.slot(action)) +end +function M.resolveSelection(action) + if type(action) ~= "table" then + return action + end + local selected = pendingSelections[action] + pendingSelections[action] = nil + if selected and selected.id == action.id then + return M.withMove(selected, selected.id, action, selected.slot) + end + return action +end +function M.exhausted(action) + return slots[action] ~= nil and action.pp <= 0 +end +function M.nativeView(action) + return nativeViews[action] +end +function M.markPortable(action) + portableViews[action] = true +end +function M.rememberReference(action, key, public, native) + local refs = publicReferences[action] + if not refs then + refs = {} + publicReferences[action] = refs + end + refs[key] = { public = public, native = native } +end +function M.publicReference(action, key) + local ref = publicReferences[action] and publicReferences[action][key] + if ref then + return ref.public, action[key] == ref.native, true + end +end +function M.delegate(battle, battler, action) + if type(action) ~= "table" or action.special then + return action + end + local slotIndex + for i, slot in ipairs(battler.curMoves or {}) do + if M.slot(action) == slot then + slotIndex = i + break + end + end + local function describe(value) + if value.id ~= nil then + local known, requested = pcall(Context.publicId, battle, battler) + if not known then + return value + end -- legacy native AI queries may use a detached actor + nativeViews[value] = { + slot = slotIndex, + battler = battle:isDouble() and (requested or 1) or requested ~= nil and 1 or nil, + double = battle:isDouble(), + } + end + return value + end + if action == battler.charging or action == battler.thrashMove or action == battler.rageMove then + return describe(action) + end + for i, slot in ipairs(battler.curMoves or {}) do + -- Native wild selection already returns a separate PP-bound action. + -- A delegated edit must retain that selected slot, just like a raw slot. + if M.slot(action) == slot then + local copy = shallow(action) + copy.slot = i + return describe(copy), + { + raw = action, + before = shallow(copy), + owner = battler, + originalSlot = action ~= slot and slot or nil, + } + end + end + return describe(action) +end +function M.normalize(data, battler, value, delegated, captured) + if captured and value == delegated then + if captured.owner == battler and same(value, captured.before) then + if captured.originalSlot and not portableViews[value] then + -- Existing API-1/2 callbacks retain their native slot handle. Keep + -- selection-only fields private until planning/execution consumes it. + pendingSelections[captured.originalSlot] = shallow(captured.raw) + return captured.originalSlot + end + return captured.raw + end + local id = resolve(data, value.move or value.id) + if not id then + return captured.raw + end + local action = shallow(value) + action.id, action.move = id, nil + local slot = (battler.curMoves or {})[action.slot] + if type(slot) == "table" then + return bind(action, slot) + end + action.pp = 0 -- a delegated action without a slot has no party PP to spend + return action + end + if type(value) == "string" or type(value) == "number" then + value = { move = value } + end + if type(value) ~= "table" then + return nil + end + -- Legacy native handles and item/switch/continuation actions retain identity. + if + value.special + or value == battler.charging + or value == battler.thrashMove + or value == battler.rageMove + then + return value + end + for _, slot in ipairs(battler.curMoves or {}) do + if value == slot then + return value + end + end + if value.kind ~= nil and value.kind ~= "move" then + return nil + end + local id = resolve(data, value.move or value.id) + if not id then + return nil + end + local action = shallow(value) + action.id, action.move = id, nil + local moves = battler.curMoves or {} + local slot = moves[action.slot] + if not (type(slot) == "table" and slot.id == id) then + action.slot, slot = nil, nil + for i, move in ipairs(moves) do + if move.id == id then + action.slot, slot = i, move + break + end + end + end + if slot then + return bind(action, slot) + end + -- Gen1 permits a declared move that isn't learned: it runs without party PP. + action.pp = 0 + return action +end +return M diff --git a/src/battle/GbaItemSchedule.lua b/src/battle/GbaItemSchedule.lua new file mode 100644 index 000000000..9502b4e94 --- /dev/null +++ b/src/battle/GbaItemSchedule.lua @@ -0,0 +1,138 @@ +-- Gen-3 item phases. Reads are deliberately not coalesced: a mod may change +-- the answer between move-end, normal turn and after-action comparisons. +local Schedule = {} + +function Schedule.battlers(battle) + local list = {} + for _, b in battle:activeBattlers() do + list[#list + 1] = b + end + if battle.linkRole == "guest" and #list == 2 and not battle:isDouble() then + list[1], list[2] = list[2], list[1] + end + return list +end + +function Schedule.initialLinkEntry(battle, holder) + local pending = battle._gbaInitialItemEntries + if not pending then + return nil + end + pending[holder] = true + if not (pending[battle.player] and pending[battle.enemy]) then + return false + end + battle._gbaInitialItemEntries = nil + local activated = false + for _, actor in ipairs(Schedule.battlers(battle)) do + if battle:holdItemTrigger(actor, "entry") then + activated = true + end + end + return activated +end + +function Schedule.moveEnd(battle, completedFrame) + local frame = completedFrame or battle._gbaMoveFrame + if frame then + require("src.battle.Gen3ChoiceBand").moveEnd(battle, frame) + frame.finished = true + end + for _, b in ipairs(Schedule.battlers(battle)) do + battle:holdItemTrigger(b, "moveEnd") + end + require("src.battle.Gen3PostHitItems").run(battle, frame) + require("src.battle.Gen3MirrorMove").history(battle, frame) +end + +function Schedule.afterAction(battle) + if battle.result then + return + end + -- battle_util.c ItemBattleEffects: restart at the first slot after a + -- successful activation, at most four passes. Leftovers is not eligible. + for _ = 1, 4 do + local did = false + for _, b in ipairs(Schedule.battlers(battle)) do + if battle:holdItemTrigger(b, "action") then + did = true + break + end + end + if not did then + break + end + end +end + +function Schedule.residualOrder(battle) + local list, speeds = Schedule.battlers(battle), {} + local TurnOrder = require("src.battle.TurnOrder") + for _, b in ipairs(list) do + speeds[b] = TurnOrder.effectiveSpeed(b, battle) + end + local priorities = (battle:isDouble() and battle._gbaResidualPriority) or {} + local rng = battle.rng or love.math.random + -- Match the reference's pairwise selection sort and tie rolls. Compute + -- this once, before weather/items can change speed or remove a battler. + for i = 1, #list - 1 do + for j = i + 1, #list do + local a, b = list[i], list[j] + local pa, pb = priorities[a.position] or 0, priorities[b.position] or 0 + local swap + if pa ~= pb then + swap = pa < pb + elseif speeds[a] == speeds[b] then + swap = rng(0, 1) == 1 + else + swap = speeds[a] < speeds[b] + end + if swap then + list[i], list[j] = b, a + end + end + end + return list +end + +function Schedule.residuals(battle) + local order = Schedule.residualOrder(battle) + -- Wish is a field phase, before weather; Ingrain belongs to each holder. + for _, b in ipairs(battle:isDouble() and order or Schedule.battlers(battle)) do + battle:gen3WishTick(b) + end + require("src.battle.Weather").upkeep(battle, order) + for _, b in ipairs(order) do + if battle.result then + break + end + if b.mon.hp > 0 then + battle:gen3IngrainTick(b) + battle:abilityEndOfTurn(b) + battle:holdItemTrigger(b, "turn") + local opponent = battle:foesOf(b)[1] + local messages, seedSource = require("src.battle.Status").residual(b, opponent, battle) + for _, message in ipairs(messages) do + battle:sayNext(message) + end + if #messages > 0 then + battle:drainNext() + end + if seedSource then + seedSource.destinyBond, seedSource.grudge = nil, nil + battle:onFaint(seedSource) + end + if b.mon.hp <= 0 then + b.destinyBond, b.grudge = nil, nil + battle:onFaint(b) + else + battle:gen3BattlerCountersTick(b, true) + end + end + end + -- The native fainted-action/item sweep precedes delayed attacks and Perish. + Schedule.afterAction(battle) + battle:gen3DelayedCountersTick() +end + +return Schedule diff --git a/src/battle/Gen3AI.lua b/src/battle/Gen3AI.lua index 42fe0fd4c..475b862bd 100644 --- a/src/battle/Gen3AI.lua +++ b/src/battle/Gen3AI.lua @@ -555,6 +555,7 @@ local function status2(b) if b.confusedTurns then v = v + 0x7 end if b.infatuated then v = v + 0xF0000 end if b.focusEnergy then v = v + 0x100000 end + if b.rage then v = v + 0x800000 end if b.substituteHP then v = v + 0x1000000 end if b.trappingTurns or b.boundTurns then v = v + 0x4000000 end if b.nightmare then v = v + 0x8000000 end @@ -652,14 +653,14 @@ end OPS["if_any_move_disabled_or_encored"] = function(vm, args) local b = who(vm, args[1]) if not b then return end - local hit = (args[2] == 0) and b.disabledSlot ~= nil + local hit = (args[2] == 0) and require("src.battle.Gen3Disable").has(b) or (args[2] == 1) and b.encoreTurns ~= nil if hit then return args[#args] end end OPS["if_curr_move_disabled_or_encored"] = function(vm, args) local b = vm.user - local hit = (args[1] == 0) and b and b.disabledSlot == vm.moveIndex + local hit = (args[1] == 0) and b and require("src.battle.Gen3Disable").blocked(b, (b.curMoves or {})[vm.moveIndex], vm.moveIndex) or (args[1] == 1) and b and b.encoreTurns ~= nil if hit then return args[#args] end end @@ -679,6 +680,9 @@ end -- Running one script over one move -- --------------------------------------------------------------------------- +require("src.battle.Gen3AIItemKnowledge").install(Gen3AI) +require("src.battle.Gen3AIOrder").install(Gen3AI) + -- Every opcode a script can reach, followed through its branches. Memoised -- on the program table: the answer depends only on the cartridge. function Gen3AI.reachable(program, entry) diff --git a/src/battle/Gen3AIItemKnowledge.lua b/src/battle/Gen3AIItemKnowledge.lua new file mode 100644 index 000000000..d9bc816f5 --- /dev/null +++ b/src/battle/Gen3AIItemKnowledge.lua @@ -0,0 +1,111 @@ +-- Native GBA AI item reads and battle-long observed-effect bytes. +local K = {} +local function emerald() + return require("src.core.GameVersion").get() == "emerald" +end +local EFFECTS = {} +local i = 0 +for name in + ([[RESTORE_HP CURE_PAR CURE_SLP CURE_PSN CURE_BRN CURE_FRZ RESTORE_PP +CURE_CONFUSION CURE_STATUS CONFUSE_SPICY CONFUSE_DRY CONFUSE_SWEET CONFUSE_BITTER +CONFUSE_SOUR ATTACK_UP DEFENSE_UP SPEED_UP SP_ATTACK_UP SP_DEFENSE_UP CRITICAL_UP +RANDOM_STAT_UP EVASION_UP RESTORE_STATS MACHO_BRACE EXP_SHARE QUICK_CLAW FRIENDSHIP_UP +CURE_ATTRACT CHOICE_BAND FLINCH BUG_POWER DOUBLE_PRIZE REPEL SOUL_DEW DEEP_SEA_TOOTH +DEEP_SEA_SCALE CAN_ALWAYS_RUN PREVENT_EVOLVE FOCUS_BAND LUCKY_EGG SCOPE_LENS STEEL_POWER +LEFTOVERS DRAGON_SCALE LIGHT_BALL GROUND_POWER ROCK_POWER GRASS_POWER DARK_POWER +FIGHTING_POWER ELECTRIC_POWER WATER_POWER FLYING_POWER POISON_POWER ICE_POWER GHOST_POWER +PSYCHIC_POWER FIRE_POWER DRAGON_POWER NORMAL_POWER UP_GRADE SHELL_BELL LUCKY_PUNCH +METAL_POWDER THICK_CLUB STICK]]):gmatch("%S+") +do + i = i + 1 + EFFECTS[name] = i +end +local function defFor(battle, item) + local defs = battle.data and battle.data.items or {} + if defs[item] then + return defs[item] + end + local numeric = tonumber(item) + if not numeric then + return nil + end + for _, def in pairs(defs) do + if type(def) == "table" and tonumber(def.index) == numeric then + return def + end + end +end +function K.item(battle, b) + local value = b and b.mon and (b.mon.item or b.mon.heldItem) + local def = value and defFor(battle, value) + return tonumber(def and def.index) or tonumber(value) or 0 +end +function K.raw(battle, item) + if item == 0 then + return 0 + end + local def = defFor(battle, item) + if not def then + return 0 + end + return tonumber(def.gen3HoldEffect) or tonumber(def.holdEffect) or EFFECTS[def.holdEffect] or 0 +end +function K.record(battle, b, effect) + if not require("src.core.GameVersion").isGen3() then + return + end + local id = b and b.position + if id == nil then + return + end + if not emerald() then + if id % 2 ~= 0 then + return + end + id = 0 + end + battle._aiHistory = battle._aiHistory or {} + local h = battle._aiHistory + h.itemEffects = h.itemEffects or {} + h.itemEffects[id] = effect +end +function K.observed(battle, b) + local h = battle._aiHistory + return tonumber(h and h.itemEffects and b and h.itemEffects[b.position]) or 0 +end +function K.get(vm, which) + local b = which == 1 and vm.user or vm.target + if not b then + return 0 + end + if emerald() then + local item = b.position == vm.user.position and K.item(vm.battle, b) or K.observed(vm.battle, b) + return K.raw(vm.battle, item) + end + if b.position and b.position % 2 == 0 then + local h = vm.battle._aiHistory + return tonumber(h and h.itemEffects and h.itemEffects[0]) or 0 + end + return K.raw(vm.battle, K.item(vm.battle, b)) +end +function K.install(ai) + ai.OPS.get_hold_effect = function(vm, args) + vm.result = K.get(vm, args[1]) + end + ai.OPS.if_holds_item = function(vm, args) + local b = ai.who(vm, args[1]) + local item = 0 + if b and b.position % 2 == vm.user.position % 2 then + item = K.item(vm.battle, b) + else + item = K.observed(vm.battle, b) + end + -- Native retail byte OR, retained instead of inventing a corrected word. + local value = tonumber(args[2]) or 0 + local bit = require("bit") + if bit.bor(value % 256, math.floor(value / 256) % 256) == item then + return args[#args] + end + end +end +return K diff --git a/src/battle/Gen3AIOrder.lua b/src/battle/Gen3AIOrder.lua new file mode 100644 index 000000000..ecc0e369d --- /dev/null +++ b/src/battle/Gen3AIOrder.lua @@ -0,0 +1,146 @@ +-- Native order snapshots use physical item metadata; actual sorting retains hooks. +local O = {} +local C = require("src.battle.Gen3OrderCore") +local bit = require("bit") +local ABILITIES = { CLOUD_NINE = 13, AIR_LOCK = 76, SWIFT_SWIM = 33, CHLOROPHYLL = 34 } +local function abilityId(value) + return tonumber(value) or ABILITIES[tostring(value or ""):upper():gsub("%s+", "_")] or 0 +end +local function berryOf(b, battle, id) + return b.enigmaBerry or battle.enigmaBerries and battle.enigmaBerries[id] or {} +end +function O.prepare(battle, turn, rng) + turn = turn or battle.turn or battle.turnCount or 0 + if not battle._nativeOrderReady or battle.randomTurnNumber == nil then + battle.randomTurnNumber = math.floor((rng or battle.rng or math.random)(0, 65535)) % 65536 + battle._nativeOrderTurn = turn + battle._nativeOrderReady = true + end + return battle.randomTurnNumber +end +function O.release(battle) + battle._nativeOrderReady = false +end +local function definition(b, battle, value) + local items = b.items or battle.data and battle.data.items or {} + if items[value] then + return items[value] + end + local number = tonumber(value) + if number then + for _, row in pairs(items) do + if type(row) == "table" and tonumber(row.index) == number then + return row + end + end + end +end +function O.hold(b, battle) + local mon = b.mon or {} + local value = mon.item or mon.heldItem or 0 + local def = definition(b, battle, value) + local item = tonumber(def and def.index) or tonumber(value) or 0 + if item == 175 then + local berry = berryOf(b, battle, b.position) + return tonumber(berry.holdEffect) or 0, tonumber(berry.holdEffectParam) or 0, item + end + def = def or {} + return tonumber(def.gen3HoldEffect) or tonumber(def.holdEffect) or C.effectIds[def.holdEffect] or 0, + tonumber(def.holdEffectParam) or 0, + item +end +local function ability(b) + return abilityId(require("src.battle.Abilities").of(b)) +end +local function badge(battle) + if type(battle.badges) == "table" then + return battle.badges[3] == true + end + if type(battle.badges) == "number" then + return bit.band(battle.badges, 4) ~= 0 + end + local constants = battle.data and battle.data.constants or {} + local flag = constants.gen3BadgeFlags and constants.gen3BadgeFlags[3] + local flags = battle.game and battle.game.save and battle.game.save.flags or {} + return flag ~= nil and flags[("FLAG_G3_%04X"):format(flag)] == true +end +function O.field(battle) + local kinds = battle.kinds or {} + local field = { + weather = C.weather(battle.weather or battle.field and battle.field.weather), + weatherActive = true, + turnWord = (tonumber(battle.randomTurnNumber) or 0) % 65536, + badge = badge(battle), + emerald = require("src.core.GameVersion").get() == "emerald", + link = battle.link == true, + frontier = battle.gen3Frontier == true or kinds.frontier == true, + recordedLink = kinds.recordedLink == true, + } + for id = 0, battle.double and 3 or 1 do + local b = battle.battlerAt and battle:battlerAt(id) + or battle.battlers and battle.battlers[id] + or (id == 0 and battle.player or id == 1 and battle.enemy) + if b and b.mon and (tonumber(b.mon.hp) or 0) > 0 then + local ab = ability(b) + if ab == 13 or ab == 76 then + field.weatherActive = false + end + end + end + return field +end +function O.snapshot(b, battle, actual) + local mon = b.mon or {} + local effect, parameter = O.hold(b, battle) + if actual then + local name + name, parameter = require("src.battle.HoldItems").effectOf( + b, + "priority", + { effect = C.effectNames[effect], parameter = parameter } + ) + effect = C.effectIds[name] or 0 + end + return { + speed = tonumber(b.curStats and b.curStats.speed) or tonumber(mon.stats and mon.stats.speed) or tonumber( + mon.speed or mon.spe + ) or 0, + stage = b.stages and b.stages.speed or 0, + ability = ability(b), + status = mon.status, + effect = effect, + parameter = parameter, + player = b.position ~= nil and b.position % 2 == 0 or b.isPlayer == true, + } +end +function O.speed(b, battle, actual) + local adjust + if actual then + local record = require("src.battle.Status").recordFor(b.statuses, b.mon and b.mon.status) + local penalty = record and record.statPenalty + if penalty and penalty.stat == "speed" and not C.paralysed(b.mon.status) then + adjust = function(speed) + return math.max(1, math.floor(speed / penalty.div)) + end + end + end + return C.speed(O.snapshot(b, battle, actual), O.field(battle), adjust) +end +function O.result(vm) + return C.result(O.speed(vm.user, vm.battle), O.speed(vm.target, vm.battle), function() + return vm.rng(0, 65535) + end) +end +function O.install(ai) + ai.OPS.if_user_goes = function(vm, args) + if O.result(vm) == (args[1] or 0) then + return args[#args] + end + end + ai.OPS.if_user_doesnt_go = function(vm, args) + if O.result(vm) ~= (args[1] or 0) then + return args[#args] + end + end +end +return O diff --git a/src/battle/Gen3Accuracy.lua b/src/battle/Gen3Accuracy.lua new file mode 100644 index 000000000..d7d95dda0 --- /dev/null +++ b/src/battle/Gen3Accuracy.lua @@ -0,0 +1,135 @@ +-- Native GBA accuracy command; older generations keep their byte model. +local Abilities = require("src.battle.Abilities") +local Runtime = require("src.mods.Runtime") +local A = {} +local STAGE = { + [-6] = 33, + [-5] = 36, + [-4] = 43, + [-3] = 50, + [-2] = 60, + [-1] = 75, + [0] = 100, + 133, + 166, + 200, + 233, + 266, + 300, +} +local function hasFlag(battle, flag) + return math.floor((tonumber(battle.oakMsgFlags) or 0) / flag) % 2 == 1 +end +function A.guaranteed(battle, user, target, move) + if battle.firstBattle and user.isPlayer then + if not hasFlag(battle, (tonumber(move.power) or 0) > 0 and 1 or 2) then + return true + end + end + return battle.pokedudeDemo ~= nil and battle.pokedudeDemo ~= false + or battle.pokedude == true + or require("src.battle.Gen3LockOn").active(battle, user, target) +end +local function absorbed(battle, user, target, move) + if target == user then + return false + end + local ability, kind = Abilities.of(target), Abilities.normalizeType(move.type) + local can = (tonumber(move.power) or 0) ~= 0 + and (ability == "VOLT_ABSORB" and kind == "ELECTRIC" or ability == "WATER_ABSORB" and kind == "WATER") + or ability == "FLASH_FIRE" and kind == "FIRE" and target.mon.status ~= "FRZ" + if can then + battle:abilityAbsorb(target, move) + return true + end + return false +end +function A.check(battle, move, user, target, accuracyRaw) + if target ~= user and target.protecting and move.protectAffected ~= false then + return false, "protected" + end + local guaranteed = A.guaranteed(battle, user, target, move) + -- Authored legacy bindings keep their one-use lifetime. The native + -- producer stores a separate target-owned timer and is not consumed here. + if user.lockedOn == target then + user.lockedOn = nil + end + local effect = tonumber(move.gen3Effect) + if not guaranteed and target ~= user and target.invulnerable then + local hidden = target.hiddenAs + local air = hidden == "air" or hidden == "ON_AIR" + local number = tonumber(move.index) + local reaches = air and (effect == 152 or effect == 207 or number == 16 or number == 239) + or (hidden == "underground" or hidden == "UNDERGROUND") and (number == 89 or number == 222) + or (hidden == "underwater" or hidden == "UNDERWATER") and (number == 57 or number == 250) + if not reaches then + return false, "invulnerable" + end + end + local sky = require("src.battle.Gen3BattleWeather").effective(battle) + if guaranteed or effect == 17 or effect == 78 or effect == 152 and sky == "RAIN" then + if absorbed(battle, user, target, move) then + return false, "absorbed" + end + return true + end + local buff = (user.stages and user.stages.accuracy or 0) + - (target.identified and 0 or target.stages and target.stages.evasion or 0) + buff = math.max(-6, math.min(6, buff)) + local base = tonumber(move.accuracy) or 100 + if effect == 152 and sky == "SUN" then + base = 50 + elseif accuracyRaw ~= nil then + base = accuracyRaw * 100 / 255 + end + local calc = math.floor(STAGE[buff] * base / 100) + local mine = Abilities.of(user) + if mine == "COMPOUND_EYES" or mine == "COMPOUNDEYES" then + calc = math.floor(calc * 130 / 100) + end + if sky == "SAND" and Abilities.of(target) == "SAND_VEIL" then + calc = math.floor(calc * 80 / 100) + end + if + mine == "HUSTLE" + and require("src.battle.TypeChart").category(Abilities.normalizeType(move.type)) == "physical" + then + calc = math.floor(calc * 80 / 100) + end + local held, parameter = require("src.battle.HoldItems").effectOf(target, "accuracy") + if held == "EVASION_UP" then + calc = math.floor(calc * (100 - parameter) / 100) + end + local function draw(c) + -- Native accuracy uses the live battle stream; the public rng field is + -- informational, while c.accuracy is the steerable threshold. + return battle.rng(1, 100) <= c.accuracy + end + local hit + if Runtime.wantsHook("battle.accuracy") then + hit = Runtime.call("battle.accuracy", draw, { + battle = battle, + move = move, + moveId = move.id, + moveNum = tonumber(move.index), + user = user, + target = target, + accuracy = calc, + rng = battle.rng, + ruleset = battle.ruleset, + data = battle.data, + accuracyRaw = accuracyRaw, + weather = sky, + }) + else + hit = draw({ accuracy = calc }) + end + if not hit then + return false + end + if absorbed(battle, user, target, move) then + return false, "absorbed" + end + return true +end +return A diff --git a/src/battle/Gen3Assist.lua b/src/battle/Gen3Assist.lua new file mode 100644 index 000000000..5f8e751b5 --- /dev/null +++ b/src/battle/Gen3Assist.lua @@ -0,0 +1,99 @@ +-- Native Assist keeps one candidate per eligible party move occurrence. +local A = {} +local Strings = require("src.core.Strings") +local BLOCKED = { + [118] = true, + [165] = true, + [166] = true, + [102] = true, + [68] = true, + [243] = true, + [182] = true, + [197] = true, + [203] = true, + [194] = true, + [214] = true, + [168] = true, + [266] = true, + [289] = true, + [270] = true, + [343] = true, + [271] = true, + [264] = true, + [119] = true, + [274] = true, +} +function A.party(battle, user) + local party + if user.isPlayer then + party = battle.playerParty or battle.game and battle.game.save and battle.game.save.party or {} + else + party = battle.enemyParty or { user.mon } + if battle.enemyPartyB then + local combined = {} + for _, m in ipairs(party) do + combined[#combined + 1] = m + end + for _, m in ipairs(battle.enemyPartyB) do + combined[#combined + 1] = m + end + party = combined + end + end + local index + for i, mon in ipairs(party) do + if mon == user.mon then + index = i + break + end + end + return party, index or user.partyIndex +end +function A.pool(battle, user) + local party, own = A.party(battle, user) + local pool = {} + for i, mon in ipairs(party) do + if i ~= own and mon and mon.species and mon.species ~= 0 and not mon.isEgg then + for slot = 1, 4 do + local inst = mon.moves and mon.moves[slot] + local ref = type(inst) == "table" and inst.id or inst + local id, def = require("src.battle.EnemyMoveChoice").resolveMove(battle.data, ref) + local n = tonumber(ref) or def and tonumber(def.index or def.numId) or id + if n and n ~= 0 and not BLOCKED[n] then + pool[#pool + 1] = id or ref + end + end + end + end + return pool +end +function A.pick(ctx) + local b = ctx.battle + local frame = b._gbaMoveFrame + local pool = frame and frame.assistPool or A.pool(b, ctx.user) + if #pool == 0 then + if frame then + frame.finished, frame.nativeNoHistory = true, true + end + ctx.say(Strings("But, it failed!")) + return nil + end + b.nextInsert = (b.nextInsert or 0) + 1 + local row = { + anim = ctx.move.id, + attackerIsPlayer = ctx.user.isPlayer, + attackerPosition = ctx.user.position, + targetPosition = ctx.target and ctx.target.position, + animContext = { moveTurn = 0 }, + } + table.insert(b.queue, b.nextInsert, row) + b.moveAnimRow = row + return pool[ctx.rng(1, #pool)] +end +function A.call(ctx, pick) + return require("src.battle.Gen3CalledMove").call(ctx, pick) +end +function A.record() + return { kind = "full", announceAnim = false, gbaAssist = true, callsMove = A.pick } +end +return A diff --git a/src/battle/Gen3BatonPass.lua b/src/battle/Gen3BatonPass.lua new file mode 100644 index 000000000..c557a1c2a --- /dev/null +++ b/src/battle/Gen3BatonPass.lua @@ -0,0 +1,404 @@ +-- Native Baton Pass owns a resumable choice, not an ordinary switch action. +local M = {} +local Runtime = require("src.mods.Runtime") +local Strings = require("src.core.Strings") +local Party = require("src.pokemon.Party") +local cache = setmetatable({}, { __mode = "k" }) +local TRANSFER = { + "confusedTurns", + "focusEnergy", + "substituteHP", + "leechSeeded", + "leechSeedSource", + "ingrained", + "perishTurns", + "cursed", + "trapped", + "escapePrevention", +} +function M.signature(user, role) + local out = {} + for _, key in ipairs(TRANSFER) do + local value = user[key] + if key == "leechSeedSource" and type(value) == "table" then + value = value.position + if role == "guest" and type(value) == "number" then + value = value % 2 == 0 and value + 1 or value - 1 + end + end + out[#out + 1] = tostring(value) + end + for _, key in ipairs({ "spatk", "spdef" }) do + out[#out + 1] = tostring((user.stages or {})[key] or 0) + end + return ":baton=" .. table.concat(out, ",") +end +function M.party(battle, user) + if battle._gen3BatonParty then + return battle:_gen3BatonParty(user) + end + return battle:benchFor(user.isPlayer, user.position) +end +function M.candidates(battle, user) + local party, excluded = M.party(battle, user), {} + for _, other in battle:activeBattlers() do + excluded[other.mon] = true + end + for pos, action in pairs(battle.pendingActions or {}) do + if pos ~= user.position and type(action) == "table" then + if action.special == "playerSwitch" and action.mon then + excluded[action.mon] = true + end + if action.special == "aiSwitch" and action.index then + local other = battle:battlerAt(pos) + local mon = other and M.party(battle, other)[action.index] + if mon then + excluded[mon] = true + end + end + end + end + for pos, index in pairs(battle.monToSwitchInto or {}) do + local other = battle:battlerAt(pos) + if pos ~= user.position and other then + local mon = M.party(battle, other)[index] + if mon then + excluded[mon] = true + end + end + end + local out = {} + for i, mon in ipairs(party) do + if not excluded[mon] and (mon.hp or 0) > 0 and not Party.isEgg(mon) then + out[#out + 1] = i + end + end + return out, party +end +local function typeRows(battle, attack, defending, value, damage) + for _, row in ipairs((battle.data.type_chart or {}).matchups or {}) do + if row.attacker == attack then + for _, kind in ipairs(defending) do + if row.defender == kind then + value = math.floor(value * row.multiplier / 10) + if damage and value == 0 and row.multiplier ~= 0 then + value = 1 + end + break + end + end + end + end + return value +end +function M.enemyChoice(battle, user, candidates, party) + local pos = user.position or (user.isPlayer and 0 or 1) + local pending = battle.monToSwitchInto or {} + if pending[pos] then + return pending[pos] + end + local opposite = pos % 2 == 0 and pos + 1 or pos - 1 + if battle:isDouble() then + opposite = (math.floor(battle.rng(0, 65535) / 2) % 2) * 2 + (1 - pos % 2) + if not battle:battlerAt(opposite) then + opposite = opposite < 2 and opposite + 2 or opposite - 2 + end + end + local foe = battle:battlerAt(opposite) + if not foe then + return nil + end + local types = foe.curTypes or {} + local t1, t2 = types[1], types[2] or types[1] + local allowed, invalid = {}, {} + for _, index in ipairs(candidates) do + allowed[index] = true + end + while true do + local best, amount = nil, 0 + for i = 1, 6 do + local mon = party[i] + if allowed[i] and not invalid[i] then + local def = battle.data.pokemon[mon.species] or {} + local value = typeRows(battle, t1, def.types or {}, 10) + value = typeRows(battle, t2, def.types or {}, value) + if amount < value then + best, amount = i, value + end + end + end + if not best then + break + end + for _, inst in ipairs(party[best].moves or {}) do + local move = battle.data.moves[inst.id] + if move and typeRows(battle, move.type, types, 10) > 10 then + return best + end + end + invalid[best] = true + end + local best, amount = nil, 0 + for i = 1, 6 do + if allowed[i] then + for _, inst in ipairs(party[i].moves or {}) do + local move = battle.data.moves[inst.id] + if move and (tonumber(move.power) or 0) ~= 1 then + local value = 2 + for _, kind in ipairs(user.curTypes or {}) do + if move.type == kind then + value = math.floor(value * 15 / 10) + break + end + end + value = typeRows(battle, move.type, types, value, true) + if amount < value then + best, amount = i, value + end + end + end + end + end + return best +end +function M.switch(battle, user, index, party) + local mon = party[index] + local pos = user.position or (user.isPlayer and 0 or 1) + battle:restoreMimicked(user) + battle:abilitySwitchOut(user) + local State = require("src.battle.BattleState") + local incoming = State.makeBattler( + battle.data, + mon, + user.isPlayer, + user.isPlayer and not user.ally and battle.game.save or nil, + battle + ) + incoming.ally, incoming.partyIndex = user.ally, index + for key, value in pairs(user.stages or {}) do + incoming.stages[key] = value + end + for _, key in ipairs(TRANSFER) do + incoming[key] = user[key] + end + user.batonPass = {} + battle:placeBattler(pos, incoming) + user.batonPass, user.wantsSwitch = nil, nil + for _, other in battle:activeBattlers() do + if other ~= incoming then + if other.leechSeedSource == user then + other.leechSeedSource = incoming + end + if other.trappedBy == user then + other.trapped, other.trappedBy, other.escapePrevention = nil, nil, nil + end + if other.infatuated == user or not battle:isDouble() then + other.infatuated = nil + end + end + end + battle:syncSides() + Runtime.emit("battle.battler_switched", { + battle = battle, + side = battle:sideOf(incoming), + battler = incoming, + previous = user, + battlerId = pos, + partyIndex = index, + reason = "baton_pass", + }) + if incoming.isPlayer then + battle:markParticipant() + end + battle:sayNext( + incoming.isPlayer and battle:sendOutText(incoming.name) + or Strings("%s sent\nout %s!", battle.opponentName or battle:trainerLabel_(), incoming.name) + ) + battle:actNext(function() + battle:startGrowIn(incoming) + require("src.core.Sound").playCry(battle.data, mon.species) + end) + -- State changes precede the following action; their presentation stays queued. + local before = battle._gen3BatonSwitching + battle._gen3BatonSwitching = true + battle:abilitySwitchIn(incoming) + battle._gen3BatonSwitching = before + if mon.hp > 0 then + require("src.battle.HeldItems").onEntry(battle, incoming) + end + return incoming +end +function M.select(battle, pending, index) + if battle._gen3BatonPending ~= pending or pending.choice or battle.result then + return false + end + if battle:battlerAt(pending.user.position) ~= pending.user then + return false + end + local current, party = M.candidates(battle, pending.user) + local valid = false + for _, i in ipairs(current) do + if i == index and party[i] == pending.offered[i] then + valid = true + end + end + if not valid then + return false + end + pending.choice = index + if pending.onSelect then + pending.onSelect(index) + end + return true +end +local function switchContext(ctx, user, index, party, frame) + ctx.user = M.switch(ctx.battle, user, index, party) + -- The native battler slot is replaced in place; host battlers are distinct + -- tables. Every active caller frame must follow that same slot replacement. + while frame do + if frame.user == user then frame.user = ctx.user end + frame = frame.parent + end +end +function M.await(battle, pending) + if battle._gen3BatonPending ~= pending then + return + end + if battle.result then + battle._gen3BatonPending = nil + return + end + if pending.choice then + battle._gen3BatonPending = nil + switchContext(pending.ctx, pending.user, pending.choice, pending.party, pending.frame) + if pending.frame then + local frame = pending.frame + while frame do + frame.finished = true + frame = frame.parent + end + require("src.battle.GbaItemSchedule").moveEnd(battle, pending.frame) + require("src.battle.GbaItemSchedule").afterAction(battle) + end + else + battle:actNext(function() + M.await(battle, pending) + end) + end +end +function M.picker(battle, pending) + return battle:buildScreen("PartyMenu", { + battle = battle, + party = pending.party, + forceSwitch = true, + keepOpen = true, + noCancel = true, + onUpdate = function(screen) + if battle._gen3BatonPoll then + battle:_gen3BatonPoll() + end + if battle.result or battle._gen3BatonPending ~= pending then + screen.closed = true + if battle.game.stack:top() == screen then + battle.game.stack:pop() + end + if battle._gen3BatonPending == pending then + battle._gen3BatonPending = nil + end + end + end, + onSwitch = function(mon, screen) + local index + for i, candidate in ipairs(pending.party) do + if candidate == mon then + index = i + break + end + end + if M.select(battle, pending, index) then + screen.closed = true + battle.game.stack:pop() + elseif screen.say then + screen:say(Strings("Choose another\nPOKéMON.")) + end + end, + onCancel = function() + if battle._gen3BatonPending == pending and not pending.choice then + battle:uiNext(function() + return M.picker(battle, pending) + end) + end + end, + }) +end +function M.perform(ctx) + local battle, user = ctx.battle, ctx.user + local candidates, party = M.candidates(battle, user) + if #candidates == 0 then + if battle._gbaMoveFrame then battle._gbaMoveFrame.failed = true end + battle:cancelMoveAnim() + battle:sayNext(Strings("But, it failed!")) + return + end + local offered = {} + for _, i in ipairs(candidates) do + offered[i] = party[i] + end + local pending = { user = user, party = party, candidates = candidates, offered = offered, ctx = ctx } + if type(battle.batonPassChooser) == "function" then + local ok, chosen = pcall(battle.batonPassChooser, user.isPlayer and "player" or "enemy", candidates) + local index = candidates[1] + for _, i in ipairs(candidates) do + if ok and i == tonumber(chosen) then + index = i + end + end + switchContext(ctx, user, index, party, battle._gbaMoveFrame) + return + end + if + not battle._gen3BatonRequest and (not user.isPlayer or user.ally or battle.interactiveChoices == false) + then + local index = not user.isPlayer and M.enemyChoice(battle, user, candidates, party) or nil + local chosen = candidates[1] + for _, i in ipairs(candidates) do + if index == i then + chosen = i + end + end + switchContext(ctx, user, chosen, party, battle._gbaMoveFrame) + return + end + battle._gen3BatonPending = pending + pending.frame = battle._gbaMoveFrame + local frame = pending.frame + while frame do + frame.suspended = true + frame = frame.parent + end + local remote = battle._gen3BatonRequest and battle:_gen3BatonRequest(pending) or false + if not remote then + battle:uiNext(function() + return M.picker(battle, pending) + end) + end + battle:actNext(function() + M.await(battle, pending) + end) +end +function M.record(base, vanilla) + if not base or base.run ~= vanilla.run or base.perform then + return base + end + if cache[base] then + return cache[base] + end + local record = {} + for key, value in pairs(base) do + record[key] = value + end + record.kind, record.run, record.perform, record.gbaBatonPass = "full", nil, M.perform, true + cache[base] = record + return record +end +return M diff --git a/src/battle/Gen3BattleWeather.lua b/src/battle/Gen3BattleWeather.lua new file mode 100644 index 000000000..a4504b8f0 --- /dev/null +++ b/src/battle/Gen3BattleWeather.lua @@ -0,0 +1,17 @@ +-- Native GBA weather suppression observes every occupied battle slot. +local M = {} +function M.effective(battle) + local sky = require("src.battle.Weather").raw(battle) + if not sky then + return nil + end + local Abilities = require("src.battle.Abilities") + for _, b in battle:activeBattlers() do + local ability = Abilities.of(b) + if ability == "CLOUD_NINE" or ability == "AIR_LOCK" then + return nil + end + end + return sky == "SANDSTORM" and "SAND" or sky +end +return M diff --git a/src/battle/Gen3BeatUp.lua b/src/battle/Gen3BeatUp.lua new file mode 100644 index 000000000..9dd80673d --- /dev/null +++ b/src/battle/Gen3BeatUp.lua @@ -0,0 +1,103 @@ +-- Gen-3 Beat Up uses party species/level, not the user's battle stats. +local Damage = require("src.battle.Damage") +local HelpingHand = require("src.battle.Gen3HelpingHand") +local B = {} +-- The native side party is shared across both trainers. Keep the engine's +-- owning arrays separate, but retain live slot reads rather than copying +-- their members: a public hit callback may replace an upcoming bench mon. +local function partiesFor(ctx) + local battle, user = ctx.battle, ctx.user + if user.isPlayer then + local first = battle.playerParty or (battle.game and battle.game.save and battle.game.save.party) or {} + return first, battle.ally and battle.ally.party + end + return battle.enemyParty or { user.mon }, battle.enemyPartyB +end +local function member(ctx, index) + if index <= ctx._gen3BeatUpSplit then + return ctx._gen3BeatUpParty[index] + end + local second = ctx._gen3BeatUpSecondParty + return second and second[index - ctx._gen3BeatUpSplit] +end +local function userIndex(ctx, count) + for i = 1, count do + if member(ctx, i) == ctx.user.mon then + return i + end + end + return ctx.user.partyIndex +end +local function eligible(ctx, mon, index) + local hp, status = mon.hp, mon.status + if index == ctx._gen3BeatUpUserIndex then + hp, status = ctx.user.mon.hp, ctx.user.mon.status + end + return (tonumber(hp) or 0) > 0 + and mon.species ~= nil + and mon.species ~= 0 + and not mon.isEgg + and (status == nil or status == 0) +end +local function base(ctx, species, stat) + local def = ctx.data and ctx.data.pokemon and ctx.data.pokemon[species] + return def and def.baseStats and def.baseStats[stat] or 50 +end +function B.prepare(ctx) + local first, second = partiesFor(ctx) + ctx._gen3BeatUpParty, ctx._gen3BeatUpSecondParty = first, second + ctx._gen3BeatUpSplit, ctx._gen3BeatUpCursor = #first, 0 + local count = #first + (second and #second or 0) + ctx._gen3BeatUpUserIndex = userIndex(ctx, count) + for i = 1, count do + local mon = member(ctx, i) + if not mon then + break + end + if eligible(ctx, mon, i) then + return true + end + end + return false +end +function B.nextDamage(ctx) + while true do + local index = ctx._gen3BeatUpCursor + 1 + ctx._gen3BeatUpCursor = index + local mon = member(ctx, index) + if not mon then + return nil + end + if eligible(ctx, mon, index) then + local amount = Damage.base( + tonumber(mon.level) or 1, + ctx.move.power or 10, + base(ctx, mon.species, "attack"), + base(ctx, ctx.target.species or ctx.target.mon.species, "defense") + ) + 2 + amount = HelpingHand.damage(ctx.user, amount) + local def = ctx.data.pokemon[mon.species] + local name = mon.nickname or (def and def.name) or tostring(mon.species) + if not ctx.user.isPlayer then + name = "Enemy " .. name + end + ctx.say(ctx.battle:romText("STRINGID_PKMNATTACK", "%s's attack!", name)) + local critical = require("src.battle.Gen3Critical").roll( + ctx.battle, + Damage.critRoll, + ctx.ruleset, + ctx.user, + ctx.move.id, + ctx.rng, + false, + ctx.data + ) + if critical then + amount = amount * 2 + end + amount = math.max(1, math.floor(amount * ctx.rng(85, 100) / 100)) + return amount, { crit = critical, typeMult = 10, eventTypeMult = 10, gbaBeatUp = true } + end + end +end +return B diff --git a/src/battle/Gen3Builtins.lua b/src/battle/Gen3Builtins.lua new file mode 100644 index 000000000..784d7de42 --- /dev/null +++ b/src/battle/Gen3Builtins.lua @@ -0,0 +1,24 @@ +-- Adapt complete builtin records before mods capture them for delegation. +local M = {} +function M.record(id, base) + if id == "ASSIST_EFFECT" and require("src.core.GameVersion").isGen3() then + return base or require("src.battle.Gen3Assist").record() + end + if id == "NATURE_POWER_EFFECT" and require("src.core.GameVersion").isGen3() then + return base or require("src.battle.Gen3NaturePower").record() + end + base = require("src.battle.Gen3Disable").builtin(id, base) + base = require("src.battle.Gen3MultiHit").builtin(id, base) + base = require("src.battle.Gen3Rage").builtin(id, base) + base = require("src.battle.Gen3TripleKick").builtin(id, base) + base = require("src.battle.Gen3MirrorMove").builtin(id, base) + base = require("src.battle.Gen3ChoiceBand").builtin(id, base) + return require("src.battle.Gen3SleepTalk").builtin(id, base) +end +function M.install(registry, owner) + if require("src.core.GameVersion").isGen3() then + registry:register("NATURE_POWER_EFFECT", require("src.battle.Gen3NaturePower").record(), owner) + registry:register("ASSIST_EFFECT", require("src.battle.Gen3Assist").record(), owner) + end +end +return M diff --git a/src/battle/Gen3CalledMove.lua b/src/battle/Gen3CalledMove.lua new file mode 100644 index 000000000..6fa3e2f3b --- /dev/null +++ b/src/battle/Gen3CalledMove.lua @@ -0,0 +1,44 @@ +-- Shared native called-move targeting and provenance. A caller may lend its +-- selected PP slot to the first child body; ordinary calls lend no PP. +local C = {} +function C.call(ctx, pick, ppSlot) + local b, user = ctx.battle, ctx.user + local move = ctx.data.moves[pick] + if not move then + return + end + local targets + if b:isDouble() and tonumber(move.target) == 32 then + -- The native all-other-battlers script uses controller slot order, + -- independent of speed and the generic foes-then-ally convenience list. + targets = {} + local order = b.linkRole == "guest" and { 1, 0, 3, 2 } or { 0, 1, 2, 3 } + for _, id in ipairs(order) do + local target = b:battlerAt(id) + if target and target ~= user and target.mon.hp > 0 then + targets[#targets + 1] = target + end + end + elseif b:isDouble() then + targets = require("src.battle.Gen3Encore").targets(b, user, move) + else + targets = { tonumber(move.target) == 16 and user or b:defaultTargetFor(user) } + end + local chained = false + for i, target in ipairs(targets) do + chained = require("src.battle.Gen3ChoiceBand").call( + ctx, + pick, + target, + i > 1, + i > 1, + i == 1 and ppSlot or nil + ) or chained + end + -- A nested native caller owns the resulting history; an ordinary child + -- records this caller, including the existing switch/resume behavior. + if not chained then + user.lastMove = ctx.move.id + end +end +return C diff --git a/src/battle/Gen3ChoiceBand.lua b/src/battle/Gen3ChoiceBand.lua new file mode 100644 index 000000000..6e1f038e9 --- /dev/null +++ b/src/battle/Gen3ChoiceBand.lua @@ -0,0 +1,178 @@ +-- GBA Choice Band owns a learned move identity. Move-end and AI/planner +-- consumers read held effects; manual selection checks the physical item. +local V = require("src.core.GameVersion") +local Held = require("src.battle.HoldItems") +local Choices = require("src.battle.EnemyMoveChoice") +local C = {} +local planned = setmetatable({}, { __mode = "k" }) +local builtinCache = setmetatable({}, { __mode = "k" }) +function C.enemyUnlimited(battle, user) + return not user.isPlayer and battle.kind ~= "link" and battle.ruleset and battle.ruleset.enemyUnlimitedPP + or false +end +function C.signature(user) + local value = user.choicedMove + if value == false then + value = nil + end + return ":choice=" .. type(value) .. ":" .. tostring(value) +end +local function definition(battle, move) + local id = move + if type(move) == "table" then + id = move.id + end + return battle.data.moves[id] or type(move) == "table" and move or nil +end +function C.identity(battle, move) + local def = definition(battle, move) + local id = move + if type(move) == "table" then + id = move.id + end + return def and (tonumber(def.index or def.numId) or def.id or id) or id +end +local function learned(battle, user, identity) + local moves = user.curMoves or user.mon.moves or {} + for index = 1, 4 do + local move = moves[index] + if move and C.identity(battle, move) == identity then + return move, index + end + end +end +function C.moveEnd(battle, frame) + if not V.isGen3() or not (frame and frame.user) then + return + end + local user = frame.user + local selected = frame.calledBy or frame.moveId or frame.move + local chosen = definition(battle, selected) + local identity = chosen and C.identity(battle, selected) + -- The read is part of the command even for an existing lock or Struggle. + if + not frame.notObeyed + and Held.effectOf(user, "check") == "CHOICE_BAND" + and identity ~= 165 + and not user.choicedMove + then + local baton = chosen and (tonumber(chosen.gen3Effect) == 127 or chosen.effect == "BATON_PASS_EFFECT") + if not (baton and not frame.failed) then + user.choicedMove = identity + end + end + if user.choicedMove and not learned(battle, user, user.choicedMove) then + user.choicedMove = nil + end +end +function C.selectionBlocked(battle, user, move) + if not V.isGen3() or not user then + return false + end + local identity = user.choicedMove + if not identity or identity == 0 or identity == 65535 then + return false + end + local item + if user.item ~= nil then + item = tonumber(user.item) or 0 + else + item = require("src.battle.Gen3AIItemKnowledge").item(battle, user) + end + return item == 186 and C.identity(battle, move) ~= identity +end +function C.plan(battle, user, action) + if not V.isGen3() then + return action + end + action = action and Choices.resolveSelection(action) + if not (user and action) or action.special or action.kind and action.kind ~= "move" then + return action + end + local locked = user.charging + or user.thrashTurns and user.thrashTurns > 0 and user.thrashMove + or user.rageMove + if locked then + return Choices.withMove(action, locked.id) + end + action = require("src.battle.Gen3Encore").plan(user, action) + if (tonumber(user.encoreTurns) or 0) > 0 and user.encoreMove then + return action + end + if + user.choicedMove + and Held.effectOf(user, "check") == "CHOICE_BAND" + and C.identity(battle, action) ~= user.choicedMove + and C.identity(battle, action) ~= 165 + then + local forced, index = learned(battle, user, user.choicedMove) + if forced and ((tonumber(forced.pp) or 1) > 0 or C.enemyUnlimited(battle, user)) then + local out = Choices.withMove(action, forced.id, forced, index) + out.targetType = tonumber(definition(battle, forced).target) or 0 + planned[out] = { index = index, id = forced.id } + return out + end + end + return action +end +function C.execution(user, action) + action = Choices.resolveSelection(action) + local plan = planned[action] + if plan then + local slot = (user.curMoves or user.mon.moves or {})[plan.index] + if Choices.slot(action) ~= slot then + -- A removed native PP slot still permits the planned move, without + -- spending retired or other current learned records. Its temporary + -- action PP is consumed before the effect callback, as for an unowned call. + local owner = slot or { id = plan.id, pp = 1 } + local rebound = Choices.withMove(action, plan.id, owner, plan.index) + planned[rebound] = plan + return rebound + end + end + return action +end +function C.call(ctx, pick, target, suppressAnimation, spreadContinuation, ppSlot) + local battle = ctx.battle + local previous, parent = battle._gbaNextCaller, battle._gbaMoveFrame + if parent then + parent.nativeCalled = true + end + local call = { parent = parent, move = ctx.move.id, ppSlot = ppSlot } + battle._gbaNextCaller = call + local ok, err = pcall( + battle.performMove, + battle, + ctx.user, + target, + { id = pick, pp = 1 }, + true, + suppressAnimation, + spreadContinuation + ) + battle._gbaNextCaller = previous + if not ok then + battle._gbaMoveFrame = parent + error(err, 0) + end + ctx.user = parent and parent.user or ctx.user + local chained = call.frame and (call.frame.nativeCalled or call.frame.nativeNoHistory) or false + if not chained then ctx.user.lastMove = ctx.move.id end + return chained +end +function C.builtin(id, base) + if not V.isGen3() or not base or (id ~= "METRONOME_EFFECT" and id ~= "MIRROR_MOVE_EFFECT") then + return base + end + if builtinCache[base] then + return builtinCache[base] + end + local out = {} + for key, value in pairs(base) do + out[key] = value + end + out.gbaNativeChoiceCaller = base.callsMove + builtinCache[base] = out + return out +end +return C diff --git a/src/battle/Gen3Critical.lua b/src/battle/Gen3Critical.lua new file mode 100644 index 000000000..3de318f76 --- /dev/null +++ b/src/battle/Gen3Critical.lua @@ -0,0 +1,72 @@ +-- Native critical command guards, shared by ordinary and scripted GBA hits. +local Version = require("src.core.GameVersion") +local C = {} +local function inflicted(battle) + return math.floor(tonumber(battle.oakMsgFlags) or 0) % 2 == 1 +end +function C.stage(battle, attacker, highCrit) + local stage = highCrit and 1 or 0 + if battle and Version.isGen3() then + if attacker.focusEnergy or attacker.expFocusEnergy then + stage = stage + 2 + end + -- Native critcalc checks physical item IDs and the current battler species, + -- independently of mutable held-effect metadata or the retained party mon. + local item = tonumber(attacker.item) + if item == nil then + item = require("src.battle.Gen3AIItemKnowledge").item(battle, attacker) + end + local id = attacker.species or (attacker.mon and attacker.mon.species) + local def = battle.data and battle.data.pokemon and battle.data.pokemon[id] + local species = tonumber(id) or tonumber(def and (def.index or def.dex)) + if item == 198 then + stage = stage + 1 + end + if item == 222 and species == 113 or item == 225 and species == 83 then + stage = stage + 2 + end + else + if attacker.focusEnergy then + stage = stage + 2 + end + stage = stage + (attacker.critStage or 0) + require("src.battle.HoldItems").critStages(attacker) + end + return math.max(0, math.min(4, stage)) +end +function C.roll(battle, draw, ruleset, attacker, moveId, rng, highCrit, data) + if not battle or not Version.isGen3() then + return draw(ruleset, attacker, moveId, rng, highCrit, data) + end + -- Oak and Emerald's first/tutorial kinds return before consuming RNG. + if battle.firstBattle and not inflicted(battle) then + return false + end + local kinds = battle.kinds or {} + if + Version.get() == "emerald" + and (battle.firstBattleKind or kinds.firstBattle or battle.wallyTutorial or kinds.tutorial == "wally") + then + return false + end + local critical = draw(ruleset, attacker, moveId, rng, highCrit, data, battle) + -- Teachy TV still consumes critcalc's draw, then discards its result. + if battle.pokedudeDemo or battle.pokedude then + return false + end + return critical +end +function C.noteHit(battle, user, target, dealt, substitute) + if + Version.isGen3() + and battle.firstBattle + and not substitute + and dealt > 0 + and user.isPlayer + and not target.isPlayer + and not inflicted(battle) + then + -- The native HP-damage command updates this after the public hit event. + battle.oakMsgFlags = (tonumber(battle.oakMsgFlags) or 0) + 1 + end +end +return C diff --git a/src/battle/Gen3DamageStats.lua b/src/battle/Gen3DamageStats.lua new file mode 100644 index 000000000..273342938 --- /dev/null +++ b/src/battle/Gen3DamageStats.lua @@ -0,0 +1,54 @@ +-- CalculateBaseDamage reads each holder once, then reuses those answers for +-- type and species/stat items. Never retain an answer across native commands. +local HoldItems = require("src.battle.HoldItems") +local D = {} +local function species(battler, battle) + local id = battler.species or battler.mon.species + local def = battle and battle.data and battle.data.pokemon and battle.data.pokemon[id] + return tonumber(id) or tonumber(def and (def.index or def.dex)), id +end +local function isSpecies(battler, battle, numbers, names) + local number, name = species(battler, battle) + return numbers[number] or names[name] +end +function D.held(attacker, defender, move, special, attack, defense, battle) + local aEffect, parameter = HoldItems.effectOf(attacker, "damage") + local dEffect = HoldItems.effectOf(defender, "damage") + local kind = require("src.battle.Abilities").normalizeType(move.type) + if HoldItems.TYPE_POWER[aEffect] == kind then + attack = math.floor(attack * (100 + (parameter or 0)) / 100) + end + if not special then + if aEffect == "CHOICE_BAND" then + attack = math.floor(attack * 3 / 2) + end + if + aEffect == "THICK_CLUB" + and isSpecies(attacker, battle, { [104] = true, [105] = true }, { CUBONE = true, MAROWAK = true }) + then + attack = attack * 2 + end + if dEffect == "METAL_POWDER" and isSpecies(defender, battle, { [132] = true }, { DITTO = true }) then + defense = defense * 2 + end + else + local dewNumbers, dewNames = { [407] = true, [408] = true }, { LATIAS = true, LATIOS = true } + if aEffect == "SOUL_DEW" and isSpecies(attacker, battle, dewNumbers, dewNames) then + attack = math.floor(attack * 3 / 2) + end + if dEffect == "SOUL_DEW" and isSpecies(defender, battle, dewNumbers, dewNames) then + defense = math.floor(defense * 3 / 2) + end + if aEffect == "DEEP_SEA_TOOTH" and isSpecies(attacker, battle, { [373] = true }, { CLAMPERL = true }) then + attack = attack * 2 + end + if dEffect == "DEEP_SEA_SCALE" and isSpecies(defender, battle, { [373] = true }, { CLAMPERL = true }) then + defense = defense * 2 + end + if aEffect == "LIGHT_BALL" and isSpecies(attacker, battle, { [25] = true }, { PIKACHU = true }) then + attack = attack * 2 + end + end + return attack, defense +end +return D diff --git a/src/battle/Gen3Disable.lua b/src/battle/Gen3Disable.lua new file mode 100644 index 000000000..dad4e6ac4 --- /dev/null +++ b/src/battle/Gen3Disable.lua @@ -0,0 +1,193 @@ +-- GBA Disable follows a move identity, not its current menu slot. +local D = {} +local V = require("src.core.GameVersion") +local Strings = require("src.core.Strings") +local builtins = setmetatable({}, { __mode = "k" }) +local records = setmetatable({}, { __mode = "k" }) +local function moves(user) + return user.curMoves or user.mon and user.mon.moves or {} +end +function D.slot(user) + if not user then + return nil + end + if V.isGen3() and user.disabledMove then + for i, move in ipairs(moves(user)) do + if i > 4 then + break + end + if move.id == user.disabledMove then + return i + end + end + return nil + end + return user.disabledSlot +end +function D.blocked(user, move, slot) + if not user then + return false + end + if V.isGen3() and user.disabledMove then + return move ~= nil and move.id == user.disabledMove + end + return slot ~= nil and user.disabledSlot == slot +end +function D.has(user) + return user ~= nil and (not not (V.isGen3() and user.disabledMove) or user.disabledSlot ~= nil) +end +local function clear(user) + user.disabledMove, user.disabledTurns, user.disabledSlot = nil, nil, nil +end +function D.apply(battle, _, target) + local slot + for i, move in ipairs(moves(target)) do + if i > 4 then + break + end + if target.lastMove and move.id == target.lastMove then + slot = i + break + end + end + local move = slot and moves(target)[slot] + if D.has(target) or not move or (tonumber(move.pp) or 0) <= 0 then + return { Strings("But, it failed!") }, false + end + target.disabledMove, target.disabledTurns = move.id, battle.rng(0, 3) % 4 + 2 + return { Strings("%s's\n%s was\ndisabled!", target.name, battle.data.moves[move.id].name) }, true +end +function D.run(ctx) + if V.isGen3() then + return D.apply(ctx.battle, ctx.user, ctx.target) + end + return require("src.battle.MoveEffects").RECORDS.DISABLE_EFFECT.run(ctx) +end +function D.perform(ctx) + local hit, why = ctx.battle:accuracyRoll(ctx.move, ctx.user, ctx.target) + if not hit then + ctx.battle:cancelMoveAnim() + if why == "protected" then + ctx.say(Strings("%s\nprotected itself!", ctx.displayName(ctx.target))) + elseif why ~= "absorbed" then + ctx.say(Strings("%s's\nattack missed!", ctx.displayName(ctx.user))) + end + return + end + local messages, success = D.apply(ctx.battle, ctx.user, ctx.target) + if not success then + ctx.battle:cancelMoveAnim() + end + for _, message in ipairs(messages) do + ctx.say(message) + end +end +-- Adapt records per dataset before mods capture/delegate their builtin run. +-- The legacy module's shared records remain untouched, including on reload. +function D.builtin(id, base) + if not base or not V.isGen3() then + return base + end + if id ~= "DISABLE_EFFECT" and id ~= "HAZE_EFFECT" and id ~= "TRANSFORM_EFFECT" then + return base + end + if builtins[base] then + return builtins[base] + end + local out = {} + for key, value in pairs(base) do + out[key] = value + end + if id == "DISABLE_EFFECT" then + out.run = D.run + elseif id == "HAZE_EFFECT" then + out.run = function(ctx) + local saved = {} + if V.isGen3() then + for pos = 0, 3 do + local b = ctx.battle.battlerAt and ctx.battle:battlerAt(pos) + or (pos == 0 and ctx.battle.player or pos == 1 and ctx.battle.enemy) + if b and b.disabledMove then + saved[b] = { b.disabledMove, b.disabledTurns, b.disabledSlot } + end + end + end + local result, success = base.run(ctx) + -- Native Haze preserves Disable. Other legacy Haze behavior is separate. + for b, state in pairs(saved) do + b.disabledMove, b.disabledTurns, b.disabledSlot = unpack(state) + end + return result, success + end + else + out.run = function(ctx) + local before = ctx.user.curMoves + local result, success = base.run(ctx) + if V.isGen3() and ctx.user.disabledMove and ctx.user.curMoves ~= before then + clear(ctx.user) + end + return result, success + end + end + builtins[base] = out + return out +end +function D.record(base, vanilla) + if + not base + or (base.run ~= vanilla.run and base.run ~= D.run) + or base.perform + or base.accuracyChecked ~= vanilla.accuracyChecked + then + return base + end + if records[base] then + return records[base] + end + local out = {} + for key, value in pairs(base) do + out[key] = value + end + out.kind, out.perform = "full", D.perform + records[base] = out + return out +end +function D.tick(battle, user) + if not user.disabledMove then + return + end + if not D.slot(user) then + clear(user) + return + end + user.disabledTurns = (user.disabledTurns or 1) - 1 + if user.disabledTurns <= 0 then + clear(user) + battle:sayNext(Strings("%s's\ndisabled no more!", user.name)) + end +end +function D.cancel(user) + user.bideTurns = nil + user.thrashTurns, user.thrashMove, user.thrashAnnounced = nil, nil, nil + user.charging, user.chargeReady, user.invulnerable, user.hiddenAs = nil, nil, nil, nil + user.rampMove, user.rampRun, user.uproarTurns = nil, nil, nil + user.lastMove = nil +end +function D.bideMove(battle, user) + local function bide(id) + local def = id and battle.data.moves[id] + return def and (tonumber(def.index) == 117 or def.effect == "BIDE_EFFECT") + end + if bide(user.lastMove) then + return { id = user.lastMove } + end + for _, move in ipairs(moves(user)) do + if bide(move.id) then + return move + end + end +end +function D.signature(user) + return ":disable=" .. tostring(user.disabledMove) .. "," .. tostring(user.disabledTurns) +end +return D diff --git a/src/battle/Gen3EffectPresentation.lua b/src/battle/Gen3EffectPresentation.lua new file mode 100644 index 000000000..4ed3439e3 --- /dev/null +++ b/src/battle/Gen3EffectPresentation.lua @@ -0,0 +1,289 @@ +-- Native battle-effect controllers. Presentation never changes battle HP, +-- status, PP or RNG; the battle queue owns its lifetime and native frame clock. +local M = {} +local V = require("src.core.GameVersion") + +function M.queue(battle, user, name) + if not V.isGen3() or type(battle.queue) ~= "table" then + return + end + battle.nextInsert = (battle.nextInsert or 0) + 1 + table.insert(battle.queue, battle.nextInsert, { + anim = name, + gbaNativeEffect = true, + attackerIsPlayer = user.isPlayer, + attackerPosition = user.position, + targetPosition = user.position, + }) +end + +local function startFade(from, target, delay, color) + -- The initialization callback performs the first background half-step. + return { + y = from, + target = target, + toggle = 1, + delay = 0, + active = true, + interval = delay or 2, + color = color or { 30 / 31, 0, 1 }, + } +end + +local function fade(state) + local f = state.fade + if not f.active then + return + end + if f.finishing ~= nil then + if f.finishing == 4 then + f.active = false + else + f.finishing = f.finishing + 1 + end + return + end + if f.toggle == 0 then + if f.delay < f.interval then + f.delay = f.delay + 1 + return + end + f.delay = 0 + f.toggle = 1 + return + end + state.coeff = f.y / 16 + local k = state.coeff + state.color = k > 0 and { (f.color[1] * k) / k, (f.color[2] * k) / k, (f.color[3] * k) / k } or { 0, 0, 0 } + f.toggle = 0 + if f.y == f.target then + f.finishing = 0 + else + f.y = f.target > f.y and math.min(f.target, f.y + 2) or math.max(f.target, f.y - 2) + end +end + +function M.start(battle, row) + if + not V.isGen3() or row.anim ~= "POISON" and row.anim ~= "FOCUS_BAND" and row.anim ~= "SUBSTITUTE_FADE" + then + return false + end + local user = row.attackerPosition ~= nil and battle:battlerAt(row.attackerPosition) + or (row.attackerIsPlayer and battle.player or battle.enemy) + if not user then + return false + end + if row.anim == "SUBSTITUTE_FADE" then + local Sub = require("src.battle.Gen3SubstitutePresentation") + local hidden = battle.picFx and battle.picFx[user] and battle.picFx[user].hidden + if battle:substituteVisible(user) and hidden then + local swap = Sub.beginSwap(battle, user, false) + swap.state.substitute = false + return false + end + battle._gbaEffectPresentation = { + user = user, + kind = row.anim, + frame = 0, + x = 0, + y = 0, + alpha = 1, + monbg = false, + coeff = 0, + color = { 0, 0, 0 }, + } + return true + end + local focus = row.anim == "FOCUS_BAND" + local pause = focus and not battle:animationsOn() + local protected = focus and battle:substituteVisible(user) or not focus and (user.substituteHP or 0) > 0 + if not pause and (not battle:animationsOn() or user.invulnerable or protected) then + return false + end + battle._gbaEffectPresentation = { + user = user, + kind = row.anim, + pause = pause and 32 or nil, + frame = 0, + x = 0, + y = 0, + coeff = 0, + color = { 0, 0, 0 }, + sound = focus and (V.get() == "firered" and 164 or 171) or (V.get() == "firered" and 141 or 148), + soundLeft = focus and 1 or 6, + soundWait = 0, + } + return true +end + +local function focusStep(state) + if state.frame == 1 then + state.fade = startFade(0, 9, 7, { 1, 0, 0 }) + state.phase, state.remaining = "ramp", 30 + state.jitterWait, state.jitterPeriod, state.jitter, state.amplitude = 0, 1, 0, 2 + state.distance = 0 + elseif state.phase == "ramp" or state.phase == "hold" then + fade(state) + state.jitterWait = state.jitterWait - 1 + if state.jitterWait < 0 then + if state.jitter == 0 then + state.jitter, state.amplitude = state.amplitude, -state.amplitude + else + state.jitter = 0 + end + state.jitterWait = state.jitterPeriod + end + if state.phase == "ramp" then + state.distance = state.distance + 128 + end + state.x = state.jitter + (state.user.isPlayer and -1 or 1) * math.floor(state.distance / 256) + state.remaining = state.remaining - 1 + if state.remaining == 0 then + if state.phase == "ramp" then + state.phase, state.remaining, state.jitterPeriod = "hold", 30, 0 + else + state.phase = "second-fade" + state.fade = startFade(9, 0, 4, { 1, 0, 0 }) + end + end + elseif state.phase == "second-fade" then + -- Sprite callbacks observe fade completion before that tick's tasks. + if not state.fade.active then + state.phase, state.delay = "delay", 6 + else + fade(state) + end + elseif state.phase == "delay" then + if state.delay > 0 then + state.delay = state.delay - 1 + elseif not state.waitReleased then + state.waitReleased = true + else + state.phase, state.remaining, state.origin = "return", 15, state.x + local velocity = -state.x * 256 / 15 + state.velocity = velocity < 0 and math.ceil(velocity) or math.floor(velocity) + state.distance = 0 + end + else + if state.remaining == 0 then + state.x = 0 + return false + end + state.remaining, state.distance = state.remaining - 1, state.distance + state.velocity + state.x = state.origin + math.floor(state.distance / 256) + end + return true +end + +function M.update(battle) + local state = battle._gbaEffectPresentation + if not state then + return false + end + state.frame = state.frame + 1 + if state.kind == "SUBSTITUTE_FADE" then + local frame = state.frame + -- Sixteen alpha decrements every third callback finish at tick 49; + -- task release, waitforvisualfinish and script delays clear monbg at 55. + -- The palette reset and ordinary native swap begin together at tick 57. + if frame <= 56 then + state.monbg = frame < 55 + state.alpha = 1 - math.min(16, math.floor((frame - 1) / 3)) / 16 + state.coeff = math.min(16, frame - 1) / 16 + state.color = state.coeff > 0 and { 1, 1, 1 } or { 0, 0, 0 } + return true + end + local Sub = require("src.battle.Gen3SubstitutePresentation") + if not state.swap then + state.coeff, state.color = 0, { 0, 0, 0 } + state.swap = Sub.beginSwap(battle, state.user, false) + end + local busy = Sub.stepSwap(state.swap) + if state.swap.phase == "in" then + state.alpha = 1 + end + if busy then + return true + end + if battle.picFx and battle.picFx[state.user] then + battle.picFx[state.user].hidden = nil + end + battle._gbaEffectReconcile = battle._gbaEffectReconcile or {} + battle._gbaEffectReconcile[state.user] = true + battle._gbaEffectPresentation = nil + return false + end + if state.pause ~= nil then + if state.pause > 0 then + state.pause = state.pause - 1 + return true + end + battle._gbaEffectPresentation = nil + return false + end + -- The native sound task precedes shake and palette callbacks. Its + -- thirteen-frame counter plays again on the following callback. + if state.soundWait > 0 then + state.soundWait = state.soundWait - 1 + elseif state.soundLeft > 0 then + local options = battle.game and battle.game.save and battle.game.save.options or {} + require("src.core.Sound").playId( + battle.data, + state.sound, + { pan = state.user.isPlayer and -64 or 63, mono = options.stereo == false } + ) + state.soundLeft, state.soundWait = state.soundLeft - 1, 13 + end + if state.kind == "FOCUS_BAND" then + if focusStep(state) then + return true + end + battle._gbaEffectPresentation = nil + return false + elseif state.frame == 1 then + state.x, state.shakes, state.shakeWait = 1, 18, 1 + state.fade, state.cycles = startFade(0, 12), 1 + else + if state.shakes > 0 then + if state.shakeWait > 0 then + state.shakeWait = state.shakeWait - 1 + else + state.shakes, state.shakeWait = state.shakes - 1, 2 + state.x = state.shakes == 0 and 0 or -state.x + end + end + if not state.fade.active and state.cycles > 0 then + state.fade, state.cycles = startFade(12, 0), state.cycles - 1 + end + fade(state) + end + if state.soundLeft == 0 and state.shakes == 0 and not state.fade.active and state.cycles == 0 then + battle._gbaEffectPresentation = nil + return false + end + return true +end + +function M.view(battle, user) + local state = battle._gbaEffectPresentation + return state and state.user == user and state or nil +end + +function M.monBg(battle, user) + local state = battle._gbaEffectPresentation + local hidden = battle.picFx and battle.picFx[user] and battle.picFx[user].hidden + return state ~= nil and state.monbg == true and user.isPlayer == state.user.isPlayer and not hidden +end + +function M.reconcile(battle) + for user in pairs(battle._gbaEffectReconcile or {}) do + if user.invulnerable and battle:animationsOn() and user.mon and user.mon.hp > 0 then + battle.picFx = battle.picFx or {} + battle.picFx[user] = battle.picFx[user] or {} + battle.picFx[user].hidden = true + end + end + battle._gbaEffectReconcile = nil +end +return M diff --git a/src/battle/Gen3Encore.lua b/src/battle/Gen3Encore.lua new file mode 100644 index 000000000..90142ede3 --- /dev/null +++ b/src/battle/Gen3Encore.lua @@ -0,0 +1,232 @@ +-- Native Encore owns a move, its original slot and a residual-phase timer. +local E = {} +local Strings = require("src.core.Strings") +local cache = setmetatable({}, { __mode = "k" }) +local function moves(user) + return user.curMoves or user.mon and user.mon.moves or {} +end +local function slotOf(user, id) + for i, move in ipairs(moves(user)) do + if i > 4 then + break + end + if move.id == id then + return i, move + end + end +end +local function clear(user) + user.encoreTurns, user.encoreMove, user.encoreSlot = nil, nil, nil +end +function E.apply(battle, _, target) + local last = target.lastMove + local def = last and battle.data.moves[last] + local number = def and tonumber(def.index or def.numId) + or ({ STRUGGLE = 165, ENCORE = 227, MIRROR_MOVE = 119 })[last] + local slot, move = slotOf(target, last) + if + not def + or number == 0 + or number == 165 + or number == 227 + or number == 119 + or (tonumber(target.encoreTurns) or 0) > 0 + or not slot + or (tonumber(move.pp) or 0) <= 0 + then + return { Strings("But, it failed!") }, false + end + target.encoreMove, target.encoreSlot = move.id, slot + target.encoreTurns = battle.rng(0, 3) % 4 + 3 + local name = target.isPlayer and target.name or ("Enemy " .. target.name) + return { Strings("%s got\nan ENCORE!", name) }, true +end +function E.forced(user, action) + if + not action + or action.special + or not user + or (tonumber(user.encoreTurns) or 0) <= 0 + or not user.encoreMove + or user.charging + or user.thrashTurns and user.thrashTurns > 0 + or user.rageMove + then + return action, false + end + local _, forced = slotOf(user, user.encoreMove) + if not forced then + return action, false + end + if action.id == forced.id and require("src.battle.EnemyMoveChoice").slot(action) == forced then + return action, false + end + return forced, action.id ~= forced.id +end +function E.menu(user) + if (tonumber(user.encoreTurns) or 0) <= 0 or not user.encoreMove then + return nil + end + local _, move = slotOf(user, user.encoreMove) + return move +end +function E.plan(user, action) + local forced = E.forced(user, action) + if not forced or forced == action or forced.id == action.id then + return action + end + -- The planner changes move priority now, while retaining the chosen target + -- and slot descriptor. Execution later binds PP to the actual Encore slot. + return require("src.battle.EnemyMoveChoice").withMove(action, forced.id) +end +function E.targets(battle, user, move, chosen) + local Targeting, Abilities = require("src.battle.Targeting"), require("src.battle.Abilities") + local id = user.position + local left, kind = id % 2 == 0 and 1 or 0, tonumber(move.target) or 0 + local center, followActive = require("src.battle.Gen3FollowMe").center(battle, user) + local function present(pos) + local b = battle:battlerAt(pos) + return b and b.mon and b.mon.hp > 0 and b + end + local function partner(pos) + return pos < 2 and pos + 2 or pos - 2 + end + local function flank() + return battle.rng(0, 1) == 1 and left or left + 2 + end + local target + if kind == Targeting.SELECTED then + if center then + target = center.position + else + for _ = 1, 256 do + local rolled = battle.rng(0, 3) % 4 + if battle.linkRole == "guest" then + rolled = rolled % 2 == 0 and rolled + 1 or rolled - 1 + end + if rolled % 2 ~= id % 2 and present(rolled) then + target = rolled + break + end + end + target = target or left + if + Abilities.normalizeType(move.type) == "ELECTRIC" + and Abilities.of(battle:battlerAt(target)) ~= "LIGHTNING_ROD" + then + for pos = left, left + 2, 2 do + if Abilities.of(battle:battlerAt(pos)) == "LIGHTNING_ROD" then + target = partner(target) + break + end + end + end + end + elseif kind == Targeting.RANDOM then + target = center and center.position or flank() + elseif + kind == Targeting.DEPENDS + or kind == Targeting.BOTH + or kind == Targeting.FOES_AND_ALLY + or kind == Targeting.OPPONENTS_FIELD + then + target = left + else + target = id + end + if center and kind == Targeting.SELECTED then + return { center } + end + if + not followActive + and Abilities.normalizeType(move.type) == "ELECTRIC" + and ((tonumber(move.power) or 0) ~= 0 or kind ~= Targeting.USER) + and Abilities.of(battle:battlerAt(target)) ~= "LIGHTNING_ROD" + then + for _, pos in ipairs(battle._actionTargetOrder or { 0, 1, 2, 3 }) do + if pos % 2 ~= id % 2 and pos ~= target and Abilities.of(battle:battlerAt(pos)) == "LIGHTNING_ROD" then + target = pos + return present(target) and { present(target) } or {} + end + end + end + if math.floor((tonumber(chosen and chosen.target) or 0) / Targeting.RANDOM) % 2 == 1 then + target = flank() + end + if not present(target) then + if target % 2 ~= id % 2 then + target = partner(target) + else + target = present(left) and left or left + 2 + end + end + if kind == Targeting.BOTH or kind == Targeting.FOES_AND_ALLY then + return Targeting.resolve(battle, user, move, present(target)) + end + return present(target) and { present(target) } or {} +end +function E.perform(ctx) + local hit, why = ctx.battle:accuracyRoll(ctx.move, ctx.user, ctx.target) + if not hit then + ctx.battle:cancelMoveAnim() + if why == "protected" then + ctx.say(Strings("%s\nprotected itself!", ctx.displayName(ctx.target))) + elseif why ~= "absorbed" then + ctx.say(Strings("%s's\nattack missed!", ctx.displayName(ctx.user))) + end + return + end + local messages, success = E.apply(ctx.battle, ctx.user, ctx.target) + if not success then + ctx.battle:cancelMoveAnim() + end + for _, message in ipairs(messages) do + ctx.say(message) + end +end +function E.record(base, vanilla) + if + not base + or base.run ~= vanilla.run + or base.perform + or base.accuracyChecked ~= vanilla.accuracyChecked + then + return base + end + if cache[base] then + return cache[base] + end + local out = {} + for key, value in pairs(base) do + out[key] = value + end + out.kind, out.perform = "full", E.perform + cache[base] = out + return out +end +function E.tick(battle, user) + if (tonumber(user.encoreTurns) or 0) <= 0 then + return + end + local slot = user.encoreSlot or slotOf(user, user.encoreMove) + local move = slot and moves(user)[slot] + if not move or move.id ~= user.encoreMove then + clear(user) + return + end + user.encoreTurns = user.encoreTurns - 1 + if user.encoreTurns <= 0 or (tonumber(move.pp) or 0) <= 0 then + clear(user) + local name = user.isPlayer and user.name or ("Enemy " .. user.name) + battle:sayNext(Strings("%s's ENCORE\nended!", name)) + end +end +function E.signature(user) + return ":encore=" + .. tostring(user.encoreTurns) + .. "," + .. tostring(user.encoreMove) + .. "," + .. tostring(user.encoreSlot) +end +return E diff --git a/src/battle/Gen3FollowMe.lua b/src/battle/Gen3FollowMe.lua new file mode 100644 index 000000000..cc1c6481c --- /dev/null +++ b/src/battle/Gen3FollowMe.lua @@ -0,0 +1,51 @@ +-- Round-local GBA Follow Me state. The native flag names a position: a new +-- occupant of that slot inherits redirection until the next turn is planned. +local Version = require("src.core.GameVersion") +local Strings = require("src.core.Strings") +local F = {} +local function position(battle, user) + return user == battle.player and 0 or user == battle.enemy and 1 or user.position +end +function F.apply(battle, user) + if not Version.isGen3() then + return { Strings("But, it failed!") } + end + local id = position(battle, user) + local side = battle.sides and battle.sides[user.isPlayer and 1 or 2] + if not side or type(id) ~= "number" or id % 1 ~= 0 or id < 0 or id > 3 then + return { Strings("But, it failed!") } + end + side._gen3FollowMePosition = id + local name = user.isPlayer and user.name or ("Enemy " .. user.name) + return { battle:romText("STRINGID_PKMNCENTERATTENTION", "%s became the\ncenter of attention!", name) } +end +function F.center(battle, user) + if not Version.isGen3() then + return nil, false + end + local side = battle.sides and battle.sides[user.isPlayer and 2 or 1] + local id = side and side._gen3FollowMePosition + if id == nil then + return nil, false + end + local holder + if id == 0 then + holder = battle.player + elseif id == 1 then + holder = battle.enemy + else + holder = battle:battlerAt(id) + end + if holder and holder.mon and (holder.mon.hp or 0) > 0 and holder.isPlayer ~= user.isPlayer then + return holder, true + end + -- The native timer still suppresses Lightning Rod even if its holder has + -- fainted. Only the next turn's planning resets the flag. + return nil, true +end +function F.clear(battle) + for _, side in ipairs(battle.sides or {}) do + side._gen3FollowMePosition = nil + end +end +return F diff --git a/src/battle/Gen3HelpingHand.lua b/src/battle/Gen3HelpingHand.lua new file mode 100644 index 000000000..b780626d6 --- /dev/null +++ b/src/battle/Gen3HelpingHand.lua @@ -0,0 +1,28 @@ +-- Native GBA Helping Hand is a battler-local, turn-scoped modifier. +local Version = require("src.core.GameVersion") +local Strings = require("src.core.Strings") +local H = {} +local function name(actor) + return actor.isPlayer and actor.name or ("Enemy " .. actor.name) +end +function H.apply(battle, user) + local partner = Version.isGen3() and battle:partnerOf(user) + if + not partner + or not partner.mon + or (partner.mon.hp or 0) <= 0 + or user._gen3HelpingHand + or partner._gen3HelpingHand + then + return { Strings("But, it failed!") } + end + partner._gen3HelpingHand = true + return { battle:romText("STRINGID_PKMNREADYTOHELP", "%s is ready to\nhelp %s!", name(user), name(partner)) } +end +function H.damage(user, amount) + if Version.isGen3() and user._gen3HelpingHand then + return math.floor(amount * 15 / 10) + end + return amount +end +return H diff --git a/src/battle/Gen3LockOn.lua b/src/battle/Gen3LockOn.lua new file mode 100644 index 000000000..591367177 --- /dev/null +++ b/src/battle/Gen3LockOn.lua @@ -0,0 +1,90 @@ +-- Target-owned native sure-hit state. Older games retain their one-use pointer. +local L = {} +function L.signature(target, role) + local turns = target.lockOnTurns + if turns == nil or turns == false or turns == 0 then + return ":lock=0" + end + local side, pos = target.lockOnBy, target.lockOnByPos + if role == "guest" then + if type(side) == "boolean" then + side = not side + end + if type(pos) == "number" then + pos = pos % 2 == 0 and pos + 1 or pos - 1 + end + end + return ":lock=" .. tostring(turns) .. "," .. tostring(side) .. "," .. tostring(pos) +end +local function position(battle, who) + if who.position ~= nil then + return who.position + end + for pos = 0, 3 do + if battle.sides and battle:battlerAt(pos) == who then + return pos + end + end + return who.isPlayer and 0 or 1 +end +function L.active(battle, user, target) + if not (user and target) then + return false + end + local turns = target.lockOnTurns + if turns ~= nil and turns ~= false and turns ~= 0 then + if battle.double and target.lockOnByPos ~= nil then + return target.lockOnByPos == position(battle, user) + end + return target.lockOnBy == nil or target.lockOnBy == user.isPlayer + end + -- Authored legacy GBA consumers can still supply the old one-use binding. + return user.lockedOn == target +end +function L.mark(battle, user, target) + target.lockOnTurns = 2 + target.lockOnBy, target.lockOnByPos = user.isPlayer, position(battle, user) +end +function L.tick(battle, target) + if battle.result or not target.mon or (target.mon.hp or 0) <= 0 then + return + end + local turns = tonumber(target.lockOnTurns) + if turns and turns > 0 then + target.lockOnTurns = turns - 1 + if target.lockOnTurns <= 0 then + target.lockOnTurns, target.lockOnBy = nil, nil + end + elseif target.lockOnTurns == true then + target.lockOnTurns = 1 + end +end +function L.replacing(battle, pos, previous, incoming) + if not incoming or incoming == previous then + return + end + local pass = previous and previous.batonPass + if pass then + incoming.lockOnTurns, incoming.lockOnBy = previous.lockOnTurns, previous.lockOnBy + -- The reference's Baton Pass copy omits the slot owner, retaining side fallback. + end + local active = {} + for p = 0, 3 do + local side, flank = p % 2 + 1, math.floor(p / 2) + 1 + local s = battle.sides and battle.sides[side] + local b = s and s.battlers and s.battlers[flank] or p == 0 and battle.player or p == 1 and battle.enemy + if b then + active[#active + 1] = b + end + end + for _, target in ipairs(active) do + if battle.double then + if target ~= previous and target.lockOnByPos == pos then + target.lockOnTurns, target.lockOnBy, target.lockOnByPos = nil, nil, nil + end + elseif target.isPlayer ~= (pos % 2 == 0) and target.lockOnBy == (pos % 2 == 0) then + target.lockOnTurns = nil + end + end +end +return L diff --git a/src/battle/Gen3MirrorMove.lua b/src/battle/Gen3MirrorMove.lua new file mode 100644 index 000000000..de9acdae7 --- /dev/null +++ b/src/battle/Gen3MirrorMove.lua @@ -0,0 +1,50 @@ +-- GBA Mirror Move reads the recipient's native move-end history, not the +-- current target's last selected action. Only qualifying receipts replace it. +local M = {} +local V = require("src.core.GameVersion") +local Strings = require("src.core.Strings") +local cache = setmetatable({}, { __mode = "k" }) +function M.pick(ctx) + local previous = ctx.user.gbaLastTakenMove + if previous and previous ~= 0 then + local id = require("src.battle.EnemyMoveChoice").resolveMove(ctx.data, previous) + if id then + return id + end + end + ctx.say(Strings("The MIRROR MOVE\nfailed!")) +end +function M.builtin(id, base) + if not V.isGen3() or id ~= "MIRROR_MOVE_EFFECT" or not base then + return base + end + if cache[base] then + return cache[base] + end + local out = {} + for key, value in pairs(base) do + out[key] = value + end + out.callsMove, out.announceAnim = M.pick, false + cache[base] = out + return out +end +function M.history(battle, frame) + if not V.isGen3() or not frame then + return + end + local user, target = frame.user, frame.target + if not user or not target or target == user then + return + end + local chosen = frame.calledBy or frame.moveId + local move = frame.calledBy and battle.data.moves[frame.calledBy] or frame.move + local eligible = move and math.floor((tonumber(move.flags) or 0) / 16) % 2 == 1 + if not frame.noEffect and not frame.missed and eligible and target.mon.hp > 0 then + target.gbaLastTakenMove = chosen + end +end +function M.signature(user) + return ":mirror=" .. tostring(user.gbaLastTakenMove) +end +return M diff --git a/src/battle/Gen3MoveAnim.lua b/src/battle/Gen3MoveAnim.lua index f6a3a09ea..4adb1af26 100644 --- a/src/battle/Gen3MoveAnim.lua +++ b/src/battle/Gen3MoveAnim.lua @@ -4018,7 +4018,9 @@ function Gen3MoveAnim:battlerOamY(battle, battler) local _, rawY = self:battlerRawCoords(battle, battler) if rawY == nil then return nil end local centreY - if battler.substituteHP and not battler.fainted then + local substitute = battle.substituteVisible and battle:substituteVisible(battler) + if battle.substituteVisible == nil then substitute = battler.substituteHP end + if substitute and not battler.fainted then -- GetSubstituteSpriteDefault_Y: raw BATTLER_COORD_Y + 17 (player) / 16 -- (opponent). The substitute still uses the ordinary 64x64 battler OAM. centreY = rawY + (battler.isPlayer and 17 or 16) diff --git a/src/battle/Gen3MoveEffects.lua b/src/battle/Gen3MoveEffects.lua index c69724dce..56182d862 100644 --- a/src/battle/Gen3MoveEffects.lua +++ b/src/battle/Gen3MoveEffects.lua @@ -24,24 +24,19 @@ -- The merge is non-destructive: a name MoveEffects already defines wins, so -- this file can never quietly redefine an effect that already worked. -- --- WHAT IS NOT HERE, and deliberately -- eight effects, and each for a --- reason rather than for want of time: --- --- FOLLOW ME and HELPING HAND do nothing outside a DOUBLE battle, and this --- engine fights one Pokemon a side. +-- Remaining effects need separate native producers: -- -- MAGIC COAT, SNATCH and IMPRISON all reach INTO another battler's move -- as it is being resolved -- bouncing it back, stealing it, forbidding it -- -- which needs a resolution step performMove does not have. -- --- ASSIST and NATURE POWER call a move chosen from outside the user's own --- move list (the rest of the party; the terrain), and SECRET POWER's side +-- ASSIST calls a move from the rest of the party. NATURE POWER now uses +-- native Gen3NaturePower registration in Gen3Builtins. SECRET POWER's side -- effect is chosen the same way. The terrain half of that is real work -- in the world layer, not the battle one. -- --- Those eight still warn once and fall through to plain damage, which is --- the honest behaviour for an effect that is not implemented. Everything --- else Emerald has runs. +-- Unimplemented effects still warn once and fall through to plain damage. +-- FOLLOW ME and HELPING HAND are registered below with native GBA turn state. local Status = require("src.battle.Status") local StatusRegistry = require("src.battle.StatusRegistry") @@ -62,6 +57,7 @@ local S = Gen3MoveEffects.secondary local F = Gen3MoveEffects.full local ACC = Gen3MoveEffects.accuracyChecked + -- MoveEffects is required lazily inside the callbacks: it requires THIS file -- while it is still loading, so a top-level require here would be a cycle. local function changeStage(battle, who, stat, delta, fromEnemy) @@ -119,6 +115,16 @@ local function confuse(battle, target, pierceSub) return { Strings("%s\nbecame confused!", displayName(target)) } end +-- Imported GBA effect 172 is already named FOLLOW_ME_EFFECT. Register the +-- ordinary record so native imports and the public effect adapter agree. +P.FOLLOW_ME_EFFECT = function(battle, user) + return require("src.battle.Gen3FollowMe").apply(battle, user) +end + +P.HELPING_HAND_EFFECT = function(battle, user) + return require("src.battle.Gen3HelpingHand").apply(battle, user) +end + -- --------------------------------------------------------------------- -- STAT-STAGE MOVES: the whole reason a Hoenn team is built the way it is -- --------------------------------------------------------------------- @@ -309,6 +315,9 @@ P.ENDURE_EFFECT = protection("enduring", -- ENCORE: the target must repeat its last move for a few turns. P.ENCORE_EFFECT = function(battle, user, target) + if require("src.core.GameVersion").isGen3() then + return require("src.battle.Gen3Encore").apply(battle, user, target) + end if target.encoreTurns or not target.lastMove then return { Strings("But, it failed!") } end @@ -318,9 +327,21 @@ P.ENCORE_EFFECT = function(battle, user, target) end ACC.ENCORE_EFFECT = true --- LOCK-ON / MIND READER: the user's next move cannot miss. -P.LOCK_ON_EFFECT = function(battle, user, target) - user.lockedOn = target +-- LOCK-ON / MIND READER: native GBA owns a target timer; older games keep +-- the existing next-move pointer. +P.LOCK_ON_EFFECT = function(battle, user, target, move) + if require("src.core.GameVersion").isGen3() then + if (target.substituteHP or 0) > 0 then return { Strings("But, it failed!") } end + local hit, reason = battle:accuracyRoll(move, user, target) + if not hit then + if reason == "absorbed" then return {} end + if reason == "protected" then return { Strings("%s\nprotected itself!", displayName(target)) } end + return { Strings("%s's\nattack missed!", displayName(user)) } + end + require("src.battle.Gen3LockOn").mark(battle, user, target) + else + user.lockedOn = target + end return { Strings("%s took aim\nat %s!", displayName(user), displayName(target)) } end @@ -362,7 +383,12 @@ end -- BATON PASS: the stat stages and the volatiles go with the switch. The -- switch itself is BattleState's; this records what must survive it. -P.BATON_PASS_EFFECT = function(battle, user) +P.BATON_PASS_EFFECT = function(battle, user, target, move, moveInst) + if require("src.core.GameVersion").isGen3() then + require("src.battle.Gen3BatonPass").perform( + require("src.battle.EffectRegistry").makeCtx(battle,user,target,move,moveInst)) + return {} + end user.batonPass = { stages = user.stages, confusedTurns = user.confusedTurns, @@ -744,10 +770,31 @@ P.SWALLOW_EFFECT = function(battle, user) return { Strings("%s\nregained health!", displayName(user)) } end --- SPIT UP is a damaging move whose power IS the counter: 100, 200, 300. +-- GBA Spit Up scales the calculated base (including +2), rather than power. +-- The earlier-generation fallback retains the existing power-based path. F.SPIT_UP_EFFECT = { - gate = function(ctx) + rev = 1, -- Native set-damage semantics change link compatibility. + onProtect = function(ctx) + if not require("src.core.GameVersion").isGen3() then return false end if (ctx.user.stockpile or 0) <= 0 then + ctx.say(Strings("But, it failed!")) + return true + end + ctx.user.stockpile = 0 + return false + end, + chooseDamage = function(ctx) + if not require("src.core.GameVersion").isGen3() then + return ctx.computeDamage({ rng = ctx.rng, powerMultiplier = math.max(1, ctx.user.stockpile or 1) }) + end + if ctx.battle.abilityAbsorb and ctx.battle:abilityAbsorb(ctx.target, ctx.move) then return nil end + local n = ctx.user.stockpile or 0 + if n <= 0 then return nil, Strings("But, it failed!") end + ctx.user.stockpile = 0 + return ctx.computeDamage({ rng = ctx.rng, forceCrit = false, gbaSetDamage = n }) + end, + gate = function(ctx) + if not require("src.core.GameVersion").isGen3() and (ctx.user.stockpile or 0) <= 0 then return false, Strings("But, it failed!") end return true @@ -1004,7 +1051,20 @@ F.FUTURE_SIGHT_EFFECT = { -- BEAT UP strikes once per healthy party member. F.BEAT_UP_EFFECT = { + rev = 3, + chooseDamage = function(ctx) + if not require("src.core.GameVersion").isGen3() then return ctx.computeDamage({ rng = ctx.rng }) end + if not require("src.battle.Gen3BeatUp").prepare(ctx) then return nil, Strings("But, it failed!") end + return 0, { crit = false, typeMult = 10, gbaBeatUp = true } + end, + perHitDamage = function(ctx, _, damage, info) + if not require("src.core.GameVersion").isGen3() then return damage, info end + return require("src.battle.Gen3BeatUp").nextDamage(ctx) + end, hitCount = function(ctx) + -- The party is read after accuracy, and eligibility can change in a mod's + -- hit callback. Six is the valid party bound; perHitDamage stops early. + if require("src.core.GameVersion").isGen3() then return 6 end local party = ctx.user.isPlayer and ctx.battle.game and ctx.battle.game.save and ctx.battle.game.save.party if not party then return 1 end @@ -1081,15 +1141,9 @@ F.LOW_KICK_EFFECT = { end, } --- TRIPLE KICK hits three times and its power escalates 10, 20, 30. --- --- This pipeline rolls the damage ONCE and applies it per hit, so a per-hit --- ramp is not something it can express. Three hits at twice the base power --- is the same TOTAL -- 3 x 2 x 10 is 60, and 10 + 20 + 30 is 60 -- so the --- damage a Hitmontop does over the move is right even though it is spread --- evenly rather than escalating. Said plainly here rather than left to be --- discovered: what is not modelled is the ramp, and the cartridge's rule --- that the move stops early if a kick misses. +-- Legacy fallback: three equal-power hits with one accuracy check. +-- Native GBA loads adapt this record per dataset in Gen3TripleKick before +-- mods capture it: its script checks every kick and assigns 10/20/30 power. F.TRIPLE_KICK_EFFECT = { hitCount = function() return 3 end, powerMultiplier = function() return 2 end, diff --git a/src/battle/Gen3MoveSelection.lua b/src/battle/Gen3MoveSelection.lua new file mode 100644 index 000000000..bfa8aad7f --- /dev/null +++ b/src/battle/Gen3MoveSelection.lua @@ -0,0 +1,137 @@ +-- The native AI scans four slots once, through the held-effect hook. Manual +-- admission uses physical Choice Band identity and never performs that read. +local V = require("src.core.GameVersion") +local Choice = require("src.battle.Gen3ChoiceBand") +local Selection = {} +local function moves(user) + return user.curMoves or user.mon.moves or {} +end +local function imprisoned(battle, user, move) + local identity = Choice.identity(battle, move) + for _, foe in ipairs(battle:foesOf(user)) do + if foe.imprison then + for slot = 1, 4 do + local other = moves(foe)[slot] + if other and Choice.identity(battle, other) == identity then + return true + end + end + end + end + return false +end +local function reason(battle, user, move, slot, heldChoice, manual) + if not move or not battle.data.moves[move.id] or Choice.identity(battle, move) == 0 then + return "empty" + end + local why + if require("src.battle.Gen3Disable").blocked(user, move, slot) then + why = "disabled" + end + if + user.tormented + and (not manual or Choice.identity(battle, move) ~= 165) + and Choice.identity(battle, user.lastMove) == Choice.identity(battle, move) + then + why = "torment" + end + if (tonumber(user.tauntTurns) or 0) > 0 and (tonumber(battle.data.moves[move.id].power) or 0) == 0 then + why = "taunt" + end + if imprisoned(battle, user, move) then + why = "imprison" + end + if heldChoice and user.choicedMove and user.choicedMove ~= Choice.identity(battle, move) then + why = "choice" + end + return why +end +function Selection.manualReason(battle, user, move, slot) + if not V.isGen3() or not user then + return nil + end + local why = reason(battle, user, move, slot, false, true) + if Choice.selectionBlocked(battle, user, move) then + why = "choice" + end + if move and tonumber(move.pp) and move.pp <= 0 then + why = "pp" + end + return why +end +function Selection.aiMask(battle, user) + if not V.isGen3() then + return nil + end + local effect = require("src.battle.HoldItems").effectOf(user, "check") + local mask = {} + for slot = 1, 4 do + local move = moves(user)[slot] + local bad = reason(battle, user, move, slot, effect == "CHOICE_BAND") ~= nil + if move and tonumber(move.pp) == 0 and not Choice.enemyUnlimited(battle, user) then + bad = true + end + if + (tonumber(user.encoreTurns) or 0) > 0 + and user.encoreMove + and Choice.identity(battle, move) ~= Choice.identity(battle, user.encoreMove) + then + bad = true + end + mask[slot] = bad + end + return mask +end +function Selection.ordinaryWild(battle) + if not V.isGen3() or battle.kind ~= "wild" then + return false + end + local kinds = battle.kinds or {} + if V.get() == "emerald" then + return not (battle.firstBattleKind or kinds.firstBattle or battle.safari or battle.roamer) + end + return not ( + battle.roamer + or battle.safari + or battle.firstBattle + or battle.wildScripted + or battle.legendary + or (tonumber(battle.aiFlags) or 0) ~= 0 + ) +end +function Selection.wildMove(battle, user, mask, rng) + if mask[1] and mask[2] and mask[3] and mask[4] then + return { id = "STRUGGLE", pp = 1, struggle = true } + end + local move, index, found + for _ = 1, 1000 do + index = rng(0, 3) + 1 + move = moves(user)[index] + local identity = move and Choice.identity(battle, move) + if identity and identity ~= 0 and battle.data.moves[move.id] then + found = true + break + end + end + if not found then + for slot = 1, 4 do + if not mask[slot] then + move = moves(user)[slot] + return require("src.battle.EnemyMoveChoice").withMove(move, move.id, move, slot) + end + end + return { id = "STRUGGLE", pp = 1, struggle = true } + end + if not move then + return { id = "STRUGGLE", pp = 1, struggle = true } + end + local action = require("src.battle.EnemyMoveChoice").withMove(move, move.id, move, index) + action.scores = { 0, 0, 0, 0 } + if battle:isDouble() then + local targetType = tonumber(battle.data.moves[move.id].target) or 0 + action.target = math.floor(targetType / 16) % 2 == 1 and user.position + or math.floor(rng(0, 65535) / 2) % 2 * 2 + end + return action +end +return Selection diff --git a/src/battle/Gen3MultiHit.lua b/src/battle/Gen3MultiHit.lua new file mode 100644 index 000000000..0b419d2be --- /dev/null +++ b/src/battle/Gen3MultiHit.lua @@ -0,0 +1,36 @@ +-- GBA's hit-count opcode runs only after a successful accuracy check. +local M = {} +local V = require("src.core.GameVersion") +local cache = setmetatable({}, { __mode = "k" }) +function M.ordinary(move) + local effect = tonumber(move.gen3Effect) + return effect == 29 or effect == 44 or effect == 77 +end +function M.builtin(id, base) + if not V.isGen3() or not base or id ~= "TWO_TO_FIVE_ATTACKS_EFFECT" then + return base + end + if cache[base] then + return cache[base] + end + local out = {} + for key, value in pairs(base) do + out[key] = value + end + out.hitCount = function(ctx) + if not V.isGen3() then + return base.hitCount(ctx) + end + local first = ctx.rng(0, 3) + return first > 1 and ctx.rng(0, 3) % 4 + 2 or first + 2 + end + cache[base] = out + return out +end +function M.sleepTalk(battle) + local frame = battle._gbaMoveFrame + local caller = frame and frame.parent and frame.parent.move + return caller + and (tonumber(caller.index) == 214 or tonumber(caller.gen3Effect) == 97 or caller.id == "SLEEP_TALK") +end +return M diff --git a/src/battle/Gen3NaturePower.lua b/src/battle/Gen3NaturePower.lua new file mode 100644 index 000000000..3b89b9f44 --- /dev/null +++ b/src/battle/Gen3NaturePower.lua @@ -0,0 +1,46 @@ +-- Native terrain caller. Nature Power announces before its child, while the +-- child's PP command owns the original selected slot and child target rules. +local N = {} +local V = require("src.core.GameVersion") +local Strings = require("src.core.Strings") +N.MOVES = { [0] = 78, 75, 89, 56, 57, 61, 157, 247, 129, 129 } +local TERRAIN = { + GRASS = 0, + LONG_GRASS = 1, + SAND = 2, + UNDERWATER = 3, + WATER = 4, + POND = 5, + MOUNTAIN = 6, + CAVE = 7, + BUILDING = 8, + PLAIN = 9, +} +function N.terrain(battle) + if battle.terrain ~= nil then + return tonumber(battle.terrain) or 8 + end + local terrain = battle.gen3Terrain or require("src.battle.Gen3Battle").terrainFor(battle.game) + return TERRAIN[terrain] or tonumber(terrain) or 8 +end +function N.pick(ctx) + if not V.isGen3() then + return nil + end + local number = N.MOVES[N.terrain(ctx.battle)] or 129 + local id, move = require("src.battle.EnemyMoveChoice").resolveMove(ctx.data, number) + if not id then + ctx.say(Strings("But, it failed!")) + return nil + end + ctx.say(Strings("NATURE POWER turned into\n%s!", move.name)) + return id +end +function N.call(ctx, pick) + local frame = ctx.battle._gbaMoveFrame + return require("src.battle.Gen3CalledMove").call(ctx, pick, frame and frame.ppIndex) +end +function N.record() + return { kind = "full", announceAnim = false, gbaNaturePower = true, callsMove = N.pick } +end +return N diff --git a/src/battle/Gen3OHKO.lua b/src/battle/Gen3OHKO.lua new file mode 100644 index 000000000..db49bc8f0 --- /dev/null +++ b/src/battle/Gen3OHKO.lua @@ -0,0 +1,74 @@ +-- GBA's OHKO command adjusts held-item damage before Sturdy/level/chance. +local M = {} +local Strings = require("src.core.Strings") +local cache = setmetatable({}, { __mode = "k" }) +local function gate(ctx) + local battle, user, target = ctx.battle, ctx.user, ctx.target + local Abilities = require("src.battle.Abilities") + local kind = Abilities.normalizeType(ctx.move.type) + if + require("src.battle.TypeChart").effectiveness(ctx.move.type, target.curTypes or target.mon.types or {}) + == 0 + or kind == "GROUND" and Abilities.of(target) == "LEVITATE" + then + return false, Strings("It doesn't affect\n%s!", ctx.displayName(target)) + end + ctx.gbaFocusBand = battle:focusBandHolds(target, target.mon.hp) + if Abilities.refusesOHKO(target) then + return false, Strings("%s's STURDY\nmade the move fail!", ctx.displayName(target)) + end + local userLevel, targetLevel = tonumber(user.mon.level) or 1, tonumber(target.mon.level) or 1 + if require("src.battle.Gen3LockOn").active(battle, user, target) and userLevel >= targetLevel then + return true + end + local chance = (tonumber(ctx.move.accuracy) or 30) + userLevel - targetLevel + if ctx.rng(1, 100) < chance and userLevel >= targetLevel then + return true + end + ctx.gbaOHKOMiss = true + return false, + userLevel < targetLevel and Strings("%s is unaffected!", ctx.displayName(target)) or Strings( + "%s's\nattack missed!", + ctx.displayName(user) + ) +end +local function chooseDamage(ctx) + local target = ctx.target + local endured = target.enduring == true + local hung = not endured and ctx.gbaFocusBand == true + local damage = target.mon.hp + if endured or hung then + damage = math.max(0, damage - 1) + end + return damage, + { + crit = false, + typeMult = 10, + ohko = not (endured or hung), + damageAdjustment = { bandRolled = true, endured = endured, hung = hung }, + } +end +function M.record(base, vanilla) + -- Explicit custom gate/damage/accuracy policies retain their own contract. + if + not base + or base.gate ~= vanilla.gate + or base.chooseDamage ~= vanilla.chooseDamage + or base.accuracyRaw ~= vanilla.accuracyRaw + or base.alwaysHits ~= vanilla.alwaysHits + then + return base + end + if cache[base] then + return cache[base] + end + local record = {} + for key, value in pairs(base) do + record[key] = value + end + record.gate, record.chooseDamage, record.alwaysHits = gate, chooseDamage, true + record.gbaOHKO = true + cache[base] = record + return record +end +return M diff --git a/src/battle/Gen3OrderCore.lua b/src/battle/Gen3OrderCore.lua new file mode 100644 index 000000000..4fddce2b1 --- /dev/null +++ b/src/battle/Gen3OrderCore.lua @@ -0,0 +1,84 @@ +-- Native GBA speed arithmetic and GetWhoStrikesFirst result codes. +local C = {} +local bit = require("bit") +local ratios = { + { 10, 40 }, + { 10, 35 }, + { 10, 30 }, + { 10, 25 }, + { 10, 20 }, + { 10, 15 }, + { 10, 10 }, + { 15, 10 }, + { 20, 10 }, + { 25, 10 }, + { 30, 10 }, + { 35, 10 }, + { 40, 10 }, +} +C.effectNames, C.effectIds = {}, {} +for name in + ([[RESTORE_HP CURE_PAR CURE_SLP CURE_PSN CURE_BRN CURE_FRZ RESTORE_PP CURE_CONFUSION CURE_STATUS CONFUSE_SPICY CONFUSE_DRY CONFUSE_SWEET CONFUSE_BITTER CONFUSE_SOUR ATTACK_UP DEFENSE_UP SPEED_UP SP_ATTACK_UP SP_DEFENSE_UP CRITICAL_UP RANDOM_STAT_UP EVASION_UP RESTORE_STATS MACHO_BRACE EXP_SHARE QUICK_CLAW FRIENDSHIP_UP CURE_ATTRACT CHOICE_BAND FLINCH BUG_POWER DOUBLE_PRIZE REPEL SOUL_DEW DEEP_SEA_TOOTH DEEP_SEA_SCALE CAN_ALWAYS_RUN PREVENT_EVOLVE FOCUS_BAND LUCKY_EGG SCOPE_LENS STEEL_POWER LEFTOVERS DRAGON_SCALE LIGHT_BALL GROUND_POWER ROCK_POWER GRASS_POWER DARK_POWER FIGHTING_POWER ELECTRIC_POWER WATER_POWER FLYING_POWER POISON_POWER ICE_POWER GHOST_POWER PSYCHIC_POWER FIRE_POWER DRAGON_POWER NORMAL_POWER UP_GRADE SHELL_BELL LUCKY_PUNCH METAL_POWDER THICK_CLUB STICK]]):gmatch( + "%S+" + ) +do + C.effectNames[#C.effectNames + 1] = name + C.effectIds[name] = #C.effectNames +end +function C.weather(value) + if type(value) == "number" then + return value + end + value = tostring(value or ""):upper() + if value == "RAIN" or value == "RAINY" or value == "DOWNPOUR" then + return 7 + end + if value == "SUN" or value == "SUNNY" or value == "HARSH_SUN" then + return 0x60 + end + return 0 +end +function C.paralysed(value) + if type(value) == "number" then + return bit.band(value, 64) ~= 0 + end + return value == "PAR" or value == "PARALYSIS" +end +function C.speed(v, field, adjust) + local mul = field.weatherActive + and ((v.ability == 33 and bit.band(field.weather, 7) ~= 0) or (v.ability == 34 and bit.band( + field.weather, + 0x60 + ) ~= 0)) + and 2 + or 1 + local ratio = ratios[math.max(1, math.min(13, math.floor(v.stage or 0) + 7))] + local value = math.floor((math.floor(v.speed or 0) % 65536) * mul * ratio[1] / ratio[2]) + local noBadge = field.link or field.emerald and (field.frontier or field.recordedLink) + if v.player and field.badge and not noBadge then + value = math.floor(value * 110 / 100) + end + if v.effect == 24 then + value = math.floor(value / 2) + end + if C.paralysed(v.status) then + value = math.floor(value / 4) + end + if adjust then + value = adjust(value) + end + if v.effect == 26 and field.turnWord < math.floor(65535 * (v.parameter or 0) / 100) then + value = 4294967295 + end + return value +end +function C.result(a, b, draw) + if a < b then + return 1 + end + if a > b then + return 0 + end + return math.floor(draw()) % 2 == 1 and 2 or 0 +end +return C diff --git a/src/battle/Gen3PPCommand.lua b/src/battle/Gen3PPCommand.lua new file mode 100644 index 000000000..4d33222b2 --- /dev/null +++ b/src/battle/Gen3PPCommand.lua @@ -0,0 +1,54 @@ +-- PP admission precedes the script; the PP command itself reads the current +-- selected slot after public callbacks. Nature Power lends its selected slot +-- to its child; ordinary called children have no selected PP. +local Choices = require("src.battle.EnemyMoveChoice") +local P = {} +function P.prepare(battle, user, target, action, continuation, called) + local index = tonumber(action.slot) + if not index then + local owner = Choices.slot(action) + for i = 1, 4 do + if (user.curMoves or user.mon.moves or {})[i] == owner then + index = i + break + end + end + end + local frame = battle._gbaMoveFrame + local calledSlot = called and frame and frame.calledPPSlot + if calledSlot then + index = calledSlot + end + local skip = continuation + or (called and not calledSlot) + or action.struggle + or require("src.battle.Gen3ChoiceBand").enemyUnlimited(battle, user) + local move = battle:moveDef(action) + local number = move and tonumber(move.index or move.numId) + local current = index and (user.curMoves or user.mon.moves or {})[index] + local command = { + index = index, + blocked = not called and not skip and number ~= 165 and current and tonumber(current.pp) == 0 or false, + done = false, + } + function command.reduce() + if command.done then + return action + end + command.done = true + if skip or not index then + return action + end + local slot = (user.curMoves or user.mon.moves or {})[index] + if Choices.slot(action) ~= slot then + action = Choices.withMove(action, action.id, slot or { id = action.id, pp = 1 }, index) + end + if slot and tonumber(slot.pp) then + local cost = require("src.battle.Targeting").gen3PPCost(battle, user, target, move) + slot.pp = math.max(0, tonumber(slot.pp) - cost) + end + return action + end + return command +end +return P diff --git a/src/battle/Gen3PostHitItems.lua b/src/battle/Gen3PostHitItems.lua new file mode 100644 index 000000000..464ff0a12 --- /dev/null +++ b/src/battle/Gen3PostHitItems.lua @@ -0,0 +1,86 @@ +-- Native move-end command shared by King's Rock and Shell Bell. It reads +-- once, with a second player-slot read only for the flinch effect. +local V = require("src.core.GameVersion") +local Held = require("src.battle.HoldItems") +local Abilities = require("src.battle.Abilities") +local Strings = require("src.core.Strings") +local P = {} +function P.markMoved(battle, actor) + actor.gbaMovedTurn = battle.turnCount or 0 +end +function P.moved(battle, actor) + return actor.gbaMovedTurn ~= nil and actor.gbaMovedTurn == (battle.turnCount or 0) +end +function P.damage(battle, damage, dealt, substitute) + local frame = battle._gbaMoveFrame + if not frame then + return + end + if (frame.firstDmg or 0) == 0 then + local rs = V.get() == "ruby" or V.get() == "sapphire" + frame.firstDmg = substitute and not rs and damage or dealt + end + if not substitute and dealt > 0 then + frame.targetDamaged = true + end +end +local function maxHP(actor) + return actor.mon.stats and actor.mon.stats.hp or actor.mon.maxHp or actor.mon.hp +end +function P.run(battle, frame) + if not V.isGen3() or not frame or not frame.user or not frame.target or (frame.firstDmg or 0) == 0 then + return false + end + local user, target = frame.user, frame.target + local effect, parameter, def, changed = Held.effectOf(user, "flinch") + if effect == "FLINCH" then + -- The native command reads the canonical player battler even for an + -- enemy attacker. A changed first answer alone selects its parameter. + local player = battle.linkRole == "guest" and battle.enemy or battle.player + local _, chance = Held.effectOf(player, "flinch") + if changed then + chance = parameter + end + if + not frame.noEffect + and frame.targetDamaged + and (battle.rng or love.math.random)(0, 99) < (tonumber(chance) or 0) + and frame.move + and frame.move.flags + and math.floor((tonumber(frame.move.flags) or 0) / 32) % 2 == 1 + and target.mon.hp > 0 + then + if + Abilities.of(target) ~= "SHIELD_DUST" + and not (target.substituteHP and target.substituteHP > 0) + and not Abilities.refusesFlinch(target) + and not P.moved(battle, target) + then + target.flinched = true + end + return true + end + elseif effect == "SHELL_BELL" then + if not frame.noEffect and user ~= target and user.mon.hp ~= maxHP(user) and user.mon.hp > 0 then + local amount = math.floor(frame.firstDmg / math.max(1, tonumber(parameter) or 0)) + if amount == 0 then + amount = 1 + end + frame.firstDmg = 0 + battle.nextInsert = (battle.nextInsert or 0) + 1 + table.insert(battle.queue, battle.nextInsert, { + anim = "HELD_ITEM_EFFECT", + attackerIsPlayer = user.isPlayer, + attackerPosition = user.position, + targetPosition = user.position, + }) + local label = def and def.name or "the item" + battle:sayNext(Strings("%s's %s restored\na little HP!", user.name or user.mon.nickname, label)) + user.mon.hp = math.min(maxHP(user), user.mon.hp + amount) + battle:drainNext(user, user.mon.hp) + return true + end + end + return false +end +return P diff --git a/src/battle/Gen3Rage.lua b/src/battle/Gen3Rage.lua new file mode 100644 index 000000000..94b84f2a5 --- /dev/null +++ b/src/battle/Gen3Rage.lua @@ -0,0 +1,80 @@ +-- Native Rage is a move-end flag, not a free-PP move lock. +local R = {} +local V = require("src.core.GameVersion") +local Strings = require("src.core.Strings") +local cache = setmetatable({}, { __mode = "k" }) +function R.activate(ctx) + if V.isGen3() then + ctx.user.rage = true + end +end +function R.builtin(id, base) + if not V.isGen3() or not base or id ~= "RAGE_EFFECT" then + return base + end + if cache[base] then + return cache[base] + end + local out = {} + for key, value in pairs(base) do + out[key] = value + end + out.gbaAfterAccuracy = R.activate + out.afterDamage = function(ctx, ...) + if V.isGen3() then + return R.activate(ctx) + end + if base.afterDamage then + return base.afterDamage(ctx, ...) + end + end + out.onMiss = function(ctx, reason) + if V.isGen3() and reason ~= "immune" and reason ~= "floored" then + ctx.user.rage = nil + end + if base.onMiss then + return base.onMiss(ctx, reason) + end + end + out.onProtect = function(ctx) + if V.isGen3() then + ctx.user.rage = nil + end + if base.onProtect then + return base.onProtect(ctx) + end + end + cache[base] = out + return out +end +function R.selected(battle, user, action) + if not V.isGen3() or not user or not user.rage then + return + end + local def = action and not action.special and battle:moveDef(action) + local number = def and tonumber(def.index or def.numId) + if number ~= 99 and not (not number and action and action.id == "RAGE") then + user.rage = nil + end +end +function R.moveEnd(battle, ctx, landed) + local user, target = ctx.user, ctx.target + if + target + and target ~= user + and target.rage + and target.mon.hp > 0 + and battle:sideOf(user) ~= battle:sideOf(target) + and not ctx.gbaNoEffect + and landed > 0 + and (tonumber(ctx.move.power) or 0) > 0 + and (target.stages.attack or 0) < 6 + then + target.stages.attack = (target.stages.attack or 0) + 1 + battle:sayNext(Strings("%s's\nRAGE is building!", ctx.displayName(target))) + end +end +function R.signature(user) + return ":rage=" .. tostring(not not user.rage) +end +return R diff --git a/src/battle/Gen3Secondary.lua b/src/battle/Gen3Secondary.lua new file mode 100644 index 000000000..d0b252e64 --- /dev/null +++ b/src/battle/Gen3Secondary.lua @@ -0,0 +1,169 @@ +-- Native post-hit chance command and the ordinary secondary effect family. +local Abilities = require("src.battle.Abilities") +local StatusRegistry = require("src.battle.StatusRegistry") +local Strings = require("src.core.Strings") +local S = {} +local effects = { + [2] = { status = "PSN" }, + [4] = { status = "BRN" }, + [5] = { status = "FRZ" }, + [6] = { status = "PAR" }, + [31] = { flinch = true }, + [36] = { tri = true }, + [68] = { stat = "attack", delta = -1 }, + [69] = { stat = "defense", delta = -1 }, + [70] = { stat = "speed", delta = -1 }, + [71] = { stat = "spatk", delta = -1 }, + [72] = { stat = "spdef", delta = -1 }, + [73] = { stat = "accuracy", delta = -1 }, + [76] = { confusion = true }, + [77] = { status = "PSN" }, + [92] = { flinch = true }, + [125] = { status = "BRN" }, + [138] = { stat = "defense", delta = 1, user = true }, + [139] = { stat = "attack", delta = 1, user = true }, + [140] = { allStats = true, user = true }, + [146] = { flinch = true }, + [150] = { flinch = true }, + [152] = { status = "PAR" }, + [158] = { flinch = true, certain = true }, + [200] = { status = "BRN" }, + [202] = { status = "TOX" }, + [209] = { status = "PSN" }, +} +local function hasType(who, kind) + for _, t in ipairs(who.curTypes or {}) do + if Abilities.normalizeType(t) == kind then + return true + end + end + return false +end +local function active(value) + return value == true or type(value) == "number" and value > 0 +end +local function protected(battle, target, spec) + if (target.mon.hp or 0) <= 0 then + return true + end + if (battle.pokedudeDemo or battle.pokedude) and not target.isPlayer then + return true + end + if spec.user then + return false + end + if (target.substituteHP or 0) > 0 then + return true + end + local ability = Abilities.of(target) + if ability == "SHIELD_DUST" and (spec.status or spec.confusion or spec.flinch or spec.tri) then + return true + end + if active(target.safeguardTurns) and (spec.status or spec.confusion) then + return true + end + return false +end +function S.withChance(ctx, spec) + local battle, user = ctx.battle, ctx.user + local chance = tonumber(ctx.move.secondaryChance) or 0 + if Abilities.of(user) == "SERENE_GRACE" then + chance = chance * 2 + end + -- The native command draws before checking Substitute, fainting or abilities; + -- explicit zero stays zero and its inclusive comparison remains observable. + if not spec.certain and battle.rng(0, 99) > chance then + return false, {} + end + local target = spec.user and user or ctx.target + if protected(battle, target, spec) then + return false, {} + end + if spec.tri then + if target.mon.status then + return false, {} + end + return S.apply(ctx, { status = ({ "BRN", "FRZ", "PAR" })[battle.rng(0, 2) + 1] }, chance) + end + return S.apply(ctx, spec, spec.certain and 100 or chance) +end +function S.apply(ctx, spec, chance) + local battle, user = ctx.battle, ctx.user + local target = spec.user and user or ctx.target + local ability = Abilities.of(target) + if spec.status then + if spec.status == "FRZ" and require("src.battle.Gen3BattleWeather").effective(battle) == "SUN" then + return false, {} + end + local before = target.mon.status + local messages = StatusRegistry.inflict(battle, target, spec.status == "TOX" and "PSN" or spec.status, { + secondary = true, + nativeGbaSecondary = true, + moveType = ctx.move.type, + source = user, + user = user, + toxic = spec.status == "TOX", + }) + return target.mon.status ~= before, messages + elseif spec.confusion then + if ability == "OWN_TEMPO" or (target.confusedTurns or 0) > 0 then + return false, {} + end + target.confusedTurns = battle.rng(0, 3) % 4 + 2 + return true, { Strings("%s became\nconfused!", ctx.displayName(target)) } + elseif spec.flinch then + if ability == "INNER_FOCUS" then + return false, {} + end + if not target.expMovedThisTurn then + target.flinched = true + end + return true, {} + elseif spec.stat or spec.allStats then + local changed = false + for _, stat in + ipairs(spec.allStats and { "attack", "defense", "speed", "spatk", "spdef" } or { spec.stat }) + do + local delta = spec.delta or 1 + local blocked = delta < 0 + and ( + ability == "SHIELD_DUST" + or chance < 100 and (active(target.mist) or ability == "CLEAR_BODY" or ability == "WHITE_SMOKE") + or chance < 100 + and (ability == "KEEN_EYE" and stat == "accuracy" or ability == "HYPER_CUTTER" and stat == "attack") + ) + if not blocked then + local before = target.stages[stat] or 0 + local after = math.max(-6, math.min(6, before + delta)) + target.stages[stat] = after + if after ~= before then + target.hazeStatReset = nil + battle:sayNext( + require("src.battle.MoveEffects").stageMessage(ctx.displayName(target), stat, after - before) + ) + changed = true + end + end + end + return changed, {} + end + return false, {} +end +function S.run(ctx, record) + local spec = effects[tonumber(ctx.move.gen3Effect)] + if not spec then + return false + end + local native = require("src.battle.MoveEffects").RECORDS[ctx.move.effect] + -- Authored effect handlers retain their own semantics and chance policy. + if not record or not native or record.run ~= native.run then + return false + end + if ctx.gbaNoEffect then return true end + local _, messages = S.withChance(ctx, spec) + for _, message in ipairs(messages) do + ctx.battle:sayNext(message) + end + return true +end +return S diff --git a/src/battle/Gen3SleepTalk.lua b/src/battle/Gen3SleepTalk.lua new file mode 100644 index 000000000..23f7c1d9c --- /dev/null +++ b/src/battle/Gen3SleepTalk.lua @@ -0,0 +1,133 @@ +-- Native Sleep Talk selection/dispatch and the sleep precheck. Other attack +-- canceller phases retain their existing controller and are audited separately. +local S = {} +local V = require("src.core.GameVersion") +local Strings = require("src.core.Strings") +local cache, statusCache = setmetatable({}, { __mode = "k" }), setmetatable({}, { __mode = "k" }) +local EXCLUDED = { [214] = true, [274] = true, [119] = true, [118] = true, [264] = true, [253] = true } +local TWO_TURN = { [26] = true, [39] = true, [75] = true, [145] = true, [151] = true, [155] = true } +local function number(move) + return move and tonumber(move.index or move.numId) or move and ({ SLEEP_TALK = 214, SNORE = 173 })[move.id] +end +local function clear(user) + user.mon.status, user.sleepTurns, user.nightmare, user.expNightmare = nil, nil, nil, nil + user.mon.sleepTurns = 0 +end +function S.beforeMove(user, rng, battle, move) + local ability = require("src.battle.Abilities").of(user) + if ability ~= "SOUNDPROOF" then + for id = 0, 3 do + local b = battle + and ( + battle.battlerAt and battle:battlerAt(id) + or (id == 0 and battle.player or id == 1 and battle.enemy) + ) + if b and b.mon.hp > 0 and (tonumber(b.uproarTurns) or 0) > 0 then + clear(user) + return true, { Strings("%s woke up in\nthe UPROAR!", user.name) } + end + end + end + local turns = tonumber(user.sleepTurns) or tonumber(user.mon.sleepTurns or user.mon.sleep) + if not turns or turns <= 0 then + turns = rng(0, 3) % 4 + 2 + end + turns = math.max(0, turns - (ability == "EARLY_BIRD" and 2 or 1)) + user.sleepTurns, user.mon.sleepTurns = turns, turns + if turns == 0 then + clear(user) + return true, { Strings("%s\nwoke up!", user.name) } + end + local n = number(move and battle and battle.data and battle.data.moves[move.id] or move) + if n == 214 then + return true, {} + end + if n == 173 then + return true, { Strings("%s\nis fast asleep!", user.name) } + end + return false, { Strings("%s\nis fast asleep!", user.name) }, false, "gen3_sleep" +end +function S.status(id, base) + if not V.isGen3() or id ~= "SLP" or not base then + return base + end + if statusCache[base] then + return statusCache[base] + end + local out = {} + for k, v in pairs(base) do + out[k] = v + end + out.beforeMove = function(user, rng, battle, move) + if not V.isGen3() then + return base.beforeMove(user, rng, battle, move) + end + return S.beforeMove(user, rng, battle, move) + end + statusCache[base] = out + return out +end +local function noCall(ctx) + -- Native call_moves returns without the move-end script on these failures. + -- The outer action still performs its separate after-action item sweep. + local frame = ctx.battle._gbaMoveFrame + if frame then + frame.finished = true + end + ctx.say(Strings("But, it failed!")) +end +function S.pick(ctx) + local user = ctx.user + if user.mon.status ~= "SLP" then + noCall(ctx) + return + end + local pool = {} + for slot = 1, 4 do + local inst = (user.curMoves or user.mon.moves or {})[slot] + local move = inst and ctx.data.moves[inst.id] + local n = number(move) + if + move + and n ~= 0 + and not EXCLUDED[n] + and not TWO_TURN[tonumber(move.gen3Effect)] + and user.disabledMove ~= inst.id + and not (user.encoreMove and (tonumber(user.encoreTurns) or 0) > 0 and user.encoreMove ~= inst.id) + then + pool[#pool + 1] = inst.id + end + end + if #pool == 0 then + noCall(ctx) + return + end + return pool[ctx.rng(1, #pool)] +end +function S.call(ctx, pick) + return require("src.battle.Gen3CalledMove").call(ctx, pick) +end +function S.run(ctx) + if ctx.user.mon.status == "SLP" then + ctx.battle:statusOnomatopoeia(ctx.user, "sleep") + end + ctx.gbaSleepTalk = true + ctx.user.callsMoveId = S.pick(ctx) + return {} +end +function S.builtin(id, base) + if not V.isGen3() or id ~= "SLEEP_TALK_EFFECT" or not base then + return base + end + if cache[base] then + return cache[base] + end + local out = {} + for k, v in pairs(base) do + out[k] = v + end + out.kind, out.gbaSleepTalk, out.callsMove, out.run = "full", true, S.pick, S.run + cache[base] = out + return out +end +return S diff --git a/src/battle/Gen3SubstituteArt.lua b/src/battle/Gen3SubstituteArt.lua new file mode 100644 index 000000000..dfefcb83a --- /dev/null +++ b/src/battle/Gen3SubstituteArt.lua @@ -0,0 +1,60 @@ +-- The native doll is a 64x64 OBJ centered on the battler's raw coordinates. +local M = {} +local V = require("src.core.GameVersion") +local Assets = require("src.render.Assets") + +function M.pose(battle, battler) + if not V.isGen3() or not battler then + return nil + end + if battle.gen3Layout and not battle:gen3Layout() then + return nil + end + local constants = battle.data and battle.data.constants or {} + local record, coords = constants.gen3SubstituteArt, constants.gen3BattlerCoords + if not record or record.version ~= 1 or record.width ~= 64 or record.height ~= 64 then + return nil + end + local positions = coords and (battle:isDouble() and coords.doubles or coords.singles) + local id = tonumber(battler.position) or (battler.isPlayer and 0 or 1) + local raw = positions and positions[id + 1] + local path = battler.isPlayer and record.back or record.front + if not raw or type(path) ~= "string" then + return nil + end + local dx, dy = battle:gen3AnimShake(battler) + return { + image = path, + x = require("src.battle.Gen3Battle").fieldOffset(battle) + raw.x - 32 + (dx or 0), + y = raw.y + (battler.isPlayer and 17 or 16) - 32 + (dy or 0), + } +end + +function M.draw(battle, battler) + local pose = M.pose(battle, battler) + if not pose then + return false + end + local image = Assets.image(pose.image) + local g = love.graphics + local r, green, blue, coeff + if battle.gen3AnimTint then + r, green, blue, coeff = battle:gen3AnimTint(battler) + end + if coeff and coeff > 0 then + local cr, cg, cb, ca = g.getColor() + local blend, alpha = g.getBlendMode() + g.setColor(cr * (1 - coeff), cg * (1 - coeff), cb * (1 - coeff), ca) + g.draw(image, pose.x, pose.y) + g.setBlendMode("add") + g.setColor(r * coeff, green * coeff, blue * coeff, ca) + g.draw(image, pose.x, pose.y) + g.setBlendMode(blend, alpha) + g.setColor(cr, cg, cb, ca) + else + g.draw(image, pose.x, pose.y) + end + return true +end + +return M diff --git a/src/battle/Gen3SubstitutePresentation.lua b/src/battle/Gen3SubstitutePresentation.lua new file mode 100644 index 000000000..47645a42f --- /dev/null +++ b/src/battle/Gen3SubstitutePresentation.lua @@ -0,0 +1,207 @@ +-- Substitute has independent gameplay HP and controller-owned presentation. +-- A move can expose its user without removing the protective Substitute. +local M = {} +local V = require("src.core.GameVersion") + +local function protected(user) + return user and (tonumber(user.substituteHP) or 0) > 0 +end + +function M.mark(battle, row, user, left) + local frame = battle._gbaMoveFrame + if not V.isGen3() or not frame or not row or not row.anim then + return + end + if not protected(user) then + return + end + frame.presentationOwner = frame.presentationOwner or {} + row.gbaPresentationOwner = frame.presentationOwner + row.gbaBehindSubstitute = protected(user) + row.gbaMultiHitLeft = left or 0 +end + +local function special(row, name) + return { + anim = name, + gbaSubstituteSpecial = true, + gbaSubstituteMove = row.anim, + attackerIsPlayer = row.attackerIsPlayer, + attackerPosition = row.attackerPosition, + targetPosition = row.attackerPosition, + } +end + +-- Finalize only this call's surviving animation rows. A called move owns its +-- own group; a multi-hit call keeps one lowered sprite until its final strike. +function M.finish(battle, frame) + local owner = frame and frame.presentationOwner + if not owner then + return + end + local lowered, i, last = false, 1, 0 + for index in pairs(battle.queue) do + if type(index) == "number" and index % 1 == 0 and index > last then + last = index + end + end + local function insert(index, row) + -- Existing action callbacks can leave holes before later queued rows. + -- Preserve every row and hole instead of relying on Lua's sparse length. + for j = last, index, -1 do + battle.queue[j + 1] = battle.queue[j] + end + battle.queue[index], last = row, last + 1 + if battle.nextInsert and index <= battle.nextInsert + 1 then + battle.nextInsert = battle.nextInsert + 1 + end + end + while i <= last do + local row = battle.queue[i] + if row and row.gbaPresentationOwner == owner then + row.gbaPresentationOwner = nil + if row.gbaBehindSubstitute then + if not lowered then + insert(i, special(row, "SUBSTITUTE_TO_MON")) + lowered, i = true, i + 1 + end + if (row.gbaMultiHitLeft or 0) < 2 then + insert(i + 1, special(row, "MON_TO_SUBSTITUTE")) + lowered, i = false, i + 1 + end + end + row.gbaBehindSubstitute, row.gbaMultiHitLeft = nil, nil + end + i = i + 1 + end +end + +function M.showing(battle, user) + local state = battle._gbaSubstitutePresentation and battle._gbaSubstitutePresentation[user] + if state then + return state.substitute + end + return protected(user) +end + +function M.offset(battle, user) + local state = battle._gbaSubstitutePresentation and battle._gbaSubstitutePresentation[user] + return state and state.x or 0 +end + +local function actor(battle, row) + if row.attackerPosition ~= nil then + return battle:battlerAt(row.attackerPosition) + end + return row.attackerIsPlayer and battle.player or battle.enemy +end + +function M.beginSwap(battle, user, substitute) + battle._gbaSubstitutePresentation = battle._gbaSubstitutePresentation or {} + local states = battle._gbaSubstitutePresentation + states[user] = states[user] or { substitute = protected(user), x = 0 } + local pos = tonumber(user.position) or (user.isPlayer and 0 or 1) + local coords = (battle.data.constants or {}).gen3BattlerCoords + local layout = coords and (battle:isDouble() and coords.doubles or coords.singles) + local point = layout and layout[pos + 1] + return { + user = user, + state = states[user], + phase = "out", + baseX = tonumber(point and point.x) or (user.isPlayer and 72 or 176), + direction = user.isPlayer and -1 or 1, + substitute = substitute, + } +end + +-- Damage calculation can remove the logical protection before its queued +-- break animation runs. Preserve the displayed doll until that command swaps it. +function M.broken(battle, user) + if not V.isGen3() then + return + end + battle._gbaSubstitutePresentation = battle._gbaSubstitutePresentation or {} + local states = battle._gbaSubstitutePresentation + states[user] = states[user] or { substitute = true, x = 0 } + require("src.battle.Gen3EffectPresentation").queue(battle, user, "SUBSTITUTE_FADE") +end + +function M.start(battle, row) + if not V.isGen3() then + return false + end + local user = actor(battle, row) + if not user then + return false + end + local move = battle.data.moves[row.gbaSubstituteMove] + local number = move and tonumber(move.index) + if not battle:animationsOn() and number ~= 144 and number ~= 164 then + return false + end + local lower = row.anim == "SUBSTITUTE_TO_MON" + battle._gbaSubstitutePresentation = battle._gbaSubstitutePresentation or {} + local states = battle._gbaSubstitutePresentation + local state = states[user] + if not state then + state = { substitute = protected(user), x = 0 } + states[user] = state + end + if lower then + if not state.substitute then + return false + end + state.lowered = true + else + if not state.lowered then + return false + end + state.lowered = nil + if not protected(user) then + return false + end + end + battle._gbaSubstituteTransition = M.beginSwap(battle, user, not lower) + return true +end + +-- Native SwapMonSpriteToFromSubstitute moves five pixels per controller tick. +-- The exchange happens offscreen, on its own tick, before the return trip. +function M.stepSwap(transition) + if not transition then + return false + end + local state = transition.state + if transition.phase == "out" then + state.x = state.x + transition.direction * 5 + local right = transition.baseX + state.x + 32 + if right < 0 or right > 304 then + transition.phase = "exchange" + end + elseif transition.phase == "exchange" then + state.substitute = transition.substitute + transition.phase = "in" + else + state.x = state.x - transition.direction * 5 + if state.x * transition.direction <= 0 then + state.x = 0 + state.substitute = transition.substitute + return false + end + end + return true +end + +function M.update(battle) + if M.stepSwap(battle._gbaSubstituteTransition) then + return true + end + battle._gbaSubstituteTransition = nil + return false +end + +function M.reconcile(battle) + battle._gbaSubstituteTransition, battle._gbaSubstitutePresentation = nil, nil +end + +return M diff --git a/src/battle/Gen3TripleKick.lua b/src/battle/Gen3TripleKick.lua new file mode 100644 index 000000000..3c952791e --- /dev/null +++ b/src/battle/Gen3TripleKick.lua @@ -0,0 +1,23 @@ +-- Native GBA Triple Kick owns a distinct per-hit accuracy/power script. +local M = {} +local V = require("src.core.GameVersion") +local cache = setmetatable({}, { __mode = "k" }) +function M.builtin(id, base) + if not V.isGen3() or not base or id ~= "TRIPLE_KICK_EFFECT" then + return base + end + if cache[base] then + return cache[base] + end + local out = {} + for key, value in pairs(base) do + out[key] = value + end + out.gbaTripleKick = true + -- The native opcode assigns 10/20/30, regardless of the extracted power. + -- The earlier-generation approximation remains in the shared static record. + out.powerMultiplier = nil + cache[base] = out + return out +end +return M diff --git a/src/battle/HeldItemRead.lua b/src/battle/HeldItemRead.lua new file mode 100644 index 000000000..e75a3893d --- /dev/null +++ b/src/battle/HeldItemRead.lua @@ -0,0 +1,196 @@ +-- Native held-effect reads share names; native consumers keep their own enums. +local Runtime = require("src.mods.Runtime") +local Version = require("src.core.GameVersion") +local M = {} +local battles = setmetatable({}, { __mode = "kv" }) +local definitions = setmetatable({}, { __mode = "kv" }) + +function M.bind(battler, battle) + if battler and battle then + battles[battler] = battle + end + return battler +end + +local gold = { + [0] = "NONE", + [1] = "BERRY", + [3] = "LEFTOVERS", + [6] = "RESTORE_PP", + [8] = "CLEANSE_TAG", + [10] = "HEAL_POISON", + [11] = "HEAL_FREEZE", + [12] = "HEAL_BURN", + [13] = "HEAL_SLEEP", + [14] = "HEAL_PARALYZE", + [15] = "HEAL_STATUS", + [16] = "HEAL_CONFUSION", + [42] = "METAL_POWDER", + [50] = "NORMAL_BOOST", + [51] = "FIGHTING_BOOST", + [52] = "FLYING_BOOST", + [53] = "POISON_BOOST", + [54] = "GROUND_BOOST", + [55] = "ROCK_BOOST", + [56] = "BUG_BOOST", + [57] = "GHOST_BOOST", + [58] = "FIRE_BOOST", + [59] = "WATER_BOOST", + [60] = "GRASS_BOOST", + [61] = "ELECTRIC_BOOST", + [62] = "PSYCHIC_BOOST", + [63] = "ICE_BOOST", + [64] = "DRAGON_BOOST", + [65] = "DARK_BOOST", + [66] = "STEEL_BOOST", + [72] = "ESCAPE", + [73] = "CRITICAL_UP", + [74] = "QUICK_CLAW", + [75] = "FLINCH", + [76] = "AMULET_COIN", + [77] = "BRIGHTPOWDER", + [79] = "FOCUS_BAND", + -- A public substitution, not a fabricated cartridge enum or saved item ID. + PREVENT_CONFUSE = "PREVENT_CONFUSE", +} +local goldIds = {} +for id, name in pairs(gold) do + gold[id] = "HELD_" .. name + goldIds[gold[id]] = id +end +function M.goldNative(name) + return goldIds[name] or name +end +function M.writeDefinition(view, key, value) + view[key] = value + local def = definitions[view] + if not def then + return + end + if key == "heldEffect" then + def.heldEffect = M.goldNative(value) + elseif key == "heldParameter" then + def.heldParam = value + else + def[key] = value + end +end + +local gba = { + RESTORE_HP = "BERRY", + CURE_PAR = "HEAL_PARALYZE", + CURE_SLP = "HEAL_SLEEP", + CURE_PSN = "HEAL_POISON", + CURE_BRN = "HEAL_BURN", + CURE_FRZ = "HEAL_FREEZE", + CURE_CONFUSION = "HEAL_CONFUSION", + CURE_STATUS = "HEAL_STATUS", + CRITICAL_UP = "PINCH_CRITICAL_UP", + SCOPE_LENS = "CRITICAL_UP", + EVASION_UP = "BRIGHTPOWDER", + DOUBLE_PRIZE = "AMULET_COIN", + REPEL = "CLEANSE_TAG", + CAN_ALWAYS_RUN = "ESCAPE", +} +local gbaNames = [[ +RESTORE_HP CURE_PAR CURE_SLP CURE_PSN CURE_BRN CURE_FRZ RESTORE_PP CURE_CONFUSION CURE_STATUS +CONFUSE_SPICY CONFUSE_DRY CONFUSE_SWEET CONFUSE_BITTER CONFUSE_SOUR ATTACK_UP DEFENSE_UP +SPEED_UP SP_ATTACK_UP SP_DEFENSE_UP CRITICAL_UP RANDOM_STAT_UP EVASION_UP RESTORE_STATS +MACHO_BRACE EXP_SHARE QUICK_CLAW FRIENDSHIP_UP CURE_ATTRACT CHOICE_BAND FLINCH BUG_POWER +DOUBLE_PRIZE REPEL SOUL_DEW DEEP_SEA_TOOTH DEEP_SEA_SCALE CAN_ALWAYS_RUN PREVENT_EVOLVE +FOCUS_BAND LUCKY_EGG SCOPE_LENS STEEL_POWER LEFTOVERS DRAGON_SCALE LIGHT_BALL GROUND_POWER +ROCK_POWER GRASS_POWER DARK_POWER FIGHTING_POWER ELECTRIC_POWER WATER_POWER FLYING_POWER +POISON_POWER ICE_POWER GHOST_POWER PSYCHIC_POWER FIRE_POWER DRAGON_POWER NORMAL_POWER +UP_GRADE SHELL_BELL LUCKY_PUNCH METAL_POWDER THICK_CLUB STICK +]] +local gbaIds = {} +for name in gbaNames:gmatch("%S+") do + local public = "HELD_" .. (gba[name] or name:gsub("_POWER$", "_BOOST")) + gba[name], gbaIds[public] = public, name +end +local byteChance = { QUICK_CLAW = true, EVASION_UP = true, FOCUS_BAND = true, FLINCH = true } + +function M.read(holder, def, effect, parameter, trigger, generation) + -- Field/party item scans and other generations do not acquire a battle hook. + if + not (holder and holder.mon) + or Version.generation() ~= generation + or not Runtime.wantsHook("held_item.trigger") + then + return effect, parameter + end + local names = generation == 2 and gold or gba + local ids = generation == 2 and goldIds or gbaIds + local public = names[effect] + if generation == 2 then + if not def then + public = nil + elseif type(effect) == "string" and not public then + public = effect + end + end + local value = parameter + if generation == 3 and byteChance[effect] then + value = math.floor(value * 256 / 100) + end + local item = holder.mon.item or holder.mon.heldItem + if generation == 3 and item == nil then item = 0 end + local view + if def then + view = {} + for k, v in pairs(def) do + view[k] = v + end + view.id, view.heldEffect, view.heldParameter = item, public, value + if generation == 2 then + definitions[view] = def + end + end + local before = {} + for k, v in pairs(view or {}) do + before[k] = v + end + local chosen, amount = Runtime.call("held_item.trigger", function(ctx) + return ctx.effect, ctx.parameter + end, { + battle = battles[holder], + mon = holder.mon, + item = item, + def = view, + effect = public, + parameter = value, + trigger = trigger or "check", + }) + -- API-3 commits write at next/return boundaries. Legacy native callbacks + -- write the temporary view directly; settle their scalar metadata here. + if view then + local keys = {} + for k in pairs(before) do + keys[k] = true + end + for k in pairs(view) do + keys[k] = true + end + for k in pairs(keys) do + if view[k] ~= before[k] and type(view[k]) ~= "function" and type(view[k]) ~= "table" then + M.writeDefinition(view, k, view[k]) + end + end + end + if type(chosen) ~= "string" then + return generation == 2 and 0 or nil, 0, true + end + amount = tonumber(amount) or value + if chosen == public and amount == value then + return effect, parameter, false + end + local native = ids[chosen] or (generation == 2 and chosen or nil) + if not native then + return generation == 2 and 0 or nil, amount, true + end + if generation == 3 and byteChance[native] then + amount = math.max(0, math.min(256, amount)) * 100 / 256 + end + return native, amount, true +end +return M diff --git a/src/battle/HeldItems.lua b/src/battle/HeldItems.lua index 195f4ad0b..cffbe23bf 100644 --- a/src/battle/HeldItems.lua +++ b/src/battle/HeldItems.lua @@ -49,6 +49,7 @@ HeldItems.EFFECT = { AMULET_COIN = 76, BRIGHTPOWDER = 77, FOCUS_BAND = 79, + PREVENT_CONFUSE = "PREVENT_CONFUSE", } local TYPE_BY_EFFECT = { @@ -103,7 +104,7 @@ function HeldItems.itemKey(data, holder) return norm(key or (def and (def.key or def.name)) or id) end -function HeldItems.effect(data, holder) +local function nativeEffect(data, holder) local def, id = itemDef(data, holder) if type(def) ~= "table" then return HeldItems.EFFECT.NONE, 0 end -- Modern Polished berries are handled by HoldItems; their enum values @@ -123,10 +124,16 @@ function HeldItems.effect(data, holder) return HeldItems.EFFECT.NONE, 0 end - return tonumber(def.heldEffect) or HeldItems.EFFECT.NONE, + return tonumber(def.heldEffect) or require("src.battle.HeldItemRead").goldNative(def.heldEffect) or HeldItems.EFFECT.NONE, tonumber(def.heldParam) or 0 end +function HeldItems.effect(data, holder, trigger) + local effect, param = nativeEffect(data, holder) + local def = itemDef(data, holder) + return require("src.battle.HeldItemRead").read(holder, def, effect, param, trigger, 2) +end + local function speciesKey(holder) local mon = monOf(holder) return norm(mon and mon.species) @@ -173,19 +180,19 @@ end -- ------------------------------------------------------------------------- function HeldItems.quickClaw(data, holder, rng) - local effect, param = HeldItems.effect(data, holder) + local effect, param = HeldItems.effect(data, holder, "priority") if effect ~= HeldItems.EFFECT.QUICK_CLAW then return false end rng = rng or love.math.random return rng(0, 255) < param end function HeldItems.accuracyPenalty(data, holder) - local effect, param = HeldItems.effect(data, holder) + local effect, param = HeldItems.effect(data, holder, "accuracy") return effect == HeldItems.EFFECT.BRIGHTPOWDER and param or 0 end function HeldItems.criticalStageBonus(data, holder) - local effect = HeldItems.effect(data, holder) + local effect = HeldItems.effect(data, holder, "damage") if effect == HeldItems.EFFECT.CRITICAL_UP then return 1 end local key, species = HeldItems.itemKey(data, holder), speciesKey(holder) if key == "STICK" and species == "FARFETCHD" then return 2 end @@ -199,7 +206,7 @@ end function HeldItems.modifyBattleStats(data, attacker, defender, atkStat, defStat, atk, dfn) local aKey, aSpecies = HeldItems.itemKey(data, attacker), speciesKey(attacker) - local dEffect = HeldItems.effect(data, defender) + local dEffect = HeldItems.effect(data, defender, "damage") local dSpecies = speciesKey(defender) if aKey == "THICK_CLUB" and (aSpecies == "CUBONE" or aSpecies == "MAROWAK") @@ -222,7 +229,7 @@ function HeldItems.modifyBattleStats(data, attacker, defender, atkStat, defStat, end function HeldItems.applyTypeBoost(data, attacker, moveType, damage) - local effect, param = HeldItems.effect(data, attacker) + local effect, param = HeldItems.effect(data, attacker, "damage") if TYPE_BY_EFFECT[effect] ~= moveType then return damage end -- ItemAttributes stores 10 for the retail type boosters: x1.10, with the -- floor occurring here inside the damage pipeline. @@ -231,10 +238,10 @@ end function HeldItems.limitDirectDamage(data, target, damage, rng) if not isAlive(target) or damage <= 0 then return damage end - local effect, param = HeldItems.effect(data, target) - if effect ~= HeldItems.EFFECT.FOCUS_BAND then return damage end local hp = tonumber(monOf(target).hp) or 0 - if hp <= 1 or damage < hp then return damage end + if hp <= 0 or damage < hp then return damage end + local effect, param = HeldItems.effect(data, target, "endure") + if effect ~= HeldItems.EFFECT.FOCUS_BAND then return damage end rng = rng or love.math.random if rng(0, 255) < param then return hp - 1 end return damage @@ -298,7 +305,7 @@ function HeldItems.tryKingsRock(data, user, target, move, rng, blockedBySubstitu if blockedBySubstitute or not isAlive(target) or not HeldItems.supportsKingsRock(move) then return false end - local effect, param = HeldItems.effect(data, user) + local effect, param = HeldItems.effect(data, user, "flinch") if effect ~= HeldItems.EFFECT.FLINCH then return false end rng = rng or love.math.random if rng(0, 255) < param then @@ -344,7 +351,7 @@ end function HeldItems.onStatus(battle, holder) if not (battle and isAlive(holder)) then return {} end - local effect = HeldItems.effect(battle.data, holder) + local effect = HeldItems.effect(battle.data, holder, "status") local wanted = STATUS_BY_EFFECT[effect] local changed = false if wanted then @@ -367,11 +374,11 @@ end function HeldItems.onConfusion(battle, holder) if not (battle and isAlive(holder)) or not holder.confusedTurns then return {} end - local effect = HeldItems.effect(battle.data, holder) + local effect, _, changed = HeldItems.effect(battle.data, holder, "status") local key = HeldItems.itemKey(battle.data, holder) if effect ~= HeldItems.EFFECT.HEAL_CONFUSION and effect ~= HeldItems.EFFECT.HEAL_STATUS - and key ~= "BITTER_BERRY" and key ~= "MIRACLE_BERRY" then + and (changed or (key ~= "BITTER_BERRY" and key ~= "MIRACLE_BERRY")) then return {} end holder.confusedTurns = nil @@ -401,7 +408,7 @@ end local function processLeftovers(battle, holder) if not isAlive(holder) then return end - local effect = HeldItems.effect(battle.data, holder) + local effect = HeldItems.effect(battle.data, holder, "residual") if effect ~= HeldItems.EFFECT.LEFTOVERS then return end local mon = monOf(holder) local maxHP = mon.stats and tonumber(mon.stats.hp) @@ -414,7 +421,7 @@ end local function processRestorePP(battle, holder) if not isAlive(holder) then return end - local effect = HeldItems.effect(battle.data, holder) + local effect = HeldItems.effect(battle.data, holder, "pp") if effect == HeldItems.EFFECT.RESTORE_PP and restorePP(battle.data, holder) then consume(holder) battle:sayNext(Strings("%s's held item\nrestored PP!", displayName(holder))) @@ -423,11 +430,11 @@ end local function processHealingItem(battle, holder) if not isAlive(holder) then return end - local effect, param = HeldItems.effect(battle.data, holder) + local effect, param = HeldItems.effect(battle.data, holder, "residual") local mon = monOf(holder) if effect == HeldItems.EFFECT.BERRY then local maxHP = mon.stats and tonumber(mon.stats.hp) - if maxHP and mon.hp * 2 <= maxHP then + if maxHP and mon.hp * 2 < maxHP then local got = heal(holder, param) if got > 0 then consume(holder) @@ -463,6 +470,12 @@ end -- that legacy bug by initializing a normal Gen II confusion duration. function HeldItems.onEntry(battle, holder) if not (battle and isAlive(holder)) then return false end + if require("src.core.GameVersion").isGen3() then + if battle._gbaInitialItemEntries then + return require("src.battle.GbaItemSchedule").initialLinkEntry(battle, holder) + end + return battle:holdItemTrigger(holder, "entry") + end -- PLATINUM'S AMULET COIN counts on EITHER side: battle_lib.c's -- SWITCH_IN_CHECK_STATE_AMULET_COIN walks every battler on the field, and its -- items name the effect "MONEY_UP" rather than the Game Boy number. @@ -494,7 +507,7 @@ end function HeldItems.observeParticipant(battle, holder) if battle and holder and holder.isPlayer then - local effect = HeldItems.effect(battle.data, holder) + local effect = HeldItems.effect(battle.data, holder, "check") if effect == HeldItems.EFFECT.AMULET_COIN then battle.amuletCoin = true end end end @@ -506,7 +519,10 @@ end function HeldItems.canEscape(data, holder) -- BLACK AND WHITE's Smoke Ball (src/battle/Gen5HoldItems.lua) if require("src.battle.Gen5HoldItems").canEscape(holder) then return true end - local effect = HeldItems.effect(data, holder) + if require("src.core.GameVersion").isGen3() then + return require("src.battle.HoldItems").effectOf(holder, "run") == "CAN_ALWAYS_RUN" + end + local effect = HeldItems.effect(data, holder, "check") return effect == HeldItems.EFFECT.ESCAPE end diff --git a/src/battle/HoldItems.lua b/src/battle/HoldItems.lua index df5e2a031..1f84adbb0 100644 --- a/src/battle/HoldItems.lua +++ b/src/battle/HoldItems.lua @@ -48,8 +48,18 @@ end -- The named effect and its parameter, or nil. An effect the extractor had -- no name for comes back as a number and matches no rule below, which is -- the same silence an unknown ability gets. -function HoldItems.effectOf(battler) - local def = HoldItems.defOf(battler) +local function nativeEffect(battler, native) + local def, item = HoldItems.defOf(battler) + -- Native Choice Band, King's Rock and Shell Bell entries are intrinsic; mutable + -- descriptive metadata cannot remove it. Public hook substitutions still + -- run below, and item ids in other generations keep their existing reader. + if require("src.core.GameVersion").isGen3() then + local number = tonumber(item) or tonumber(def and def.index) + if number == 186 then return "CHOICE_BAND", 0, def end + if number == 187 then return "FLINCH", 10, def end + if number == 219 then return "SHELL_BELL", 8, def end + end + if type(native) == "table" then return native.effect, native.parameter, def end if not def then return nil end local effect,param=require('src.inventory.PolishedBerries').held(def,battler.mon) if effect then return effect,param,def end @@ -58,6 +68,14 @@ function HoldItems.effectOf(battler) return effect, tonumber(def.holdEffectParam) or 0, def end +function HoldItems.effectOf(battler, trigger, native) + local effect, param, def = nativeEffect(battler, native) + if not require("src.mods.Runtime").wantsHook("held_item.trigger") then return effect, param, def end + local definition = def or HoldItems.defOf(battler) + local chosen, amount, changed = require("src.battle.HeldItemRead").read(battler, definition, effect, param or 0, trigger, 3) + return chosen, amount, def, changed +end + HoldItems.effect = HoldItems.effectOf -- BLACK AND WHITE's items keep their hold effect as a number; a battler @@ -109,7 +127,7 @@ local SOUL_DEW_SPECIES = { LATIAS = true, LATIOS = true } function HoldItems.attackMultiplier(attacker, move, special) local g5 = bw(attacker) if g5 then return g5.attackMultiplier(attacker, move, special) end - local effect = HoldItems.effectOf(attacker) + local effect = HoldItems.effectOf(attacker, "damage") if not effect then return 1, 1 end -- CHOICE BAND: half again as much, physical only if effect == "CHOICE_BAND" and not special then return 3, 2 end @@ -129,7 +147,7 @@ end function HoldItems.defenceMultiplier(defender, move, special) local g5 = bw(defender) if g5 then return g5.defenceMultiplier(defender, move, special) end - local effect = HoldItems.effectOf(defender) + local effect = HoldItems.effectOf(defender, "damage") if not effect then return 1, 1 end local row = SPECIES_STAT[effect] if row and row.species[speciesOf(defender)] then @@ -165,7 +183,7 @@ HoldItems.TYPE_POWER = TYPE_POWER -- Applied to the FINISHED damage, the way the cartridge applies it: as -- damage * (100 + param) / 100. function HoldItems.damageMultiplier(attacker, move) - local effect, param = HoldItems.effectOf(attacker) + local effect, param = HoldItems.effectOf(attacker, "damage") if not (effect and move) then return 1, 1 end local boostedType = TYPE_POWER[effect] if boostedType and boostedType == normalizeType(move.type) then @@ -184,7 +202,7 @@ end function HoldItems.accuracyMultiplier(attacker, defender) local g5 = bw(attacker) or bw(defender) if g5 then return g5.accuracyMultiplier(attacker, defender) end - local effect, param = HoldItems.effectOf(defender) + local effect, param = HoldItems.effectOf(defender, "accuracy") if effect == "EVASION_UP" then return 100 - (param or 0) end return 100 end @@ -199,7 +217,7 @@ local CRIT_SPECIES = { function HoldItems.critStages(battler) local g5 = bw(battler) if g5 then return g5.critStages(battler) end - local effect = HoldItems.effectOf(battler) + local effect = HoldItems.effectOf(battler, "damage") if not effect then return 0 end if effect == "SCOPE_LENS" then return 1 end local list = CRIT_SPECIES[effect] @@ -212,7 +230,7 @@ end function HoldItems.quickClawChance(battler) local g5 = bw(battler) if g5 then return g5.quickClawChance(battler) end - local effect, param = HoldItems.effectOf(battler) + local effect, param = HoldItems.effectOf(battler, "priority") if effect ~= "QUICK_CLAW" then return 0 end return param or 0 end @@ -223,7 +241,7 @@ end function HoldItems.flinchChance(attacker, move) local g5 = bw(attacker) if g5 then return g5.flinchChance(attacker, move) end - local effect, param = HoldItems.effectOf(attacker) + local effect, param = HoldItems.effectOf(attacker, "flinch") if effect ~= "FLINCH" then return 0 end if move and move.kingsRockAffected == false then return 0 end return param or 0 @@ -232,14 +250,14 @@ end -- FOCUS BAND: a `param` percent chance to be left standing on one HP by a -- hit that would otherwise knock the holder out. function HoldItems.focusBandChance(battler) - local effect, param = HoldItems.effectOf(battler) + local effect, param = HoldItems.effectOf(battler, "endure") if effect ~= "FOCUS_BAND" then return 0 end return param or 0 end -- SHELL BELL: the attacker takes back a `param`th of the damage it dealt. function HoldItems.shellBellShare(attacker) - local effect, param = HoldItems.effectOf(attacker) + local effect, param = HoldItems.effectOf(attacker, "flinch") if effect ~= "SHELL_BELL" then return nil end if not param or param <= 0 then return nil end return param @@ -306,11 +324,23 @@ end -- { kind = "critUp", delta = 2 } LANSAT BERRY -- { kind = "restoreStats" } WHITE HERB function HoldItems.trigger(battler, when) - local effect, param = HoldItems.effectOf(battler) + local gba = require("src.core.GameVersion").isGen3() + local effect, param = HoldItems.effectOf(battler, when == "entry" and "check" or "residual") if not effect then return nil end local mon = battler.mon if not mon or (mon.hp or 0) <= 0 then return nil end local max = maxHpOf(battler) + if when == "entry" then + if effect == "DOUBLE_PRIZE" then return { kind = "prize", consume = false } end + if effect ~= "RESTORE_STATS" then return nil end + end + -- MOVEEND_ITEM_EFFECTS_ALL is a selective cure/herb scan, not the normal + -- HP/PP/pinch check. In particular it must not add a fifth Berry grant + -- before the four after-action passes. + if when == "moveEnd" and not (CURE_STATUS[effect] or effect == "CURE_STATUS" + or effect == "CURE_CONFUSION" or effect == "CURE_ATTRACT" or effect == "RESTORE_STATS") then + return nil + end -- LEFTOVERS, the one that is never eaten. Its parameter is 10 and it is -- NOT a percentage: ItemBattleEffects' LEFTOVERS arm reads maxHP / 16 and @@ -329,7 +359,7 @@ function HoldItems.trigger(battler, when) if effect == "RESTORE_HP" then if mon.hp * 2 > max then return nil end if mon.hp >= max then return nil end - return { kind = "heal", effect = effect, amount = math.max(1, param), + return { kind = "heal", effect = effect, amount = math.max(gba and 0 or 1, param), consume = true } end @@ -340,13 +370,15 @@ function HoldItems.trigger(battler, when) if mon.hp * 2 > max then return nil end if mon.hp >= max then return nil end return { kind = "heal", effect = effect, - amount = math.max(1, math.floor(max / (param > 0 and param or 8))), - confuse = HoldItems.dislikesFlavour(battler, flavourStat), + amount = math.max(1, math.floor(max / (gba and math.max(1, param) or (param > 0 and param or 8)))), + confuse = HoldItems.dislikesFlavour(battler, flavourStat) + and (not gba or (require("src.battle.Abilities").of(battler) ~= "OWN_TEMPO" + and not battler.confusedTurns)), consume = true } end if effect == "RESTORE_PP" then - if when ~= "turn" then return nil end + if when ~= "turn" and when ~= "action" then return nil end -- the caller finds the empty move; nil here means every move has PP local slot for i, m in ipairs(battler.curMoves or mon.moves or {}) do @@ -354,10 +386,10 @@ function HoldItems.trigger(battler, when) end if not slot then return nil end return { kind = "restorePP", effect = effect, slot = slot, - amount = math.max(1, param), consume = true } + amount = math.max(gba and 0 or 1, param), consume = true } end - if when ~= "turn" then return nil end + if when ~= "turn" and when ~= "entry" and when ~= "action" and when ~= "moveEnd" then return nil end local cured = CURE_STATUS[effect] if cured then @@ -389,18 +421,25 @@ function HoldItems.trigger(battler, when) -- the pinch berries, at a quarter of maximum HP or less local pinchStat = PINCH_STAT[effect] - local divisor = (param and param > 0) and param or 4 + local divisor = gba and math.max(1, param) or ((param > 0) and param or 4) if pinchStat then - if mon.hp * divisor > max then return nil end + if mon.hp * divisor > max or (gba and ((battler.stages or {})[pinchStat] or 0) >= 6) then return nil end return { kind = "statUp", effect = effect, stat = pinchStat, delta = 1, consume = true } end if effect == "RANDOM_STAT_UP" then if mon.hp * divisor > max then return nil end + local eligible = false + for _, stat in ipairs(HoldItems.RANDOM_STATS) do + if ((battler.stages or {})[stat] or 0) < 6 then eligible = true break end + end + if gba and not eligible then return nil end return { kind = "statUp", effect = effect, stat = nil, delta = 2, consume = true } end if effect == "CRITICAL_UP" then + if require("src.core.GameVersion").isGen3() + and (battler.focusEnergy or battler.expFocusEnergy) then return nil end if mon.hp * divisor > max then return nil end return { kind = "critUp", effect = effect, delta = 2, consume = true } end diff --git a/src/battle/MoveEffects.lua b/src/battle/MoveEffects.lua index ed4249843..9ae155305 100644 --- a/src/battle/MoveEffects.lua +++ b/src/battle/MoveEffects.lua @@ -144,7 +144,7 @@ end -- SHIELD DUST still refuses a doubled roll. local function sideChance(move, fallback, user, target) local own = move and tonumber(move.effectChance) - local base = (own and own > 0) and own or fallback + local base = (own and (own > 0 or move._api3EffectChance)) and own or fallback if user or target then base = require("src.battle.Abilities").secondaryOdds(user, target, base) end @@ -201,6 +201,9 @@ local function confuse(battle, target, pierceSub) if target.confusedTurns or (target.substituteHP and not pierceSub) then return { Strings("But, it failed!") } end + if HeldItems.effect(battle.data, target, "confuse") == HeldItems.EFFECT.PREVENT_CONFUSE then + return { Strings("But, it failed!") } + end target.confusedTurns = battle.rng(2, 5) HeldItems.setConfusionCounter(battle, target, target.confusedTurns) local msgs = { Strings("%s\nbecame confused!", displayName(target)) } @@ -267,6 +270,7 @@ MoveEffects.primary = { if t == "GRASS" then return { Strings("But, it failed!") } end end target.leechSeeded = true + if require("src.core.GameVersion").isGen3() then target.leechSeedSource = user end return { Strings("%s\nwas seeded!", displayName(target)) } end, diff --git a/src/battle/Status.lua b/src/battle/Status.lua index 955361db0..1f8b91c0b 100644 --- a/src/battle/Status.lua +++ b/src/battle/Status.lua @@ -232,7 +232,9 @@ end -- the merged view when a battle is on hand, the vanilla records otherwise function Status.recordFor(statuses, id) if id == nil then return nil end - return (statuses or Status.RECORDS)[id] + local record = (statuses or Status.RECORDS)[id] + if not statuses then record = require("src.battle.Gen3SleepTalk").status(id, record) end + return record end local function battleStatuses(battle) @@ -243,7 +245,7 @@ end -- The active status record's beforeMove runs at its priority slot: above -- VOLATILE_PRIORITY before the held/disable/confusion block (sleep, -- freeze), at or below after it (paralysis) -- the original's order. -function Status.beforeMove(battler, rng, battle) +function Status.beforeMove(battler, rng, battle, move) local mon = battler.mon -- Haze curing this mon's sleep/freeze forfeits its pending move for -- the turn, silently (haze.asm writes $ff/CANNOT_MOVE to the selected @@ -252,7 +254,8 @@ function Status.beforeMove(battler, rng, battle) battler.skipMove = nil return false, {} end - if battler.flinched then + local gba = require("src.core.GameVersion").isGen3() + if battler.flinched and not gba then battler.flinched = false return false, { Strings("%s\nflinched!", name(battler)) } end @@ -261,21 +264,31 @@ function Status.beforeMove(battler, rng, battle) local priority = handler and (record.beforeMovePriority or 0) local msgs = {} local function runStatus() - local canMove, statusMsgs, selfHit = handler(battler, rng, battle) + local canMove, statusMsgs, selfHit, reason = handler(battler, rng, battle, move) for _, m in ipairs(statusMsgs or {}) do msgs[#msgs + 1] = m end - return canMove, selfHit + return canMove, selfHit, reason end if handler and priority > VOLATILE_PRIORITY then - local canMove, selfHit = runStatus() - if not canMove or selfHit then return canMove, msgs, selfHit end + local canMove, selfHit, reason = runStatus() + if not canMove or selfHit then return canMove, msgs, selfHit, reason end handler = nil end + if gba and battler.flinched then + battler.flinched = false + msgs[#msgs + 1] = Strings("%s\nflinched!", name(battler)) + return false, msgs, false, "gen3_flinch" + end if battler.boundTurns and battler.boundTurns > 0 then battler.boundTurns = battler.boundTurns - 1 msgs[#msgs + 1] = Strings("%s\ncan't move!", name(battler)) return false, msgs end - if battler.disabledTurns then + if require("src.core.GameVersion").isGen3() and battler.disabledMove then + if require("src.battle.Gen3Disable").blocked(battler, move) then + msgs[#msgs + 1] = Strings("%s's\nmove is disabled!", name(battler)) + return false, msgs, false, "gen3_disabled" + end + elseif battler.disabledTurns then battler.disabledTurns = battler.disabledTurns - 1 if battler.disabledTurns <= 0 then battler.disabledTurns, battler.disabledSlot = nil, nil @@ -291,15 +304,19 @@ function Status.beforeMove(battler, rng, battle) table.insert(msgs, Strings("%s\nsnapped out of\nconfusion!", name(battler))) else table.insert(msgs, Strings("%s\nis confused!", name(battler))) - -- cp 50 percent + 1 / jr c: hurt itself on rand >= 128 (128/256) - if rng(0, 255) < 128 then + -- GBA's attack canceller flips a two-value roll. Older games keep + -- their byte-range convention; shared callbacks see the native ABI. + local selfHit + if require("src.core.GameVersion").isGen3() then selfHit = rng(0, 1) == 0 + else selfHit = rng(0, 255) < 128 end + if selfHit then return false, msgs, true -- hurt itself end end end if handler then - local canMove, selfHit = runStatus() - if not canMove or selfHit then return canMove, msgs, selfHit end + local canMove, selfHit, reason = runStatus() + if not canMove or selfHit then return canMove, msgs, selfHit, reason end end return true, msgs end @@ -313,13 +330,34 @@ function Status.residual(battler, opponent, battle) -- mon had already moved, drop the flag before it leaks into next turn battler.skipMove = nil if mon.hp <= 0 then return msgs end + local gba = require("src.core.GameVersion").isGen3() + local seedSource + if gba and battler.leechSeeded then + local source = battler.leechSeedSource or opponent + -- Resolve the slot again: a switched-out seeder does not receive HP. + if source and source.position ~= nil and battle.battlerAt then + source = battle:battlerAt(source.position) + elseif source and source.isPlayer ~= nil then + source = source.isPlayer and battle.player or battle.enemy + end + if source and source.mon.hp > 0 then + local dmg = math.min(mon.hp, math.max(1, math.floor(mon.stats.hp / 8))) + mon.hp = mon.hp - dmg + if require("src.battle.Abilities").of(battler) == "LIQUID_OOZE" then + source.mon.hp = math.max(0, source.mon.hp - dmg) + if source.mon.hp == 0 then seedSource = source end + else source.mon.hp = math.min(source.mon.stats.hp, source.mon.hp + dmg) end + msgs[#msgs + 1] = Strings("LEECH SEED saps\n%s!", name(battler)) + end + end + if mon.hp <= 0 then return msgs, seedSource end local record = Status.recordFor(battleStatuses(battle), mon.status) if record and record.residual then for _, m in ipairs(record.residual(battler, opponent, battle) or {}) do msgs[#msgs + 1] = m end end - if battler.leechSeeded and mon.hp > 0 and opponent.mon.hp > 0 then + if not gba and battler.leechSeeded and mon.hp > 0 and opponent.mon.hp > 0 then -- the shared Toxic counter multiplies (and advances on) the seed -- drain too -- the Gen 1 Leech Seed glitch -- (HandlePoisonBurnLeechSeed_DecreaseOwnHP) @@ -333,7 +371,7 @@ function Status.residual(battler, opponent, battle) opponent.mon.hp = math.min(opponent.mon.stats.hp, opponent.mon.hp + dmg) table.insert(msgs, Strings("LEECH SEED saps\n%s!", name(battler))) end - return msgs + return msgs, seedSource end return Status diff --git a/src/battle/StatusRegistry.lua b/src/battle/StatusRegistry.lua index d65321c78..c94e57007 100644 --- a/src/battle/StatusRegistry.lua +++ b/src/battle/StatusRegistry.lua @@ -21,17 +21,21 @@ end -- Returns messages; empty means the status did not land. function StatusRegistry.inflict(battle, target, status, opts) opts = opts or {} + local nativeGba = opts.nativeGbaSecondary and require("src.core.GameVersion").isGen3() if target.mon.status then return {} end -- Substitutes block poison (PoisonEffect calls CheckTargetSubstitute) -- and every secondary status, but NOT primary Sleep or Thunder Wave, -- their handlers never check the substitute in Gen 1. - if target.substituteHP and (opts.secondary or status == "PSN") then + local substitute = nativeGba and (target.substituteHP or 0) > 0 + or not nativeGba and target.substituteHP + if substitute and (opts.secondary or status == "PSN") then return {} end -- FreezeBurnParalyzeEffect: a secondary status never lands when the -- move's type matches either of the target's types (Body Slam can't -- paralyze Normals, Fire can't burn Fire, Ice can't freeze Ice) - if opts.secondary and status ~= "PSN" then + if opts.secondary and status ~= "PSN" + and not nativeGba then for _, t in ipairs(target.curTypes or {}) do if opts.moveType == t then return {} end end @@ -46,7 +50,10 @@ function StatusRegistry.inflict(battle, target, status, opts) -- SAFEGUARD: the veil refuses every status for its five turns. A status a -- Pokemon inflicts on ITSELF still lands -- REST is the whole reason that -- distinction exists -- which is what opts.selfInflicted marks. - if target.safeguardTurns and not opts.selfInflicted then + local safeguard = nativeGba and (target.safeguardTurns == true + or type(target.safeguardTurns) == "number" and target.safeguardTurns > 0) + or not nativeGba and target.safeguardTurns + if safeguard and not opts.selfInflicted then return {} end local statuses = battle and battle.data and battle.data.statuses @@ -54,10 +61,15 @@ function StatusRegistry.inflict(battle, target, status, opts) -- `battle` is passed so a record can ask the RULESET: Hoenn's poison -- immunity covers STEEL as well as POISON, and the records are shared if record and record.canInflict + and not (opts.nativeGbaSecondary and status == "PAR" and require("src.core.GameVersion").isGen3() + and record.canInflict == Status.RECORDS.PAR.canInflict) and not record.canInflict(target, opts, battle) then return {} end target.mon.status = status + if require("src.core.GameVersion").isGen3() then + target.mon.toxic = status == "PSN" and opts.toxic == true or nil + end local msgs local display = displayName(target) if record and record.onInflict then @@ -67,8 +79,9 @@ function StatusRegistry.inflict(battle, target, status, opts) record and record.label or tostring(status)) } end Runtime.emit("battle.status_inflicted", { - battle = battle, target = target, status = status, source = opts.source, + battle = battle, target = target, status = opts.nativeGbaSecondary and opts.toxic and "TOX" or status, source = opts.source, }) + if status == "PSN" then require("src.battle.Gen3EffectPresentation").queue(battle, target, "POISON") end -- Gen II status berries resolve immediately after the condition lands. -- Keep the infliction text first, then append the held-item cure line. for _, msg in ipairs(HeldItems.onStatus(battle, target)) do diff --git a/src/battle/Targeting.lua b/src/battle/Targeting.lua index 6f1c9b876..8e7f8933d 100644 --- a/src/battle/Targeting.lua +++ b/src/battle/Targeting.lua @@ -179,6 +179,10 @@ end -- battle passes the one foe it always passed and gets it straight back. function Targeting.resolve(battle, user, move, chosen) local k = kindOf(move) + if k == Targeting.SELECTED then + local center = require("src.battle.Gen3FollowMe").center(battle, user) + if center then return { center } end + end -- BLACK AND WHITE: FOLLOW ME / RAGE POWDER draw every single-target move -- from the other side to themselves this turn (src/battle/Gen5MoveRules.lua) @@ -280,4 +284,102 @@ function Targeting.redirect(battle, user, move, target) return { pool[1] } end +-- Public GBA actions retain positional targets until execution, so a switch +-- between selection and execution resolves the current occupant of that slot. +function Targeting.fromAction(battle, action) + if type(action) ~= "table" or action.target == nil then + return nil, false + end + local ref, id = action.target + if type(ref) == "number" then + id = ref + elseif type(ref) == "table" then + id = ref == battle.player and 0 or ref == battle.enemy and 1 or ref.position + end + if type(id) ~= "number" or id % 1 ~= 0 or id < 0 or id > 3 then + return nil, true + end + if id == 0 then + return battle.player, true + end + if id == 1 then + return battle.enemy, true + end + return battle:battlerAt(id), true +end + +function Targeting.actionTargets(battle, user, move, chosen) + local kind = kindOf(move) + local abilities = require("src.battle.Abilities") + local _, followMeActive = require("src.battle.Gen3FollowMe").center(battle, user) + -- Gen 3 Lightning Rod redirects Electric selected attacks; it does not + -- grant the later-generation immunity or Special Attack boost. + if + (kind == Targeting.SELECTED or kind == Targeting.DEPENDS or kind == Targeting.USER_OR_SELECTED) + and move.type == "ELECTRIC" + and not followMeActive + and abilities.of(chosen) ~= "LIGHTNING_ROD" + then + local rank = {} + for i, id in ipairs(battle._actionTargetOrder or { 0, 1, 2, 3 }) do + rank[id] = i + end + local best, bestRank + for _, actor in battle:activeBattlers() do + if + actor.isPlayer ~= user.isPlayer + and actor.mon + and (actor.mon.hp or 0) > 0 + and abilities.of(actor) == "LIGHTNING_ROD" + then + local n = rank[actor.position] or 5 + if not bestRank or n < bestRank then + best, bestRank = actor, n + end + end + end + if best then + chosen = best + end + end + local targets = Targeting.resolve(battle, user, move, chosen) + if kind == Targeting.BOTH then + for i, actor in ipairs(targets) do + if actor == chosen and i > 1 then + table.remove(targets, i) + table.insert(targets, 1, actor) + break + end + end + elseif kind == Targeting.FOES_AND_ALLY then + table.sort(targets, function(a, b) + return a.position < b.position + end) + end + return targets +end + +function Targeting.gen3PPCost(battle, user, target, move) + local extra = require("src.battle.Abilities").extraPP + local kind, cost = kindOf(move), 1 + if + battle:isDouble() + and (kind == Targeting.BOTH or kind == Targeting.FOES_AND_ALLY or kind == Targeting.OPPONENTS_FIELD) + then + for _, actor in battle:activeBattlers() do + if + actor ~= user + and actor.mon + and (actor.mon.hp or 0) > 0 + and (kind == Targeting.FOES_AND_ALLY or actor.isPlayer ~= user.isPlayer) + then + cost = cost + extra(actor) + end + end + elseif target and target ~= user and kind ~= Targeting.USER then + cost = cost + extra(target) + end + return cost +end + return Targeting diff --git a/src/battle/TrainerAI.lua b/src/battle/TrainerAI.lua index 4247a725a..d8ee090a8 100644 --- a/src/battle/TrainerAI.lua +++ b/src/battle/TrainerAI.lua @@ -331,13 +331,19 @@ end function TrainerAI.chooseMove(battler, rng, battle) rng = rng or love.math.random + local nativeMask = battle and require("src.battle.Gen3MoveSelection").aiMask(battle, battler) + if nativeMask and require("src.battle.Gen3MoveSelection").ordinaryWild(battle) then + return require("src.battle.Gen3MoveSelection").wildMove(battle, battler, nativeMask, rng) + end -- Gen 1: SelectEnemyMove never consults wEnemyMonPP; Struggle only when -- every move slot is missing/disabled (core.asm:2957-2999). modern_clean -- depletes enemy PP and falls back to Struggle when none remain. local unlimited = battle and battle.ruleset and battle.ruleset.enemyUnlimitedPP local usable = {} for i, mv in ipairs(battler.curMoves) do - if battler.disabledSlot ~= i and (unlimited or mv.pp > 0) then + if (nativeMask and i <= 4 and not nativeMask[i]) + or (not nativeMask and not require("src.battle.Gen3Disable").blocked(battler, mv, i) + and (unlimited or mv.pp > 0)) then table.insert(usable, mv) end end diff --git a/src/battle/TurnOrder.lua b/src/battle/TurnOrder.lua index bb481a7ad..1c56ede90 100644 --- a/src/battle/TurnOrder.lua +++ b/src/battle/TurnOrder.lua @@ -17,6 +17,7 @@ local Abilities = require("src.battle.Abilities") -- nowhere else the weather is visible from here. Every existing caller -- passes nothing and gets exactly the old answer. local function effectiveSpeed(battler, battle) + if require("src.core.GameVersion").isGen3() and battle then return require("src.battle.Gen3AIOrder").speed(battler, battle, true) end local spd = Stats.applyStage(battler.curStats.speed, battler.stages and battler.stages.speed or 0) -- ApplyBadgeStatBoosts: the SOULBADGE boosts speed; the rows come from @@ -59,6 +60,10 @@ local function effectiveSpeed(battler, battle) if battler.items or battler.bwBattle then spd = require("src.battle.Gen5HoldItems").speed(battler, spd) end + if require("src.core.GameVersion").isGen3() + and require("src.battle.HoldItems").effectOf(battler, "priority") == "MACHO_BRACE" then + spd = math.max(1, math.floor(spd / 2)) + end return spd end @@ -76,6 +81,21 @@ end -- who moves first. function TurnOrder.firstMover(a, aMove, b, bMove, rng, invertTie, battle, data) rng = rng or love.math.random + if require("src.core.GameVersion").isGen3() and battle then + local pa, pb = priority(aMove), priority(bMove) + local O, C = require("src.battle.Gen3AIOrder"), require("src.battle.Gen3OrderCore") + local sa, sb + if invertTie then + sb = O.speed(b, battle, true) + sa = O.speed(a, battle, true) + else + sa = O.speed(a, battle, true) + sb = O.speed(b, battle, true) + end + if pa ~= pb then return pa > pb end + if sa ~= sb then return sa > sb end + return (C.result(sa, sb, function() return rng(0, 1) end) == 0) ~= (invertTie == true) + end local pa, pb = priority(aMove), priority(bMove) if pa ~= pb then return pa > pb end @@ -93,11 +113,13 @@ function TurnOrder.firstMover(a, aMove, b, bMove, rng, invertTie, battle, data) local first, second = a, b local firstIsA = true if invertTie then first, second, firstIsA = b, a, false end - if HeldItems.effect(data, first) == HeldItems.EFFECT.QUICK_CLAW then - if HeldItems.quickClaw(data, first, rng) then return firstIsA end + local firstEffect, firstParam = HeldItems.effect(data, first, "priority") + if firstEffect == HeldItems.EFFECT.QUICK_CLAW then + if rng(0, 255) < firstParam then return firstIsA end end - if HeldItems.effect(data, second) == HeldItems.EFFECT.QUICK_CLAW then - if HeldItems.quickClaw(data, second, rng) then return not firstIsA end + local secondEffect, secondParam = HeldItems.effect(data, second, "priority") + if secondEffect == HeldItems.EFFECT.QUICK_CLAW then + if rng(0, 255) < secondParam then return not firstIsA end end local sa, sb = effectiveSpeed(a, battle), effectiveSpeed(b, battle) @@ -132,6 +154,26 @@ TurnOrder.effectiveSpeed = effectiveSpeed -- which is the order the cartridge fills its own array in, so equal-speed -- entries fall out the same way. function TurnOrder.order(entries, rng, battle) + if require("src.core.GameVersion").isGen3() and battle then + for i = 1, #entries - 1 do + for j = i + 1, #entries do + local a, b = entries[i], entries[j] + local function vanilla(first, firstMove, second, secondMove, ctx) + return TurnOrder.firstMover(first, firstMove, second, secondMove, + ctx.rng, ctx.invertTie, ctx.battle, ctx.data) + end + local Runtime = require("src.mods.Runtime") + local first + if Runtime.wantsHook("battle.turn_order") then + first = Runtime.call("battle.turn_order", vanilla, a.battler, a.move, b.battler, b.move, + { battle = battle, data = battle.data, rng = rng, firstId = a.battler.position, + secondId = b.battler.position, playerMove = a.move and a.move.id, enemyMove = b.move and b.move.id }) + else first = TurnOrder.firstMover(a.battler, a.move, b.battler, b.move, rng, false, battle) end + if not first then entries[i], entries[j] = b, a end + end + end + return entries + end local n = #entries for pass = 1, n - 1 do local swapped = false diff --git a/src/battle/Weather.lua b/src/battle/Weather.lua index e3a3fc95e..69c797d24 100644 --- a/src/battle/Weather.lua +++ b/src/battle/Weather.lua @@ -286,9 +286,9 @@ local function maxHpOf(battler) return (mon.stats and mon.stats.hp) or mon.maxHp or 8 end -local function weatherDamage(battle, battler, weather) +local function weatherDamage(battle, battler, weather, gba) if not (battler and battler.mon) or battler.mon.hp <= 0 then return end - if underground(battler) then return end + if underground(battler) or (gba and battler.hiddenAs == "underwater") then return end -- SAND VEIL: a CACNEA stands in its own sandstorm untouched. Gen 1 and -- Gen 2 answer false here, so nothing about the older sandstorm changes. if require("src.battle.Abilities") @@ -303,19 +303,22 @@ local function weatherDamage(battle, battler, weather) battler.isPlayer) -- GetEighthMaxHP is GetQuarterMaxHP followed by one more shift, each step -- floored at 1: floor(floor(maxHP / 4) / 2), minimum 1 - local eighth = math.max(1, math.floor(math.floor(maxHpOf(battler) / 4) / 2)) + local eighth = math.max(1, math.floor(maxHpOf(battler) / (gba and 16 or 8))) battle:applyDamage(battler, eighth) battle:sayNext(weather == Weather.HAIL and Strings("%s is\npelted by HAIL!", displayName(battler)) or Strings("The SANDSTORM hits\n%s!", displayName(battler))) battle:drainNext() - if battler.mon.hp <= 0 then battle:onFaint(battler) end + if battler.mon.hp <= 0 then + if gba then battler.destinyBond, battler.grudge = nil, nil end + battle:onFaint(battler) + end end -- HandleWeather, called once per turn from HandleBetweenTurnEffects after -- HandleFutureSight and before HandleWrap. The count is decremented first: -- on zero the "ended" line prints and NO sandstorm damage is dealt. -function Weather.upkeep(battle) +function Weather.upkeep(battle, order) local field = battle and battle.field local weather = field and field.weather if not weather then return end @@ -342,8 +345,12 @@ function Weather.upkeep(battle) -- HandleWeather damages the player's side first and the enemy's second. -- That order is the serial-connection one (hSerialConnectionStatus), not a -- speed check, so a single-player battle is always player-then-enemy. - weatherDamage(battle, battle.player, weather) - weatherDamage(battle, battle.enemy, weather) + if order then + for _, b in ipairs(order) do weatherDamage(battle, b, weather, true) end + else + weatherDamage(battle, battle.player, weather) + weatherDamage(battle, battle.enemy, weather) + end end return Weather diff --git a/src/battle/rulesets/gen3_emerald.lua b/src/battle/rulesets/gen3_emerald.lua index 3a7fc6011..3df351346 100644 --- a/src/battle/rulesets/gen3_emerald.lua +++ b/src/battle/rulesets/gen3_emerald.lua @@ -13,6 +13,9 @@ return { name = "gen3_emerald", + -- Gen 3 uses wide stats and applies burn/screens/weather before the +2. + baseDamageStyle = "gba", + -- The 1/256 miss is GONE. Gen 1 rolled rand(0..255) against -- floor(acc * 255 / 100), so a 100%-accurate move still failed on a roll of -- 255. Gen 3 checks accuracy as a percentage out of 100 and a move at 100% @@ -32,6 +35,9 @@ return { -- re-floors, so the two produce different numbers; this is not a -- reformulation of the same rule. critMultiplier = 2, + -- Cmd_critcalc's multiplier is applied before Cmd_typecalc's STAB and + -- per-type floors. Applying it after resistance loses odd damage points. + critBeforeType = true, -- TWO ON THE FIELD CHANGES TWO NUMBERS, and both of them live in -- CalculateBaseDamage (0806957C) rather than anywhere the port would diff --git a/src/core/ChipAudio.lua b/src/core/ChipAudio.lua index 288901d82..f772bc2d9 100644 --- a/src/core/ChipAudio.lua +++ b/src/core/ChipAudio.lua @@ -269,7 +269,7 @@ local MUSIC_FILL_PER_CALL = 3 local function fillSync(limit) local music = currentMusic - if not music or not music.engine or music.engine:finished() then return end + if not music or not music.engine or not music.nativeBgm and music.engine:finished() then return end -- INTERRUPTION: never synthesize into a Source whose device the OS has -- taken (see sessionSuspended). Returning leaves the engine untouched, so -- nothing about the song's position is lost while the call is in progress. @@ -277,7 +277,7 @@ local function fillSync(limit) limit = limit or MUSIC_FILL_PER_CALL local free = freeBuffers(music.source) if not free then return end - while free > 0 and limit > 0 and not music.engine:finished() do + while free > 0 and limit > 0 and (music.nativeBgm or not music.engine:finished()) do -- soundData is pure synthesis and stays OUTSIDE the guard on purpose: a -- raise from there is a malformed song def, which is a real bug and must -- not be quietly turned into silence. Only queue -- the call that talks @@ -293,22 +293,27 @@ local function fillSync(limit) end end -local function playMusicSync(data, header, allowLoops, synthName) +local function playMusicSync(data, header, allowLoops, synthName, nativeBgm, reuseSource) local synth = synthFor(synthName) -- build before tearing down: a def that fails to compile must leave the -- outgoing song sounding local ok, engine = pcall(synth.newEngine, data, header, { allowLoops = allowLoops }) if not ok then return nil, engine end - local ok2, source = pcall( - love.audio.newQueueableSource, SAMPLE_RATE, 16, 2, MUSIC_BUFFER_COUNT) - if not ok2 or not source then warnNoQueue(source) return nil, source end + local source = reuseSource + if not source then + local ok2 + ok2, source = pcall(love.audio.newQueueableSource, SAMPLE_RATE, 16, 2, MUSIC_BUFFER_COUNT) + if not ok2 or not source then warnNoQueue(source) return nil, source end + end ChipAudio.stopMusic() currentMusic = { source = source, engine = engine, threaded = false, synth = synth, synthName = synthName, - started = true, finished = false } - fillSync(MUSIC_FILL_INITIAL) - if not musicHeld then source:play() end + nativeBgm = nativeBgm, started = not nativeBgm, finished = false } + if not nativeBgm then + fillSync(MUSIC_FILL_INITIAL) + if not musicHeld then source:play() end + end return source end @@ -318,13 +323,14 @@ end local musicGen = 0 -function ChipAudio.playMusic(data, header, allowLoops, synthName) +function ChipAudio.playMusic(data, header, allowLoops, synthName, options) local synth = synthFor(synthName) + local nativeBgm = options and options.nativeBgm == true if not ensureWorker() then announcePath("synchronous", (love.thread and love.thread.newThread) and "worker would not start" or "no love.thread") - return playMusicSync(data, header, allowLoops, synthName) + return playMusicSync(data, header, allowLoops, synthName, nativeBgm) end announcePath("threaded worker") -- validate the def on this thread (cheap: engine construction, no synthesis) @@ -346,6 +352,8 @@ function ChipAudio.playMusic(data, header, allowLoops, synthName) channelPitches = ChipSynth.getChannelPitches() }) currentMusic = { source = source, gen = gen, threaded = true, synth = synth, synthName = synthName, + nativeBgm = nativeBgm, + nativeEpoch = 0, started = false, finished = false, -- kept so a worker that dies before delivering its first -- buffer can be recovered onto the sync path below @@ -380,13 +388,16 @@ local function updateThreaded() -- the result is indistinguishable from "this build has no music". That is -- the shape of a silent port. Re-drive it on the synchronous path, which -- is the same path a host without love.thread uses all the time. - if not m.started and not m.finished and m.def then + if (not m.started or m.nativeBgm) and not m.finished and m.def then local def = m.def currentMusic = nil require("src.core.Logger").warn( - "chip audio: worker died before first buffer, replaying on the " - .. "synchronous path") - playMusicSync(def.data, def.header, def.allowLoops, def.synth) + "chip audio: worker died, recovering on the synchronous path") + -- Native Music still owns this source and its fade. Keep queued PCM + -- and recover the producer behind that same source, including when a + -- worker dies after its first buffer. + playMusicSync(def.data, def.header, def.allowLoops, def.synth, m.nativeBgm, + m.nativeBgm and m.source or nil) end return end @@ -399,7 +410,7 @@ local function updateThreaded() local buf = pendingBuf if buf then pendingBuf = nil else buf = outCh:pop() end if not buf then break end - if buf.gen ~= m.gen then + if buf.gen ~= m.gen or m.nativeBgm and (buf.nativeEpoch or 0) ~= (m.nativeEpoch or 0) then -- stale buffer from a superseded song: drop it elseif buf.done then m.finished = true @@ -421,7 +432,7 @@ local function updateThreaded() end end end - if not m.started and not musicHeld then + if not m.nativeBgm and not m.started and not musicHeld then -- getFreeBufferCount used to be called bare here. On the frame that -- straddles an interruption this is a raise, and it is raised from inside -- Music.update with nothing between it and love.run: one of the concrete @@ -437,12 +448,13 @@ end function ChipAudio.update() local m = currentMusic if not m then return end + if m.nativePaused then return end -- INTERRUPTION: nothing is fed to the device while the OS holds the session if sessionSuspended then return end if m.threaded then updateThreaded() else - fillSync() + fillSync(m.nativeBgm and 1 or nil) end end @@ -451,11 +463,27 @@ end -- pause/resume behavior. function ChipAudio.ensureMusicPlaying() local m = currentMusic - if not m or m.finished or musicHeld then return end + if not m or m.finished or musicHeld or m.nativePaused then return end -- INTERRUPTION: a queue that stopped draining because the OS took the -- audio session is not a render stall, and must not be "recovered" by -- poking the dead device once every frame of the call if sessionSuspended then return end + if m.nativeBgm then + -- The native service fills the queue before starting it, after Music + -- has applied this tick's fade. Finished sequences keep their silent + -- carrier until a native stop or replacement command owns the stream. + local free = freeBuffers(m.source) + if free and (MUSIC_BUFFER_COUNT - free) > 0 then + local ok, playing = pcall(m.source.isPlaying, m.source) + if ok and not playing then + local started = safeAudio("QueueableSource:play", m.source.play, m.source) + if started then m.started = true end + elseif ok and playing then + m.started = true + end + end + return + end if m.threaded then if not m.started then return end local ok, playing = pcall(function() return m.source:isPlaying() end) @@ -477,6 +505,36 @@ function ChipAudio.ensureMusicPlaying() end end +function ChipAudio.nativeBgmState() + local m = currentMusic + if not (m and m.nativeBgm) then return nil end + return { threaded = m.threaded, paused = m.nativePaused or false, started = m.started } +end + +function ChipAudio.pauseNativeMusic() + local m = currentMusic + if not (m and m.nativeBgm) or m.nativePaused then return end + m.nativePaused = true + m.nativeEpoch = (m.nativeEpoch or 0) + 1 + safeAudio("Source:stop", m.source.stop, m.source) + pendingBuf = nil + if m.threaded then + if outCh then outCh:clear() end + cmdCh:push({ cmd = "nativePause", gen = m.gen, nativeEpoch = m.nativeEpoch }) + elseif m.engine then + m.engine.voices = {} + for _, track in ipairs(m.engine.tracks or {}) do track.voices = {} end + end +end + +function ChipAudio.resumeNativeMusic() + local m = currentMusic + if not (m and m.nativeBgm) then return end + m.nativePaused = false + if m.threaded then cmdCh:push({ cmd = "nativeResume", gen = m.gen, nativeEpoch = m.nativeEpoch }) end + safeAudio("Source:play", m.source.play, m.source) +end + -- Silence the song for the length of a fanfare and start whatever was held -- back once it ends. Held state outlives a song change: Music.play may swap -- songs while the jingle is still sounding. @@ -499,6 +557,7 @@ local forceAwaitingFirstBuffer -- test-only override (see _simulate*) function ChipAudio.awaitingFirstBuffer() if forceAwaitingFirstBuffer then return true end local m = currentMusic + if m and m.nativeBgm and not m.started and not m.finished then return true end if not (m and m.threaded and not m.started and not m.finished) then return false end @@ -674,6 +733,9 @@ function ChipAudio.newM4AEffect(data, def) if not song then return nil end local sd = synthFor("m4a").renderEffect(data, song) if not sd then return nil end + if def.nativePan ~= nil or def.nativeMono ~= nil then + sd = require("src.core.NativeSoundPan").apply(sd, def.nativePan, def.nativeMono) + end return love.audio.newSource(sd, "static") end diff --git a/src/core/Game.lua b/src/core/Game.lua index 9dc07ce9d..8db759da8 100644 --- a/src/core/Game.lua +++ b/src/core/Game.lua @@ -612,7 +612,9 @@ 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) + require("src.core.Sound").updateCry() + require("src.core.Music").update(self.data or Data) + require("src.core.Sound").update() end close() end @@ -1662,7 +1664,7 @@ function Game:audioSession(active, why) if not self.audioSuspended then return end self.audioSuspended = false Logger.info("audio session: %s regained -- rebuilding audio", why) - Music.resume(Data) + Music.resume(self.data or Data) -- The app was parked inside SDL's event loop for the whole call, so the -- first love.timer.step() after it returns the LENGTH OF THE CALL. -- FixedStep already clamps its accumulator (FixedStep.maxAccum, 0.25s = @@ -1846,7 +1848,7 @@ function Game:applyOptions(opts) opts = opts or (self.save and self.save.options) or {} local Music = require("src.core.Music") local Sound = require("src.core.Sound") - if Music.applyOptions then Music.applyOptions(opts) end + if Music.applyOptions then Music.applyOptions(opts, self.data or Data) end if Sound.applyOptions then Sound.applyOptions(opts) end -- EMERALD'S BUTTON MODE, which decides what L and R do. Applied here -- rather than read at the input site so that a mode set on the OPTION diff --git a/src/core/Gen3Fanfare.lua b/src/core/Gen3Fanfare.lua new file mode 100644 index 000000000..c803c14c9 --- /dev/null +++ b/src/core/Gen3Fanfare.lua @@ -0,0 +1,56 @@ +-- Native fanfare metadata remains independent of rendered PCM duration. +local Songs = require("src.core.Gen3SongIds") +local Fanfare = {} +local function entry(data, id) + local metadata = data and data.constants and data.constants.gen3Fanfares + local rows = metadata and metadata.fanfares + if type(rows) ~= "table" then + return nil, false + end + for _, row in pairs(rows) do + if type(row) == "table" and tonumber(row.index) == id then + return row, true + end + end + return nil, true +end +function Fanfare.resolve(value) + if type(value) == "string" then + local hex = value:match("^SONG_(%x+)$") + if hex then + return tonumber(hex, 16) + end + end + return Songs.resolve(value) +end +function Fanfare.info(data, value) + local id = Fanfare.resolve(value) + if type(id) ~= "number" then + return nil + end + local row, haveTable = entry(data, id) + if haveTable and not row then + id = Songs.resolve("MUS_LEVEL_UP") + row = entry(data, id) + end + local songs = data and data.audio and data.audio.songs + local song = songs and songs[("SONG_%03X"):format(id)] or {} + if type(song) ~= "table" then + song = {} + end + return { + id = id, + frames = song.fanfareFrames or row and row.frames or 160, + player = tonumber(song.player) or 2, + } +end +function Fanfare.isSong(data, id) + local songs = data and data.audio and data.audio.songs or {} + local name = type(id) == "number" and Songs.nameOf(id) or id + if name and songs[name] ~= nil then + return false + end + local song = type(id) == "number" and songs[("SONG_%03X"):format(id)] + return song and song.kind == "fanfare" or entry(data, id) ~= nil +end +return Fanfare diff --git a/src/core/Gen3Music.lua b/src/core/Gen3Music.lua new file mode 100644 index 000000000..670bdce28 --- /dev/null +++ b/src/core/Gen3Music.lua @@ -0,0 +1,269 @@ +-- Native BGM command state. Source construction stays with the music service. +local SongIds = require("src.core.Gen3SongIds") +local NativeMusic = {} +NativeMusic.__index = NativeMusic +function NativeMusic.new(host) + return setmetatable({ host = host, serial = 0 }, NativeMusic) +end +function NativeMusic:resolve(value) + return SongIds.resolve(value, self.host.version()) +end +function NativeMusic:setLevel(value, apply) + self.level = value + self.host.level(value, apply) +end +function NativeMusic:reset() + self:cancelFanfare() + self.current, self.mapSong, self.savedSong = nil, nil, nil + self.fadeOut, self.fadeIn, self.deferred = nil, nil, nil + self.serial = self.serial + 1 + self:setLevel(self.host.master()) +end +function NativeMusic:cancelFanfare() + self.fanfareActive, self.fanfareFrames, self.bgmPaused = false, 0, false + self.fanfareRestore, self.deferred = nil, nil + if self.fanfareWait then + self.fanfareWait.done = true + self.fanfareWait = nil + end +end +function NativeMusic:stopped() + self.fanfareRestore = nil + self.current, self.fadeOut, self.fadeIn, self.deferred = nil, nil, nil, nil + self.serial = self.serial + 1 + self:setLevel(self.host.master()) +end +function NativeMusic:started(id, preparing) + self.fanfareActive, self.fanfareFrames, self.bgmPaused, self.fanfareRestore = false, 0, false, nil + self.current = { id = id } + self.mapSong = self.mapSong or id + self.fadeOut, self.fadeIn, self.deferred = nil, nil, nil + self.serial = self.serial + 1 + self:setLevel(self.host.master(), not preparing) +end +function NativeMusic:setSavedSong(value) + local id = self:resolve(value) + self.savedSong = id ~= 0 and id ~= 0xFFFF and id or nil +end +function NativeMusic:setMapSong(value) + self.mapSong = self:resolve(value) +end +function NativeMusic:playSong(data, value, forced) + local id = self:resolve(value) + if id == nil or id == 0 or id == 0xFFFF then + self.host.stop() + self:stopped() + return true + end + if require("src.core.Gen3Fanfare").isSong(data, id) then + return self:playFanfare(data, id) + end + if not forced and self.current and self.current.id == id and not self.fadeOut then + if self.fanfareActive then + self.deferred = nil + end + return true + end + local restart = forced or self.fadeOut ~= nil + self:started(id, true) + self.host.play(data, id, restart) + -- Missing output hardware does not erase the native command's logical song. + self.current = { id = id } + self.mapSong = self.mapSong or id + return true +end +function NativeMusic:currentMapMusic() + if self.fanfareActive and self.deferred ~= nil then + return self.deferred + end + if self.fadeOut then + return self.fadeOut.nextSong or 0 + end + return self.current and self.current.id or 0 +end +function NativeMusic:refresh(apply) + local level = self.host.master() + if self.fadeOut then + local f = self.fadeOut + local u = math.min(1, f.t / math.max(f.dur, 0.01)) + level = self.host.shared() and self.host.sharedLevel(1 - u) or f.start * (1 - u) + elseif self.fadeIn then + local u = math.min(1, self.fadeIn.t / math.max(self.fadeIn.dur, 0.01)) + level = self.host.shared() and self.host.sharedLevel(u) or level * u + end + self:setLevel(level, apply) +end +function NativeMusic:optionChanged(apply) + -- A shared file override receives its ordinary option update first; the + -- next native audio service reapplies its fade. Native streams retain start. + if self.host.shared() then + self:setLevel(self.host.master(), apply) + else + self:refresh(apply) + end +end +function NativeMusic:fadeOutBgm(speed) + speed = tonumber(speed) or 4 + if self.current and self.host.outputAvailable() then + self.fadeOut = { + t = 0, + dur = 16 * speed / 60, + start = self.host.master(), + songId = self.current.id, + owner = self.serial, + } + else + self.host.stop() + self.current = nil + end +end +function NativeMusic:fadeInBgm(data, value, speed) + self:playSong(data, value) + self.fadeIn = { t = 0, dur = 16 * (tonumber(speed) or 4) / 60 } + if self.host.shared() then + self.host.sharedLevel(0) + end + self:setLevel(0) +end +function NativeMusic:fadeOutAndPlay(data, value, speed, fadeIn) + local id = self:resolve(value) + if self.fanfareActive then + self.deferred = id + return true + end + if not (self.current and self.host.nativeOutput()) then + return self:playSong(data, id) + end + if not (self.fadeOut and self.fadeOut.mapMusic) then + self:fadeOutBgm(speed) + end + if self.fadeOut then + self.fadeOut.nextSong, self.fadeOut.fadeIn, self.fadeOut.mapMusic = id, fadeIn, true + end + return true +end +function NativeMusic:fadeDefaultBgm(data) + local id = self.host.defaultSong() or self.mapSong + if id ~= nil and self:currentMapMusic() ~= id then + return self:fadeOutAndPlay(data, id, 8) + end + return true +end +function NativeMusic:update(data) + if self.fanfareActive then + self.fanfareFrames = self.fanfareFrames - 1 + if self.fanfareFrames <= 0 or self.host.suspended() then + self.fanfareActive = false + local deferred, restore = self.deferred, self.fanfareRestore + self.deferred, self.fanfareRestore = nil, nil + if deferred ~= nil and deferred ~= self.host.nativeGeneration() then + self:playSong(data, deferred, true) + elseif self.host.nativeRunning() then + self.bgmPaused = false + self.host.resumeNative() + elseif restore ~= nil then + self:playSong(data, restore, true) + else + self.bgmPaused = false + end + if self.fanfareWait then + self.fanfareWait.done = true + self.fanfareWait = nil + end + end + end + local f = self.fadeOut + if f then + f.t = f.t + 1 / 60 + local u = math.min(1, f.t / math.max(f.dur, 0.01)) + local same = f.owner == self.serial and self.current and self.current.id == f.songId + if same then + self:refresh() + end + if u >= 1 then + if same then + self.host.stop() + self.current = nil + end + self.fadeOut = nil + if same and f.nextSong then + if f.fadeIn then + self:fadeInBgm(data, f.nextSong, f.fadeIn) + else + self:playSong(data, f.nextSong) + end + end + end + end + if self.fadeIn and self.host.outputAvailable() then + local incoming = self.fadeIn + incoming.t = incoming.t + 1 / 60 + local u = math.min(1, incoming.t / math.max(incoming.dur, 0.01)) + self:setLevel(self.host.shared() and self.host.sharedLevel(u) or self.host.master() * u) + if incoming.t >= math.max(incoming.dur, 0.01) then + self.fadeIn = nil + end + end +end +function NativeMusic:playFanfare(data, value) + local info = require("src.core.Gen3Fanfare").info(data, value) + if not info then + return false + end + if not self.fanfareActive then + self.fanfareRestore = self.host.nativeGeneration() or self.current and self.current.id + self.deferred, self.bgmPaused = nil, true + self.host.pauseNative() + end + self.fanfareActive, self.fanfareFrames = true, info.frames + self.host.stopCry() + self.host.playFanfare(data, info) + return true +end +function NativeMusic:fanfareFinished() + return self.host.suspended() or not self.fanfareActive +end +function NativeMusic:waitFanfare() + if self:fanfareFinished() then + return nil + end + local wait = { polls = 0, done = false } + self.fanfareWait = wait + return function() + wait.polls = wait.polls + 1 + return wait.polls > 360 or wait.done or self:fanfareFinished() + end +end +function NativeMusic:fanfareSnapshot() + return { + active = self.fanfareActive or false, + frames = self.fanfareFrames or 0, + restore = self.fanfareRestore, + paused = self.bgmPaused or false, + } +end +function NativeMusic:snapshot() + local function copy(record) + if not record then + return nil + end + local out = {} + for key, value in pairs(record) do + if key ~= "owner" then + out[key] = value + end + end + return out + end + return { + song = self.current and self.current.id, + mapSong = self.mapSong, + savedSong = self.savedSong, + currentMapMusic = self:currentMapMusic(), + fadeOut = copy(self.fadeOut), + fadeIn = copy(self.fadeIn), + deferred = self.deferred, + level = self.level, + } +end +return NativeMusic diff --git a/src/core/Gen3MusicPolicy.lua b/src/core/Gen3MusicPolicy.lua new file mode 100644 index 000000000..679add823 --- /dev/null +++ b/src/core/Gen3MusicPolicy.lua @@ -0,0 +1,98 @@ +-- Emerald's live default music selection, using host map and save formats. +local SongIds = require("src.core.Gen3SongIds") +local Policy = {} + +-- Functional map identities shared with the admitted Emerald profile. +local MAP_NAMES = { + MAP_G00_N05 = "LILYCOVE_CITY", + MAP_G00_N06 = "MOSSDEEP_CITY", + MAP_G00_N07 = "SOOTOPOLIS_CITY", + MAP_G00_N08 = "EVER_GRANDE_CITY", + MAP_G00_N26 = "ROUTE111", + MAP_G00_N39 = "ROUTE124", + MAP_G00_N40 = "ROUTE125", + MAP_G00_N41 = "ROUTE126", + MAP_G00_N42 = "ROUTE127", + MAP_G00_N43 = "ROUTE128", + MAP_G00_N44 = "ROUTE129", + MAP_G00_N45 = "ROUTE130", + MAP_G00_N46 = "ROUTE131", + MAP_G14_N09 = "MOSSDEEP_CITY_SPACE_CENTER_1F", + MAP_G14_N10 = "MOSSDEEP_CITY_SPACE_CENTER_2F", + MAP_G32_N00 = "ROUTE119_WEATHER_INSTITUTE_1F", + MAP_G32_N01 = "ROUTE119_WEATHER_INSTITUTE_2F", +} +local LEGENDARY = { + LILYCOVE_CITY = true, + MOSSDEEP_CITY = true, + SOOTOPOLIS_CITY = true, + EVER_GRANDE_CITY = true, + ROUTE124 = true, + ROUTE125 = true, + ROUTE126 = true, + ROUTE127 = true, + ROUTE128 = true, +} +local LATE_LEGENDARY = { ROUTE129 = true, ROUTE130 = true, ROUTE131 = true } + +function Policy.defaultSong(data, context, fallback) + context = context or {} + local world = context.overworld + local id = world and world.map and world.map.id + if id == nil then + return fallback + end + local def = data and data.maps and data.maps[id] + local name = type(id) == "string" and (MAP_NAMES[id] or id:match("^EM_(.+)$")) or nil + if def and tonumber(def.group) and tonumber(def.number) then + name = MAP_NAMES[("MAP_G%02d_N%02d"):format(def.group, def.number)] or name + end + local save = context.save or context.game and context.game.save or {} + local function var(key) + return tonumber(save.gen3Vars and save.gen3Vars[key]) or 0 + end + local function song(key) + return SongIds.resolve(key, "emerald") + end + if name == "ROUTE111" and tonumber(save.gen3Weather) == 8 then + return song("MUS_DESERT") + end + if name == "SOOTOPOLIS_CITY" and var(16586) == 1 then + return song("MUS_NONE") + end + if + save.flags + and save.flags.FLAG_G3_088A == true + and (LEGENDARY[name] or LATE_LEGENDARY[name] and var(16478) >= 4) + then + return song("MUS_ABNORMAL_WEATHER") + end + if + (name == "MOSSDEEP_CITY_SPACE_CENTER_1F" or name == "MOSSDEEP_CITY_SPACE_CENTER_2F") + and var(16477) > 0 + and var(16477) <= 2 + then + return song("MUS_ENCOUNTER_MAGMA") + end + if + (name == "ROUTE119_WEATHER_INSTITUTE_1F" or name == "ROUTE119_WEATHER_INSTITUTE_2F") + and var(16563) == 0 + then + return song("MUS_MT_CHIMNEY") + end + local music + if def and def.music ~= nil then + music = tonumber(def.music) or 0 + else + local label = data and data.audio and data.audio.mapSongs and data.audio.mapSongs[id] + music = type(label) == "string" and tonumber(label:match("^SONG_(%x+)$") or "", 16) + or tonumber(SongIds.resolve(label, "emerald")) + or 0 + end + if music == song("MUS_ROUTE118") then + local x = world and world.player and world.player.cellX + return song((tonumber(x) or 0) < 24 and "MUS_ROUTE110" or "MUS_ROUTE119") + end + return music +end +return Policy diff --git a/src/core/Gen3SongIds.lua b/src/core/Gen3SongIds.lua new file mode 100644 index 000000000..37d1fc8aa --- /dev/null +++ b/src/core/Gen3SongIds.lua @@ -0,0 +1,346 @@ +-- Native music identifiers for the admitted GBA profiles. +-- Functional control metadata; no recordings or cartridge image data. +local NAMES = { + emerald = { + MUS_ABANDONED_SHIP = 381, + MUS_ABNORMAL_WEATHER = 443, + MUS_AQUA_MAGMA_HIDEOUT = 430, + MUS_AWAKEN_LEGEND = 388, + MUS_BIRCH_LAB = 383, + MUS_B_ARENA = 458, + MUS_B_DOME = 467, + MUS_B_DOME_LOBBY = 473, + MUS_B_FACTORY = 469, + MUS_B_FRONTIER = 457, + MUS_B_PALACE = 463, + MUS_B_PIKE = 468, + MUS_B_PYRAMID = 461, + MUS_B_PYRAMID_TOP = 462, + MUS_B_TOWER = 465, + MUS_B_TOWER_RS = 384, + MUS_CABLE_CAR = 425, + MUS_CAUGHT = 352, + MUS_CAVE_OF_ORIGIN = 386, + MUS_CONTEST = 440, + MUS_CONTEST_LOBBY = 452, + MUS_CONTEST_RESULTS = 446, + MUS_CONTEST_WINNER = 439, + MUS_CREDITS = 455, + MUS_CYCLING = 403, + MUS_C_COMM_CENTER = 356, + MUS_C_VS_LEGEND_BEAST = 358, + MUS_DESERT = 409, + MUS_DEWFORD = 427, + MUS_DUMMY = 0, + MUS_ENCOUNTER_AQUA = 419, + MUS_ENCOUNTER_BRENDAN = 421, + MUS_ENCOUNTER_CHAMPION = 454, + MUS_ENCOUNTER_COOL = 417, + MUS_ENCOUNTER_ELITE_FOUR = 450, + MUS_ENCOUNTER_FEMALE = 407, + MUS_ENCOUNTER_GIRL = 379, + MUS_ENCOUNTER_HIKER = 451, + MUS_ENCOUNTER_INTENSE = 416, + MUS_ENCOUNTER_INTERVIEWER = 453, + MUS_ENCOUNTER_MAGMA = 441, + MUS_ENCOUNTER_MALE = 380, + MUS_ENCOUNTER_MAY = 415, + MUS_ENCOUNTER_RICH = 397, + MUS_ENCOUNTER_SUSPICIOUS = 423, + MUS_ENCOUNTER_SWIMMER = 385, + MUS_ENCOUNTER_TWINS = 449, + MUS_END = 456, + MUS_EVER_GRANDE = 422, + MUS_EVOLUTION = 377, + MUS_EVOLUTION_INTRO = 376, + MUS_EVOLVED = 371, + MUS_FALLARBOR = 437, + MUS_FOLLOW_ME = 420, + MUS_FORTREE = 382, + MUS_GAME_CORNER = 426, + MUS_GSC_PEWTER = 357, + MUS_GSC_ROUTE38 = 351, + MUS_GYM = 364, + MUS_HALL_OF_FAME = 436, + MUS_HALL_OF_FAME_ROOM = 447, + MUS_HEAL = 368, + MUS_HELP = 410, + MUS_INTRO = 414, + MUS_INTRO_BATTLE = 442, + MUS_LEVEL_UP = 367, + MUS_LILYCOVE = 408, + MUS_LILYCOVE_MUSEUM = 373, + MUS_LINK_CONTEST_P1 = 393, + MUS_LINK_CONTEST_P2 = 394, + MUS_LINK_CONTEST_P3 = 395, + MUS_LINK_CONTEST_P4 = 396, + MUS_LITTLEROOT = 405, + MUS_LITTLEROOT_TEST = 350, + MUS_MOVE_DELETED = 378, + MUS_MT_CHIMNEY = 406, + MUS_MT_PYRE = 432, + MUS_MT_PYRE_EXTERIOR = 434, + MUS_NONE = 65535, + MUS_OBTAIN_BADGE = 369, + MUS_OBTAIN_BERRY = 387, + MUS_OBTAIN_B_POINTS = 459, + MUS_OBTAIN_ITEM = 370, + MUS_OBTAIN_SYMBOL = 466, + MUS_OBTAIN_TMHM = 372, + MUS_OCEANIC_MUSEUM = 375, + MUS_OLDALE = 363, + MUS_PETALBURG = 362, + MUS_PETALBURG_WOODS = 366, + MUS_POKE_CENTER = 400, + MUS_POKE_MART = 404, + MUS_RAYQUAZA_APPEARS = 464, + MUS_REGISTER_MATCH_CALL = 460, + MUS_RG_BERRY_PICK = 542, + MUS_RG_CAUGHT = 534, + MUS_RG_CAUGHT_INTRO = 531, + MUS_RG_CELADON = 521, + MUS_RG_CINNABAR = 491, + MUS_RG_CREDITS = 502, + MUS_RG_CYCLING = 494, + MUS_RG_DEX_RATING = 529, + MUS_RG_ENCOUNTER_BOY = 497, + MUS_RG_ENCOUNTER_DEOXYS = 555, + MUS_RG_ENCOUNTER_GIRL = 496, + MUS_RG_ENCOUNTER_GYM_LEADER = 554, + MUS_RG_ENCOUNTER_RIVAL = 527, + MUS_RG_ENCOUNTER_ROCKET = 495, + MUS_RG_FOLLOW_ME = 484, + MUS_RG_FUCHSIA = 520, + MUS_RG_GAME_CORNER = 485, + MUS_RG_GAME_FREAK = 533, + MUS_RG_GYM = 487, + MUS_RG_HALL_OF_FAME = 498, + MUS_RG_HEAL = 493, + MUS_RG_INTRO_FIGHT = 489, + MUS_RG_JIGGLYPUFF = 488, + MUS_RG_LAVENDER = 492, + MUS_RG_MT_MOON = 500, + MUS_RG_MYSTERY_GIFT = 541, + MUS_RG_NET_CENTER = 540, + MUS_RG_NEW_GAME_EXIT = 537, + MUS_RG_NEW_GAME_INSTRUCT = 535, + MUS_RG_NEW_GAME_INTRO = 536, + MUS_RG_OAK = 514, + MUS_RG_OAK_LAB = 513, + MUS_RG_OBTAIN_KEY_ITEM = 530, + MUS_RG_PALLET = 512, + MUS_RG_PEWTER = 526, + MUS_RG_PHOTO = 532, + MUS_RG_POKE_CENTER = 515, + MUS_RG_POKE_FLUTE = 550, + MUS_RG_POKE_JUMP = 538, + MUS_RG_POKE_MANSION = 501, + MUS_RG_POKE_TOWER = 518, + MUS_RG_RIVAL_EXIT = 528, + MUS_RG_ROCKET_HIDEOUT = 486, + MUS_RG_ROUTE1 = 503, + MUS_RG_ROUTE11 = 506, + MUS_RG_ROUTE24 = 504, + MUS_RG_ROUTE3 = 505, + MUS_RG_SEVII_123 = 547, + MUS_RG_SEVII_45 = 548, + MUS_RG_SEVII_67 = 549, + MUS_RG_SEVII_CAVE = 543, + MUS_RG_SEVII_DUNGEON = 546, + MUS_RG_SEVII_ROUTE = 545, + MUS_RG_SILPH = 519, + MUS_RG_SLOW_PALLET = 557, + MUS_RG_SS_ANNE = 516, + MUS_RG_SURF = 517, + MUS_RG_TEACHY_TV_MENU = 558, + MUS_RG_TEACHY_TV_SHOW = 544, + MUS_RG_TITLE = 490, + MUS_RG_TRAINER_TOWER = 556, + MUS_RG_UNION_ROOM = 539, + MUS_RG_VERMILLION = 525, + MUS_RG_VICTORY_GYM_LEADER = 524, + MUS_RG_VICTORY_ROAD = 507, + MUS_RG_VICTORY_TRAINER = 522, + MUS_RG_VICTORY_WILD = 523, + MUS_RG_VIRIDIAN_FOREST = 499, + MUS_RG_VS_CHAMPION = 511, + MUS_RG_VS_DEOXYS = 551, + MUS_RG_VS_GYM_LEADER = 508, + MUS_RG_VS_LEGEND = 553, + MUS_RG_VS_MEWTWO = 552, + MUS_RG_VS_TRAINER = 509, + MUS_RG_VS_WILD = 510, + MUS_ROULETTE = 392, + MUS_ROUTE101 = 359, + MUS_ROUTE104 = 401, + MUS_ROUTE110 = 360, + MUS_ROUTE113 = 418, + MUS_ROUTE118 = 32767, + MUS_ROUTE119 = 402, + MUS_ROUTE120 = 361, + MUS_ROUTE122 = 374, + MUS_RUSTBORO = 399, + MUS_SAFARI_ZONE = 428, + MUS_SAILING = 431, + MUS_SCHOOL = 435, + MUS_SEALED_CHAMBER = 438, + MUS_SLATEPORT = 433, + MUS_SLOTS_JACKPOT = 389, + MUS_SLOTS_WIN = 390, + MUS_SOOTOPOLIS = 445, + MUS_SURF = 365, + MUS_TITLE = 413, + MUS_TOO_BAD = 391, + MUS_TRICK_HOUSE = 448, + MUS_UNDERWATER = 411, + MUS_VERDANTURF = 398, + MUS_VICTORY_AQUA_MAGMA = 424, + MUS_VICTORY_GYM_LEADER = 354, + MUS_VICTORY_LEAGUE = 355, + MUS_VICTORY_ROAD = 429, + MUS_VICTORY_TRAINER = 412, + MUS_VICTORY_WILD = 353, + MUS_VS_AQUA_MAGMA = 475, + MUS_VS_AQUA_MAGMA_LEADER = 483, + MUS_VS_CHAMPION = 478, + MUS_VS_ELITE_FOUR = 482, + MUS_VS_FRONTIER_BRAIN = 471, + MUS_VS_GYM_LEADER = 477, + MUS_VS_KYOGRE_GROUDON = 480, + MUS_VS_MEW = 472, + MUS_VS_RAYQUAZA = 470, + MUS_VS_REGI = 479, + MUS_VS_RIVAL = 481, + MUS_VS_TRAINER = 476, + MUS_VS_WILD = 474, + MUS_WEATHER_GROUDON = 444, + }, + firered = { + MUS_BERRY_PICK = 330, + MUS_CAUGHT = 322, + MUS_CAUGHT_INTRO = 319, + MUS_CELADON = 309, + MUS_CINNABAR = 279, + MUS_CREDITS = 290, + MUS_CYCLING = 282, + MUS_DEX_RATING = 317, + MUS_DUMMY = 0, + MUS_ENCOUNTER_BOY = 285, + MUS_ENCOUNTER_DEOXYS = 343, + MUS_ENCOUNTER_GIRL = 284, + MUS_ENCOUNTER_GYM_LEADER = 342, + MUS_ENCOUNTER_RIVAL = 315, + MUS_ENCOUNTER_ROCKET = 283, + MUS_EVOLUTION = 264, + MUS_EVOLUTION_INTRO = 263, + MUS_EVOLVED = 259, + MUS_FOLLOW_ME = 272, + MUS_FUCHSIA = 308, + MUS_GAME_CORNER = 273, + MUS_GAME_FREAK = 321, + MUS_GYM = 275, + MUS_HALL_OF_FAME = 286, + MUS_HEAL = 256, + MUS_HEAL_UNUSED = 281, + MUS_INTRO_FIGHT = 277, + MUS_JIGGLYPUFF = 276, + MUS_LAVENDER = 280, + MUS_LEVEL_UP = 257, + MUS_MOVE_DELETED = 270, + MUS_MT_MOON = 288, + MUS_MYSTERY_GIFT = 329, + MUS_NET_CENTER = 328, + MUS_NEW_GAME_EXIT = 325, + MUS_NEW_GAME_INSTRUCT = 323, + MUS_NEW_GAME_INTRO = 324, + MUS_NONE = 65535, + MUS_OAK = 302, + MUS_OAK_LAB = 301, + MUS_OBTAIN_BADGE = 260, + MUS_OBTAIN_BERRY = 262, + MUS_OBTAIN_ITEM = 258, + MUS_OBTAIN_KEY_ITEM = 318, + MUS_OBTAIN_TMHM = 261, + MUS_PALLET = 300, + MUS_PEWTER = 314, + MUS_PHOTO = 320, + MUS_POKE_CENTER = 303, + MUS_POKE_FLUTE = 338, + MUS_POKE_JUMP = 326, + MUS_POKE_MANSION = 289, + MUS_POKE_TOWER = 306, + MUS_RIVAL_EXIT = 316, + MUS_ROCKET_HIDEOUT = 274, + MUS_ROUTE1 = 291, + MUS_ROUTE11 = 294, + MUS_ROUTE24 = 292, + MUS_ROUTE3 = 293, + MUS_RS_VS_GYM_LEADER = 265, + MUS_RS_VS_TRAINER = 266, + MUS_SCHOOL = 267, + MUS_SEVII_123 = 335, + MUS_SEVII_45 = 336, + MUS_SEVII_67 = 337, + MUS_SEVII_CAVE = 331, + MUS_SEVII_DUNGEON = 334, + MUS_SEVII_ROUTE = 333, + MUS_SILPH = 307, + MUS_SLOTS_JACKPOT = 268, + MUS_SLOTS_WIN = 269, + MUS_SLOW_PALLET = 345, + MUS_SS_ANNE = 304, + MUS_SURF = 305, + MUS_TEACHY_TV_MENU = 346, + MUS_TEACHY_TV_SHOW = 332, + MUS_TITLE = 278, + MUS_TOO_BAD = 271, + MUS_TRAINER_TOWER = 344, + MUS_UNION_ROOM = 327, + MUS_VERMILLION = 313, + MUS_VICTORY_GYM_LEADER = 312, + MUS_VICTORY_ROAD = 295, + MUS_VICTORY_TRAINER = 310, + MUS_VICTORY_WILD = 311, + MUS_VIRIDIAN_FOREST = 287, + MUS_VS_CHAMPION = 299, + MUS_VS_DEOXYS = 339, + MUS_VS_GYM_LEADER = 296, + MUS_VS_LEGEND = 341, + MUS_VS_MEWTWO = 340, + MUS_VS_TRAINER = 297, + MUS_VS_WILD = 298, + }, +} +local PRIMARY = {} +for version, names in pairs(NAMES) do + local ids = {} + for name, id in pairs(names) do + if not ids[id] or name < ids[id] then + ids[id] = name + end + end + PRIMARY[version] = ids +end +local SongIds = {} +local function profile(version) + return version or require("src.core.GameVersion").get() +end +function SongIds.resolve(value, version) + if type(value) == "string" and not tonumber(value) then + local names = NAMES[profile(version)] or {} + local trimmed = value:gsub("^%s+", ""):gsub("%s+$", "") + return names[value] or names[trimmed] or names[trimmed:upper()] or value + end + return tonumber(value) or value +end +function SongIds.nameOf(id, version) + return (PRIMARY[profile(version)] or {})[id] +end +function SongIds.names(version) + local names = {} + for name, id in pairs(NAMES[profile(version)] or {}) do + names[name] = id + end + return names +end +return SongIds diff --git a/src/core/M4ASynth.lua b/src/core/M4ASynth.lua index 7dfa83025..76e2431af 100644 --- a/src/core/M4ASynth.lua +++ b/src/core/M4ASynth.lua @@ -926,6 +926,42 @@ local CRY_CAP = 5 -- seconds; nothing in the cartridge is close -- which sits around a tenth of full scale local CRY_GAIN = 0.5 +-- Cry recordings carry WaveData.type, including Game Freak DPCM. Keep this +-- reader separate from sequenced instrument voices and expose decoded input +-- so the native cry controller can use its own pitch/envelope/mode rules. +function M4A.nativeCrySample(data, index) + local audio = data and data.audio or {} + local metadata, gen3 = audio.gen3Cries, audio.gen3 + index = tonumber(index) + if not (metadata and gen3 and metadata.table and index and index == index and index >= 0 and index % 1 == 0) then return nil end + if metadata.count and index >= metadata.count then return nil end + local image = loadImage(audio) + local base = gen3.base or 0 + local pointer = pointerAt(image, base, metadata.table + index * TONE_BYTES + 4) + if not pointer then return nil end + local kind, freq, count = u16At(image, base, pointer), u32At(image, base, pointer + 4), u32At(image, base, pointer + 12) + if not (kind and freq and count and count > 0 and count <= 4 * 1024 * 1024) then return nil end + local size = kind == 1 and math.ceil(count / 64) * 33 or count + if kind ~= 0 and kind ~= 1 then return nil end + local offset = pointer + WAVE_HEADER - base + if offset < 0 or offset + size > #image then return nil end + local pcm = image:sub(offset + 1, offset + size) + local Cry = require("src.core.NativeCryPcm") + if kind == 1 then pcm = Cry.decodeDpcm(pcm, count) end + if #pcm ~= count then return nil end + return pcm, Cry.waveRate(freq), { index = index, type = kind, samples = count, wave = pointer } +end + +function M4A.renderNativeCry(data, index, options) + options = options or {} + local pcm, rate = M4A.nativeCrySample(data, index) + if not pcm then return nil end + local Cry = require("src.core.NativeCryPcm") + local params = Cry.params(options.mode, options.version, options.volume) + local sound, info = Cry.render(pcm, rate, params, { outRate = SAMPLE_RATE, pan = options.pan, mono = options.mono }) + return sound, info, params +end + function M4A.renderCry(data, index, options) options = options or {} local audio = (data and data.audio) or {} diff --git a/src/core/Music.lua b/src/core/Music.lua index e5b95baf6..4780e1572 100644 --- a/src/core/Music.lua +++ b/src/core/Music.lua @@ -20,6 +20,9 @@ local VOLUME = 0.7 -- step's treble (highgain 0.4^level), so 2X/3X are the 1X filter -- applied twice/three times over. local volumeScale = 1 +local volumeLevel = 7 +local nativeVolumeScale = 1 +local audioMode, sharedAudioMode = "both", "both" local FILTER_HIGHGAIN = { 0.4, 0.16, 0.064 } local filterLevel = 0 @@ -37,6 +40,42 @@ local state -- extractor writes it as constants.gen3Cry.duckBgm/scale) and Music.update -- clears it once the cry source has finished. local cryDuck = nil +local nativeCryLevel, nativeCryHold, nativeCryRestore = 1, 0, false +local nativeCryFinished +local nativeBgm, nativeBgmLevel, nativeBgmOutput, nativeBgmContext +local nativeBgmShared, nativeBgmIncomingShared +local sharedStoredLevel = volumeLevel +local nativeModAudio = false +local nativeAudioData +local function hasSharedAudio(audio) + audio = audio or {} + for label in pairs(audio.songs or {}) do + if type(label) == "string" and not label:match("^SONG_%x+$") then return true end + end + for _, owners in pairs(audio._owners or {}) do + if type(owners) == "table" then + for _, owner in pairs(owners) do if owner and owner ~= "base" then return true end end + end + end + return false +end +-- Native world music has its own map/bike state. Shared mod music exposes +-- the generic music service's context, as the native mod bridge does. +local sharedHookState = { onBike = false, surfing = false } +local function musicHookState() + if nativeBgm and require("src.core.GameVersion").isGen3() then return sharedHookState end + return state +end +local function sharedMusicSource() + if nativeBgm and require("src.core.GameVersion").isGen3() then + if nativeBgmIncomingShared ~= nil then return nativeBgmIncomingShared end + return not state.nativeStream + end + return true +end +local function nativeSharedPlayback() + return nativeBgm and require("src.core.GameVersion").isGen3() and nativeBgm.host.shared() +end -- AUDIO SESSION SUSPEND / RESUME state (mobile interruption: an incoming -- call). Declared up here with the other module state because Music.stop -- @@ -66,19 +105,25 @@ local dsLevel, dsPlayer, dsFade = 1, 1, nil local function applyVolume(src) if not src then return end - local vol = VOLUME * volumeScale * dsLevel * dsPlayer + local native = nativeBgm and require("src.core.GameVersion").isGen3() + local shared = native and nativeBgm.host.shared() + local option = shared and (sharedAudioMode == "external_only" and 0 + or math.max(0, math.min(7, sharedStoredLevel)) / 7) or native and nativeBgmLevel or volumeScale + local vol = VOLUME * option * dsLevel * dsPlayer + vol = vol * (shared and 1 or nativeCryLevel) if cryDuck then vol = vol * cryDuck.scale end - if Runtime.wantsHook("music.volume") then + if sharedMusicSource() and Runtime.wantsHook("music.volume") then + local fields = musicHookState() local ctx = { song = state.current, - mapSong = state.mapSong, - onBike = state.onBike, - surfing = state.surfing, + mapSong = fields.mapSong, + onBike = fields.onBike, + surfing = fields.surfing, fading = state.fade ~= nil, - optionScale = volumeScale, + optionScale = shared and option or volumeScale, } - local ok, Game = pcall(require, "src.core.Game") - if ok and Game then + local Game = package.loaded["src.core.Game"] + if type(Game) == "table" then local ow = Game.overworld if ow and ow.player then ctx.x, ctx.y = ow.player.cellX, ow.player.cellY @@ -87,12 +132,23 @@ local function applyVolume(src) end end vol = Runtime.call("music.volume", function(v) return v end, vol, ctx) - vol = tonumber(vol) or (VOLUME * volumeScale) + vol = tonumber(vol) or (VOLUME * (shared and option or volumeScale)) if vol < 0 then vol = 0 end end pcall(src.setVolume, src, vol) end +local function applySharedLevel(level) + if level ~= nil then sharedStoredLevel = level end + volumeScale = sharedAudioMode == "external_only" and 0 + or math.max(0, math.min(7, sharedStoredLevel)) / 7 + -- The shared service owns no Source while native music is playing. + if not state.nativeStream then + applyVolume(state.source) + applyVolume(state.loopSource) + end +end + -- Source:setFilter needs OpenAL EFX; the pcall degrades to unfiltered -- audio where it's missing (and under the headless stub) local function applyFilter(src) @@ -149,6 +205,29 @@ function Music.duckForCry(data, src) end end +function Music.nativeCryStarted(params, options, finished) + nativeCryFinished = finished + local changed = false + if options.release == true then + if params.mode ~= 1 and options.multi ~= true then nativeCryLevel, changed = 85 / 256, true end + elseif params.mode ~= 1 and options.noDuck ~= true then + nativeCryLevel, nativeCryHold, nativeCryRestore, changed = 85 / 256, 2, true, true + end + if changed and not state.fade and not nativeSharedPlayback() then + applyVolume(state.source) + applyVolume(state.loopSource) + end +end + +function Music.nativeCryStopped() + nativeCryLevel, nativeCryHold, nativeCryRestore = 1, 0, false + if not state.fade and not nativeSharedPlayback() then applyVolume(state.source); applyVolume(state.loopSource) end +end + +function Music.nativeCryMixSnapshot() + return { level = nativeCryLevel, hold = nativeCryHold, restore = nativeCryRestore } +end + -- true while a cry is still holding the song down local function cryDuckActive() if not cryDuck then return false end @@ -269,7 +348,7 @@ end -- Build the new song's sources; the caller only tears the old song down -- once this succeeded, so a broken def costs nothing but a log line. -- Returns src, loopSrc, isChip -- or nil plus the reason. -local function startSong(data, def, wantLoop) +local function startSong(data, def, wantLoop, nativeStream) -- A GEN 3 SONG IS NEITHER OF THE TWO SHAPES BELOW, and that is why Hoenn -- was silent. It carries no chip program and no sound file: it carries -- track pointers and a voicegroup, and it is played by a sequencer over @@ -287,7 +366,7 @@ local function startSong(data, def, wantLoop) return nil, nil, nil, tostring(src) elseif type(def.tracks) == "table" and #def.tracks > 0 then local ok, src = pcall( - require("src.core.ChipAudio").playMusic, data, def, wantLoop, "m4a") + require("src.core.ChipAudio").playMusic, data, def, wantLoop, "m4a", { nativeBgm = nativeStream }) if ok and src then return src, nil, true end return nil, nil, nil, ok and "no source" or tostring(src) elseif def.chip or (def.address and def.bank) then @@ -308,13 +387,15 @@ end -- the single choke point every song choice passes through, so one hook -- covers map themes, battle themes, jingles and scene music local function selectSong(song, ctx) + if ctx and ctx.selected then return song end if not Runtime.wantsHook("music.select") then return song end + local fields = musicHookState() return Runtime.call("music.select", function(chosen) return chosen end, song, { reason = ctx and ctx.reason or "direct", mapId = ctx and ctx.mapId, - mapSong = state.mapSong, - onBike = state.onBike, - surfing = state.surfing, + mapSong = fields.mapSong, + onBike = fields.onBike, + surfing = fields.surfing, kind = ctx and ctx.kind, battleKind = ctx and ctx.kind, trainerId = ctx and ctx.trainerId, @@ -324,17 +405,53 @@ end function Music.play(data, song, loop, ctx) if not song then return end if not love.audio then return end -- headless test stub - song = selectSong(song, ctx) + local gen3 = require("src.core.GameVersion").isGen3() + local requested = gen3 and songDef(data, song) + local nativeCue = gen3 and (ctx and ctx.nativeShared == false + or (not (ctx and ctx.nativeShared) and type(song) == "string" and song:match("^SONG_%x+$") + and type(requested) == "table" and type(requested.tracks) == "table" and #requested.tracks > 0)) + if not nativeCue then song = selectSong(song, ctx) end -- a hook may silence the cue outright, or swap in a label the dedupe -- below has to compare against - if not song or song == state.current then return end + if not song or song == state.current and not (ctx and ctx.nativeRestart) then return end local def = songDef(data, song) - if not def or state.failed[song] then return end + if not def then return end local wantLoop = loop ~= false - local src, loopSrc, isChip, err = startSong(data, def, wantLoop) + local nativeStream = false + if require("src.core.GameVersion").isGen3() then + nativeBgmIncomingShared = ctx and ctx.nativeShared + if nativeBgmIncomingShared == nil then + nativeBgmIncomingShared = not (type(song) == "string" and song:match("^SONG_%x+$") + and type(def.tracks) == "table" and #def.tracks > 0) + end + nativeStream = not nativeBgmIncomingShared and type(def.tracks) == "table" and #def.tracks > 0 + end + if state.failed[song] and not nativeStream then + nativeBgmIncomingShared = nil + return + end + local function startedNativeSong() + if nativeBgm and require("src.core.GameVersion").isGen3() then + local id = ctx and ctx.nativeSong or tonumber(type(song) == "string" and song:match("^SONG_(%x+)$") or "", 16) or require("src.core.Gen3SongIds").resolve(song) + nativeBgm:started(id, true) + end + end + -- Native identity changes even when the output device cannot build a Source. + -- Shared mod music keeps its existing successful-start admission instead. + if nativeStream and nativeBgmShared and state.current then + -- The native player releases shared mod music before opening its carrier. + -- Failure to build that carrier must not leave the previous mod sounding. + Music.stop(true) + nativeBgmIncomingShared = false + end + if nativeStream then startedNativeSong() end + local src, loopSrc, isChip, err = startSong(data, def, wantLoop, nativeStream) if not src then - state.failed[song] = true - reportBadDef(data, song, err) + nativeBgmIncomingShared = nil + if not nativeStream then + state.failed[song] = true + reportBadDef(data, song, err) + end return end stopSource(state.source) @@ -345,6 +462,7 @@ function Music.play(data, song, loop, ctx) state.fade = nil -- a new sequence starts at its full level; the player volume stays dsLevel, dsFade = 1, nil + if not nativeStream then startedNativeSong() end if loopSrc then -- intro plays once, then update() chains to the loop body -- (for one-shot jingles the body plays once and doesn't repeat) @@ -361,13 +479,16 @@ function Music.play(data, song, loop, ctx) -- (update() starts it, like the paused-song resume) if fanfareActive() then state.fanfareResume = true - else + elseif not isChip then pcall(src.play, src) end local previous = state.current state.source, state.loopSource, state.chip = src, loopSrc, isChip + state.nativeStream = nativeStream state.current = song - if Runtime.wants("music.started") then + nativeBgmShared, nativeBgmIncomingShared = nativeBgmIncomingShared, nil + if nativeStream then nativeBgmOutput = true end + if not nativeStream and Runtime.wants("music.started") then Runtime.emit("music.started", { song = song, previous = previous, chip = isChip, reason = ctx and ctx.reason or "direct", @@ -380,25 +501,37 @@ function Music.current() return state.current end -function Music.stop() +function Music.stop(nativeInternal) local previous = state.current + local nativeStream = state.nativeStream and require("src.core.GameVersion").isGen3() stopSource(state.source) stopSource(state.loopSource) require("src.core.ChipAudio").stopMusic() state.current, state.source, state.loopSource, state.fade = nil, nil, nil, nil state.chip = false + state.nativeStream = false + nativeBgmShared, nativeBgmIncomingShared = nil, nil + if nativeInternal ~= true then + if nativeBgm then nativeBgm:cancelFanfare(); nativeBgm:stopped() end + require("src.core.Sound").invalidateNativeFanfares() + end state.pendingRestore = nil -- An explicit stop outranks a pending resume re-cue: whoever called this -- wants silence, and the retry in Music.update fires on a nil state.current resumeRetry = nil - if previous and Runtime.wants("music.stopped") then + if previous and not nativeStream and Runtime.wants("music.stopped") then Runtime.emit("music.stopped", { song = previous }) end + if previous and not nativeStream and require("src.core.GameVersion").isGen3() then + applySharedLevel(volumeLevel) + end end -- hot reload: forget the failed defs and the playing label so the next cue -- re-resolves against the freshly merged registries function Music.reload() + nativeBgmOutput, nativeBgmContext = nil, nil + if nativeBgm then nativeBgm:reset() end state.failed = {} Music.stop() end @@ -454,8 +587,12 @@ function Music.playMap(data, mapId, onBike, surfing) if hour < 4 or hour >= 20 then song = nights[mapId] end end state.mapSong = song + if require("src.core.GameVersion").isGen3() then Music.nativeCommand(data, "map", song) end state.onBike = not not onBike state.surfing = not not surfing + if not require("src.core.GameVersion").isGen3() then + sharedHookState.mapSong, sharedHookState.onBike, sharedHookState.surfing = song, state.onBike, state.surfing + end local play = effectiveMapSong(data, song) -- FieldBGM_PlayForMapHeader starts the map's BGM at its full level, so a -- script's fade-out does not carry silence onto a map with the same song @@ -464,7 +601,13 @@ function Music.playMap(data, mapId, onBike, surfing) applyVolume(state.source) applyVolume(state.loopSource) end - if play then Music.play(data, play, nil, { reason = "map", mapId = mapId }) end + if play then + if require("src.core.GameVersion").isGen3() then + Music.nativeCommand(data, "mapcue", play) + else + Music.play(data, play, nil, { reason = "map", mapId = mapId }) + end + end end -- toggle the surf override mid-map (starting/ending a surf) @@ -472,12 +615,14 @@ end -- for Surf), without restarting a map song that is already right function Music.setBike(data, onBike) state.onBike = not not onBike + if not require("src.core.GameVersion").isGen3() then sharedHookState.onBike = state.onBike end local play = effectiveMapSong(data, state.mapSong) if play then Music.play(data, play, nil, { reason = "map" }) end end function Music.setSurfing(data, surfing) state.surfing = not not surfing + if not require("src.core.GameVersion").isGen3() then sharedHookState.surfing = state.surfing end local play = effectiveMapSong(data, state.mapSong) if play then Music.play(data, play, nil, { reason = "map" }) end end @@ -567,11 +712,27 @@ function Music.dsLevels() return dsLevel, dsPlayer end -- 0-7 music volume (0 mutes), applied to the playing song and the -- queued loop body as well as everything played later function Music.setVolumeLevel(level) - volumeScale = math.max(0, math.min(7, level or 7)) / 7 + volumeLevel = math.max(0, math.min(7, level or 7)) + sharedStoredLevel = volumeLevel + volumeScale = sharedAudioMode == "external_only" and 0 or volumeLevel / 7 + nativeVolumeScale = audioMode == "external_only" and 0 or volumeLevel / 7 + if nativeBgm and require("src.core.GameVersion").isGen3() then nativeBgm:optionChanged(false) end applyVolume(state.source) applyVolume(state.loopSource) end +function Music.setAudioMode(mode) + sharedAudioMode = mode or "both" + require("src.audio.AudioMix").set(sharedAudioMode ~= "game_only") + if require("src.core.GameVersion").isGen3() then + applySharedLevel(sharedStoredLevel) + else + audioMode = sharedAudioMode + Music.setVolumeLevel(volumeLevel) + end +end +function Music.audioMode() return sharedAudioMode end + -- music low-pass filter level, 0 (OFF) to 3 function Music.setFilterLevel(level) filterLevel = math.max(0, math.min(3, level or 0)) @@ -581,9 +742,27 @@ end -- re-apply persisted audio options (Game calls this on boot and after -- loading a save) -function Music.applyOptions(opts) +function Music.applyOptions(opts, data) require("src.core.ChipAudio").setStereo(opts and opts.stereo) - Music.setVolumeLevel(opts and opts.musicVol or 7) + if require("src.core.GameVersion").isGen3() then + if data and data.audio then nativeAudioData = data.audio end + nativeModAudio = hasSharedAudio(nativeAudioData) + -- Native options become live before invoking shared hooks. Reentrant + -- world actions must see the new native master, not the old shared trim. + audioMode = opts and opts.audioMode or "both" + volumeLevel = math.max(0, math.min(7, tonumber(opts and opts.musicVol) or 7)) + nativeVolumeScale = audioMode == "external_only" and 0 or volumeLevel / 7 + require("src.audio.AudioMix").set(audioMode ~= "game_only") + if nativeModAudio then + Music.setAudioMode(opts and opts.audioMode or sharedAudioMode) + applySharedLevel(opts and opts.musicVol or sharedStoredLevel or 7) + end + if nativeBgm then nativeBgm:optionChanged(false) end + if state.nativeStream then applyVolume(state.source); applyVolume(state.loopSource) end + else + Music.setAudioMode(opts and opts.audioMode or sharedAudioMode) + Music.setVolumeLevel(opts and opts.musicVol or volumeLevel or 7) + end Music.setFilterLevel(opts and opts.musicFilter or 0) end @@ -612,6 +791,7 @@ function Music.suspend() -- beat it was scoring is long over, and state.pendingRestore already means -- "put the map theme back when this ends", which is what resume then does. suspendedSong = (not state.pendingRestore) and state.current or nil + if nativeBgm then nativeBgm:cancelFanfare() end require("src.core.ChipAudio").suspend() require("src.core.Sound").suspend() stopSource(state.source) @@ -674,43 +854,7 @@ local function sourceStopped(src) return ok and not playing end --- call once per frame: chains a finished intro into its loop body and --- restores the map theme after a one-shot jingle -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 - -- Source we had is gone, and Music.resume is what rebuilds them. Nothing - -- 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 - -- 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 - -- ticked here, on the once-a-frame clock, and only while NOTHING is - -- playing -- if the resume took, or the player crossed a seam and the new - -- map cued its own theme, this must not fight it. - if resumeRetry then - if state.current ~= nil or resumeRetry.left <= 0 then - resumeRetry = nil - else - resumeRetry.wait = resumeRetry.wait - 1 - if resumeRetry.wait <= 0 then - resumeRetry.wait = RESUME_RETRY_FRAMES - resumeRetry.left = resumeRetry.left - 1 - state.failed[resumeRetry.song] = nil - Music.play(data, resumeRetry.song, true, { reason = "resume" }) - end - end - end - if state.chip then require("src.core.ChipAudio").update() end - -- restore the song's level once the cry that ducked it has finished - if cryDuck and not cryDuckActive() then - cryDuck = nil - if not state.fade then - applyVolume(state.source) - applyVolume(state.loopSource) - end - end +local function updateSharedPlayback(data) -- distance / indoor muffling mods re-apply volume every frame while -- subscribed; otherwise applyVolume only runs on song/option changes if Runtime.wantsHook("music.volume") and not state.fade then @@ -774,4 +918,163 @@ function Music.update(data) end end +-- call once per frame: chains a finished intro into its loop body and +-- restores the map theme after a one-shot jingle +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 + -- Source we had is gone, and Music.resume is what rebuilds them. Nothing + -- 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 + -- 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 + -- ticked here, on the once-a-frame clock, and only while NOTHING is + -- playing -- if the resume took, or the player crossed a seam and the new + -- map cued its own theme, this must not fight it. + if resumeRetry then + if state.current ~= nil or resumeRetry.left <= 0 then + resumeRetry = nil + else + resumeRetry.wait = resumeRetry.wait - 1 + if resumeRetry.wait <= 0 then + resumeRetry.wait = RESUME_RETRY_FRAMES + resumeRetry.left = resumeRetry.left - 1 + state.failed[resumeRetry.song] = nil + Music.play(data, resumeRetry.song, true, { reason = "resume" }) + end + end + end + if state.chip and not state.nativeStream then require("src.core.ChipAudio").update() end + local nativeService = nativeBgm and require("src.core.GameVersion").isGen3() + if nativeService and nativeBgmShared then updateSharedPlayback(data) end + if nativeCryHold > 0 then + nativeCryHold = nativeCryHold - 1 + elseif nativeCryRestore and nativeCryFinished and nativeCryFinished() then + nativeCryRestore, nativeCryLevel = false, 1 + if not state.fade and not nativeSharedPlayback() then applyVolume(state.source); applyVolume(state.loopSource) end + end + -- restore the song's level once the cry that ducked it has finished + if cryDuck and not cryDuckActive() then + cryDuck = nil + if not state.fade then + applyVolume(state.source) + applyVolume(state.loopSource) + end + end + if nativeBgm and require("src.core.GameVersion").isGen3() then nativeBgm:update(data) end + if state.chip and state.nativeStream then require("src.core.ChipAudio").update() end + if not nativeService then + updateSharedPlayback(data) + elseif state.chip and state.nativeStream then + require("src.core.ChipAudio").ensureMusicPlaying() + end +end + +-- Native world commands use numeric identities independently of physical labels. +function Music.nativeCommand(data, operation, value, keep, context) + local Ids = require("src.core.Gen3SongIds") + local function number(label) + return type(label) == "string" and tonumber(label:match("^SONG_(%x+)$") or "", 16) or Ids.resolve(label) + end + nativeAudioData = data and data.audio + nativeModAudio = hasSharedAudio(nativeAudioData) + if not nativeBgm then + nativeBgm = require("src.core.Gen3Music").new({ + version = function() return require("src.core.GameVersion").get() end, + master = function() return nativeVolumeScale end, + sharedLevel = function(factor) + sharedStoredLevel = math.max(0, math.min(7, volumeLevel * factor)) + volumeScale = sharedAudioMode == "external_only" and 0 or sharedStoredLevel / 7 + return volumeScale + end, + level = function(level, apply) + nativeBgmLevel = level + if apply ~= false then applyVolume(state.source); applyVolume(state.loopSource) end + end, + outputAvailable = function() return nativeBgmOutput or state.source ~= nil end, + nativeOutput = function() return nativeBgmOutput and true or false end, + shared = function() + if nativeBgmIncomingShared ~= nil then return nativeBgmIncomingShared end + return nativeBgmShared and true or false + end, + suspended = function() return sessionSuspended end, + nativeRunning = function() return require("src.core.ChipAudio").nativeBgmState() ~= nil end, + nativeGeneration = function() + local playback = require("src.core.ChipAudio").nativeBgmState() + return playback and playback.threaded and nativeBgm.current and nativeBgm.current.id or nil + end, + pauseNative = function() require("src.core.ChipAudio").pauseNativeMusic() end, + resumeNative = function() + nativeBgm:setLevel(nativeVolumeScale) + require("src.core.ChipAudio").resumeNativeMusic() + end, + stopCry = function() require("src.core.Sound").stopCry() end, + playFanfare = function(currentData, info) + require("src.core.Sound").playNativeFanfare(currentData, info, nativeVolumeScale, filterLevel) + end, + defaultSong = function() + if require("src.core.GameVersion").get() == "emerald" then + local currentData = nativeBgmContext and nativeBgmContext.game and nativeBgmContext.game.data or data + return require("src.core.Gen3MusicPolicy").defaultSong(currentData, nativeBgmContext, nativeBgm.mapSong) + end + return nativeBgm.mapSong + end, + stop = function() Music.stop(true) end, + play = function(currentData, id, restart) + local songs = currentData.audio and currentData.audio.songs or {} + local canonical = type(id) == "number" and Ids.nameOf(id) or id + local override = canonical and songs[canonical] ~= nil + local label = override and canonical + or type(id) == "number" and ("SONG_%03X"):format(id) or id + if not songs[label] then Music.stop(); return end + local def = songs[label] + local shared = override or not (type(def.tracks) == "table" and #def.tracks > 0) + if shared then Music.stop(true) end + Music.play(currentData, label, true, { reason = "gen3", nativeSong = id, nativeRestart = restart, nativeShared = shared }) + end, + }) + if state.current ~= nil then nativeBgm:started(number(state.current)) end + nativeBgm:setMapSong(number(state.mapSong)) + nativeBgm:refresh() + end + nativeBgmContext = context + if operation == "map" then + nativeBgm:setMapSong(number(value)) + elseif operation == "mapcue" then + local id = number(value) + if nativeBgm.fanfareActive then nativeBgm.deferred = id; return true end + return nativeBgm:playSong(data, id) + elseif operation == "playfanfare" then return nativeBgm:playFanfare(data, value) + elseif operation == "playbgm" or operation == "fadenewbgm" then + if operation == "playbgm" and (keep == 1 or keep == true) then nativeBgm:setSavedSong(value) end + return nativeBgm:playSong(data, value) + elseif operation == "savebgm" then nativeBgm:setSavedSong(value) + elseif operation == "fadeoutbgm" then nativeBgm:fadeOutBgm(4) + elseif operation == "fadeinbgm" then nativeBgm:fadeInBgm(data, nativeBgm.mapSong or nativeBgm.savedSong, 4) + elseif operation == "fadedefaultbgm" then return nativeBgm:fadeDefaultBgm(data) + end +end +function Music.nativeFanfare(data, value, context) + return Music.nativeCommand(data, "playfanfare", value, nil, context) +end +function Music.nativeWaitFanfare() + return nativeBgm and nativeBgm:waitFanfare() or nil +end +function Music.nativeFanfareFinished() + return not nativeBgm or nativeBgm:fanfareFinished() +end +function Music.nativeFanfareSnapshot() + return nativeBgm and nativeBgm:fanfareSnapshot() or { active = false, frames = 0, paused = false } +end +function Music.nativeSnapshot() + return nativeBgm and nativeBgm:snapshot() or nil +end +-- The launcher and mod loader invalidate this bus before entering the next +-- dataset. Stopping a song alone deliberately keeps map/saved music for +-- ordinary playback, so it cannot retire the native session's controller. +require("src.render.Assets").register(function() + if nativeBgm then Music.reload() end +end) return Music diff --git a/src/core/NativeCryPcm.lua b/src/core/NativeCryPcm.lua new file mode 100644 index 000000000..d07273044 --- /dev/null +++ b/src/core/NativeCryPcm.lua @@ -0,0 +1,177 @@ +-- Native GBA cry controls, independent of the music sequencer's instruments. +local Cry = {} +local FRAME_RATE = 16777216 / 280896 +-- length, release, pitch, chorus, reverse, optional native volume +local MODES = { + { 140, 0, 15360, 0, false }, + { 20, 225, 15360, 0, false }, + { 140, 225, 15600, 20, false, 90 }, + { 50, 200, 15800, 20, false, 90 }, + { 25, 100, 15600, 192, true, 90 }, + { 140, 200, 14440, 0, false }, + { 140, 220, 15555, 192, false, 90 }, + { 10, 100, 14848, 0, false }, + { 60, 225, 15616, 0, false }, + { 15, 125, 15200, 0, true }, + { 100, 225, 15200, 0, false }, + { 140, 0, 15000, 0, false }, + { 20, 225, 15000, 0, false }, +} +function Cry.params(mode, version, volume) + mode = tonumber(mode) or 0 + if not MODES[mode + 1] then + mode = 0 + end + local row = MODES[mode + 1] + return { + mode = mode, + length = row[1], + release = row[2], + pitch = row[3], + chorus = row[4], + reverse = row[5], + volume = version == "emerald" and mode == 6 and 70 or row[6] or tonumber(volume) or 120, + } +end + +function Cry.waveRate(freq) + freq = tonumber(freq) or 0 + if freq <= 0 then + return 13379 + end + local rate = math.floor(freq / 1024 + 0.5) + if rate < 500 then + return 13379 + end + return math.min(48000, rate) +end + +-- Each compressed block starts with an absolute signed sample. The first +-- nibble is unused; subsequent nibbles add signed square-law deltas. Keep +-- the accumulator unbounded and clamp only the emitted sample. +function Cry.decodeDpcm(bytes, count) + if type(bytes) ~= "string" or not count or count <= 0 then + return "" + end + local out, written = {}, 0 + local function emit(value) + written = written + 1 + out[written] = string.char((math.max(-128, math.min(127, value)) + 256) % 256) + end + local function delta(nibble) + local signed = nibble < 8 and nibble or nibble - 16 + return signed < 0 and -(signed * signed) or signed * signed + end + for block = 0, math.ceil(count / 64) - 1 do + local start = block * 33 + 1 + local first = bytes:byte(start) + if not first then + break + end + local value = first >= 128 and first - 256 or first + emit(value) + for sample = 1, 63 do + if written >= count then + break + end + local offset = math.floor(sample / 2) + 1 + local byte = bytes:byte(start + offset) + if not byte then + break + end + local nibble = sample % 2 == 1 and byte % 16 or math.floor(byte / 16) + value = value + delta(nibble) + emit(value) + end + if written >= count then + break + end + end + return table.concat(out) +end + +function Cry.render(pcm, rate, params, options) + options = options or {} + if type(pcm) ~= "string" or #pcm == 0 then + return nil + end + local outRate = options.outRate or rate or 13379 + rate = rate or 13379 + local length = math.max(0, math.floor(tonumber(params.length) or 0)) + local release = math.max(0, math.floor(tonumber(params.release) or 0)) + local tail, envelope = {}, 255 + while true do + envelope = math.floor(envelope * release / 256) + if envelope <= 0 then + break + end + tail[#tail + 1] = envelope / 255 + end + local endFrame = length + #tail + local pitch = ((tonumber(params.pitch) or 15360) + 128) % 65536 + if pitch >= 32768 then + pitch = pitch - 65536 + end + local key, tune = math.floor(pitch / 256) % 128, math.floor(pitch / 2) % 128 + local tunes = { tune } + local chorus = (tonumber(params.chorus) or 0) % 256 + if chorus ~= 0 then + if chorus >= 128 then + chorus = chorus - 256 + end + tunes[2] = (chorus + tune) % 128 + end + local voices, samples = {}, 0 + local envelopeSamples = math.ceil(endFrame * outRate / FRAME_RATE) + for _, value in ipairs(tunes) do + local multiplier = 2 ^ (((key - 60) * 64 + value - 64) / (64 * 12)) + local step = rate * multiplier / outRate + local natural = math.ceil(#pcm / step) + local count = math.min(natural, envelopeSamples) + voices[#voices + 1] = { step = step, count = count, natural = natural } + samples = math.max(samples, count) + end + local channels = options.pan and not options.mono and 2 or 1 + local info = { + frames = math.min(endFrame, math.ceil(voices[1].natural * FRAME_RATE / outRate)), + endFrame = endFrame, + voices = #voices, + outRate = outRate, + seconds = samples / outRate, + } + if not (love and love.sound and love.sound.newSoundData) then + return nil, info + end + local sound = love.sound.newSoundData(math.max(1, samples), outRate, 16, channels) + local gain = (tonumber(params.volume) or 120) / 127 + local left, right = 1, 1 + if options.pan then + left, right = (127 - options.pan) / 191, (128 + options.pan) / 191 + end + for i = 0, samples - 1 do + local frame = math.floor(i * FRAME_RATE / outRate) + local env = frame < length and 1 or tail[frame - length + 1] or 0 + local value = 0 + if env * gain > 0 then + for _, voice in ipairs(voices) do + if i < voice.count then + local index = math.floor(i * voice.step) + if params.reverse then + index = #pcm - 1 - index + end + local byte = pcm:byte(index + 1) or 0 + value = value + (byte >= 128 and byte - 256 or byte) / 128 + end + end + value = math.max(-1, math.min(1, value * (env * gain))) + end + if channels == 2 then + sound:setSample(i, 1, value * left) + sound:setSample(i, 2, value * right) + else + sound:setSample(i, value * (left + right) * 0.5) + end + end + return sound, info +end +return Cry diff --git a/src/core/NativeCryPlayer.lua b/src/core/NativeCryPlayer.lua new file mode 100644 index 000000000..7bb1ad682 --- /dev/null +++ b/src/core/NativeCryPlayer.lua @@ -0,0 +1,193 @@ +-- Native GBA cry state is independent of the SE players and legacy cry cache. +local Player = {} +local clock, untilFrame, source, controls = 0, nil, nil, nil +local voices = {} +local rendered = setmetatable({}, { __mode = "k" }) +local function sounding(src) + if not src then + return false + end + local ok, value = pcall(src.isPlaying, src) + return ok and value and true or false +end +local function rawSpecies(data, species, options) + if options.nativeSpecies ~= nil then + return tonumber(options.nativeSpecies) + end + if type(species) == "number" then + return species + end + for number, id in pairs(data and data.constants and data.constants.speciesOrder or {}) do + if id == species then + return number + end + end +end +function Player.finished() + if untilFrame and clock >= untilFrame then + return true + end + if source then + return not sounding(source) + end + return untilFrame == nil or clock >= untilFrame +end +function Player.tick() + clock = clock + 1 +end +function Player.wait() + local previous = clock + return function() + if Player.finished() then + return true + end + if clock == previous then + Player.tick() + end + previous = clock + return Player.finished() + end +end +function Player.play(data, species, options, legacy, volumeScale, mono) + options = options or {} + local audio = data and data.audio or {} + local definition = audio.cries and audio.cries[species] + local owner = audio._owners and audio._owners.cries and audio._owners.cries[species] + local raw = rawSpecies(data, species, options) + local nativeIndex = require("src.core.NativeCrySpecies").index(raw) + local explicitIndex = type(definition) == "table" and tonumber(definition.m4a) + local legacyIndex = raw and raw >= 277 and raw - 26 or nativeIndex + local overridden = definition ~= nil + and ( + owner ~= nil and owner ~= "base" + or explicitIndex == nil + or explicitIndex ~= nativeIndex and explicitIndex ~= legacyIndex + ) + if overridden then + local replacement = legacy() + if not options.isolated then + source = replacement + untilFrame = not source and clock + 64 or nil + end + return replacement, true + end + local params = require("src.core.NativeCryPcm").params( + options.mode, + require("src.core.GameVersion").get(), + options.volume + ) + if not options.isolated then + controls = params + end + if not (audio.gen3 and audio.gen3Cries and audio.gen3Cries.table and nativeIndex ~= nil) then + if not options.isolated then + untilFrame = clock + 64 + end + return nil + end + local pan = options.pan and options.pan ~= 0 and require("src.core.NativeSoundPan").value(options.pan) + or nil + local outputMono = options.mono + if outputMono == nil then + outputMono = mono + end + local key = table.concat({ + nativeIndex, + tostring(options.mode), + tostring(options.volume), + tostring(pan), + outputMono and "m" or "s", + require("src.core.GameVersion").get(), + }, ":") + local cache = rendered[data] or { count = 0 } + rendered[data] = cache + local hit = cache[key] + local ok, sound, info = true, hit and hit.sound, hit and hit.info + if not hit then + ok, sound, info = pcall(require("src.core.M4ASynth").renderNativeCry, data, nativeIndex, { + mode = options.mode, + version = require("src.core.GameVersion").get(), + volume = options.volume, + pan = pan, + mono = outputMono, + }) + if ok and sound and info then + if cache.count >= 24 then + cache = { count = 0 } + rendered[data] = cache + end + cache.count = cache.count + 1 + cache[key] = { sound = sound, info = info } + end + end + if not ok or not info then + if not options.isolated then + untilFrame = clock + 64 + end + return nil + end + if not options.isolated then + source, untilFrame = nil, clock + info.frames + require("src.core.Music").nativeCryStarted(params, options, Player.finished) + end + local result + if sound and love.audio and love.audio.newSource then + local made, src = pcall(love.audio.newSource, sound, "static") + if made and src then + pcall(src.setVolume, src, volumeScale) + local played = pcall(src.play, src) + if played then + result = src + if not options.isolated then + source = src + end + local active = {} + for _, entry in ipairs(voices) do + if sounding(entry.source) then + active[#active + 1] = entry + end + end + voices = active + voices[#voices + 1] = { source = src, species = species } + end + end + end + return result +end +function Player.stop() + if source then + pcall(source.stop, source) + end + source, untilFrame = nil, nil + require("src.core.Music").nativeCryStopped() +end +function Player.invalidate(name) + rendered = setmetatable({}, { __mode = "k" }) + local retained = {} + local cleared = false + for _, entry in ipairs(voices) do + if not name or name == "cry:" .. tostring(entry.species) then + pcall(entry.source.stop, entry.source) + if entry.source == source then + source, untilFrame, cleared = nil, nil, true + end + else + retained[#retained + 1] = entry + end + end + voices = retained + if not name then + source, untilFrame, cleared = nil, nil, true + end + if cleared then + require("src.core.Music").nativeCryStopped() + end +end +function Player.snapshot() + local params = {} + for key, value in pairs(controls or {}) do + params[key] = value + end + return { clock = clock, untilFrame = untilFrame, finished = Player.finished(), controls = params } +end +return Player diff --git a/src/core/NativeCrySpecies.lua b/src/core/NativeCrySpecies.lua new file mode 100644 index 000000000..08b41d2d9 --- /dev/null +++ b/src/core/NativeCrySpecies.lua @@ -0,0 +1,157 @@ +-- Native GBA species IDs 277..411 use a control table, not cry order. +-- Verified against the identical FireRed/Emerald native 135-entry maps. +local HOENN = { + 273, + 274, + 275, + 270, + 271, + 272, + 276, + 277, + 278, + 359, + 360, + 378, + 375, + 290, + 291, + 292, + 293, + 294, + 283, + 284, + 285, + 286, + 287, + 288, + 301, + 302, + 303, + 266, + 267, + 374, + 373, + 269, + 280, + 279, + 310, + 311, + 377, + 381, + 312, + 313, + 251, + 329, + 330, + 306, + 253, + 362, + 318, + 319, + 368, + 320, + 321, + 333, + 334, + 289, + 260, + 324, + 325, + 326, + 304, + 305, + 254, + 255, + 316, + 317, + 338, + 339, + 340, + 327, + 328, + 383, + 307, + 331, + 332, + 262, + 322, + 323, + 308, + 309, + 363, + 336, + 337, + 263, + 264, + 258, + 256, + 361, + 252, + 298, + 299, + 300, + 314, + 315, + 376, + 382, + 380, + 379, + 341, + 342, + 343, + 335, + 282, + 281, + 259, + 261, + 367, + 364, + 365, + 366, + 356, + 357, + 358, + 344, + 345, + 346, + 347, + 295, + 296, + 297, + 351, + 352, + 372, + 348, + 349, + 350, + 353, + 354, + 355, + 370, + 369, + 371, + 257, + 384, + 385, + 386, + 387, +} +local CrySpecies = {} +function CrySpecies.index(species) + species = tonumber(species) + if not species or species ~= species or species % 1 ~= 0 then + return nil + end + if species == 0 then + return 0 + end + if species >= 1 and species <= 251 then + return species - 1 + end + if species >= 252 and species <= 276 then + return 200 + end + return HOENN[species - 276] +end +return CrySpecies diff --git a/src/core/NativeSoundPan.lua b/src/core/NativeSoundPan.lua new file mode 100644 index 000000000..658ad5bbf --- /dev/null +++ b/src/core/NativeSoundPan.lua @@ -0,0 +1,31 @@ +-- GBA PlaySE12PanpotControl: trim the opposite channel without recentering +-- the track's original stereo mix. Mono folds the trimmed channels together. +local M = {} +function M.value(pan) + pan = tonumber(pan) or 0 + if pan ~= pan or pan == math.huge or pan == -math.huge then + return 0 + end + return math.max(-64, math.min(63, pan)) +end +function M.apply(source, pan, mono) + pan = M.value(pan) / 64 + local left, right = 1 - math.max(0, pan), 1 - math.max(0, -pan) + local count, channels = source:getSampleCount(), source:getChannelCount() + local result = love.sound.newSoundData(count, source:getSampleRate(), source:getBitDepth(), mono and 1 or 2) + local function bounded(value) + return math.max(-1, math.min(1, value)) + end + for i = 0, count - 1 do + local l = bounded(source:getSample(i, 1) * left) + local r = bounded(source:getSample(i, channels > 1 and 2 or 1) * right) + if mono then + result:setSample(i, (l + r) / 2) + else + result:setSample(i, 1, l) + result:setSample(i, 2, r) + end + end + return result +end +return M diff --git a/src/core/Sound.lua b/src/core/Sound.lua index 7093a32f5..da34f3bd8 100644 --- a/src/core/Sound.lua +++ b/src/core/Sound.lua @@ -12,6 +12,12 @@ local Runtime = require("src.mods.Runtime") local Sound = {} local cache = {} +-- Native M4A players own one live effect each, independently of cached PCM variants. +local nativePlayers = {} +local nativeSeWaits = {} +local nativeCryMono = false +local nativeFanfareSource +local nativeFanfares = setmetatable({}, { __mode = "k" }) -- port addition: 0-7 SFX volume from save.options.sfxVol (OptionsMenu), -- scaling the 0.8 base every source gets local BASE_VOLUME = 0.8 @@ -225,14 +231,110 @@ local function newSfxSource(data, key, def, pitch, tempo) return newFileSource(def) end +local function nativeEffect(data, def) + if not require("src.core.GameVersion").isGen3() or type(def) ~= "table" or def.nds ~= nil then + return + end + local id = tonumber(def.m4a) + if not id or id ~= id or id < 0 or id > 65535 or id % 1 ~= 0 then + return + end + local song = data and data.audio and data.audio.songs and data.audio.songs[("SONG_%03X"):format(id)] + if type(song) ~= "table" then + return + end + return tonumber(song.player) or 1, id +end + +local function forgetNative(src) + for player, entry in pairs(nativePlayers) do + if entry.src == src then + nativePlayers[player] = nil + end + end +end + +-- Native fanfares use the music option, occupy their native SE player and +-- retain their audible tail independently of the script's fanfare timer. +function Sound.playNativeFanfare(data, info, musicVolume, musicFilter) + if sessionSuspended or not (love.audio and data and data.audio and data.audio.gen3) then return nil end + if nativeFanfareSource then + pcall(nativeFanfareSource.stop, nativeFanfareSource) + forgetNative(nativeFanfareSource) + nativeFanfareSource = nil + end + local previous = nativePlayers[info.player] + if previous then pcall(previous.src.stop, previous.src); nativePlayers[info.player] = nil end + local store = nativeFanfares[data] or { count = 0 } + nativeFanfares[data] = store + local src = store[info.id] + if not src then + local song = data.audio.songs and data.audio.songs[("SONG_%03X"):format(info.id)] + local ok, sd = pcall(require("src.core.M4ASynth").renderEffect, data, song, + { seconds = info.frames / 60 + 4 }) + if not (ok and sd) then return nil end + local made + made, src = pcall(love.audio.newSource, sd, "static") + if not (made and src) then return nil end + if store.count >= 24 then store = { count = 0 }; nativeFanfares[data] = store end + store[info.id], store.count = src, store.count + 1 + local filter = (tonumber(musicFilter) or 0) > 0 and { + type = "lowpass", volume = 1, highgain = .4 ^ musicFilter, + } or nil + if src.setFilter then pcall(src.setFilter, src, filter) end + end + pcall(src.stop, src) + pcall(src.setVolume, src, musicVolume) + if not pcall(src.play, src) then return nil end + nativeFanfareSource = src + local key = ("SONG_%03X"):format(info.id) + nativePlayers[info.player] = { src = src, id = info.id, key = key, name = key } + return src +end + +local function nativeSeId(data, key) + local id = tonumber(key) + if id and id == id and id >= 0 and id <= 65535 and id % 1 == 0 then return id end + local audio = data and data.audio or {} + local def = audio.sfx and audio.sfx[key] + if type(def) == "table" and tonumber(def.m4a) then return tonumber(def.m4a) end + for _, song in pairs(audio.songs or {}) do + if type(song) == "table" and (song.sfxName == key or song.id == key) then return tonumber(song.index) end + end +end + local function playPath(data, key, def, pitch, tempo) key = tostring(key) - if not love.audio or not def then return nil end + if not love.audio or not def then + return nil + end -- 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 + if sessionSuspended then + return nil + end + local baseKey = key + local player, nativeId = nativeEffect(data, def) + if player ~= nil then + local previous = nativePlayers[player] + if previous then + safeAudio("Source:stop", previous.src.stop, previous.src) + nativePlayers[player] = nil + end + -- A cached PCM variant may still be playing on another native player. + -- Keep a separately cached voice rather than rewinding that player's one. + local cached = cache[key] + for _, entry in pairs(nativePlayers) do + if cached and entry.src == cached then + key = key .. "@mplay:" .. tostring(player) + break + end + end + end local src = cache[key] - if src == false then return nil end -- known bad, already logged + if src == false then + return nil + end -- known bad, already logged if not src then local s, err = newSfxSource(data, key, def, pitch, tempo) if not s then @@ -244,7 +346,13 @@ local function playPath(data, key, def, pitch, tempo) cache[key] = s src = s end - if not restart(src) then return nil end + if not restart(src) then + return nil + end + if player ~= nil then + nativePlayers[player] = + { src = src, id = nativeId, key = key, name = baseKey:match("^([^@]+)") or baseKey } + end return src end @@ -295,14 +403,15 @@ end -- anywhere in the ROM. So this answers a number, by looking for a role that -- already plays it. -- --- A number no role covers plays nothing and says nothing: the animation is --- still right, it is just quieter, and inventing a substitute sound would be --- worse than silence. +-- Native GBA numbers without a named role resolve through their imported +-- song metadata. Explicit pan/mono controls own separate source variants; +-- other generations retain their existing named-role lookup. local byNumber = nil -function Sound.playId(data, id) +function Sound.playId(data, id, options) id = tonumber(id) local sfx = data and data.audio and data.audio.sfx - if not (id and sfx) then return end + if not id then return end + sfx = sfx or {} if byNumber == nil or byNumber.__sfx ~= sfx then byNumber = { __sfx = sfx } for name, def in pairs(sfx) do @@ -311,6 +420,34 @@ function Sound.playId(data, id) end end local name = byNumber[id] + if require("src.core.GameVersion").isGen3() then + if id ~= id or id < 0 or id > 65535 or id % 1 ~= 0 then return end + local songKey = ("SONG_%03X"):format(id) + local song = data and data.audio and data.audio.songs and data.audio.songs[songKey] + if song and (not name or type(options) == "table") then + name = name or song.sfxName or songKey + local def = {} + local selected = sfx[name] + if selected == false then return end + if type(selected) == "string" then + def.file = selected + else + for key, value in pairs(selected or { m4a = id }) do def[key] = value end + end + options = type(options) == "table" and options or {} + if options.pan ~= nil or options.mono ~= nil then + def.nativePan = require("src.core.NativeSoundPan").value(options.pan) + def.nativeMono = options.mono and true or false + end + local key = name .. "@native:" .. tostring(def.nativePan) .. ":" .. tostring(def.nativeMono) + local src = playPath(data, key, def) + if src then + if ducks(data, name, def) then require("src.core.Music").duckForFanfare(src) end + played("sfx", name) + end + return src + end + end if not name then return end return Sound.play(data, name) end @@ -437,6 +574,10 @@ end -- effect can sound several times at once on separate players; it bypasses -- the SE player slots because the caller picked its player itself. function Sound.playSe(data, key, opts) + if require("src.core.GameVersion").isGen3() then + local id = nativeSeId(data, key) + return id ~= nil and Sound.playId(data, id, opts) or nil + end local def = Sound.seDef(data, key) if not def then return nil end local name = def.name or def.nds @@ -472,16 +613,66 @@ function Sound.playSe(data, key, opts) end function Sound.isSePlaying(data, key, handle) + local V = require("src.core.GameVersion") + if V.isGen3() then + if sessionSuspended then return false end + local id = key ~= nil and nativeSeId(data, key) or nil + if key ~= nil and id == nil then return false end + for player, entry in pairs(nativePlayers) do + if (id ~= nil and entry.id == id) or (key == nil and (V.get() ~= "emerald" or player ~= 3)) then + if sounding(entry.src) then return true end + end + end + return false + end local name = Sound.seName(data, key) if name ~= nil and handle ~= nil then name = ("%s#%s"):format(tostring(name), tostring(handle)) end return name ~= nil and Sound.isPlaying(name) end function Sound.stopSe(data, key) + if require("src.core.GameVersion").isGen3() then + local id = key ~= nil and nativeSeId(data, key) or nil + if key ~= nil and id == nil then return end + for player, entry in pairs(nativePlayers) do + if key == nil or entry.id == id then + pcall(entry.src.stop, entry.src) + nativePlayers[player] = nil + end + end + return + end local name = Sound.seName(data, key) if name ~= nil then Sound.stop(name) end end +-- Native world waitse has two clocks: a latched 180-tick audio callback +-- and a separate 181-poll script fallback. Audio continues at 60 Hz when +-- logic is accelerated; observing silence here must survive a later sound. +function Sound.waitSe(data) + local wait = { data = data, frames = 180, polls = 0, done = false } + nativeSeWaits[#nativeSeWaits + 1] = wait + return function() + wait.polls = wait.polls + 1 + return wait.polls > 180 or wait.done or not Sound.isSePlaying(data) + end +end + +-- Called once per real-time audio tick, after the frame's logic steps. +function Sound.update() + if #nativeSeWaits == 0 then return end + local pending = {} + for _, wait in ipairs(nativeSeWaits) do + wait.frames = wait.frames - 1 + if wait.frames <= 0 or not Sound.isSePlaying(wait.data) then + wait.done = true + else + pending[#pending + 1] = wait + end + end + nativeSeWaits = pending +end + -- Play a move's sound with its MoveSoundTable pitch/tempo modifiers -- (data/moves/sfx.asm; GetMoveSound loads them into wFrequencyModifier/ -- wTempoModifier and the battle sound engine applies them to every @@ -596,7 +787,7 @@ end -- returns the source (nil headless) so callers that block on the cry -- like the original's PlayCry -> WaitForSoundToFinish can poll it -function Sound.playCry(data, species, opts) +local function playLegacyCry(data, species, opts, nativeOverride) if not love.audio then return nil end -- INTERRUPTION: same cache-poisoning gate as playPath (sessionSuspended) if sessionSuspended then return nil end @@ -630,7 +821,7 @@ function Sound.playCry(data, species, opts) end -- set every time rather than at build: the cached source outlives a change -- of game, and the scale is the loaded cartridge's - pcall(src.setVolume, src, volumeFor(key) * cryScale(data)) + pcall(src.setVolume, src, volumeFor(key) * (nativeOverride and 1 or cryScale(data))) -- Interactive Dex playback owns its source; loop/effect settings must never -- leak onto the cached cry used by battles, NPC scripts or other menus. if opts and opts.isolated and src.clone then @@ -638,11 +829,42 @@ function Sound.playCry(data, species, opts) if ok and copy then src=copy end end if not restart(src) then return nil end - require("src.core.Music").duckForCry(data, src) + if not nativeOverride then require("src.core.Music").duckForCry(data, src) end played("cry", species, species) return src end +function Sound.playCry(data, species, opts) + if require("src.core.GameVersion").isGen3() then + if sessionSuspended then return nil end + if type(species) == "number" then + species = data and data.constants and data.constants.speciesOrder and data.constants.speciesOrder[species] or species + end + local src, override = require("src.core.NativeCryPlayer").play(data, species, opts, + function() return playLegacyCry(data, species, opts, true) end, volumeScale, nativeCryMono) + -- The legacy override already emitted its own event. + if src and not override then played("cry", species, species) end + return src + end + return playLegacyCry(data, species, opts) +end + +function Sound.isCryFinished() + return require("src.core.NativeCryPlayer").finished() +end + +function Sound.waitCry() + return require("src.core.NativeCryPlayer").wait() +end + +function Sound.updateCry() + require("src.core.NativeCryPlayer").tick() +end + +function Sound.stopCry() + require("src.core.NativeCryPlayer").stop() +end + -- GROWL/ROAR are the only two moves that play a cry (IsCryMove checks -- wAnimationID); GetMoveSound still adds their own MoveSoundTable pitch/ -- tempo bytes on top of the cry's species modifiers before the tempo @@ -671,8 +893,21 @@ end -- .musicLoop polls wChannelSoundIDs+CHAN5 until SFX_SAFARI_ZONE_PA -- ends.) Headless / never-played names read as silent. function Sound.isPlaying(name) - local src = cache[tostring(name)] - if not src then return false end + name = tostring(name) + if require("src.core.GameVersion").isGen3() then + for _, entry in pairs(nativePlayers) do + if entry.name == name or entry.key == name then + local ok, playing = pcall(entry.src.isPlaying, entry.src) + if ok and playing then + return true + end + end + end + end + local src = cache[name] + if not src then + return false + end local ok, playing = pcall(src.isPlaying, src) return ok and playing or false end @@ -680,8 +915,21 @@ end -- cut a one-shot short (the SFX_STOP_ALL_MUSIC beats around the -- elevator shake stop the last collision thud mid-ring) function Sound.stop(name) - local src = cache[tostring(name)] - if src then pcall(src.stop, src) end + name = tostring(name) + local src = cache[name] + if src then + pcall(src.stop, src) + end + if require("src.core.GameVersion").isGen3() then + for player, entry in pairs(nativePlayers) do + if entry.name == name or entry.key == name then + if entry.src ~= src then + pcall(entry.src.stop, entry.src) + end + nativePlayers[player] = nil + end + end + end end function Sound.anyPlaying(kind) @@ -780,10 +1028,31 @@ 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.invalidateNativeFanfares(name) + for _, store in pairs(nativeFanfares) do + for id, src in pairs(store) do + if type(id) == "number" and (not name or name == ("SONG_%03X"):format(id)) then + pcall(src.stop, src) + forgetNative(src) + if nativeFanfareSource == src then nativeFanfareSource = nil end + store[id] = nil + end + end + end + if not name then nativeFanfares = setmetatable({}, { __mode = "k" }) end +end + function Sound.invalidate(name) + byNumber = nil + local nativeCry = package.loaded["src.core.NativeCryPlayer"] + if nativeCry then nativeCry.invalidate(name) end + Sound.invalidateNativeFanfares(name) local function evict(store, key) local src = store[key] - if src then pcall(src.stop, src) end + if src then + pcall(src.stop, src) + forgetNative(src) + end store[key] = nil end for _, store in ipairs({ cache, loopCache }) do @@ -813,6 +1082,13 @@ Assets.register(Sound.invalidate) function Sound.suspend() if sessionSuspended then return end sessionSuspended = true + local nativeCry = package.loaded["src.core.NativeCryPlayer"] + if nativeCry then nativeCry.invalidate() end + -- Native effects can sustain and own additional cached voices as well. + for player, entry in pairs(nativePlayers) do + safeAudio("Source:stop", entry.src.stop, entry.src) + nativePlayers[player] = nil + end -- 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 @@ -840,6 +1116,7 @@ end -- re-apply persisted audio options (Game calls this on boot and after -- loading a save) function Sound.applyOptions(opts) + nativeCryMono = opts and opts.stereo == false or false Sound.setVolumeLevel(opts and opts.sfxVol or 7) Sound.setPikaVolumeLevel(opts and opts.pikaVol or 7) 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..eb4632e3c 100644 --- a/src/core/chip_worker.lua +++ b/src/core/chip_worker.lua @@ -8,6 +8,7 @@ -- cmd = "play" { gen, header, allowLoops, audio, -- channelVolumes?, channelPitches? } -- cmd = "stop" halt production +-- cmd = "nativePause" / "nativeResume" { gen } native BGM ownership -- cmd = "channelMix" { volumes, pitches } per-hw volume/pitch -- cmd = "invalidate" drop the bank cache -- cmd = "quit" end the thread @@ -61,6 +62,8 @@ local gen = nil -- active song generation, or nil when stopped local engine = nil -- the engine producing the current song local synth = ChipSynth -- ...and which module built it local finished = false -- the current song ran out (non-looping) +local nativePaused = false +local nativeEpoch = 0 local data = nil -- { audio = } for ROM bank/wave reads local function handle(cmd) @@ -68,6 +71,8 @@ local function handle(cmd) ChipSynth.setStereo(cmd.stereo) gen = cmd.gen finished = false + nativePaused = false + nativeEpoch = 0 engine = nil outCh:clear() -- drop any buffers left from the previous song data = { audio = cmd.audio } @@ -90,7 +95,18 @@ local function handle(cmd) gen = nil engine = nil finished = false + nativePaused = false outCh:clear() + elseif cmd.cmd == "nativePause" and cmd.gen == gen then + nativePaused = true + nativeEpoch = cmd.nativeEpoch or nativeEpoch + 1 + outCh:clear() + if engine then + engine.voices = {} + for _, track in ipairs(engine.tracks or {}) do track.voices = {} end + end + elseif cmd.cmd == "nativeResume" and cmd.gen == gen and (cmd.nativeEpoch or nativeEpoch) == nativeEpoch then + nativePaused = false elseif cmd.cmd == "channelMix" then ChipSynth.setStereo(cmd.stereo) if cmd.volumes ~= nil then ChipSynth.setChannelVolumes(cmd.volumes) end @@ -116,16 +132,16 @@ while true do end if quit then break end - if engine and not finished and gen and outCh:getCount() < LOOKAHEAD then - local activeGen = gen + if engine and not finished and not nativePaused and gen and outCh:getCount() < LOOKAHEAD then + local activeGen, activeEpoch = gen, nativeEpoch local ok, sd = pcall((synth or ChipSynth).soundData, engine, BUF, 2) if not ok then - outCh:push({ gen = activeGen, error = tostring(sd) }) + outCh:push({ gen = activeGen, nativeEpoch = activeEpoch, error = tostring(sd) }) finished = true else - outCh:push({ gen = activeGen, sd = sd }) + outCh:push({ gen = activeGen, nativeEpoch = activeEpoch, sd = sd }) if engine:finished() then - outCh:push({ gen = activeGen, done = true }) + outCh:push({ gen = activeGen, nativeEpoch = activeEpoch, done = true }) finished = true end end diff --git a/src/import/Gen3SubstituteArt.lua b/src/import/Gen3SubstituteArt.lua new file mode 100644 index 000000000..d2b49a593 --- /dev/null +++ b/src/import/Gen3SubstituteArt.lua @@ -0,0 +1,74 @@ +-- Import only the player's own verified native Substitute sheets/palette. +local M = {} +local Rom = require("src.import.RomGba") +local ImageWriter = require("src.import.ImageWriter") + +local function blob(extractor, name, size) + local off = extractor:symbol(name) + local rom = extractor.rom + if not off or off < 0 or off % 4 ~= 0 or off + 4 > rom.size then + return nil, name + end + if rom:u8(off) ~= 0x10 or math.floor(rom:u32(off) / 256) ~= size then + return nil, name + end + local ok, raw = pcall(rom.lz77, rom, off) + if not ok or type(raw) ~= "table" or #raw ~= size then + return nil, name + end + return raw, off +end + +local function image(raw, colors) + local pixels = Rom.tiles4bpp(raw, 8, 8) + local out = ImageWriter.blank(64, 64) + for y = 1, 64 do + for x = 1, 64 do + local index = pixels[y][x] + local color = colors[index + 1] + out:setPixel(x - 1, y - 1, color[1] / 255, color[2] / 255, color[3] / 255, index == 0 and 0 or 1) + end + end + return out +end + +function M.extract(extractor) + local palette, palAt = blob(extractor, "gSubstituteDollPal", 32) + local front, frontAt = blob(extractor, "gSubstituteDollFrontGfx", 2048) + local back, backAt = blob(extractor, "gSubstituteDollBackGfx", 2048) + if not palette or not front or not back then + return nil, "missing or malformed native Substitute art" + end + for i = 2, 32, 2 do + if palette[i] >= 128 then + return nil, "invalid Substitute BGR555 palette" + end + end + -- The shared native animation-pack contract carries five-bit channels in + -- the high bits of each byte. Retain RGB even behind transparent index 0. + local colors = {} + for i = 1, 16 do + local word = palette[i * 2 - 1] + palette[i * 2] * 256 + colors[i] = { word % 32 * 8, math.floor(word / 32) % 32 * 8, math.floor(word / 1024) % 32 * 8 } + end + local frontImage, backImage = image(front, colors), image(back, colors) + local frontPath, backPath = "battle/substitute_front.png", "battle/substitute_back.png" + extractor:saveImage(frontImage, frontPath) + extractor:saveImage(backImage, backPath) + local record = { + version = 1, + width = 64, + height = 64, + paletteEncoding = "rgb555-msb", + front = "assets/generated/" .. frontPath, + back = "assets/generated/" .. backPath, + source = { palette = palAt, front = frontAt, back = backAt }, + } + local constants = extractor._constants or {} + constants.gen3SubstituteArt = record + extractor._constants = constants + extractor:write("constants", constants) + return record +end + +return M diff --git a/src/import/RomExtractorGen3.lua b/src/import/RomExtractorGen3.lua index c2ce0b55e..266061075 100644 --- a/src/import/RomExtractorGen3.lua +++ b/src/import/RomExtractorGen3.lua @@ -53,7 +53,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 -- assigned from the completed stage lists below -- --------------------------------------------------------------------------- -- The stage tables below are keyed to Emerald's ROM. A sibling cartridge @@ -34611,12 +34611,9 @@ end -- twentieth of a second and four seconds long. A run of pointers into music -- would fail that immediately. -- --- WHICH SPECIES GETS WHICH is arithmetic, not a table: the cartridge's --- species list has twenty-five dead slots in the middle of it (the same ones --- the animation stage finds), and the cries are numbered over the species --- that are really there. So a species' cry is its position among the REAL --- species -- and the dead slots, which the game can still be made to ask --- about, borrow UNOWN's. +-- The native species-to-cry control table reorders all Hoenn species. It is +-- not the species' position after removing old Unown slots. The verified +-- native mapping also gives the old Unown slots their shared recording. -- --------------------------------------------------------------------------- local GEN3_CRY_STRIDE = 12 @@ -34708,28 +34705,17 @@ function RomExtractorGen3:extractCries() end local found = pairs_[1] - -- which species are really there: the dead slots have no cry of their own - local hole = {} - for i = GEN3_ANIM_HOLE_FIRST, GEN3_ANIM_HOLE_LAST do hole[i] = true end - local cries, written, longest = {}, 0, 0 - local cryId, unownId = 0, nil + local CrySpecies = require("src.core.NativeCrySpecies") for i, id in ipairs(order) do - if not hole[i] then - if id == "UNOWN" then unownId = cryId end + local cryId = CrySpecies.index(i) + if cryId and cryId < found.count then local _, seconds = sampleAt(found.at + cryId * GEN3_CRY_STRIDE) - if cryId < found.count and seconds then + if seconds then cries[id] = { m4a = cryId, seconds = seconds } written = written + 1 if seconds > longest then longest = seconds end end - cryId = cryId + 1 - end - end - -- the dead slots borrow UNOWN's, which is what the cartridge does with them - if unownId then - for i, id in ipairs(order) do - if hole[i] and not cries[id] then cries[id] = { m4a = unownId } end end end @@ -34745,6 +34731,7 @@ function RomExtractorGen3:extractCries() reverse = found.reverse, count = found.count, species = written, + mapping = "native-species-v1", source = ("ROM:%07X and its mirror at %07X, %d cries") :format(found.at, found.reverse, found.count), } @@ -58837,7 +58824,7 @@ end RomExtractorGen3.ASSET_STAGES = { "extractAudioImage", "extractFanfares", "extractSongNames", "extractSfxNames", "extractEncounterMusic", - "extractSprites", "extractTrainerSprites", "extractIntroSprites", + "extractSprites", "extractSubstituteArt", "extractTrainerSprites", "extractIntroSprites", "extractMonIcons", "extractBattleBackgrounds", "extractBattleScenes", "extractSummaryScreen", "extractPlayerBackPic", @@ -58878,6 +58865,14 @@ RomExtractorGen3.ASSET_STAGES = { "extractTradeScene", } +STAGE_COUNT = #RomExtractorGen3.DATA_STAGES + #RomExtractorGen3.ASSET_STAGES + +function RomExtractorGen3:extractSubstituteArt() + self:beginStage("Gen3 Substitute art") + local record, why = require("src.import.Gen3SubstituteArt").extract(self) + if not record then Logger.warn("gen3: %s", why) end +end + -- STAGE: the in-game trade sequence's art -- the wireless GBA, the signal -- rings, the platform, the balls, the link mon and every palette and table -- DoTradeAnim_Wireless drives them with. Its own module, because it is keyed diff --git a/src/link/Fingerprint.lua b/src/link/Fingerprint.lua index 699eb68ef..5043d7357 100644 --- a/src/link/Fingerprint.lua +++ b/src/link/Fingerprint.lua @@ -18,6 +18,16 @@ local Runtime = require("src.mods.Runtime") local Fingerprint = {} +-- Runtime command semantics are part of native GBA lockstep compatibility, +-- even when both peers load byte-identical data. Keep older game digests. +local function nativeBattleRevision() + return require("src.core.GameVersion").isGen3() and 20 or nil +end +Fingerprint.nativeBattleRevision = nativeBattleRevision +function Fingerprint.nativePartyRevision() + return require("src.core.GameVersion").isGen3() and require("src.link.Gen3Party").REVISION or nil +end + -- ------- FNV-1a, two lanes -- Two 32-bit lanes with different offset bases, concatenated into a 64-bit @@ -151,11 +161,20 @@ local RECORD_FIELDS = { pokemon = SPECIES_FIELDS, moves = MOVE_FIELDS, statuses = STATUS_FIELDS, move_effects = EFFECT_FIELDS } Fingerprint.FIELDS = RECORD_FIELDS +local GBA_MOVE_FIELDS = {} +for _, field in ipairs(MOVE_FIELDS) do GBA_MOVE_FIELDS[#GBA_MOVE_FIELDS + 1] = field end +for _, field in ipairs({ "index", "gen3Effect", "secondaryChance", "target", "flags", "protectAffected" }) do + GBA_MOVE_FIELDS[#GBA_MOVE_FIELDS + 1] = field +end +Fingerprint.GBA_MOVE_FIELDS = GBA_MOVE_FIELDS +local function fieldsFor(kind) + return kind == "moves" and nativeBattleRevision() and GBA_MOVE_FIELDS or RECORD_FIELDS[kind] +end local function writeSection(out, data, kind) local map = data[kind] if map == nil then return end - local fields = RECORD_FIELDS[kind] + local fields = fieldsFor(kind) out[#out + 1] = "[" .. kind .. "]" for _, id in ipairs(sortedIds(map)) do local record = map[id] @@ -238,6 +257,10 @@ local function surface(data, mods) writeSection(out, data, "move_effects") writeConstants(out, data) writeLinkFields(out, data) + local revision = nativeBattleRevision() + if revision then out[#out + 1] = "[native_gba_commands]" .. revision end + local partyRevision = Fingerprint.nativePartyRevision() + if partyRevision then out[#out + 1] = "[native_gba_party]" .. partyRevision end out[#out + 1] = "[mods]" .. modKey(mods) return table.concat(out) end @@ -247,7 +270,8 @@ Fingerprint.surface = surface -- mods: { { id, version, affectsLink } } -- the hello's mod array function Fingerprint.compute(data, mods) if not data then return digest("") end - local key = modKey(mods) + local key = modKey(mods) .. "|native_gba:" .. tostring(nativeBattleRevision()) + .. "|native_party:" .. tostring(Fingerprint.nativePartyRevision()) local hit = cache[data] if hit and hit.key == key then return hit.value end local value = Runtime.call("link.fingerprint", function(d, m) @@ -262,14 +286,15 @@ end local recordCache = setmetatable({}, { __mode = "k" }) function Fingerprint.records(data, kind) - local fields = RECORD_FIELDS[kind] + local fields = fieldsFor(kind) assert(fields, "no record allowlist for " .. tostring(kind)) local perData = recordCache[data] if not perData then perData = {} recordCache[data] = perData end - if perData[kind] then return perData[kind] end + local recordKey = kind .. "|native_gba:" .. tostring(nativeBattleRevision()) + if perData[recordKey] then return perData[recordKey] end local map = data[kind] or {} local out = {} for id, record in pairs(map) do @@ -279,7 +304,7 @@ function Fingerprint.records(data, kind) out[id] = digest(table.concat(buf)) end end - perData[kind] = out + perData[recordKey] = out return out end diff --git a/src/link/Gen3BatonChoice.lua b/src/link/Gen3BatonChoice.lua new file mode 100644 index 000000000..3638e98d1 --- /dev/null +++ b/src/link/Gen3BatonChoice.lua @@ -0,0 +1,93 @@ +-- One ordered, validated mid-turn replacement choice on each native peer. +local M = {} +local function integer(n, lo, hi) + return type(n) == "number" and n >= lo and n <= hi and n % 1 == 0 +end +local function key(turn, serial, actor) + return turn .. "/" .. serial .. "/" .. actor +end +function M.attach(battle, opts) + local state = { choices = {}, role = opts.role, spectator = opts.spectator, invalid = opts.invalid } + battle._gen3BatonLink = state + battle._gen3BatonParty = function(_, user) + return user.isPlayer and opts.mine or opts.theirs + end + local function actor(user) + return user.isPlayer and opts.role or (opts.role == "host" and "guest" or "host") + end + local function take(pending, packet) + if not require("src.battle.Gen3BatonPass").select(battle, pending, packet.index) then + state.invalid("invalid Baton Pass selection") + end + end + state.take = take + battle._gen3BatonRequest = function(_, pending) + local turn = battle.turnCount or 0 + if state.turn ~= turn then + state.turn, state.serial, state.choices = turn, 0, {} + end + state.serial = state.serial + 1 + pending.turn, pending.serial, pending.actor = turn, state.serial, actor(pending.user) + local remote = opts.spectator or not pending.user.isPlayer + if remote then + local packet = state.choices[key(turn, pending.serial, pending.actor)] + if packet then + take(pending, packet) + end + else + pending.onSelect = function(index) + opts.send({ + type = "baton_pass", + turn = turn, + serial = pending.serial, + actor = pending.actor, + index = index, + }) + end + end + return remote + end +end +function M.receive(battle, packet, sender) + local state = battle._gen3BatonLink + if not state or battle.result or battle.linkEnded then + return false + end + local turn = battle.turnCount or 0 + local owner = packet.actor + if + not integer(packet.turn, 1, math.max(1, turn)) + or packet.turn ~= turn + or not integer(packet.serial, 1, 64) + or not integer(packet.index, 1, 6) + or owner ~= "host" and owner ~= "guest" + or not state.spectator and owner == state.role + or state.spectator and sender ~= owner + then + state.invalid("invalid Baton Pass choice packet") + return false + end + if state.turn ~= turn then + state.turn, state.serial, state.choices = turn, 0, {} + end + local k = key(packet.turn, packet.serial, owner) + local previous = state.choices[k] + if previous then + if previous.index ~= packet.index then + state.invalid("conflicting Baton Pass choice") + end + return previous.index == packet.index + end + state.choices[k] = { index = packet.index } + local pending = battle._gen3BatonPending + if + pending + and pending.turn == packet.turn + and pending.serial == packet.serial + and pending.actor == owner + then + state.take(pending, packet) + end + return true +end +return M diff --git a/src/link/Gen3Party.lua b/src/link/Gen3Party.lua new file mode 100644 index 000000000..49b99fb4a --- /dev/null +++ b/src/link/Gen3Party.lua @@ -0,0 +1,314 @@ +-- Native GBA party data. Keep this separate from the older DV/stat-exp codec. +local C = { REVISION = 1 } +local STATS = { "hp", "attack", "defense", "speed", "spatk", "spdef" } +local CONDITIONS = { "cool", "beauty", "cute", "smart", "tough", "sheen" } +function C.signature(mon) + local out = {} + for _, k in ipairs({ "personality", "abilitySlot", "level", "exp", "otId", "otSecretId", "happiness" }) do + out[#out + 1] = tostring(mon[k] or 0) + end + for _, kind in ipairs({ "ivs", "evs" }) do + for _, k in ipairs(STATS) do + out[#out + 1] = tostring((mon[kind] or {})[k] or 0) + end + end + return ":gen3=" .. table.concat(out, ",") +end +local STATUSES = { SLP = true, PSN = true, BRN = true, FRZ = true, PAR = true } +local function integer(value, lo, hi, default) + local n = tonumber(value) + if not n or n ~= n or n == math.huge or n == -math.huge then + n = default or lo + end + return math.max(lo, math.min(hi, math.floor(n))) +end +local function tableOf(v) + return type(v) == "table" and v or {} +end +local function name(value, limit) + if type(value) ~= "string" then + return "" + end + local out, count = {}, 0 + for c in value:gsub("%c", ""):gmatch("[%z\1-\127\194-\244][\128-\191]*") do + if count == limit then + break + end + out[#out + 1], count = c, count + 1 + end + return table.concat(out) +end +local function copyStats(value, max) + local out = {} + for _, k in ipairs(STATS) do + out[k] = integer(tableOf(value)[k], 0, max, 0) + end + return out +end +function C.pack(mon, plainCopy) + local pid = integer(mon.personality, 0, 4294967295, 0) + local Identity = require("src.pokemon.IdentityRolls") + local data = Identity.data() + -- Live links have a mounted registry. Standalone tools can omit it; the + -- receiver still derives gender from its authoritative species and PID. + local gender = data + and data.pokemon + and data.pokemon[mon.species] + and Identity.gender(data, { species = mon.species, personality = pid }, 3) + or nil + local slot = mon.abilitySlot or (mon.altAbility and 2) or pid % 2 + 1 + local fullOt = integer(mon.otId, 0, 4294967295, 0) + local ribbons = integer(mon.ribbons, 0, 4294967295, 0) + if mon.modernFatefulEncounter ~= nil then + local has = math.floor(ribbons / 2147483648) % 2 == 1 + if mon.modernFatefulEncounter and not has then + ribbons = ribbons + 2147483648 + end + if not mon.modernFatefulEncounter and has then + ribbons = ribbons - 2147483648 + end + end + local moves = {} + for i, mv in ipairs(tableOf(mon.moves)) do + if i > 4 then + break + end + moves[i] = { id = mv.id, pp = mv.pp, ppUps = mv.ppUps or 0 } + end + return { + gen3Codec = C.REVISION, + species = mon.species, + level = mon.level, + exp = mon.exp, + hp = mon.hp, + status = mon.status, + sleepTurns = mon.sleepTurns, + toxic = mon.status == "PSN" and mon.toxic == true or nil, + personality = pid, + gender = gender, + nature = pid % 25, + abilitySlot = integer(slot, 1, 2, 1), + ivs = copyStats(mon.ivs, 31), + evs = copyStats(mon.evs, 255), + nickname = name(mon.nickname, 10), + moves = moves, + item = mon.item, + ot = name(mon.gen3OtName or mon.otName or mon.ot, 7), + otId = fullOt % 65536, + secretId = integer(mon.secretId or mon.otSecretId, 0, 65535, math.floor(fullOt / 65536)), + otGender = integer(mon.otGender, 0, 1, 0), + happiness = integer(mon.friendship or mon.happiness, 0, 255, 70), + gen3Language = integer(mon.gen3Language or mon.language, 0, 255, mon.isEgg and 1 or 2), + gen3Pokerus = integer(mon.gen3Pokerus or mon.pokerus, 0, 255, 0), + gen3Markings = integer(mon.gen3Markings or mon.markings, 0, 15, 0), + contest = plainCopy(mon.contest), + ribbons = ribbons, + metLocation = integer(mon.metLocation, 0, 255, 0), + metLevel = integer(mon.metLevel, 0, 100, 0), + metGame = integer(mon.metGame, 0, 15, 0), + ball = mon.ball, + isEgg = mon.isEgg == true, + eggCycles = mon.isEgg and integer(mon.eggCycles, 0, 255, 0) or nil, + fatefulEncounter = mon.fatefulEncounter == true, + modernFatefulEncounter = mon.modernFatefulEncounter == true, + extra = plainCopy(mon.extra), + } +end +function C.unpack(data, packed, opts, plainCopy) + opts = opts or {} + if type(packed) ~= "table" or packed.gen3Codec ~= C.REVISION then + return nil, "native party codec required" + end + local def = data.pokemon[packed.species] + if not def then + return nil, "unknown POKéMON" + end + local strict = opts.strict == true + local function field(value, lo, hi, default) + local n = tonumber(value) + if strict and (not n or n ~= n or n < lo or n > hi or n ~= math.floor(n)) then + return nil + end + return integer(value, lo, hi, default) + end + local level = field(packed.level, 1, 100, 5) + if not level then + return nil, "bad level" + end + local pid = field(packed.personality, 0, 4294967295, 0) + if not pid then + return nil, "bad personality" + end + if strict and packed.nature ~= pid % 25 then + return nil, "nature mismatch" + end + local ivs, evs, total = {}, {}, 0 + for _, k in ipairs(STATS) do + ivs[k] = field(tableOf(packed.ivs)[k], 0, 31, 0) + evs[k] = field(tableOf(packed.evs)[k], 0, 255, 0) + if not ivs[k] then + return nil, "bad ivs" + end + if not evs[k] then + return nil, "bad evs" + end + total = total + evs[k] + end + if total > 510 then + if strict then + return nil, "bad evs" + end + for i = #STATS, 1, -1 do + local k = STATS[i] + local cut = math.min(evs[k], total - 510) + evs[k], total = evs[k] - cut, total - cut + end + end + local slot = field(packed.abilitySlot, 1, 2, 1) + if not slot then + return nil, "bad ability" + end + local abilities = def.abilities or {} + if not abilities[2] or abilities[2] == 0 or abilities[2] == "NONE" then + slot = 1 + end + local constants = data.constants or {} + local nature = (constants.natureOrder or {})[pid % 25 + 1] + if not nature then + for id, row in pairs(constants.natures or {}) do + if row.index == pid % 25 then + nature = id + break + end + end + end + local Growth, Stats = require("src.pokemon.Growth"), require("src.pokemon.Stats") + local function expAt(n) + return Growth.withTables(constants.experienceTables, function() + return Growth.expForLevel(def.growthRate, n, data.growth_rates) + end) + end + local minExp, maxExp = expAt(level), expAt(math.min(level + 1, 100)) + if level < 100 then + maxExp = maxExp - 1 + end + local exp = field(packed.exp, minExp, maxExp, minExp) + if not exp then + return nil, "bad level" + end + local forced = tonumber(opts.forceLevel) + if forced and (forced ~= forced or math.abs(forced) == math.huge) then + forced = nil + end + if forced then + level = integer(forced, 1, 100, 50) + exp = expAt(level) + end + -- PID's native matrix works even without an English nature registry/global preview. + local stats = Stats.calcGen3(def, level, ivs, evs) + local order = { "attack", "defense", "speed", "spatk", "spdef" } + local up, down = math.floor(pid % 25 / 5) + 1, pid % 5 + 1 + if up ~= down then + stats[order[up]] = math.floor(stats[order[up]] * 110 / 100) + stats[order[down]] = math.floor(stats[order[down]] * 90 / 100) + end + stats.special = stats.spatk + local moves, seen = {}, {} + local entries = tableOf(packed.moves) + if strict and (#entries < 1 or #entries > 4) then + return nil, "bad move" + end + for _, mv in ipairs(entries) do + mv = tableOf(mv) + local mdef = data.moves[mv.id] + if not mdef or seen[mv.id] or #moves == 4 then + if strict then + return nil, "bad move" + end + else + local ups = field(mv.ppUps, 0, 3, 0) + if not ups then + return nil, "bad move" + end + local maxPp = mdef.pp + math.floor(mdef.pp * 20 * ups / 100) + local pp = field(mv.pp, 0, maxPp, maxPp) + if not pp then + return nil, "bad move" + end + moves[#moves + 1], seen[mv.id] = { id = mv.id, pp = pp, ppUps = ups, maxPp = maxPp }, true + end + end + if #moves == 0 then + return nil, "bad move" + end + if packed.item ~= nil and not data.items[packed.item] then + return nil, "bad item" + end + if packed.ball ~= nil and not data.items[packed.ball] then + return nil, "bad ball" + end + local otId, secretId = field(packed.otId, 0, 65535, 0), field(packed.secretId, 0, 65535, 0) + if not otId or not secretId then + return nil, "bad ot" + end + local ot, nickname = name(packed.ot, 7), name(packed.nickname, 10) + if strict and (ot ~= packed.ot or nickname ~= packed.nickname) then + return nil, "bad name" + end + local status = not forced and STATUSES[packed.status] and packed.status or nil + local gender = + require("src.pokemon.IdentityRolls").gender(data, { species = packed.species, personality = pid }, 3) + if strict and packed.gender ~= nil and packed.gender ~= gender then + return nil, "gender mismatch" + end + local mon = { + species = packed.species, + level = level, + exp = exp, + personality = pid, + ivs = ivs, + evs = evs, + nature = nature, + abilitySlot = slot, + ability = abilities[slot], + altAbility = slot == 2 or nil, + gender = gender, + stats = stats, + hp = forced and stats.hp or integer(packed.hp, 0, stats.hp, stats.hp), + status = status, + sleepTurns = status == "SLP" and integer(packed.sleepTurns, 1, 7, 1) or nil, + toxic = status == "PSN" and packed.toxic == true or nil, + moves = moves, + nickname = nickname, + item = packed.item, + ball = packed.ball, + ot = ot, + gen3OtName = ot, + otId = otId, + secretId = secretId, + otSecretId = secretId, + shiny = require("src.pokemon.IdentityRolls").nativeGen3Shiny(pid, otId, secretId), + otGender = integer(packed.otGender, 0, 1, 0), + friendship = integer(packed.happiness, 0, 255, def.friendship or 70), + happiness = integer(packed.happiness, 0, 255, def.friendship or 70), + gen3Language = integer(packed.gen3Language, 0, 255, 2), + gen3Pokerus = integer(packed.gen3Pokerus, 0, 255, 0), + gen3Markings = integer(packed.gen3Markings, 0, 15, 0), + contest = {}, + ribbons = integer(packed.ribbons, 0, 4294967295, 0), + metLocation = integer(packed.metLocation, 0, 255, 0), + metLevel = integer(packed.metLevel, 0, 100, 0), + metGame = integer(packed.metGame, 0, 15, 0), + isEgg = packed.isEgg == true or nil, + eggCycles = packed.isEgg == true and integer(packed.eggCycles, 0, 255, 0) or nil, + fatefulEncounter = packed.fatefulEncounter == true, + modernFatefulEncounter = packed.modernFatefulEncounter == true + or math.floor(integer(packed.ribbons, 0, 4294967295, 0) / 2147483648) % 2 == 1, + extra = plainCopy(packed.extra), + } + for _, k in ipairs(CONDITIONS) do + mon.contest[k] = integer(tableOf(packed.contest)[k], 0, 255, 0) + end + return mon +end +return C diff --git a/src/link/Handshake.lua b/src/link/Handshake.lua index aec8437e7..2a55d340b 100644 --- a/src/link/Handshake.lua +++ b/src/link/Handshake.lua @@ -158,6 +158,8 @@ function Handshake.hello(game, mode) engineVersion = Version.engine, apiVersion = Version.modApi, fingerprint = Fingerprint.compute(game and game.data, mods), + nativeBattleRevision = Fingerprint.nativeBattleRevision(), + nativePartyRevision = Fingerprint.nativePartyRevision(), linkModified = Handshake.linkModified(game), mods = mods, } @@ -174,6 +176,7 @@ end function Handshake.checkCompat(localHello, remoteHello) localHello = localHello or {} if not remoteHello or not remoteHello.protocol then + if localHello.nativeBattleRevision then return "refused", "native_rules_required" end if localHello.linkModified then return "refused", "peer_v1_modified" end @@ -182,6 +185,12 @@ function Handshake.checkCompat(localHello, remoteHello) if major(remoteHello.engineVersion) ~= major(localHello.engineVersion) then return "refused", "engine_mismatch" end + if localHello.nativePartyRevision ~= remoteHello.nativePartyRevision then + return "refused", "native_party_codec_mismatch" + end + if localHello.nativeBattleRevision ~= remoteHello.nativeBattleRevision then + return "subset", "native_rules_mismatch" + end if remoteHello.fingerprint == localHello.fingerprint then return "full", nil end @@ -265,6 +274,12 @@ function Handshake.describe(localHello, remoteHello, verdict, mode) local lines = {} local peer = (remoteHello and remoteHello.name) or "THEY" if verdict == "refused" then + if localHello.nativeBattleRevision and not (remoteHello and remoteHello.protocol) then + wrap(lines, "The other game needs") + wrap(lines, "an engine update") + wrap(lines, "before linking.") + return lines + end if not (remoteHello and remoteHello.protocol) then wrap(lines, "The other game is") wrap(lines, "an older version") @@ -278,6 +293,14 @@ function Handshake.describe(localHello, remoteHello, verdict, mode) end return lines end + if localHello.nativeBattleRevision ~= (remoteHello and remoteHello.nativeBattleRevision) then + wrap(lines, "The battle rules") + wrap(lines, "need matching") + wrap(lines, "engine versions.") + if mode == "trade" then wrap(lines, "Trading is limited") + wrap(lines, "to shared POKéMON.") end + return lines + end wrap(lines, "Your games differ.") local diff = Handshake.modDiff(localHello, remoteHello) listMods(lines, peer .. " has:", diff.onlyTheirs) diff --git a/src/link/LinkBattle.lua b/src/link/LinkBattle.lua index 21881d63f..363d3a1d2 100644 --- a/src/link/LinkBattle.lua +++ b/src/link/LinkBattle.lua @@ -152,17 +152,29 @@ local function volStr(b) return table.concat(out, ",") end -local function activeStr(b) +local function toxicSignature(mon) + if not require("src.core.GameVersion").isGen3() then return "" end + return ":toxic=" .. tostring(mon.status == "PSN" and mon.toxic == true) + .. require("src.link.Gen3Party").signature(mon) +end +local function activeStr(b, role) return ("%s:%d:%s:%s:%s:%s"):format(b.mon.species, b.mon.hp, tostring(b.mon.status), tostring(b.mon.item), - stageStr(b), ppStr(b.mon)) + stageStr(b), ppStr(b.mon)) .. toxicSignature(b.mon) + .. (require("src.core.GameVersion").isGen3() and require("src.battle.Gen3LockOn").signature(b, role) or "") + .. (require("src.core.GameVersion").isGen3() and require("src.battle.Gen3BatonPass").signature(b, role) or "") + .. (require("src.core.GameVersion").isGen3() and require("src.battle.Gen3Encore").signature(b) or "") + .. (require("src.core.GameVersion").isGen3() and require("src.battle.Gen3Disable").signature(b) or "") + .. (require("src.core.GameVersion").isGen3() and require("src.battle.Gen3Rage").signature(b) or "") + .. (require("src.core.GameVersion").isGen3() and require("src.battle.Gen3ChoiceBand").signature(b) or "") + .. (require("src.core.GameVersion").isGen3() and require("src.battle.Gen3MirrorMove").signature(b) or "") end local function benchStr(party) local out = {} for i, mon in ipairs(party or {}) do out[i] = ("%s:%d:%s:%s"):format(tostring(mon.species), mon.hp or 0, - tostring(mon.status), tostring(mon.item)) + tostring(mon.status), tostring(mon.item)) .. toxicSignature(mon) end return table.concat(out, "|") end @@ -174,7 +186,7 @@ local function stateSig(self, role, myParty, theirParty) local hostParty = role == "host" and myParty or theirParty local guestParty = role == "host" and theirParty or myParty return { - actives = Fingerprint.digest(activeStr(host) .. "|" .. activeStr(guest)), + actives = Fingerprint.digest(activeStr(host, role) .. "|" .. activeStr(guest, role)), volatile = Fingerprint.digest(volStr(host) .. "|" .. volStr(guest)), bench = Fingerprint.digest(benchStr(hostParty) .. "|" .. benchStr(guestParty)), } @@ -244,6 +256,7 @@ function LinkBattle.new(game, net, opts) or "RATTATA", 5) self.kind = "link" self.linkRole = role + if require("src.core.GameVersion").isGen3() then self._gbaInitialItemEntries = {} end self.net = net -- BattleState:update only runs while it's the top of the state -- stack, but the player can push PartyMenu/ChoiceBox/NamingScreen on @@ -388,6 +401,13 @@ function LinkBattle.new(game, net, opts) end end + if require("src.core.GameVersion").isGen3() then + require("src.link.Gen3BatonChoice").attach(self, { + role=role, mine=myParty, theirs=theirParty, send=send, + invalid=function() endAsDraw(self, Strings("Link choice mismatch!")) end, + }) + end + -- both actions in hand: resolve the turn identically on both machines local function resolveLockstep(s, myMsg, theirMsg) if myMsg.kind == "run" or theirMsg.kind == "run" then @@ -417,9 +437,24 @@ function LinkBattle.new(game, net, opts) s:act(function() local theirAction = decodeTheirAction(s, theirMsg) + local selectedMine,selectedTheirs=myAction,theirAction + if require("src.core.GameVersion").isGen3() then + local Choice=require("src.battle.Gen3ChoiceBand") + -- Held hooks can be stateful. Both simulations query the canonical + -- host actor first, as the spectator does, regardless of local side. + if role == "host" then + myAction = Choice.plan(s,s.player,myAction) + theirAction = Choice.plan(s,s.enemy,theirAction) + else + theirAction = Choice.plan(s,s.enemy,theirAction) + myAction = Choice.plan(s,s.player,myAction) + end + local Rage=require("src.battle.Gen3Rage") + Rage.selected(s,s.player,myAction);Rage.selected(s,s.enemy,theirAction) + end Runtime.emit("battle.turn_started", { battle = s, turn = s.turnCount, - playerAction = myAction, enemyAction = theirAction, + playerAction = selectedMine, enemyAction = selectedTheirs, }) if myAction and theirAction then -- the tie-break roll is shared: the guest inverts it so both @@ -572,12 +607,14 @@ function LinkBattle.new(game, net, opts) end local baseUpdate = self.update - self.update = function(s, dt) + local function poll(s) net:update() for _, msg in ipairs(net:poll()) do if msg.type == "action" then s.remoteAction = msg tryResolve(s) + elseif msg.type == "baton_pass" then + require("src.link.Gen3BatonChoice").receive(s, msg) elseif msg.type == "hash" then s.remoteHashes[msg.turn or 0] = msg.value s.remoteParts[msg.turn or 0] = msg.parts @@ -606,6 +643,10 @@ function LinkBattle.new(game, net, opts) if net.closed and not s.linkEnded and not s.result then endAsDraw(s) end + end + self._gen3BatonPoll = poll + self.update = function(s, dt) + poll(s) -- Both early returns below skip baseUpdate, which is where the -- presentational clock normally advances -- so tick it here, in the same -- order baseUpdate would (fx, then the queue). Without this an @@ -714,6 +755,7 @@ function LinkBattle.newSpectator(game, net, opts) local self = BattleState.newWild(game, guestParty[1] and guestParty[1].species or "RATTATA", 5) self.kind = "link" -- exact same visual treatment as a real link battle + if require("src.core.GameVersion").isGen3() then self._gbaInitialItemEntries = {} end self.spectating = true -- Tournament.lua's marker: don't report a result for this one self.net = net game.linkNet = net @@ -775,6 +817,13 @@ function LinkBattle.newSpectator(game, net, opts) end) end + if require("src.core.GameVersion").isGen3() then + require("src.link.Gen3BatonChoice").attach(self, { + role="host", mine=hostParty, theirs=guestParty, spectator=true, + invalid=function() endSpectate(self, "Invalid Baton Pass choice.") end, + }) + end + local function resolveSpecTurn(s, hostMsg, guestMsg) if hostMsg.kind == "run" or guestMsg.kind == "run" then endSpectate(s, "The match ended.") @@ -805,9 +854,16 @@ function LinkBattle.newSpectator(game, net, opts) and decodeWireAction(s, hostMsg, s.player) or nil local guestAction = guestMsg.kind ~= "switch" and guestMsg.kind ~= "run" and decodeWireAction(s, guestMsg, s.enemy) or nil + local selectedHost,selectedGuest=hostAction,guestAction + if require("src.core.GameVersion").isGen3() then + local Choice=require("src.battle.Gen3ChoiceBand") + hostAction,guestAction=Choice.plan(s,s.player,hostAction),Choice.plan(s,s.enemy,guestAction) + local Rage=require("src.battle.Gen3Rage") + Rage.selected(s,s.player,hostAction);Rage.selected(s,s.enemy,guestAction) + end Runtime.emit("battle.turn_started", { battle = s, turn = s.turnCount, - playerAction = hostAction, enemyAction = guestAction, + playerAction = selectedHost, enemyAction = selectedGuest, }) if hostAction and guestAction then local hostMove, guestMove = orderMove(hostAction), orderMove(guestAction) @@ -868,7 +924,7 @@ function LinkBattle.newSpectator(game, net, opts) s.afterQueue = "finish" end - self.hostMsg, self.guestMsg = nil, nil + local hostActions, guestActions = {}, {} local baseUpdate = self.update self.update = function(s, dt) net:update() @@ -876,12 +932,10 @@ function LinkBattle.newSpectator(game, net, opts) if msg.type == "spectate" then local inner = msg.msg if inner.type == "action" then - if msg.side == "host" then s.hostMsg = inner else s.guestMsg = inner end - if s.hostMsg and s.guestMsg then - local h, g = s.hostMsg, s.guestMsg - s.hostMsg, s.guestMsg = nil, nil - resolveSpecTurn(s, h, g) - end + local pending = msg.side == "host" and hostActions or guestActions + pending[#pending + 1] = inner + elseif inner.type == "baton_pass" then + require("src.link.Gen3BatonChoice").receive(s, inner, msg.side) elseif inner.type == "bye" or inner.type == "forfeit" then if not s.result then endSpectate(s, "The match ended.") end end @@ -898,6 +952,13 @@ function LinkBattle.newSpectator(game, net, opts) if net.closed and not s.linkEnded and not s.result then endSpectate(s) end + -- Participants can finish animations before the spectator. Keep later + -- actions FIFO and advance the logical turn only after the current queue + -- has completed; otherwise its remaining commands observe the next turn. + if not s.result and not s.linkEnded and (s.phase == "menu" or s.phase == "waitBoth") + and #hostActions > 0 and #guestActions > 0 then + resolveSpecTurn(s, table.remove(hostActions, 1), table.remove(guestActions, 1)) + end -- same as the real-participant loop: every path that skips baseUpdate -- still has to advance the presentational clock, and a spectator sits in -- waitBoth between every single turn diff --git a/src/link/Protocol.lua b/src/link/Protocol.lua index 787c37899..1f345aa12 100644 --- a/src/link/Protocol.lua +++ b/src/link/Protocol.lua @@ -50,6 +50,9 @@ Protocol.plainCopy = plainCopy -- mon as traded (boosted EXP, high-level disobedience). Omitting them made -- a received mon show the receiver as its OT (#215). function Protocol.packMon(mon) + if require("src.core.GameVersion").isGen3() then + return require("src.link.Gen3Party").pack(mon, plainCopy) + end local moves = {} for _, mv in ipairs(mon.moves) do table.insert(moves, { id = mv.id, pp = mv.pp, ppUps = mv.ppUps }) @@ -60,6 +63,7 @@ function Protocol.packMon(mon) exp = mon.exp, hp = mon.hp, status = mon.status, + toxic = require("src.core.GameVersion").isGen3() and mon.status == "PSN" and mon.toxic == true or nil, nickname = mon.nickname, item = mon.item, dvs = mon.dvs, @@ -76,6 +80,9 @@ end -- peers have agreed on a verdict: a mon that cannot be rebuilt identically -- is rejected by name instead of quietly mutated into something else. function Protocol.unpackMon(data, packed, opts) + if require("src.core.GameVersion").isGen3() then + return require("src.link.Gen3Party").unpack(data, packed, opts, plainCopy) + end local Stats = require("src.pokemon.Stats") local Growth = require("src.pokemon.Growth") local strict = opts and opts.strict @@ -153,6 +160,7 @@ function Protocol.unpackMon(data, packed, opts) stats = stats, hp = hp, status = status, + toxic = require("src.core.GameVersion").isGen3() and not forced and status == "PSN" and packed.toxic == true or nil, nickname = packed.nickname, item = packed.item, ot = ot, diff --git a/src/mods/Api3.lua b/src/mods/Api3.lua new file mode 100644 index 000000000..f5de4a039 --- /dev/null +++ b/src/mods/Api3.lua @@ -0,0 +1,199 @@ +-- API-3 entry over the existing native adapters. Unsupported operations have +-- callable handles but never delegate to a native method with a different contract. +local Records = require("src.mods.Api3Records") +local Runtime = require("src.mods.Runtime") +local copy = require("src.mods.Merge").deepCopy +local A = {} +local WORLD = [[overworld current activeBlockAt party canReorderParty reorderParty + availableFieldActions useFieldAction canFly flyTo mapOverview warpTo toggleObject + setFlag getFlag effectiveEncounters replaceBlock spawnNpc liveMaps removeNpc npc + queueScript startWildBattle invalidateMap]] +local SUPPORTED = {} +for name in + ([[overworld current activeBlockAt party canReorderParty reorderParty + availableFieldActions useFieldAction canFly flyTo mapOverview effectiveEncounters + setFlag getFlag liveMaps queueScript]]):gmatch("%S+") +do + SUPPORTED[name] = true +end +local function pack(...) + return { n = select("#", ...), ... } +end +local unpack = table.unpack or unpack +local function block(gen) + return { + tileSize = gen == 3 and 16 or 32, + cellSize = 16, + coordinateBase = 0, + kind = gen == 3 and "metatile" or "block", + } +end +function A.install(api, loader, readContext, overviewContext, engineRequire) + local gen = api.generation + local supportedGeneration = gen >= 1 and gen <= 3 + -- Passthrough registries keep native shapes but expose the same handle metadata. + for name, entry in pairs(api.content) do + local handle = entry + if type(handle) == "table" and handle.supports == nil then + local available, reason = api:supports("content", name) + handle.available = available + local get, each = handle.get, handle.each + if get then + handle.get = function(self, id) + local value, why = get(self, id) + return copy(value), why + end + end + if each then + handle.each = function(self) + local iter, state, key = each(self) + return function() + local id, value = iter(state, key) + key = id + return id, copy(value) + end + end + end + handle.supports = function() + return available, reason + end + end + if type(handle) == "table" and handle.has == nil and handle.get then + handle.has = function(_, id) + return handle:get(id) ~= nil + end + end + end + local scripts = api.world -- PortableScripts owns the per-loader/session queue. + local world = {} + local nativeGame, nativeWorld + local function current() + return loader:_game(true) -- Never bootstrap Game for an observational early call. + end + local function native(game) + if game ~= nativeGame then + nativeWorld = engineRequire("src.world.WorldAPI").new(game, api.id, readContext) + nativeWorld.overviewContext = overviewContext + nativeGame = game + end + return nativeWorld + end + local function dispatch(name, ...) + if not supportedGeneration or not SUPPORTED[name] then + return nil, "unsupported: API 3 world method " .. name + end + local game = current() + if not game then + return nil, "no game" + end + if name == "setFlag" or name == "getFlag" then + if not game.save then + return nil, "no save" + end + local key, value = ... + if type(key) ~= "string" or key:sub(1, 4) ~= "mod:" then + return nil, "unsupported: API 3 native flag mapping" + end + if name == "setFlag" and type(value) ~= "boolean" then + return nil, "flag value must be boolean" + end + local id = key:sub(5) + if #id == 0 or #id > 256 or id:find("%c") then + return nil, "invalid mod flag" + end + local store = game.save.modData + if store ~= nil and type(store) ~= "table" then + return nil, "invalid mod save store" + end + local bucket = store and store[api.id] + if bucket ~= nil and type(bucket) ~= "table" then + return nil, "invalid mod save store" + end + local flags = bucket and bucket.worldFlags + if flags ~= nil and type(flags) ~= "table" then + return nil, "invalid mod flag store" + end + if name == "getFlag" then + return flags ~= nil and flags[id] == true + end + local before = flags ~= nil and flags[id] == true + game.save.modData = store or {} + bucket = bucket or {} + game.save.modData[api.id] = bucket + flags = flags or {} + bucket.worldFlags = flags + flags[id] = value or nil + if before ~= value then + Runtime.emit("flag.changed", { name = "mod." .. api.id .. "." .. id, value = value }) + end + return true + end + if name == "queueScript" then + return scripts:queueScript(...) + end + local raw = native(game) + local result = pack(raw[name](raw, ...)) + if name == "party" and type(result[1]) == "table" then + for _, mon in ipairs(result[1]) do + Records.mon(mon, game.data, gen) + end + elseif (name == "current" or name == "mapOverview") and type(result[1]) == "table" then + result[1].block = block(gen) + end + return unpack(result, 1, result.n) + end + for name in WORLD:gmatch("%S+") do + local method = name + world[method] = function(_, ...) + local owner = Runtime.currentMod + Runtime.currentMod = nil + local values = pack(pcall(dispatch, method, ...)) + Runtime.currentMod = owner + if not values[1] then + return nil, "world operation failed: " .. tostring(values[2]) + end + return unpack(values, 2, values.n) + end + end + api.world = world + local battle = api.battle + api.battle = { + snapshot = function() + local snapshot, why = battle:snapshot() + if not snapshot then + return nil, why + end + local game = current() + local data = game and game.data + Records.mon(snapshot.player, data, gen) + Records.mon(snapshot.enemy, data, gen) + for _, name in ipairs({ "party", "battlers" }) do + for _, mon in ipairs(snapshot[name] or {}) do + Records.mon(mon, data, gen) + end + end + return snapshot + end, + submit = function(_, ...) + return battle:submit(...) + end, + } + -- Version datasets have no native Gen2Recomped adapter yet. Keeping the + -- handle callable allows the same mod to fall back without engine imports. + api.datasets = { + open = function() + return nil, "unsupported: version datasets" + end, + } + local supports = api.supports + function api:supports(surface, name) + if surface == "world" then + if supportedGeneration and SUPPORTED[name] then + return true + end + return false, "unsupported: API 3 world method " .. tostring(name) + end + return supports(self, surface, name) + end +end +return A diff --git a/src/mods/Api3Catalog.lua b/src/mods/Api3Catalog.lua new file mode 100644 index 000000000..0549286b9 --- /dev/null +++ b/src/mods/Api3Catalog.lua @@ -0,0 +1,57 @@ +-- Public callback-name snapshot from Gen1Recomp API 3, commit +-- 79ea62f45fb2ea8f65d081a109431e4eb1bbdb36. Recognition is not availability: +-- Capabilities still requires a native Gen2Recomped call site. +local function names(text) + local out = {} + for name in text:gmatch("%S+") do + out[name] = true + end + return out +end +local C = {} +C.hooks = names([[ +battle.accuracy battle.bottom_ui_visible battle.catch_exp battle.caught_marker_visible +battle.charge_required battle.crit battle.damage battle.enemy_action +battle.enemy_switch_or_item battle.exp_award battle.field_residual battle.low_health_alarm +battle.menu_auxiliary battle.move_grid_navigation battle.overlay battle.run +battle.status_hud_visible battle.style battle.turn_order breeding.compatibility +catch.nickname catch.party_full catch.rate core.logic_speed +core.quit_to_launcher core.update encounter.fishing encounter.roll +encounter.species encounter.table evolution.check exp.gain +fieldmove.eligibility gender.roll held_item.trigger input.gamepad +input.key input.pointer input.step input.wheel +intro.oak_speech.build item.use link.fingerprint map.palette +movement.collision movement.speed music.select music.volume +phone.contact_list player.sprite pokemon.icon pokemon.level_visible +pokemon.sprite render.compose render.hud render.letterbox +render.output render.output_enabled render.viewport render.window +render.zones save.new_game save.write screen.render_visible +script.command shiny.roll trainer.before_battle trainer.party +transition.style ui.list_menu ui.naming.grid ui.options.rows +ui.party.grid_navigation ui.party.submenu ui.pc.items ui.start_menu.items +ui.title_menu.items warp.destination world.follower.spawn world.talk +world.tod zoom.range +]]) +C.events = names([[ +apricorn.converted assets.transformed battle.ball_thrown battle.battler_switched +battle.damage_dealt battle.ended battle.exp_gained battle.fainted +battle.move_used battle.started battle.status_inflicted battle.turn_ended +battle.turn_started breeding.egg_created bug_contest.scored checkpoint.restored +clock.day_changed discord.join_requested egg.hatched flag.changed +game.ready happiness.changed intro.boot.copyright intro.boot.gamefreak +intro.boot.movie intro.boot.movie_ended intro.boot.title intro.oak_speech.answered +intro.oak_speech.finished intro.oak_speech.started intro.oak_speech.step link.battle_ended +link.connected link.desync link.ended mail.read +mail.written map.entered map.exited map.reloaded +mod.options_changed mods.loaded music.started music.stopped +phone.call_received player.warped pokemon.before_give pokemon.caught +pokemon.evolved pokemon.level_up pokemon.move_learned pokemon.received +pokerus.infected radio.channel roamer.encountered roamer.moved +save.created save.loaded save.loading save.writing +screen.popped screen.pushed script.ended script.started +sound.played trade.completed unown.unlocked world.blacked_out +world.block_replaced world.boulder_moved world.interacted world.live_maps_preparing +world.live_maps_updated world.npc_spawned world.object_toggled world.stepped +world.tod_changed world.trainer_engaged +]]) +return C diff --git a/src/mods/Api3Records.lua b/src/mods/Api3Records.lua new file mode 100644 index 000000000..a102c7234 --- /dev/null +++ b/src/mods/Api3Records.lua @@ -0,0 +1,60 @@ +-- 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) + if gen == 3 and (out.native or {}).toxic == true and out.status == "PSN" then out.status = "TOX" end + 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/BattleIntents.lua b/src/mods/BattleIntents.lua new file mode 100644 index 000000000..3792d3b13 --- /dev/null +++ b/src/mods/BattleIntents.lua @@ -0,0 +1,185 @@ +-- Data-only intent validation and private cross-facade submission state. +local Intents = {} +local states = setmetatable({}, { __mode = "k" }) +local FIELDS = { + menu = { choice = true }, + move = { slot = true }, + target = { target = true }, + back = {}, + safari = { action = true }, + mimic = { index = true }, +} +local MENU = { fight = true, party = true, item = true, run = true } +local SAFARI = { ball = true, bait = true, rock = true, run = true } +local function integer(v, lo, hi) + return type(v) == "number" and v == v and v % 1 == 0 and v >= lo and v <= hi +end +function Intents.normalize(raw) + if type(raw) ~= "table" or getmetatable(raw) ~= nil then + return nil, "intent must be a plain table" + end + if not integer(raw.id, 1, 9007199254740991) then + return nil, "intent id must be a positive safe integer" + end + if not integer(raw.revision, 1, 9007199254740991) then + return nil, "invalid battle revision" + end + local fields = type(raw.kind) == "string" and FIELDS[raw.kind] + if not fields then + return nil, "unknown battle intent" + end + for key in pairs(raw) do + if key ~= "id" and key ~= "revision" and key ~= "kind" and not fields[key] then + return nil, "unknown battle intent field" + end + end + local out = { id = raw.id, revision = raw.revision, kind = raw.kind } + if raw.kind == "menu" then + if type(raw.choice) ~= "string" or not MENU[raw.choice] then + return nil, "unknown battle menu choice" + end + out.choice = raw.choice + elseif raw.kind == "move" then + if not integer(raw.slot, 1, 4) then + return nil, "invalid move slot" + end + out.slot = raw.slot + elseif raw.kind == "target" then + if not integer(raw.target, 0, 3) then + return nil, "target must be a battler id 0-3" + end + out.target = raw.target + elseif raw.kind == "safari" then + if type(raw.action) ~= "string" or not SAFARI[raw.action] then + return nil, "invalid safari action" + end + out.action = raw.action + elseif raw.kind == "mimic" then + if not integer(raw.index, 1, 4) then + return nil, "invalid mimic index" + end + out.index = raw.index + end + return out +end +function Intents.state(game) + local state = states[game] + if not state then + state = { epoch = 0, busy = false } + states[game] = state + end + return state +end + +local BATTLE_FLAGS = { + "waitingUI", + "dead", + "demo", + "dudeDemo", + "pokedudeDemo", + "palace", + "arena", + "spectating", + "bugContest", + "noItems", + "gen4Safari", +} +local BATTLER_FLAGS = { + "disabledSlot", + "disabledMove", + "disabledTurns", + "tauntTurns", + "tormented", + "lastMove", + "mustRecharge", + "charging", + "thrashTurns", + "thrashMove", + "rageMove", + "rage", + "trappingTurns", + "bideTurns", + "encoreTurns", + "encoreMove", + "encoreSlot", + "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 + if require("src.core.GameVersion").isGen3() then + flags.choicedMove, flags.imprison = actor.choicedMove, actor.imprison + 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..c92abb2d0 --- /dev/null +++ b/src/mods/BattleSnapshots.lua @@ -0,0 +1,586 @@ +-- 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 = require("src.battle.Gen3Disable").blocked(actor, move, 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 + if require("src.core.GameVersion").isGen3() then + row.disabled = require("src.battle.Gen3MoveSelection").manualReason(battle, actor, move, slot) ~= nil + end + -- 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/Builtins.lua b/src/mods/Builtins.lua index 470f67ad7..e5d970ceb 100644 --- a/src/mods/Builtins.lua +++ b/src/mods/Builtins.lua @@ -7,7 +7,8 @@ -- tables, or an edit through one dataset (hot reload, a test loading -- twice) reaches the other and the module's own statics. Functions ride -- the copy by reference, so handlers keep their identity and the merged --- value stays equal to the vanilla one -- the mod-free merge is a no-op. +-- value stays equal to vanilla. Native GBA command adapters are installed +-- per dataset before mods capture builtin handlers for delegation. -- Modules are required lazily -- the loader must not drag the battle and -- script stacks in with it at require time. local Logger = require("src.core.Logger") @@ -83,9 +84,10 @@ end -- the write verbs copy their payload before it lands; centralized here so -- the isolation holds for every registrant instead of leaning on each -- module to hand over fresh tables -local function isolate(registry) +local function isolate(registry, adapt) return setmetatable({ register = function(_, id, value, owner) + if adapt then value = adapt(id, value) end return registry:register(id, Merge.deepCopy(value), owner) end, override = function(_, id, value, owner) @@ -99,7 +101,9 @@ end function Builtins.install(content, data) for _, entry in ipairs(REGISTRANTS) do - local registry = content[entry.name] and isolate(content[entry.name]) + local adapt = entry.name == "move_effects" and require("src.battle.Gen3Builtins").record or nil + if entry.name == "statuses" then adapt = require("src.battle.Gen3SleepTalk").status end + local registry = content[entry.name] and isolate(content[entry.name], adapt) if registry then if entry.install then local modules, complete = {}, true @@ -115,6 +119,9 @@ function Builtins.install(content, data) end end end + if registry and entry.name == "move_effects" then + require("src.battle.Gen3Builtins").install(registry, Builtins.OWNER) + end end end diff --git a/src/mods/CallbackRecords.lua b/src/mods/CallbackRecords.lua new file mode 100644 index 000000000..8416d27ae --- /dev/null +++ b/src/mods/CallbackRecords.lua @@ -0,0 +1,850 @@ +local Api3Records = require("src.mods.Api3Records") +local EnemyMoveChoice = require("src.battle.EnemyMoveChoice") +-- 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 portableEnemyAction = api3 and generation == 3 and name == "battle.enemy_action" + local identityRoll = name == "shiny.roll" or name == "gender.roll" + 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 == "rollIvs" then + return { hp = raw.hp, atk = raw.attack, def = raw.defense, + spe = raw.speed, spa = raw.spatk, spd = raw.spdef } + elseif 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 = portable and generation == 3 and raw.toxic == true and readStatus(raw.status) == "PSN" and "TOX" or 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 == "heldItem" then + local out = { native = copy(raw) } + for k, v in pairs(raw) do + if type(v) ~= "table" and type(v) ~= "function" then out[k] = v end + end + out.id = read("items", raw.id) + return out + elseif kind == "move" or kind == "species" or kind == "item" or kind == "identitySpecies" then + local out = { native = copy(raw), name = raw.name } + local registryKind = (kind == "species" or kind == "identitySpecies") 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" or kind == "identitySpecies" 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 + if kind == "identitySpecies" then out.genderRatio = raw.genderRatio end + 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) + local nativeAction = portableEnemyAction and kind == "action" and EnemyMoveChoice.nativeView(raw) + if nativeAction then + EnemyMoveChoice.markPortable(raw) + local def = moveDef(raw.id) + local number = def and tonumber(def.index or def.numId) + if not number and type(raw.id) == "number" then number = raw.id end + out.kind = raw.kind or "move" + out.move = raw.move ~= nil and raw.move or (raw.struggle and "STRUGGLE" or number) + out.slot = raw.slot or nativeAction.slot + out.user = raw.user or "enemy" + out.battler = raw.battler ~= nil and raw.battler or nativeAction.battler + out.targetType = raw.targetType or (nativeAction.double and (tonumber(def and def.target) or 0)) or nil + end + for k, v in pairs(raw) do + local role = recordRoles[k] + if identityRoll and k == "def" then role = "identitySpecies" end + if identityRoll and (k == "dvs" or k == "ivs") then role = "context" end + if identityRoll and generation == 3 and k == "ivs" then role = "rollIvs" end + if name == "held_item.trigger" and k == "def" then role = "heldItem" end + 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 nativeAction and k == "move" then + out[k] = v + elseif 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 name == "held_item.trigger" and generation == 3 and kind == "context" and k == "item" and v == 0 then + out[k] = 0 -- Native empty-hand sentinel; it is not a content item ID. + 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 + if portableEnemyAction and kind == "action" then + for _, key in ipairs({ "move", "id" }) do + local authored, unchanged, known = EnemyMoveChoice.publicReference(raw, key) + if unchanged then out[key] = authored + elseif known and key == "move" then + local def = moveDef(raw[key]) + out[key] = def and tonumber(def.index or def.numId) or raw[key] + 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, original) + -- GBA enemy-action normalization accepts scalar move choices and false + -- fallback. Other record boundaries keep their existing validation. + if name == "battle.enemy_action" and generation == 3 and kind == "action" + and (type(value) == "string" or type(value) == "number" or value == false) then + return value + end + 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" + or (identityRoll and (kind == "context" or kind == "rollIvs" or kind == "identitySpecies")), + "replacement " .. tostring(kind) .. " must be a bound record" + ) + local target = identityRoll and api3 and type(original) == "table" and original or nil + local previous = target and byRaw[target] and byRaw[target][kind] + e = { public = value, kind = kind, raw = target or {}, + before = previous and previous.before or {}, replacement = true } + if target then + byView[value] = e + entries[#entries + 1] = e + end + end + if visiting[e] then + return e.raw + end + visiting[e] = true + local raw, before = e.raw, e.before + kind = e.kind + if kind == "heldItem" then + local keys = shallow(before) + for k in pairs(value) do keys[k] = true end + for key in pairs(keys) do + if key ~= "native" and not equal(value[key], before[key]) then + local v = value[key] + assert(type(v) ~= "table" and type(v) ~= "function", "held definition field must be scalar") + if key == "id" then v = native("items", v, raw.id) end + pending[#pending + 1] = function() + require("src.battle.HeldItemRead").writeDefinition(raw, key, v) + end + end + end + elseif kind == "rollIvs" then + local aliases = { hp = "hp", atk = "attack", def = "defense", + spe = "speed", spa = "spatk", spd = "spdef" } + for k, v in pairs(value) do + assert(aliases[k], "unknown IV field") + if v ~= before[k] then stage(raw, aliases[k], integer(v, 0, 31, "IV")) end + end + for k in pairs(before) do assert(value[k] ~= nil, "IV field is required") end + elseif kind == "identitySpecies" then + local current, original = shallow(value), shallow(before) + current.genderRatio, original.genderRatio = nil, nil + if e.replacement then + for key in pairs(original) do if current[key] == nil then original[key] = nil end end + end + assert(equal(current, original), "species definition is read-only except genderRatio") + if value.genderRatio ~= before.genderRatio then + stage(raw, "genderRatio", integer(value.genderRatio, 0, 255, "gender ratio")) + end + elseif 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) + if portable and generation == 3 then + local toxic = v == "TOX" + stage(raw, "toxic", toxic or nil) + for _, parent in ipairs(entries) do + if parent.kind == "battler" and parent.raw.mon == raw then + stage(parent.raw, "toxicCounter", toxic and 1 or nil) + end + end + if toxic then v = "PSN" end + end + 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 portableEnemyAction and kind == "action" and not value.special + and (k == "move" or k == "id") and v ~= before[k] then + local known = type(v) == "string" and ids:resolve("moves", v) + local converted = known and native("moves", v, old) or v + stage(raw, k, converted) + pending[#pending + 1] = function() + EnemyMoveChoice.rememberReference(raw, k, v, converted) + end + elseif kind == "action" and name == "battle.enemy_action" and generation == 3 + and not value.special and (k == "move" or k == "id") and v ~= before[k] + and (type(v) == "number" or type(v) == "string") then + local known = type(v) == "string" and ids:resolve("moves", v) + -- Keep canonical IDs native inside nested callbacks. Numeric and + -- alternate name spellings are resolved by the actual producer. + stage(raw, k, known and native("moves", v, old) or v) + 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 identityRoll and k == "def" then role = "identitySpecies" end + if identityRoll and (k == "dvs" or k == "ivs") then role = "context" end + if identityRoll and generation == 3 and k == "ivs" then role = "rollIvs" end + 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, identityRoll and api3 and old or nil) + 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, original) + return restore(v, kind, original) + 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..90ccf0acf --- /dev/null +++ b/src/mods/Capabilities.lua @@ -0,0 +1,111 @@ +-- Availability of the existing API-2 surfaces, before a Game/overworld exists. +-- Keep callback names tied to native call sites, not to the permissive buses: +-- successfully subscribing to a name does not mean the engine ever fires it. +local GameVersion = require("src.core.GameVersion") +local Schemas = require("src.mods.Schemas") + +local Capabilities = {} +local callbacks = { hooks = {}, events = {} } +local function add(surface, generations, names) + for name in names:gmatch("%S+") do + callbacks[surface][name] = generations + end +end + +-- Shared simulation, world, input, rendering and lifecycle code. A callback +-- need not fire on every action (e.g. an always-hit move skips accuracy). +add("hooks", "12345", [[ + battle.accuracy battle.catch_exp battle.crit battle.damage battle.enemy_action + battle.exp_award battle.low_health_alarm battle.run battle.turn_order catch.rate + encounter.fishing encounter.roll encounter.species evolution.check exp.gain + fieldmove.eligibility input.step link.fingerprint map.palette movement.collision + movement.speed music.select music.volume player.sprite pokemon.icon pokemon.sprite + render.compose render.hud render.letterbox render.zones save.new_game save.write + script.command trainer.party transition.style ui.list_menu ui.options.rows + ui.title_menu.items warp.destination world.tod zoom.range +]]) +-- Screens have distinct implementations: Screens.lua aliases the DS party, +-- naming and start menus; those implementations do not call the GB/GBA hooks. +-- OverworldController:openPC bypasses ui.pc.items on Gen 3/4 (and Gen 5 uses +-- its own script PC). Gen4Battle paints its HUD through a separate seam and +-- does not dispatch battle.overlay, unlike BattleState/Gen3Battle/WideBattle. +add("hooks", "123", "battle.overlay ui.naming.grid ui.party.submenu ui.start_menu.items") +add("hooks", "12", "ui.pc.items") +add("hooks", "45", "battle.status_hud_visible") +add("hooks", "23", "shiny.roll gender.roll") +add("hooks", "23", "held_item.trigger") +add("hooks", "123", "battle.charge_required") +add("hooks", "12", "intro.oak_speech.build") + +add("events", "12345", [[ + assets.transformed battle.ball_thrown battle.battler_switched battle.damage_dealt + battle.ended battle.exp_gained battle.fainted battle.move_used battle.started + battle.status_inflicted battle.turn_ended battle.turn_started discord.join_requested + flag.changed game.ready link.connected link.desync link.ended map.entered map.exited + map.reloaded mod.option_action mod.options_changed mods.loaded music.started + music.stopped player.warped pokemon.before_give pokemon.caught pokemon.evolved + pokemon.level_up pokemon.move_learned pokemon.received save.created save.loaded + save.loading save.writing screen.popped screen.pushed script.ended script.started + sound.played trade.completed world.blacked_out world.block_replaced + world.blocks_replaced world.boulder_moved world.interacted world.npc_spawned + world.object_toggled world.stepped world.tick world.tod_changed world.trainer_engaged +]]) +add("events", "2345", "pokemon.egg_hatched") +add("events", "12", [[ + intro.oak_speech.answered intro.oak_speech.finished intro.oak_speech.started + intro.oak_speech.step +]]) +add("events", "3", "intro.birch_speech.started intro.birch_speech.finished") + +-- Data:applyDefaults selects BirchSpeech on Emerald, Gen3OakSpeechFRLG on +-- FireRed, and no OakSpeech on Prism. DS imports select their own intros. +local function nativeVersion(name, version) + if name:match("^intro%.birch_speech%.") then return version == "emerald" end + if name:match("^intro%.oak_speech%.") then return version ~= "prism" end + return true +end + +-- Public WorldAPI methods only; constructors, internal helpers and methods +-- on returned NPC handles are not mod.world methods. These remain callable +-- at the title screen, returning their normal "no overworld"/"no save" error. +-- Elevation readers can return "map carries no elevation" on any dataset. +local world = {} +for name in ([[ + overworld current warpTo toggleObject setFlag getFlag replaceBlock spawnNpc + removeNpc loadedMaps elevationInfo elevationAt terrainAt elevationRanks heightAt + elevationGrid npc queueScript invalidateMap +]]):gmatch("%S+") do world[name] = true end + +function Capabilities.supports(surface, name, version) + if surface ~= "content" and surface ~= "world" + and surface ~= "hooks" and surface ~= "events" then + return false, "unsupported capability surface: " .. tostring(surface) + end + if type(name) ~= "string" or name == "" then + return false, "capability name must be a non-empty string" + end + local info = GameVersion.VERSIONS[version] + if not info then return false, "unknown game version: " .. tostring(version) end + if surface == "content" then + -- Every loader builds the same API-2 registries from Schemas. This says + -- the registration API exists, not that record shapes/IDs are portable. + if Schemas.REGISTRIES[name] then return true end + elseif surface == "world" then + if world[name] then return true end + else + -- A mod may listen to another mod's namespace without that mod installed; + -- this only promises the bus supports it, never that a producer is present. + if surface == "events" and name:match("^mod%.[%w_%-]+%..+") then return true end + local gens = callbacks[surface][name] + if gens then + if gens:find(tostring(info.generation), 1, true) and nativeVersion(name, version) then + return true + end + return false, ("%s capability %s has no native call site in %s") + :format(surface, name, version) + end + end + return false, ("unknown %s capability: %s"):format(surface, name) +end + +return Capabilities diff --git a/src/mods/Loader.lua b/src/mods/Loader.lua index 4094a38c4..fa299c0ce 100644 --- a/src/mods/Loader.lua +++ b/src/mods/Loader.lua @@ -11,18 +11,25 @@ local Data = require("src.core.Data") local Version = require("src.core.Version") local Assets = require("src.render.Assets") local ModUI = require("src.ui.ModUI") +local PortableScripts = require("src.mods.PortableScripts") local AssetTransform = require("src.mods.AssetTransform") local Manifest = require("src.mods.Manifest") local Merge = require("src.mods.Merge") local Registry = require("src.mods.Registry") local Schemas = require("src.mods.Schemas") +local Capabilities = require("src.mods.Capabilities") local Semver = require("src.mods.Semver") local Events = require("src.mods.Events") local Hooks = require("src.mods.Hooks") local ModStorage = require("src.mods.Storage") +local WorldReads = require("src.mods.WorldReads") local Runtime = require("src.mods.Runtime") +local BattleSnapshots = require("src.mods.BattleSnapshots") +local UIActions = require("src.mods.UIActions") local ModGens = require("src.mods.ModGens") local ModSeams = require("src.mods.ModSeams") +local WorldActions = require("src.world.WorldActions") +local WorldOverview = require("src.mods.WorldOverview") -- Module-name aliases for mods written against the Gold port's per-generation -- layout (src.world.gen2.Player and friends). Installed here because this is -- the module that loads mods: the searcher has to be in place before the @@ -831,7 +838,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 +848,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 +877,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 +894,14 @@ function Loader:_api(mod) label = hostInfo and hostInfo.label or hostId, engine = engineVersion, modApi = (function() - local okE, V = pcall(require, "src.core.Version") + local okE, V = pcall(engineRequire, "src.core.Version") return okE and V and V.modApi or nil end)(), developer = loader.dev == true, }, -- a deep copy: what a mod does to its own view never reaches the loader manifest = Merge.deepCopy(mod.manifest), + callbackFormat = mod.manifest.callback_format or "native", content = {}, exports = {}, DELETE = Registry.DELETE, @@ -914,9 +925,9 @@ function Loader:_api(mod) hooks = { wrap = function(_, name, callback, priority) return loader.hooks:wrap(name, callback, priority, modId) end }, - -- the widget toolkit facade (12 4.5) is one shared surface, not - -- per-mod state; each widget inside it loads on first touch - ui = ModUI, + -- Per-mod action/revision state, with the existing lazy widget builders. + -- Reading the facade early must not construct Game or a native screen. + ui = UIActions.new(function() return loader:_game(true) end, loader, ModUI), -- namespaced per mod; M11 backs these with save.modData / -- options.modOptions, the shape mods compile against is already final save = { @@ -999,6 +1010,9 @@ function Loader:_api(mod) }, } self.exports[modId] = api.exports + if api.callbackFormat == "unified" then + engineRequire("src.mods.UnifiedCallbacks").install(loader, api, modId, hostGen) + end -- a handle, not the mod object: {id, version, exports} or nil when the -- other mod is absent, disabled, failed, or has not run yet. Tolerates -- mod:find(id) as well as the documented mod.find(id). @@ -1020,6 +1034,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 +1137,41 @@ function Loader:_api(mod) end return data end) + -- Capture the host selected for this API, as with generation/gameVersion. + -- Probing must not instantiate mod.world/mod.game or register callbacks. + function api:supports(surface, name) + return Capabilities.supports(surface, name, hostId) + end -- mod.world / mod.game / mod.storage all materialize on first touch, for - -- the same reason: a headless load must not drag the world stack in, and - -- the Game the facade acts on is still being wired when the entry chunk + -- the same reason: a headless load must not drag the world stack in. + -- Battle capability queries can also be installed before Game exists. + BattleSnapshots.withCapabilities(api) + UIActions.withCapabilities(api) + -- The Game the facade acts on is still being wired when the entry chunk -- runs. mod.storage is the bulk counterpart to mod.save -- one sandboxed -- namespace per mod for payloads too large to belong in a save file. + -- When capability queries are installed, these additive methods exist on + -- every host; individual actions still validate native version and state. + if api.supports then + local supports = api.supports + function api:supports(surface, name) + if surface == "world" and WorldActions.supports(name) then return true end + return supports(self, surface, name) + end + end local world, storage - setmetatable(api, { __index = function(_, key) + local overviewContext = WorldOverview.install(api, loader) + local readContext = WorldReads.context(loader) + -- Capability queries are optional on older API-2 hosts. When present, + -- report the additive readers without constructing Game or the world. + if api.supports then + local supports = api.supports + function api:supports(surface, name) + if surface == "world" and WorldReads.supports(name) then return true end + return supports(self, surface, name) + end + end + setmetatable(api, PortableScripts.wrap({ __index = function(_, key) if key == "game" then return loader:_game() end if key == "storage" then -- Do not pass loader.fs when it is love.filesystem: that always writes @@ -1139,17 +1185,37 @@ function Loader:_api(mod) local game = loader:_game() local module = game and engineRequire("src.world.WorldAPI") if not module then return nil end - world = module.new(game, modId) + world = module.new(game, modId, readContext) + world.overviewContext = overviewContext return world - end }) + end }, api, loader, mod.manifest)) + -- When capability queries are installed, report the selected content + -- format's actual availability. Other surfaces retain their host query. + if api.contentFormat == "unified" and api.supports then + local supports, content = api.supports, api.content + api.supports = function(self, surface, name) + local handle = type(name) == "string" and content[name] + if surface == "content" and handle and handle.supports then return handle:supports() end + return supports(self, surface, name) + end + end + if (mod.manifest.api or 1) >= 3 then + engineRequire("src.mods.Api3").install(api, loader, readContext, overviewContext, engineRequire) + end return api end -- the live Game. An injected reference wins so a headless caller can hand -- over a stub; otherwise the boot singleton, whose stack and overworld fill -- in after this loader returns -- holding the table keeps the facade live. -function Loader:_game() - return self.game or engineRequire("src.core.Game") +function Loader:_game(loadedOnly) + if self.game then return self.game end + -- Observational facades must not bootstrap Game from an early/headless read. + if loadedOnly then + local game = package.loaded["src.core.Game"] + return type(game) == "table" and game or nil + end + return engineRequire("src.core.Game") end function Loader:_loadMod(mod) @@ -1196,6 +1262,7 @@ 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) + PortableScripts.cancelAll(self, modId, "mod rolled back") for name in pairs(self.journal or self.content) do self.content[name]:rollback(modId) end @@ -1310,6 +1377,7 @@ local function stampAudioOwners(data, name, registry) end function Loader:load(data) + 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 @@ -1450,6 +1518,9 @@ 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 + -- Scope native query context to this bus; SDK/runtime restoration swaps + -- the whole bus, so no process-global dataset outlives its loader. + self.hooks.data = data Runtime.install(self.events, self.hooks, self.errors) self:_validate() local ordered = self:_resolve() @@ -1653,6 +1724,7 @@ 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() + PortableScripts.cancelAll(nil, nil, "mods unloaded") local undone = select(1, ModSeams.unwindAll()) pcall(function() Runtime.install(Events.new(), Hooks.new(), nil) diff --git a/src/mods/Manifest.lua b/src/mods/Manifest.lua index ac5614634..3267b2c54 100644 --- a/src/mods/Manifest.lua +++ b/src/mods/Manifest.lua @@ -223,6 +223,16 @@ function Manifest.validate(raw, path) assert(api <= Version.modApi, ("requires mod API %d; this engine provides %d") :format(api, Version.modApi)) local strict = api >= 2 + -- API 3 is one portable contract, not three independent API-2 opt-ins. + for _, field in ipairs({ "content_format", "callback_format", "script_format" }) do + assert(api < 3 or raw[field] == nil or raw[field] == "unified", + "API 3 requires unified " .. field) + end + local defaultFormat = api >= 3 and "unified" or "native" + assert(raw.content_format == nil or raw.content_format == "native" + or raw.content_format == "unified", "content_format must be native or unified") + assert(raw.content_format ~= "unified" or api >= 2, + "unified content requires mod API 2") local profile = raw.profile or "content" if not Manifest.PROFILES[profile] then @@ -240,6 +250,10 @@ function Manifest.validate(raw, path) end end + local scriptFormat = raw.script_format == nil and defaultFormat or raw.script_format + assert(scriptFormat == "native" or scriptFormat == "unified", "script_format must be native or unified") + assert(scriptFormat ~= "unified" or api >= 2, "unified scripts require mod API 2") + local gameVersionOk, gameVersionErr = Semver.validRange(raw.game_version) assert(gameVersionOk, ("malformed game_version %q: %s") :format(tostring(raw.game_version), tostring(gameVersionErr))) @@ -279,6 +293,10 @@ function Manifest.validate(raw, path) return value end + assert(raw.callback_format == nil or raw.callback_format == "native" or raw.callback_format == "unified", + "callback_format must be native or unified") + assert(raw.callback_format ~= "unified" or api >= 2, "callback_format unified requires mod API 2") + local conflicts = mergeConflictLists(raw.conflicts, raw.incompatible) -- `support`: where to thank the mod's creator -- a Ko-fi, a Patreon, a @@ -305,6 +323,7 @@ function Manifest.validate(raw, path) version = raw.version, entry = raw.entry, api = api, + content_format = raw.content_format or defaultFormat, priority = tonumber(raw.priority) or 0, dependencies = array(raw.dependencies), optional_dependencies = array(raw.optional_dependencies), @@ -316,6 +335,7 @@ function Manifest.validate(raw, path) category = raw.category or "OTHER", game_version = raw.game_version, description = raw.description or "", + callback_format = raw.callback_format or defaultFormat, github = github, support = support, updateCheck = updateCheck, @@ -325,6 +345,7 @@ function Manifest.validate(raw, path) affects_link = affectsLink, permissions = permissions, permissionSet = permissionSet, + script_format = scriptFormat, options_schema = optionalFile(raw.options_schema, "options_schema"), -- Base files the mod needs but cannot ship -- a Stadium 2 cartridge, a -- second game's ROM. Declared here, satisfied by the launcher, read by diff --git a/src/mods/MoveEffectContract.lua b/src/mods/MoveEffectContract.lua new file mode 100644 index 000000000..1d1ec1c27 --- /dev/null +++ b/src/mods/MoveEffectContract.lua @@ -0,0 +1,1213 @@ +-- Generated by tools/move-effect-contract/generate.py; do not hand-edit. +return { + ["bindings"] = { + ["ACCURACY_DOWN1_EFFECT"] = {["1"]={["native"]="ACCURACY_DOWN1_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_ACCURACY_DOWN",["semantic"]=23},["3"]={["chance"]=0,["native"]=23,["semantic"]=23}}, + ["ASSIST"] = {["3"]={["native"]=180,["semantic"]=180}}, + ["ATTACK_DOWN1_EFFECT"] = {["1"]={["native"]="ATTACK_DOWN1_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_ATTACK_DOWN",["semantic"]=18},["3"]={["chance"]=0,["native"]=18,["semantic"]=18}}, + ["ATTACK_DOWN2_EFFECT"] = {["2"]={["chance"]=0,["native"]="EFFECT_ATTACK_DOWN_2",["semantic"]=58},["3"]={["chance"]=0,["native"]=58,["semantic"]=58}}, + ["ATTACK_DOWN_SIDE_EFFECT"] = {["1"]={["native"]="ATTACK_DOWN_SIDE_EFFECT"},["2"]={["chance"]=33,["native"]="EFFECT_ATTACK_DOWN_HIT",["semantic"]=68},["3"]={["chance"]=33,["native"]=68,["semantic"]=68}}, + ["ATTACK_TWICE_EFFECT"] = {["1"]={["native"]="ATTACK_TWICE_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_DOUBLE_HIT",["semantic"]=44},["3"]={["chance"]=0,["native"]=44,["semantic"]=44}}, + ["ATTACK_UP1_EFFECT"] = {["1"]={["native"]="ATTACK_UP1_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_ATTACK_UP",["semantic"]=10},["3"]={["chance"]=0,["native"]=10,["semantic"]=10}}, + ["ATTACK_UP2_EFFECT"] = {["1"]={["native"]="ATTACK_UP2_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_ATTACK_UP_2",["semantic"]=50},["3"]={["chance"]=0,["native"]=50,["semantic"]=50}}, + ["BIDE_EFFECT"] = {["1"]={["native"]="BIDE_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_BIDE",["semantic"]=26},["3"]={["chance"]=0,["native"]=26,["semantic"]=26}}, + ["BLAZE_KICK"] = {["3"]={["native"]=200,["semantic"]=200}}, + ["BRICK_BREAK"] = {["3"]={["native"]=186,["semantic"]=186}}, + ["BULK_UP"] = {["3"]={["native"]=208,["semantic"]=208}}, + ["BURN_SIDE_EFFECT1"] = {["1"]={["native"]="BURN_SIDE_EFFECT1"},["2"]={["chance"]=10,["native"]="EFFECT_BURN_HIT",["semantic"]=4},["3"]={["chance"]=10,["native"]=4,["semantic"]=4}}, + ["BURN_SIDE_EFFECT2"] = {["1"]={["native"]="BURN_SIDE_EFFECT2"},["2"]={["chance"]=30,["native"]="EFFECT_BURN_HIT",["semantic"]=4},["3"]={["chance"]=30,["native"]=4,["semantic"]=4}}, + ["CALM_MIND"] = {["3"]={["native"]=211,["semantic"]=211}}, + ["CAMOUFLAGE"] = {["3"]={["native"]=213,["semantic"]=213}}, + ["CHARGE"] = {["3"]={["native"]=174,["semantic"]=174}}, + ["CHARGE_EFFECT"] = {["1"]={["native"]="CHARGE_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_RAZOR_WIND",["semantic"]=39},["3"]={["chance"]=0,["native"]=39,["semantic"]=39}}, + ["CONFUSION_EFFECT"] = {["1"]={["native"]="CONFUSION_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_CONFUSE",["semantic"]=49},["3"]={["chance"]=0,["native"]=49,["semantic"]=49}}, + ["CONFUSION_SIDE_EFFECT"] = {["1"]={["native"]="CONFUSION_SIDE_EFFECT"},["2"]={["chance"]=10,["native"]="EFFECT_CONFUSE_HIT",["semantic"]=76},["3"]={["chance"]=10,["native"]=76,["semantic"]=76}}, + ["CONVERSION_2"] = {["3"]={["native"]=93,["semantic"]=93}}, + ["CONVERSION_EFFECT"] = {["1"]={["native"]="CONVERSION_EFFECT"},["3"]={["chance"]=0,["native"]=30,["semantic"]=30}}, + ["COSMIC_POWER"] = {["3"]={["native"]=206,["semantic"]=206}}, + ["DEFENSE_DOWN1_EFFECT"] = {["1"]={["native"]="DEFENSE_DOWN1_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_DEFENSE_DOWN",["semantic"]=19},["3"]={["chance"]=0,["native"]=19,["semantic"]=19}}, + ["DEFENSE_DOWN2_EFFECT"] = {["1"]={["native"]="DEFENSE_DOWN2_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_DEFENSE_DOWN_2",["semantic"]=59},["3"]={["chance"]=0,["native"]=59,["semantic"]=59}}, + ["DEFENSE_DOWN_SIDE_EFFECT"] = {["1"]={["native"]="DEFENSE_DOWN_SIDE_EFFECT"},["2"]={["chance"]=33,["native"]="EFFECT_DEFENSE_DOWN_HIT",["semantic"]=69},["3"]={["chance"]=33,["native"]=69,["semantic"]=69}}, + ["DEFENSE_UP1_EFFECT"] = {["1"]={["native"]="DEFENSE_UP1_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_DEFENSE_UP",["semantic"]=11},["3"]={["chance"]=0,["native"]=11,["semantic"]=11}}, + ["DEFENSE_UP2_EFFECT"] = {["1"]={["native"]="DEFENSE_UP2_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_DEFENSE_UP_2",["semantic"]=51},["3"]={["chance"]=0,["native"]=51,["semantic"]=51}}, + ["DESTINY_BOND"] = {["3"]={["native"]=98,["semantic"]=98}}, + ["DISABLE_EFFECT"] = {["1"]={["native"]="DISABLE_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_DISABLE",["semantic"]=86},["3"]={["chance"]=0,["native"]=86,["semantic"]=86}}, + ["DOUBLE_EDGE"] = {["3"]={["native"]=198,["semantic"]=198}}, + ["DRAGON_DANCE"] = {["3"]={["native"]=212,["semantic"]=212}}, + ["DRAIN_HP_EFFECT"] = {["1"]={["native"]="DRAIN_HP_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_LEECH_HIT",["semantic"]=3},["3"]={["chance"]=0,["native"]=3,["semantic"]=3}}, + ["DREAM_EATER_EFFECT"] = {["1"]={["native"]="DREAM_EATER_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_DREAM_EATER",["semantic"]=8},["3"]={["chance"]=0,["native"]=8,["semantic"]=8}}, + ["EARTHQUAKE"] = {["3"]={["native"]=147,["semantic"]=147}}, + ["EFFECT_ACCURACY_DOWN_HIT"] = {["2"]={["native"]="EFFECT_ACCURACY_DOWN_HIT",["semantic"]=73},["3"]={["native"]=73,["semantic"]=73}}, + ["EFFECT_ALL_UP_HIT"] = {["2"]={["native"]="EFFECT_ALL_UP_HIT",["semantic"]=140},["3"]={["native"]=140,["semantic"]=140}}, + ["EFFECT_ATTACK_UP_HIT"] = {["2"]={["native"]="EFFECT_ATTACK_UP_HIT",["semantic"]=139},["3"]={["native"]=139,["semantic"]=139}}, + ["EFFECT_ATTRACT"] = {["2"]={["native"]="EFFECT_ATTRACT",["semantic"]=120},["3"]={["native"]=120,["semantic"]=120}}, + ["EFFECT_BATON_PASS"] = {["2"]={["native"]="EFFECT_BATON_PASS",["semantic"]=127},["3"]={["native"]=127,["semantic"]=127}}, + ["EFFECT_BEAT_UP"] = {["2"]={["native"]="EFFECT_BEAT_UP",["semantic"]=154},["3"]={["native"]=154,["semantic"]=154}}, + ["EFFECT_BELLY_DRUM"] = {["2"]={["native"]="EFFECT_BELLY_DRUM",["semantic"]=142},["3"]={["native"]=142,["semantic"]=142}}, + ["EFFECT_CONVERSION2"] = {["3"]={["native"]=93,["semantic"]=93}}, + ["EFFECT_CURSE"] = {["2"]={["native"]="EFFECT_CURSE",["semantic"]=109},["3"]={["native"]=109,["semantic"]=109}}, + ["EFFECT_DEFENSE_CURL"] = {["2"]={["native"]="EFFECT_DEFENSE_CURL",["semantic"]=156},["3"]={["native"]=156,["semantic"]=156}}, + ["EFFECT_DEFENSE_UP_HIT"] = {["2"]={["native"]="EFFECT_DEFENSE_UP_HIT",["semantic"]=138},["3"]={["native"]=138,["semantic"]=138}}, + ["EFFECT_DESTINY_BOND"] = {["3"]={["native"]=98,["semantic"]=98}}, + ["EFFECT_EARTHQUAKE"] = {["3"]={["native"]=147,["semantic"]=147}}, + ["EFFECT_ENCORE"] = {["2"]={["native"]="EFFECT_ENCORE",["semantic"]=90},["3"]={["native"]=90,["semantic"]=90}}, + ["EFFECT_ENDURE"] = {["2"]={["native"]="EFFECT_ENDURE",["semantic"]=116},["3"]={["native"]=116,["semantic"]=116}}, + ["EFFECT_FAKE_OUT"] = {["3"]={["native"]=158,["semantic"]=158}}, + ["EFFECT_FALSE_SWIPE"] = {["2"]={["native"]="EFFECT_FALSE_SWIPE",["semantic"]=101},["3"]={["native"]=101,["semantic"]=101}}, + ["EFFECT_FLAME_WHEEL"] = {["3"]={["native"]=125,["semantic"]=125}}, + ["EFFECT_FORESIGHT"] = {["2"]={["native"]="EFFECT_FORESIGHT",["semantic"]=113},["3"]={["native"]=113,["semantic"]=113}}, + ["EFFECT_FRUSTRATION"] = {["2"]={["native"]="EFFECT_FRUSTRATION",["semantic"]=123},["3"]={["native"]=123,["semantic"]=123}}, + ["EFFECT_FURY_CUTTER"] = {["2"]={["native"]="EFFECT_FURY_CUTTER",["semantic"]=119},["3"]={["native"]=119,["semantic"]=119}}, + ["EFFECT_FUTURE_SIGHT"] = {["2"]={["native"]="EFFECT_FUTURE_SIGHT",["semantic"]=148},["3"]={["native"]=148,["semantic"]=148}}, + ["EFFECT_GUST"] = {["3"]={["native"]=149,["semantic"]=149}}, + ["EFFECT_HEAL_BELL"] = {["3"]={["native"]=102,["semantic"]=102}}, + ["EFFECT_LOCK_ON"] = {["2"]={["native"]="EFFECT_LOCK_ON",["semantic"]=94},["3"]={["native"]=94,["semantic"]=94}}, + ["EFFECT_MAGNITUDE"] = {["2"]={["native"]="EFFECT_MAGNITUDE",["semantic"]=126},["3"]={["native"]=126,["semantic"]=126}}, + ["EFFECT_MEAN_LOOK"] = {["2"]={["native"]="EFFECT_MEAN_LOOK",["semantic"]=106},["3"]={["native"]=106,["semantic"]=106}}, + ["EFFECT_MIRROR_COAT"] = {["2"]={["native"]="EFFECT_MIRROR_COAT",["semantic"]=144},["3"]={["native"]=144,["semantic"]=144}}, + ["EFFECT_MOONLIGHT"] = {["2"]={["native"]="EFFECT_MOONLIGHT",["semantic"]=134},["3"]={["native"]=134,["semantic"]=134}}, + ["EFFECT_MORNING_SUN"] = {["2"]={["native"]="EFFECT_MORNING_SUN",["semantic"]=132},["3"]={["native"]=132,["semantic"]=132}}, + ["EFFECT_NIGHTMARE"] = {["2"]={["native"]="EFFECT_NIGHTMARE",["semantic"]=107},["3"]={["native"]=107,["semantic"]=107}}, + ["EFFECT_PAIN_SPLIT"] = {["3"]={["native"]=91,["semantic"]=91}}, + ["EFFECT_PERISH_SONG"] = {["2"]={["native"]="EFFECT_PERISH_SONG",["semantic"]=114},["3"]={["native"]=114,["semantic"]=114}}, + ["EFFECT_PRESENT"] = {["3"]={["native"]=122,["semantic"]=122}}, + ["EFFECT_PRIORITY_HIT"] = {["2"]={["native"]="EFFECT_PRIORITY_HIT",["semantic"]=103}}, + ["EFFECT_PROTECT"] = {["2"]={["native"]="EFFECT_PROTECT",["semantic"]=111},["3"]={["native"]=111,["semantic"]=111}}, + ["EFFECT_PSYCH_UP"] = {["2"]={["native"]="EFFECT_PSYCH_UP",["semantic"]=143},["3"]={["native"]=143,["semantic"]=143}}, + ["EFFECT_PURSUIT"] = {["3"]={["native"]=128,["semantic"]=128}}, + ["EFFECT_RAIN_DANCE"] = {["2"]={["native"]="EFFECT_RAIN_DANCE",["semantic"]=136},["3"]={["native"]=136,["semantic"]=136}}, + ["EFFECT_RAPID_SPIN"] = {["3"]={["native"]=129,["semantic"]=129}}, + ["EFFECT_RETURN"] = {["2"]={["native"]="EFFECT_RETURN",["semantic"]=121},["3"]={["native"]=121,["semantic"]=121}}, + ["EFFECT_REVERSAL"] = {["3"]={["native"]=99,["semantic"]=99}}, + ["EFFECT_ROLLOUT"] = {["2"]={["native"]="EFFECT_ROLLOUT",["semantic"]=117},["3"]={["native"]=117,["semantic"]=117}}, + ["EFFECT_SACRED_FIRE"] = {["2"]={["native"]="EFFECT_SACRED_FIRE",["semantic"]=125},["3"]={["native"]=125,["semantic"]=125}}, + ["EFFECT_SAFEGUARD"] = {["2"]={["native"]="EFFECT_SAFEGUARD",["semantic"]=124},["3"]={["native"]=124,["semantic"]=124}}, + ["EFFECT_SANDSTORM"] = {["2"]={["native"]="EFFECT_SANDSTORM",["semantic"]=115},["3"]={["native"]=115,["semantic"]=115}}, + ["EFFECT_SKETCH"] = {["2"]={["native"]="EFFECT_SKETCH",["semantic"]=95},["3"]={["native"]=95,["semantic"]=95}}, + ["EFFECT_SLEEP_TALK"] = {["2"]={["native"]="EFFECT_SLEEP_TALK",["semantic"]=97},["3"]={["native"]=97,["semantic"]=97}}, + ["EFFECT_SNORE"] = {["2"]={["native"]="EFFECT_SNORE",["semantic"]=92},["3"]={["native"]=92,["semantic"]=92}}, + ["EFFECT_SPIKES"] = {["2"]={["native"]="EFFECT_SPIKES",["semantic"]=112},["3"]={["native"]=112,["semantic"]=112}}, + ["EFFECT_SPITE"] = {["2"]={["native"]="EFFECT_SPITE",["semantic"]=100},["3"]={["native"]=100,["semantic"]=100}}, + ["EFFECT_SP_ATK_DOWN_HIT"] = {["3"]={["native"]=71,["semantic"]=71}}, + ["EFFECT_SP_ATK_UP_2"] = {["3"]={["native"]=53,["semantic"]=53}}, + ["EFFECT_STOMP"] = {["3"]={["native"]=150,["semantic"]=150}}, + ["EFFECT_SUNNY_DAY"] = {["2"]={["native"]="EFFECT_SUNNY_DAY",["semantic"]=137},["3"]={["native"]=137,["semantic"]=137}}, + ["EFFECT_SWAGGER"] = {["3"]={["native"]=118,["semantic"]=118}}, + ["EFFECT_SYNTHESIS"] = {["2"]={["native"]="EFFECT_SYNTHESIS",["semantic"]=133},["3"]={["native"]=133,["semantic"]=133}}, + ["EFFECT_THIEF"] = {["2"]={["native"]="EFFECT_THIEF",["semantic"]=105},["3"]={["native"]=105,["semantic"]=105}}, + ["EFFECT_THUNDER"] = {["3"]={["native"]=152,["semantic"]=152}}, + ["EFFECT_TRIPLE_KICK"] = {["2"]={["native"]="EFFECT_TRIPLE_KICK",["semantic"]=104},["3"]={["native"]=104,["semantic"]=104}}, + ["EFFECT_TRI_ATTACK"] = {["3"]={["native"]=36,["semantic"]=36}}, + ["EFFECT_TWISTER"] = {["3"]={["native"]=146,["semantic"]=146}}, + ["ENDEAVOR"] = {["3"]={["native"]=189,["semantic"]=189}}, + ["ERUPTION"] = {["3"]={["native"]=190,["semantic"]=190}}, + ["EVASION_DOWN1_EFFECT"] = {["2"]={["chance"]=0,["native"]="EFFECT_EVASION_DOWN",["semantic"]=24},["3"]={["chance"]=0,["native"]=24,["semantic"]=24}}, + ["EVASION_UP1_EFFECT"] = {["1"]={["native"]="EVASION_UP1_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_EVASION_UP",["semantic"]=16},["3"]={["chance"]=0,["native"]=16,["semantic"]=16}}, + ["EXPLODE_EFFECT"] = {["1"]={["native"]="EXPLODE_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_SELFDESTRUCT",["semantic"]=7},["3"]={["chance"]=0,["native"]=7,["semantic"]=7}}, + ["FACADE"] = {["3"]={["native"]=169,["semantic"]=169}}, + ["FAKE_OUT"] = {["3"]={["native"]=158,["semantic"]=158}}, + ["FLAIL"] = {["3"]={["native"]=99,["semantic"]=99}}, + ["FLATTER"] = {["3"]={["native"]=166,["semantic"]=166}}, + ["FLINCH_MINIMIZE_HIT"] = {["3"]={["native"]=150,["semantic"]=150}}, + ["FLINCH_SIDE_EFFECT1"] = {["1"]={["native"]="FLINCH_SIDE_EFFECT1"},["2"]={["chance"]=10,["native"]="EFFECT_FLINCH_HIT",["semantic"]=31},["3"]={["chance"]=10,["native"]=31,["semantic"]=31}}, + ["FLINCH_SIDE_EFFECT2"] = {["1"]={["native"]="FLINCH_SIDE_EFFECT2"},["2"]={["chance"]=30,["native"]="EFFECT_FLINCH_HIT",["semantic"]=31},["3"]={["chance"]=30,["native"]=31,["semantic"]=31}}, + ["FLY_EFFECT"] = {["1"]={["native"]="FLY_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_FLY",["semantic"]=155},["3"]={["chance"]=0,["native"]=155,["semantic"]=155}}, + ["FOCUS_ENERGY_EFFECT"] = {["1"]={["native"]="FOCUS_ENERGY_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_FOCUS_ENERGY",["semantic"]=47},["3"]={["chance"]=0,["native"]=47,["semantic"]=47}}, + ["FOCUS_PUNCH"] = {["3"]={["native"]=170,["semantic"]=170}}, + ["FOLLOW_ME"] = {["3"]={["native"]=172,["semantic"]=172}}, + ["FREEZE_SIDE_EFFECT1"] = {["1"]={["native"]="FREEZE_SIDE_EFFECT1"},["2"]={["chance"]=10,["native"]="EFFECT_FREEZE_HIT",["semantic"]=5},["3"]={["chance"]=10,["native"]=5,["semantic"]=5}}, + ["FREEZE_SIDE_EFFECT2"] = {["2"]={["chance"]=30,["native"]="EFFECT_FREEZE_HIT",["semantic"]=5},["3"]={["chance"]=30,["native"]=5,["semantic"]=5}}, + ["GRUDGE"] = {["3"]={["native"]=194,["semantic"]=194}}, + ["GUST"] = {["3"]={["native"]=149,["semantic"]=149}}, + ["HAIL"] = {["3"]={["native"]=164,["semantic"]=164}}, + ["HAZE_EFFECT"] = {["1"]={["native"]="HAZE_EFFECT"},["3"]={["chance"]=0,["native"]=25,["semantic"]=25}}, + ["HEAL_BELL"] = {["3"]={["native"]=102,["semantic"]=102}}, + ["HEAL_EFFECT"] = {["1"]={["native"]="HEAL_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_HEAL",["semantic"]=32},["3"]={["chance"]=0,["native"]=32,["semantic"]=32}}, + ["HELPING_HAND"] = {["3"]={["native"]=176,["semantic"]=176}}, + ["HIT"] = {["1"]={["native"]="NO_ADDITIONAL_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_NORMAL_HIT",["semantic"]=0},["3"]={["chance"]=0,["native"]=0,["semantic"]=0}}, + ["HYPER_BEAM_EFFECT"] = {["1"]={["native"]="HYPER_BEAM_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_HYPER_BEAM",["semantic"]=80},["3"]={["chance"]=0,["native"]=80,["semantic"]=80}}, + ["IMPRISON"] = {["3"]={["native"]=192,["semantic"]=192}}, + ["INGRAIN"] = {["3"]={["native"]=181,["semantic"]=181}}, + ["JUMP_KICK_EFFECT"] = {["1"]={["native"]="JUMP_KICK_EFFECT"},["3"]={["chance"]=0,["native"]=45,["semantic"]=45}}, + ["KNOCK_OFF"] = {["3"]={["native"]=188,["semantic"]=188}}, + ["LEECH_SEED_EFFECT"] = {["1"]={["native"]="LEECH_SEED_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_LEECH_SEED",["semantic"]=84},["3"]={["chance"]=0,["native"]=84,["semantic"]=84}}, + ["LIGHT_SCREEN_EFFECT"] = {["1"]={["native"]="LIGHT_SCREEN_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_LIGHT_SCREEN",["semantic"]=35},["3"]={["chance"]=0,["native"]=35,["semantic"]=35}}, + ["LOW_KICK"] = {["3"]={["native"]=196,["semantic"]=196}}, + ["MAGIC_COAT"] = {["3"]={["native"]=183,["semantic"]=183}}, + ["MEMENTO"] = {["3"]={["native"]=168,["semantic"]=168}}, + ["METRONOME_EFFECT"] = {["1"]={["native"]="METRONOME_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_METRONOME",["semantic"]=83},["3"]={["chance"]=0,["native"]=83,["semantic"]=83}}, + ["MIMIC_EFFECT"] = {["1"]={["native"]="MIMIC_EFFECT"},["3"]={["chance"]=0,["native"]=82,["semantic"]=82}}, + ["MINIMIZE"] = {["3"]={["native"]=108,["semantic"]=108}}, + ["MIRROR_MOVE_EFFECT"] = {["1"]={["native"]="MIRROR_MOVE_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_MIRROR_MOVE",["semantic"]=9},["3"]={["chance"]=0,["native"]=9,["semantic"]=9}}, + ["MIST_EFFECT"] = {["1"]={["native"]="MIST_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_MIST",["semantic"]=46},["3"]={["chance"]=0,["native"]=46,["semantic"]=46}}, + ["MUD_SPORT"] = {["3"]={["native"]=201,["semantic"]=201}}, + ["NATURE_POWER"] = {["3"]={["native"]=173,["semantic"]=173}}, + ["NO_ADDITIONAL_EFFECT"] = {["1"]={["native"]="NO_ADDITIONAL_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_NORMAL_HIT",["semantic"]=0},["3"]={["chance"]=0,["native"]=0,["semantic"]=0}}, + ["OHKO_EFFECT"] = {["1"]={["native"]="OHKO_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_OHKO",["semantic"]=38},["3"]={["chance"]=0,["native"]=38,["semantic"]=38}}, + ["OVERHEAT"] = {["3"]={["native"]=204,["semantic"]=204}}, + ["PAIN_SPLIT"] = {["3"]={["native"]=91,["semantic"]=91}}, + ["PARALYZE_EFFECT"] = {["1"]={["native"]="PARALYZE_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_PARALYZE",["semantic"]=67},["3"]={["chance"]=0,["native"]=67,["semantic"]=67}}, + ["PARALYZE_SIDE_EFFECT1"] = {["1"]={["native"]="PARALYZE_SIDE_EFFECT1"},["2"]={["chance"]=10,["native"]="EFFECT_PARALYZE_HIT",["semantic"]=6},["3"]={["chance"]=10,["native"]=6,["semantic"]=6}}, + ["PARALYZE_SIDE_EFFECT2"] = {["1"]={["native"]="PARALYZE_SIDE_EFFECT2"},["2"]={["chance"]=30,["native"]="EFFECT_PARALYZE_HIT",["semantic"]=6},["3"]={["chance"]=30,["native"]=6,["semantic"]=6}}, + ["PAY_DAY_EFFECT"] = {["1"]={["native"]="PAY_DAY_EFFECT"},["2"]={["chance"]=100,["native"]="EFFECT_PAY_DAY",["semantic"]=34},["3"]={["chance"]=100,["native"]=34,["semantic"]=34}}, + ["POISON_EFFECT"] = {["1"]={["native"]="POISON_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_POISON",["semantic"]=66},["3"]={["chance"]=0,["native"]=66,["semantic"]=66}}, + ["POISON_FANG"] = {["3"]={["native"]=202,["semantic"]=202}}, + ["POISON_SIDE_EFFECT1"] = {["1"]={["native"]="POISON_SIDE_EFFECT1"},["2"]={["chance"]=20,["native"]="EFFECT_POISON_HIT",["semantic"]=2},["3"]={["chance"]=20,["native"]=2,["semantic"]=2}}, + ["POISON_SIDE_EFFECT2"] = {["1"]={["native"]="POISON_SIDE_EFFECT2"},["2"]={["chance"]=40,["native"]="EFFECT_POISON_HIT",["semantic"]=2},["3"]={["chance"]=40,["native"]=2,["semantic"]=2}}, + ["POISON_TAIL"] = {["3"]={["native"]=209,["semantic"]=209}}, + ["PRESENT"] = {["3"]={["native"]=122,["semantic"]=122}}, + ["PURSUIT"] = {["3"]={["native"]=128,["semantic"]=128}}, + ["RAGE_EFFECT"] = {["1"]={["native"]="RAGE_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_RAGE",["semantic"]=81},["3"]={["chance"]=0,["native"]=81,["semantic"]=81}}, + ["RAPID_SPIN"] = {["3"]={["native"]=129,["semantic"]=129}}, + ["RECOIL_EFFECT"] = {["1"]={["native"]="RECOIL_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_RECOIL_HIT",["semantic"]=48},["3"]={["chance"]=0,["native"]=48,["semantic"]=48}}, + ["RECYCLE"] = {["3"]={["native"]=184,["semantic"]=184}}, + ["REFLECT_EFFECT"] = {["1"]={["native"]="REFLECT_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_REFLECT",["semantic"]=65},["3"]={["chance"]=0,["native"]=65,["semantic"]=65}}, + ["REFRESH"] = {["3"]={["native"]=193,["semantic"]=193}}, + ["REVENGE"] = {["3"]={["native"]=185,["semantic"]=185}}, + ["ROLE_PLAY"] = {["3"]={["native"]=178,["semantic"]=178}}, + ["SECRET_POWER"] = {["3"]={["native"]=197,["semantic"]=197}}, + ["SKILL_SWAP"] = {["3"]={["native"]=191,["semantic"]=191}}, + ["SKY_UPPERCUT"] = {["3"]={["native"]=207,["semantic"]=207}}, + ["SLEEP"] = {["1"]={["native"]="SLEEP_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_SLEEP",["semantic"]=1},["3"]={["chance"]=0,["native"]=1,["semantic"]=1}}, + ["SLEEP_EFFECT"] = {["1"]={["native"]="SLEEP_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_SLEEP",["semantic"]=1},["3"]={["chance"]=0,["native"]=1,["semantic"]=1}}, + ["SMELLINGSALT"] = {["3"]={["native"]=171,["semantic"]=171}}, + ["SNATCH"] = {["3"]={["native"]=195,["semantic"]=195}}, + ["SOFTBOILED"] = {["3"]={["native"]=157,["semantic"]=157}}, + ["SPECIAL_ATTACK_DOWN_HIT"] = {["3"]={["native"]=71,["semantic"]=71}}, + ["SPECIAL_ATTACK_UP_2"] = {["3"]={["native"]=53,["semantic"]=53}}, + ["SPECIAL_DAMAGE_EFFECT"] = {["1"]={["native"]="SPECIAL_DAMAGE_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_LEVEL_DAMAGE",["semantic"]=87},["3"]={["chance"]=0,["native"]=87,["semantic"]=87}}, + ["SPECIAL_DOWN2_EFFECT"] = {["3"]={["chance"]=0,["native"]=62,["semantic"]=62}}, + ["SPECIAL_DOWN_SIDE_EFFECT"] = {["1"]={["native"]="SPECIAL_DOWN_SIDE_EFFECT"},["2"]={["chance"]=33,["native"]="EFFECT_SP_DEF_DOWN_HIT",["semantic"]=72},["3"]={["chance"]=33,["native"]=72,["semantic"]=72}}, + ["SPECIAL_UP1_EFFECT"] = {["1"]={["native"]="SPECIAL_UP1_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_SP_ATK_UP",["semantic"]=13},["3"]={["chance"]=0,["native"]=13,["semantic"]=13}}, + ["SPECIAL_UP2_EFFECT"] = {["1"]={["native"]="SPECIAL_UP2_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_SP_DEF_UP_2",["semantic"]=54},["3"]={["chance"]=0,["native"]=54,["semantic"]=54}}, + ["SPEED_DOWN1_EFFECT"] = {["1"]={["native"]="SPEED_DOWN1_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_SPEED_DOWN",["semantic"]=20},["3"]={["chance"]=0,["native"]=20,["semantic"]=20}}, + ["SPEED_DOWN2_EFFECT"] = {["2"]={["chance"]=0,["native"]="EFFECT_SPEED_DOWN_2",["semantic"]=60},["3"]={["chance"]=0,["native"]=60,["semantic"]=60}}, + ["SPEED_DOWN_SIDE_EFFECT"] = {["1"]={["native"]="SPEED_DOWN_SIDE_EFFECT"},["2"]={["chance"]=33,["native"]="EFFECT_SPEED_DOWN_HIT",["semantic"]=70},["3"]={["chance"]=33,["native"]=70,["semantic"]=70}}, + ["SPEED_UP2_EFFECT"] = {["1"]={["native"]="SPEED_UP2_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_SPEED_UP_2",["semantic"]=52},["3"]={["chance"]=0,["native"]=52,["semantic"]=52}}, + ["SPIT_UP"] = {["3"]={["native"]=161,["semantic"]=161}}, + ["SPLASH_EFFECT"] = {["1"]={["native"]="SPLASH_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_SPLASH",["semantic"]=85},["3"]={["chance"]=0,["native"]=85,["semantic"]=85}}, + ["STOCKPILE"] = {["3"]={["native"]=160,["semantic"]=160}}, + ["SUBSTITUTE_EFFECT"] = {["1"]={["native"]="SUBSTITUTE_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_SUBSTITUTE",["semantic"]=79},["3"]={["chance"]=0,["native"]=79,["semantic"]=79}}, + ["SUPERPOWER"] = {["3"]={["native"]=182,["semantic"]=182}}, + ["SUPER_FANG_EFFECT"] = {["1"]={["native"]="SUPER_FANG_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_SUPER_FANG",["semantic"]=40},["3"]={["chance"]=0,["native"]=40,["semantic"]=40}}, + ["SWAGGER"] = {["3"]={["native"]=118,["semantic"]=118}}, + ["SWALLOW"] = {["3"]={["native"]=162,["semantic"]=162}}, + ["SWIFT_EFFECT"] = {["1"]={["native"]="SWIFT_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_ALWAYS_HIT",["semantic"]=17},["3"]={["chance"]=0,["native"]=17,["semantic"]=17}}, + ["SWITCH_AND_TELEPORT_EFFECT"] = {["1"]={["native"]="SWITCH_AND_TELEPORT_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_FORCE_SWITCH",["semantic"]=28},["3"]={["chance"]=0,["native"]=28,["semantic"]=28}}, + ["TAUNT"] = {["3"]={["native"]=175,["semantic"]=175}}, + ["TEETER_DANCE"] = {["3"]={["native"]=199,["semantic"]=199}}, + ["THRASH_PETAL_DANCE_EFFECT"] = {["1"]={["native"]="THRASH_PETAL_DANCE_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_RAMPAGE",["semantic"]=27},["3"]={["chance"]=0,["native"]=27,["semantic"]=27}}, + ["THUNDER"] = {["3"]={["native"]=152,["semantic"]=152}}, + ["TICKLE"] = {["3"]={["native"]=205,["semantic"]=205}}, + ["TORMENT"] = {["3"]={["native"]=165,["semantic"]=165}}, + ["TRANSFORM_EFFECT"] = {["1"]={["native"]="TRANSFORM_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_TRANSFORM",["semantic"]=57},["3"]={["chance"]=0,["native"]=57,["semantic"]=57}}, + ["TRAPPING_EFFECT"] = {["1"]={["native"]="TRAPPING_EFFECT"},["2"]={["chance"]=100,["native"]="EFFECT_TRAP_TARGET",["semantic"]=42},["3"]={["chance"]=100,["native"]=42,["semantic"]=42}}, + ["TRICK"] = {["3"]={["native"]=177,["semantic"]=177}}, + ["TRI_ATTACK"] = {["3"]={["native"]=36,["semantic"]=36}}, + ["TWINEEDLE_EFFECT"] = {["1"]={["native"]="TWINEEDLE_EFFECT"},["3"]={["chance"]=20,["native"]=77,["semantic"]=77}}, + ["TWISTER"] = {["3"]={["native"]=146,["semantic"]=146}}, + ["TWO_TO_FIVE_ATTACKS_EFFECT"] = {["1"]={["native"]="TWO_TO_FIVE_ATTACKS_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_MULTI_HIT",["semantic"]=29},["3"]={["chance"]=0,["native"]=29,["semantic"]=29}}, + ["UPROAR"] = {["3"]={["native"]=159,["semantic"]=159}}, + ["VITAL_THROW"] = {["3"]={["native"]=78,["semantic"]=78}}, + ["WATER_SPORT"] = {["3"]={["native"]=210,["semantic"]=210}}, + ["WEATHER_BALL"] = {["3"]={["native"]=203,["semantic"]=203}}, + ["WISH"] = {["3"]={["native"]=179,["semantic"]=179}}, + ["YAWN"] = {["3"]={["native"]=187,["semantic"]=187}}, + }, + ["hostMapSha256"] = "640d77b2a86740d18458262bdf424070ad9c52164365dbdb4879f3af2ba038ee", + ["native3"] = { + ["0"] = {"NO_ADDITIONAL_EFFECT"}, + ["1"] = {"SLEEP_EFFECT"}, + ["10"] = {"ATTACK_UP1_EFFECT"}, + ["100"] = {"SPITE_EFFECT"}, + ["101"] = {"FALSE_SWIPE_EFFECT"}, + ["102"] = {"HEAL_BELL_EFFECT"}, + ["103"] = {"NO_ADDITIONAL_EFFECT"}, + ["104"] = {"TRIPLE_KICK_EFFECT"}, + ["105"] = {"THIEF_EFFECT"}, + ["106"] = {"MEAN_LOOK_EFFECT"}, + ["107"] = {"NIGHTMARE_EFFECT"}, + ["108"] = {"EVASION_UP1_EFFECT"}, + ["109"] = {"CURSE_EFFECT"}, + ["11"] = {"DEFENSE_UP1_EFFECT"}, + ["111"] = {"PROTECT_EFFECT"}, + ["112"] = {"SPIKES_EFFECT"}, + ["113"] = {"FORESIGHT_EFFECT"}, + ["114"] = {"PERISH_SONG_EFFECT"}, + ["115"] = {"SANDSTORM_EFFECT"}, + ["116"] = {"ENDURE_EFFECT"}, + ["117"] = {"ROLLOUT_EFFECT"}, + ["118"] = {"SWAGGER_EFFECT"}, + ["119"] = {"FURY_CUTTER_EFFECT"}, + ["120"] = {"ATTRACT_EFFECT"}, + ["121"] = {"RETURN_EFFECT"}, + ["122"] = {"PRESENT_EFFECT"}, + ["123"] = {"FRUSTRATION_EFFECT"}, + ["124"] = {"SAFEGUARD_EFFECT"}, + ["125"] = {"BURN_SIDE_EFFECT1","BURN_SIDE_EFFECT2"}, + ["126"] = {"MAGNITUDE_EFFECT"}, + ["127"] = {"BATON_PASS_EFFECT"}, + ["128"] = {"PURSUIT_EFFECT"}, + ["129"] = {"RAPID_SPIN_EFFECT"}, + ["13"] = {"SP_ATK_UP1_EFFECT"}, + ["130"] = {"SPECIAL_DAMAGE_EFFECT"}, + ["132"] = {"MORNING_SUN_EFFECT"}, + ["133"] = {"SYNTHESIS_EFFECT"}, + ["134"] = {"MOONLIGHT_EFFECT"}, + ["135"] = {"HIDDEN_POWER_EFFECT"}, + ["136"] = {"RAIN_DANCE_EFFECT"}, + ["137"] = {"SUNNY_DAY_EFFECT"}, + ["138"] = {"DEFENSE_UP_HIT_EFFECT"}, + ["139"] = {"ATTACK_UP_HIT_EFFECT"}, + ["140"] = {"ALL_UP_HIT_EFFECT"}, + ["142"] = {"BELLY_DRUM_EFFECT"}, + ["143"] = {"PSYCH_UP_EFFECT"}, + ["144"] = {"MIRROR_COAT_EFFECT"}, + ["145"] = {"CHARGE_EFFECT"}, + ["146"] = {"FLINCH_SIDE_EFFECT1"}, + ["147"] = {"EARTHQUAKE_EFFECT"}, + ["148"] = {"FUTURE_SIGHT_EFFECT"}, + ["149"] = {"GUST_EFFECT"}, + ["150"] = {"FLINCH_SIDE_EFFECT2"}, + ["151"] = {"SOLARBEAM_EFFECT"}, + ["152"] = {"THUNDER_EFFECT"}, + ["153"] = {"SWITCH_AND_TELEPORT_EFFECT"}, + ["154"] = {"BEAT_UP_EFFECT"}, + ["155"] = {"FLY_EFFECT"}, + ["156"] = {"DEFENSE_UP1_EFFECT"}, + ["157"] = {"HEAL_EFFECT"}, + ["158"] = {"FAKE_OUT_EFFECT"}, + ["159"] = {"UPROAR_EFFECT"}, + ["16"] = {"EVASION_UP1_EFFECT"}, + ["160"] = {"STOCKPILE_EFFECT"}, + ["161"] = {"SPIT_UP_EFFECT"}, + ["162"] = {"SWALLOW_EFFECT"}, + ["164"] = {"HAIL_EFFECT"}, + ["165"] = {"TORMENT_EFFECT"}, + ["166"] = {"FLATTER_EFFECT"}, + ["167"] = {"WILL_O_WISP_EFFECT"}, + ["168"] = {"MEMENTO_EFFECT"}, + ["169"] = {"FACADE_EFFECT"}, + ["17"] = {"SWIFT_EFFECT"}, + ["170"] = {"FOCUS_PUNCH_EFFECT"}, + ["171"] = {"SMELLINGSALT_EFFECT"}, + ["172"] = {"FOLLOW_ME_EFFECT"}, + ["173"] = {"NATURE_POWER_EFFECT"}, + ["174"] = {"CHARGE_UP_EFFECT"}, + ["175"] = {"TAUNT_EFFECT"}, + ["176"] = {"HELPING_HAND_EFFECT"}, + ["177"] = {"TRICK_EFFECT"}, + ["178"] = {"ROLE_PLAY_EFFECT"}, + ["179"] = {"WISH_EFFECT"}, + ["18"] = {"ATTACK_DOWN1_EFFECT"}, + ["180"] = {"ASSIST_EFFECT"}, + ["181"] = {"INGRAIN_EFFECT"}, + ["182"] = {"SUPERPOWER_EFFECT"}, + ["183"] = {"MAGIC_COAT_EFFECT"}, + ["184"] = {"RECYCLE_EFFECT"}, + ["185"] = {"REVENGE_EFFECT"}, + ["186"] = {"BRICK_BREAK_EFFECT"}, + ["187"] = {"YAWN_EFFECT"}, + ["188"] = {"KNOCK_OFF_EFFECT"}, + ["189"] = {"ENDEAVOR_EFFECT"}, + ["19"] = {"DEFENSE_DOWN1_EFFECT"}, + ["190"] = {"ERUPTION_EFFECT"}, + ["191"] = {"SKILL_SWAP_EFFECT"}, + ["192"] = {"IMPRISON_EFFECT"}, + ["193"] = {"REFRESH_EFFECT"}, + ["194"] = {"GRUDGE_EFFECT"}, + ["195"] = {"SNATCH_EFFECT"}, + ["196"] = {"LOW_KICK_EFFECT"}, + ["197"] = {"SECRET_POWER_EFFECT"}, + ["198"] = {"RECOIL_EFFECT"}, + ["199"] = {"TEETER_DANCE_EFFECT"}, + ["2"] = {"POISON_SIDE_EFFECT2"}, + ["20"] = {"SPEED_DOWN1_EFFECT"}, + ["200"] = {"BLAZE_KICK_EFFECT"}, + ["201"] = {"MUD_SPORT_EFFECT"}, + ["202"] = {"POISON_FANG_EFFECT"}, + ["203"] = {"WEATHER_BALL_EFFECT"}, + ["204"] = {"OVERHEAT_EFFECT"}, + ["205"] = {"TICKLE_EFFECT"}, + ["206"] = {"COSMIC_POWER_EFFECT"}, + ["207"] = {"SKY_UPPERCUT_EFFECT"}, + ["208"] = {"BULK_UP_EFFECT"}, + ["209"] = {"POISON_TAIL_EFFECT"}, + ["210"] = {"WATER_SPORT_EFFECT"}, + ["211"] = {"CALM_MIND_EFFECT"}, + ["212"] = {"DRAGON_DANCE_EFFECT"}, + ["213"] = {"CAMOUFLAGE_EFFECT"}, + ["23"] = {"ACCURACY_DOWN1_EFFECT"}, + ["24"] = {"EVASION_DOWN1_EFFECT"}, + ["25"] = {"HAZE_EFFECT"}, + ["26"] = {"BIDE_EFFECT"}, + ["27"] = {"THRASH_PETAL_DANCE_EFFECT"}, + ["28"] = {"SWITCH_AND_TELEPORT_EFFECT"}, + ["29"] = {"TWO_TO_FIVE_ATTACKS_EFFECT"}, + ["3"] = {"DRAIN_HP_EFFECT"}, + ["30"] = {"CONVERSION_EFFECT"}, + ["31"] = {"FLINCH_SIDE_EFFECT1","FLINCH_SIDE_EFFECT2"}, + ["32"] = {"HEAL_EFFECT"}, + ["33"] = {"POISON_EFFECT"}, + ["34"] = {"PAY_DAY_EFFECT"}, + ["35"] = {"LIGHT_SCREEN_EFFECT"}, + ["36"] = {"TRI_ATTACK_EFFECT"}, + ["37"] = {"HEAL_EFFECT"}, + ["38"] = {"OHKO_EFFECT"}, + ["39"] = {"CHARGE_EFFECT"}, + ["4"] = {"BURN_SIDE_EFFECT1"}, + ["40"] = {"SUPER_FANG_EFFECT"}, + ["41"] = {"SPECIAL_DAMAGE_EFFECT"}, + ["42"] = {"TRAPPING_EFFECT"}, + ["43"] = {"HIGH_CRITICAL_EFFECT"}, + ["44"] = {"ATTACK_TWICE_EFFECT"}, + ["45"] = {"JUMP_KICK_EFFECT"}, + ["46"] = {"MIST_EFFECT"}, + ["47"] = {"FOCUS_ENERGY_EFFECT"}, + ["48"] = {"RECOIL_EFFECT"}, + ["49"] = {"CONFUSION_EFFECT"}, + ["5"] = {"FREEZE_SIDE_EFFECT1"}, + ["50"] = {"ATTACK_UP2_EFFECT"}, + ["51"] = {"DEFENSE_UP2_EFFECT"}, + ["52"] = {"SPEED_UP2_EFFECT"}, + ["53"] = {"SP_ATK_UP2_EFFECT"}, + ["54"] = {"SP_DEF_UP2_EFFECT"}, + ["57"] = {"TRANSFORM_EFFECT"}, + ["58"] = {"ATTACK_DOWN2_EFFECT"}, + ["59"] = {"DEFENSE_DOWN2_EFFECT"}, + ["6"] = {"PARALYZE_SIDE_EFFECT1","PARALYZE_SIDE_EFFECT2"}, + ["60"] = {"SPEED_DOWN2_EFFECT"}, + ["62"] = {"SP_DEF_DOWN2_EFFECT"}, + ["65"] = {"REFLECT_EFFECT"}, + ["66"] = {"POISON_EFFECT"}, + ["67"] = {"PARALYZE_EFFECT"}, + ["68"] = {"ATTACK_DOWN_SIDE_EFFECT"}, + ["69"] = {"DEFENSE_DOWN_SIDE_EFFECT"}, + ["7"] = {"EXPLODE_EFFECT"}, + ["70"] = {"SPEED_DOWN_SIDE_EFFECT"}, + ["71"] = {"SP_ATK_DOWN_HIT_EFFECT"}, + ["72"] = {"SP_DEF_DOWN_SIDE_EFFECT"}, + ["73"] = {"ACCURACY_DOWN_SIDE_EFFECT"}, + ["75"] = {"CHARGE_EFFECT"}, + ["76"] = {"CONFUSION_SIDE_EFFECT"}, + ["77"] = {"TWINEEDLE_EFFECT"}, + ["78"] = {"SWIFT_EFFECT"}, + ["79"] = {"SUBSTITUTE_EFFECT"}, + ["8"] = {"DREAM_EATER_EFFECT"}, + ["80"] = {"HYPER_BEAM_EFFECT"}, + ["81"] = {"RAGE_EFFECT"}, + ["82"] = {"MIMIC_EFFECT"}, + ["83"] = {"METRONOME_EFFECT"}, + ["84"] = {"LEECH_SEED_EFFECT"}, + ["85"] = {"SPLASH_EFFECT"}, + ["86"] = {"DISABLE_EFFECT"}, + ["87"] = {"SPECIAL_DAMAGE_EFFECT"}, + ["88"] = {"SPECIAL_DAMAGE_EFFECT"}, + ["89"] = {"COUNTER_EFFECT"}, + ["9"] = {"MIRROR_MOVE_EFFECT"}, + ["90"] = {"ENCORE_EFFECT"}, + ["91"] = {"PAIN_SPLIT_EFFECT"}, + ["92"] = {"SNORE_EFFECT"}, + ["93"] = {"CONVERSION2_EFFECT"}, + ["94"] = {"LOCK_ON_EFFECT"}, + ["95"] = {"SKETCH_EFFECT"}, + ["97"] = {"SLEEP_TALK_EFFECT"}, + ["98"] = {"DESTINY_BOND_EFFECT"}, + ["99"] = {"REVERSAL_EFFECT"}, + }, + ["read3"] = { + ["0"] = "NO_ADDITIONAL_EFFECT", + ["1"] = "SLEEP_EFFECT", + ["10"] = "ATTACK_UP1_EFFECT", + ["100"] = "EFFECT_SPITE", + ["101"] = "EFFECT_FALSE_SWIPE", + ["102"] = "HEAL_BELL", + ["103"] = "EFFECT_PRIORITY_HIT", + ["104"] = "EFFECT_TRIPLE_KICK", + ["105"] = "EFFECT_THIEF", + ["106"] = "EFFECT_MEAN_LOOK", + ["107"] = "EFFECT_NIGHTMARE", + ["108"] = "MINIMIZE", + ["109"] = "EFFECT_CURSE", + ["11"] = "DEFENSE_UP1_EFFECT", + ["110"] = "native:3:110", + ["111"] = "EFFECT_PROTECT", + ["112"] = "EFFECT_SPIKES", + ["113"] = "EFFECT_FORESIGHT", + ["114"] = "EFFECT_PERISH_SONG", + ["115"] = "EFFECT_SANDSTORM", + ["116"] = "EFFECT_ENDURE", + ["117"] = "EFFECT_ROLLOUT", + ["118"] = "SWAGGER", + ["119"] = "EFFECT_FURY_CUTTER", + ["12"] = "native:3:SPEED_UP", + ["120"] = "EFFECT_ATTRACT", + ["121"] = "EFFECT_RETURN", + ["122"] = "PRESENT", + ["123"] = "EFFECT_FRUSTRATION", + ["124"] = "EFFECT_SAFEGUARD", + ["125"] = "EFFECT_SACRED_FIRE", + ["126"] = "EFFECT_MAGNITUDE", + ["127"] = "EFFECT_BATON_PASS", + ["128"] = "PURSUIT", + ["129"] = "RAPID_SPIN", + ["13"] = "SPECIAL_UP1_EFFECT", + ["130"] = "native:3:SONICBOOM", + ["131"] = "native:3:131", + ["132"] = "EFFECT_MORNING_SUN", + ["133"] = "EFFECT_SYNTHESIS", + ["134"] = "EFFECT_MOONLIGHT", + ["135"] = "native:3:HIDDEN_POWER", + ["136"] = "EFFECT_RAIN_DANCE", + ["137"] = "EFFECT_SUNNY_DAY", + ["138"] = "EFFECT_DEFENSE_UP_HIT", + ["139"] = "EFFECT_ATTACK_UP_HIT", + ["14"] = "native:3:SPECIAL_DEFENSE_UP", + ["140"] = "EFFECT_ALL_UP_HIT", + ["141"] = "native:3:141", + ["142"] = "EFFECT_BELLY_DRUM", + ["143"] = "EFFECT_PSYCH_UP", + ["144"] = "EFFECT_MIRROR_COAT", + ["145"] = "native:3:SKULL_BASH", + ["146"] = "TWISTER", + ["147"] = "EARTHQUAKE", + ["148"] = "EFFECT_FUTURE_SIGHT", + ["149"] = "GUST", + ["15"] = "native:3:ACCURACY_UP", + ["150"] = "FLINCH_MINIMIZE_HIT", + ["151"] = "native:3:SOLAR_BEAM", + ["152"] = "THUNDER", + ["153"] = "native:3:TELEPORT", + ["154"] = "EFFECT_BEAT_UP", + ["155"] = "FLY_EFFECT", + ["156"] = "EFFECT_DEFENSE_CURL", + ["157"] = "SOFTBOILED", + ["158"] = "FAKE_OUT", + ["159"] = "UPROAR", + ["16"] = "EVASION_UP1_EFFECT", + ["160"] = "STOCKPILE", + ["161"] = "SPIT_UP", + ["162"] = "SWALLOW", + ["163"] = "native:3:163", + ["164"] = "HAIL", + ["165"] = "TORMENT", + ["166"] = "FLATTER", + ["167"] = "EFFECT_BURN", + ["168"] = "MEMENTO", + ["169"] = "FACADE", + ["17"] = "SWIFT_EFFECT", + ["170"] = "FOCUS_PUNCH", + ["171"] = "SMELLINGSALT", + ["172"] = "FOLLOW_ME", + ["173"] = "NATURE_POWER", + ["174"] = "CHARGE", + ["175"] = "TAUNT", + ["176"] = "HELPING_HAND", + ["177"] = "TRICK", + ["178"] = "ROLE_PLAY", + ["179"] = "WISH", + ["18"] = "ATTACK_DOWN1_EFFECT", + ["180"] = "ASSIST", + ["181"] = "INGRAIN", + ["182"] = "SUPERPOWER", + ["183"] = "MAGIC_COAT", + ["184"] = "RECYCLE", + ["185"] = "REVENGE", + ["186"] = "BRICK_BREAK", + ["187"] = "YAWN", + ["188"] = "KNOCK_OFF", + ["189"] = "ENDEAVOR", + ["19"] = "DEFENSE_DOWN1_EFFECT", + ["190"] = "ERUPTION", + ["191"] = "SKILL_SWAP", + ["192"] = "IMPRISON", + ["193"] = "REFRESH", + ["194"] = "GRUDGE", + ["195"] = "SNATCH", + ["196"] = "LOW_KICK", + ["197"] = "SECRET_POWER", + ["198"] = "DOUBLE_EDGE", + ["199"] = "TEETER_DANCE", + ["2"] = "POISON_SIDE_EFFECT1", + ["20"] = "SPEED_DOWN1_EFFECT", + ["200"] = "BLAZE_KICK", + ["201"] = "MUD_SPORT", + ["202"] = "POISON_FANG", + ["203"] = "WEATHER_BALL", + ["204"] = "OVERHEAT", + ["205"] = "TICKLE", + ["206"] = "COSMIC_POWER", + ["207"] = "SKY_UPPERCUT", + ["208"] = "BULK_UP", + ["209"] = "POISON_TAIL", + ["21"] = "native:3:SPECIAL_ATTACK_DOWN", + ["210"] = "WATER_SPORT", + ["211"] = "CALM_MIND", + ["212"] = "DRAGON_DANCE", + ["213"] = "CAMOUFLAGE", + ["214"] = "native:3:214", + ["215"] = "native:3:215", + ["216"] = "native:3:216", + ["217"] = "native:3:217", + ["218"] = "native:3:218", + ["219"] = "native:3:219", + ["22"] = "native:3:SPECIAL_DEFENSE_DOWN", + ["220"] = "native:3:220", + ["221"] = "native:3:221", + ["222"] = "native:3:222", + ["223"] = "native:3:223", + ["224"] = "native:3:224", + ["225"] = "native:3:225", + ["226"] = "native:3:226", + ["227"] = "native:3:227", + ["228"] = "native:3:228", + ["229"] = "native:3:229", + ["23"] = "ACCURACY_DOWN1_EFFECT", + ["230"] = "native:3:230", + ["231"] = "native:3:231", + ["232"] = "native:3:232", + ["233"] = "native:3:233", + ["234"] = "native:3:234", + ["235"] = "native:3:235", + ["236"] = "native:3:236", + ["237"] = "native:3:237", + ["238"] = "native:3:238", + ["239"] = "native:3:239", + ["24"] = "EVASION_DOWN1_EFFECT", + ["240"] = "native:3:240", + ["241"] = "native:3:241", + ["242"] = "native:3:242", + ["243"] = "native:3:243", + ["244"] = "native:3:244", + ["245"] = "native:3:245", + ["246"] = "native:3:246", + ["247"] = "native:3:247", + ["248"] = "native:3:248", + ["249"] = "native:3:249", + ["25"] = "HAZE_EFFECT", + ["250"] = "native:3:250", + ["251"] = "native:3:251", + ["252"] = "native:3:252", + ["253"] = "native:3:253", + ["254"] = "native:3:254", + ["255"] = "native:3:255", + ["26"] = "BIDE_EFFECT", + ["27"] = "THRASH_PETAL_DANCE_EFFECT", + ["28"] = "SWITCH_AND_TELEPORT_EFFECT", + ["29"] = "TWO_TO_FIVE_ATTACKS_EFFECT", + ["3"] = "DRAIN_HP_EFFECT", + ["30"] = "CONVERSION_EFFECT", + ["31"] = "FLINCH_SIDE_EFFECT1", + ["32"] = "HEAL_EFFECT", + ["33"] = "native:3:TOXIC", + ["34"] = "PAY_DAY_EFFECT", + ["35"] = "LIGHT_SCREEN_EFFECT", + ["36"] = "TRI_ATTACK", + ["37"] = "native:3:REST", + ["38"] = "OHKO_EFFECT", + ["39"] = "CHARGE_EFFECT", + ["4"] = "BURN_SIDE_EFFECT1", + ["40"] = "SUPER_FANG_EFFECT", + ["41"] = "native:3:DRAGON_RAGE", + ["42"] = "TRAPPING_EFFECT", + ["43"] = "native:3:HIGH_CRITICAL", + ["44"] = "ATTACK_TWICE_EFFECT", + ["45"] = "JUMP_KICK_EFFECT", + ["46"] = "MIST_EFFECT", + ["47"] = "FOCUS_ENERGY_EFFECT", + ["48"] = "RECOIL_EFFECT", + ["49"] = "CONFUSION_EFFECT", + ["5"] = "FREEZE_SIDE_EFFECT1", + ["50"] = "ATTACK_UP2_EFFECT", + ["51"] = "DEFENSE_UP2_EFFECT", + ["52"] = "SPEED_UP2_EFFECT", + ["53"] = "SPECIAL_ATTACK_UP_2", + ["54"] = "SPECIAL_UP2_EFFECT", + ["55"] = "native:3:ACCURACY_UP_2", + ["56"] = "native:3:EVASION_UP_2", + ["57"] = "TRANSFORM_EFFECT", + ["58"] = "ATTACK_DOWN2_EFFECT", + ["59"] = "DEFENSE_DOWN2_EFFECT", + ["6"] = "PARALYZE_SIDE_EFFECT1", + ["60"] = "SPEED_DOWN2_EFFECT", + ["61"] = "native:3:SPECIAL_ATTACK_DOWN_2", + ["62"] = "SPECIAL_DOWN2_EFFECT", + ["63"] = "native:3:ACCURACY_DOWN_2", + ["64"] = "native:3:EVASION_DOWN_2", + ["65"] = "REFLECT_EFFECT", + ["66"] = "POISON_EFFECT", + ["67"] = "PARALYZE_EFFECT", + ["68"] = "ATTACK_DOWN_SIDE_EFFECT", + ["69"] = "DEFENSE_DOWN_SIDE_EFFECT", + ["7"] = "EXPLODE_EFFECT", + ["70"] = "SPEED_DOWN_SIDE_EFFECT", + ["71"] = "SPECIAL_ATTACK_DOWN_HIT", + ["72"] = "SPECIAL_DOWN_SIDE_EFFECT", + ["73"] = "EFFECT_ACCURACY_DOWN_HIT", + ["74"] = "native:3:EVASION_DOWN_HIT", + ["75"] = "native:3:SKY_ATTACK", + ["76"] = "CONFUSION_SIDE_EFFECT", + ["77"] = "TWINEEDLE_EFFECT", + ["78"] = "VITAL_THROW", + ["79"] = "SUBSTITUTE_EFFECT", + ["8"] = "DREAM_EATER_EFFECT", + ["80"] = "HYPER_BEAM_EFFECT", + ["81"] = "RAGE_EFFECT", + ["82"] = "MIMIC_EFFECT", + ["83"] = "METRONOME_EFFECT", + ["84"] = "LEECH_SEED_EFFECT", + ["85"] = "SPLASH_EFFECT", + ["86"] = "DISABLE_EFFECT", + ["87"] = "SPECIAL_DAMAGE_EFFECT", + ["88"] = "native:3:PSYWAVE", + ["89"] = "native:3:COUNTER", + ["9"] = "MIRROR_MOVE_EFFECT", + ["90"] = "EFFECT_ENCORE", + ["91"] = "PAIN_SPLIT", + ["92"] = "EFFECT_SNORE", + ["93"] = "CONVERSION_2", + ["94"] = "EFFECT_LOCK_ON", + ["95"] = "EFFECT_SKETCH", + ["96"] = "native:3:96", + ["97"] = "EFFECT_SLEEP_TALK", + ["98"] = "DESTINY_BOND", + ["99"] = "FLAIL", + }, + ["reference"] = { + ["commit"] = "79ea62f45fb2ea8f65d081a109431e4eb1bbdb36", + ["sha256"] = {["src/battle/g3u/EffectMap1.lua"]="15a66e3c011128a94f5d84689581c78e1f7f737da7c17d60d46b8cdf3ad22cfd",["src/battle/g3u/EffectMap2.lua"]="009ed83f5a76638050110b4304e66bc514b61ded28bbf4e4b75e5d1f448cb708",["src/core/game3/battle/effect_ids.lua"]="00ce6a4196c2b05d56c8b575ec3eb20d8e32d16a99646716f18f08624795f496",["src/mods/UnifiedEffects.lua"]="bde10a80bd8c64a71a279b8a143757a8f26f30848c095ce49b6a085690181676"}, + }, + ["reserved"] = { + ["ABSORB"] = true, + ["ACCURACY_DOWN"] = true, + ["ACCURACY_DOWN1_EFFECT"] = true, + ["ACCURACY_DOWN2_EFFECT"] = true, + ["ACCURACY_DOWN_2"] = true, + ["ACCURACY_DOWN_HIT"] = true, + ["ACCURACY_UP"] = true, + ["ACCURACY_UP1_EFFECT"] = true, + ["ACCURACY_UP2_EFFECT"] = true, + ["ACCURACY_UP_2"] = true, + ["AFTER_HIT"] = true, + ["ALL_STATS_UP_HIT"] = true, + ["ALWAYS_HIT"] = true, + ["ASSIST"] = true, + ["ATTACK_DOWN"] = true, + ["ATTACK_DOWN1_EFFECT"] = true, + ["ATTACK_DOWN2_EFFECT"] = true, + ["ATTACK_DOWN_2"] = true, + ["ATTACK_DOWN_HIT"] = true, + ["ATTACK_DOWN_SIDE_EFFECT"] = true, + ["ATTACK_TWICE_EFFECT"] = true, + ["ATTACK_UP"] = true, + ["ATTACK_UP1_EFFECT"] = true, + ["ATTACK_UP2_EFFECT"] = true, + ["ATTACK_UP_2"] = true, + ["ATTACK_UP_HIT"] = true, + ["ATTRACT"] = true, + ["BATON_PASS"] = true, + ["BEAT_UP"] = true, + ["BELLY_DRUM"] = true, + ["BIDE"] = true, + ["BIDE_EFFECT"] = true, + ["BLAZE_KICK"] = true, + ["BRICK_BREAK"] = true, + ["BULK_UP"] = true, + ["BURN_HIT"] = true, + ["BURN_SIDE_EFFECT1"] = true, + ["BURN_SIDE_EFFECT2"] = true, + ["CALM_MIND"] = true, + ["CAMOUFLAGE"] = true, + ["CHARGE"] = true, + ["CHARGE_EFFECT"] = true, + ["CONFUSE"] = true, + ["CONFUSE_HIT"] = true, + ["CONFUSION_EFFECT"] = true, + ["CONFUSION_SIDE_EFFECT"] = true, + ["CONVERSION"] = true, + ["CONVERSION_2"] = true, + ["CONVERSION_EFFECT"] = true, + ["COSMIC_POWER"] = true, + ["COUNTER"] = true, + ["CURSE"] = true, + ["DEFENSE_CURL"] = true, + ["DEFENSE_DOWN"] = true, + ["DEFENSE_DOWN1_EFFECT"] = true, + ["DEFENSE_DOWN2_EFFECT"] = true, + ["DEFENSE_DOWN_2"] = true, + ["DEFENSE_DOWN_HIT"] = true, + ["DEFENSE_DOWN_SIDE_EFFECT"] = true, + ["DEFENSE_UP"] = true, + ["DEFENSE_UP1_EFFECT"] = true, + ["DEFENSE_UP2_EFFECT"] = true, + ["DEFENSE_UP_2"] = true, + ["DEFENSE_UP_HIT"] = true, + ["DESTINY_BOND"] = true, + ["DISABLE"] = true, + ["DISABLE_EFFECT"] = true, + ["DOUBLE_EDGE"] = true, + ["DOUBLE_HIT"] = true, + ["DRAGON_DANCE"] = true, + ["DRAGON_RAGE"] = true, + ["DRAIN_HP_EFFECT"] = true, + ["DREAM_EATER"] = true, + ["DREAM_EATER_EFFECT"] = true, + ["EARTHQUAKE"] = true, + ["EFFECT_01"] = true, + ["EFFECT_1E"] = true, + ["EFFECT_ACCURACY_DOWN"] = true, + ["EFFECT_ACCURACY_DOWN_2"] = true, + ["EFFECT_ACCURACY_DOWN_HIT"] = true, + ["EFFECT_ACCURACY_UP"] = true, + ["EFFECT_ACCURACY_UP_2"] = true, + ["EFFECT_ALL_UP_HIT"] = true, + ["EFFECT_ALWAYS_HIT"] = true, + ["EFFECT_ATTACK_DOWN"] = true, + ["EFFECT_ATTACK_DOWN_2"] = true, + ["EFFECT_ATTACK_DOWN_HIT"] = true, + ["EFFECT_ATTACK_UP"] = true, + ["EFFECT_ATTACK_UP_2"] = true, + ["EFFECT_ATTACK_UP_HIT"] = true, + ["EFFECT_ATTRACT"] = true, + ["EFFECT_BATON_PASS"] = true, + ["EFFECT_BEAT_UP"] = true, + ["EFFECT_BELLY_DRUM"] = true, + ["EFFECT_BIDE"] = true, + ["EFFECT_BURN_HIT"] = true, + ["EFFECT_CONFUSE"] = true, + ["EFFECT_CONFUSE_HIT"] = true, + ["EFFECT_CONVERSION"] = true, + ["EFFECT_CONVERSION2"] = true, + ["EFFECT_COUNTER"] = true, + ["EFFECT_CURSE"] = true, + ["EFFECT_DEFENSE_CURL"] = true, + ["EFFECT_DEFENSE_DOWN"] = true, + ["EFFECT_DEFENSE_DOWN_2"] = true, + ["EFFECT_DEFENSE_DOWN_HIT"] = true, + ["EFFECT_DEFENSE_UP"] = true, + ["EFFECT_DEFENSE_UP_2"] = true, + ["EFFECT_DEFENSE_UP_HIT"] = true, + ["EFFECT_DEFROST_OPPONENT"] = true, + ["EFFECT_DESTINY_BOND"] = true, + ["EFFECT_DISABLE"] = true, + ["EFFECT_DOUBLE_HIT"] = true, + ["EFFECT_DREAM_EATER"] = true, + ["EFFECT_EARTHQUAKE"] = true, + ["EFFECT_ENCORE"] = true, + ["EFFECT_ENDURE"] = true, + ["EFFECT_EVASION_DOWN"] = true, + ["EFFECT_EVASION_DOWN_2"] = true, + ["EFFECT_EVASION_DOWN_HIT"] = true, + ["EFFECT_EVASION_UP"] = true, + ["EFFECT_EVASION_UP_2"] = true, + ["EFFECT_FAKE_OUT"] = true, + ["EFFECT_FALSE_SWIPE"] = true, + ["EFFECT_FLAME_WHEEL"] = true, + ["EFFECT_FLINCH_HIT"] = true, + ["EFFECT_FLY"] = true, + ["EFFECT_FOCUS_ENERGY"] = true, + ["EFFECT_FORCE_SWITCH"] = true, + ["EFFECT_FORESIGHT"] = true, + ["EFFECT_FREEZE_HIT"] = true, + ["EFFECT_FRUSTRATION"] = true, + ["EFFECT_FURY_CUTTER"] = true, + ["EFFECT_FUTURE_SIGHT"] = true, + ["EFFECT_GUST"] = true, + ["EFFECT_HEAL"] = true, + ["EFFECT_HEAL_BELL"] = true, + ["EFFECT_HIDDEN_POWER"] = true, + ["EFFECT_HYPER_BEAM"] = true, + ["EFFECT_JUMP_KICK"] = true, + ["EFFECT_LEECH_HIT"] = true, + ["EFFECT_LEECH_SEED"] = true, + ["EFFECT_LEVEL_DAMAGE"] = true, + ["EFFECT_LIGHT_SCREEN"] = true, + ["EFFECT_LOCK_ON"] = true, + ["EFFECT_MAGNITUDE"] = true, + ["EFFECT_MEAN_LOOK"] = true, + ["EFFECT_METRONOME"] = true, + ["EFFECT_MIMIC"] = true, + ["EFFECT_MIRROR_COAT"] = true, + ["EFFECT_MIRROR_MOVE"] = true, + ["EFFECT_MIST"] = true, + ["EFFECT_MOONLIGHT"] = true, + ["EFFECT_MORNING_SUN"] = true, + ["EFFECT_MULTI_HIT"] = true, + ["EFFECT_NIGHTMARE"] = true, + ["EFFECT_NORMAL_HIT"] = true, + ["EFFECT_OHKO"] = true, + ["EFFECT_PAIN_SPLIT"] = true, + ["EFFECT_PARALYZE"] = true, + ["EFFECT_PARALYZE_HIT"] = true, + ["EFFECT_PAY_DAY"] = true, + ["EFFECT_PERISH_SONG"] = true, + ["EFFECT_POISON"] = true, + ["EFFECT_POISON_HIT"] = true, + ["EFFECT_POISON_MULTI_HIT"] = true, + ["EFFECT_PRESENT"] = true, + ["EFFECT_PRIORITY_HIT"] = true, + ["EFFECT_PROTECT"] = true, + ["EFFECT_PSYCH_UP"] = true, + ["EFFECT_PSYWAVE"] = true, + ["EFFECT_PURSUIT"] = true, + ["EFFECT_RAGE"] = true, + ["EFFECT_RAIN_DANCE"] = true, + ["EFFECT_RAMPAGE"] = true, + ["EFFECT_RAPID_SPIN"] = true, + ["EFFECT_RAZOR_WIND"] = true, + ["EFFECT_RECOIL_HIT"] = true, + ["EFFECT_REFLECT"] = true, + ["EFFECT_RESET_STATS"] = true, + ["EFFECT_RETURN"] = true, + ["EFFECT_REVERSAL"] = true, + ["EFFECT_ROLLOUT"] = true, + ["EFFECT_SACRED_FIRE"] = true, + ["EFFECT_SAFEGUARD"] = true, + ["EFFECT_SANDSTORM"] = true, + ["EFFECT_SELFDESTRUCT"] = true, + ["EFFECT_SKETCH"] = true, + ["EFFECT_SKULL_BASH"] = true, + ["EFFECT_SKY_ATTACK"] = true, + ["EFFECT_SLEEP"] = true, + ["EFFECT_SLEEP_TALK"] = true, + ["EFFECT_SNORE"] = true, + ["EFFECT_SOLARBEAM"] = true, + ["EFFECT_SPEED_DOWN"] = true, + ["EFFECT_SPEED_DOWN_2"] = true, + ["EFFECT_SPEED_DOWN_HIT"] = true, + ["EFFECT_SPEED_UP"] = true, + ["EFFECT_SPEED_UP_2"] = true, + ["EFFECT_SPIKES"] = true, + ["EFFECT_SPITE"] = true, + ["EFFECT_SPLASH"] = true, + ["EFFECT_SP_ATK_DOWN"] = true, + ["EFFECT_SP_ATK_DOWN_2"] = true, + ["EFFECT_SP_ATK_DOWN_HIT"] = true, + ["EFFECT_SP_ATK_UP"] = true, + ["EFFECT_SP_ATK_UP_2"] = true, + ["EFFECT_SP_DEF_DOWN"] = true, + ["EFFECT_SP_DEF_DOWN_2"] = true, + ["EFFECT_SP_DEF_DOWN_HIT"] = true, + ["EFFECT_SP_DEF_UP"] = true, + ["EFFECT_SP_DEF_UP_2"] = true, + ["EFFECT_STATIC_DAMAGE"] = true, + ["EFFECT_STOMP"] = true, + ["EFFECT_SUBSTITUTE"] = true, + ["EFFECT_SUNNY_DAY"] = true, + ["EFFECT_SUPER_FANG"] = true, + ["EFFECT_SWAGGER"] = true, + ["EFFECT_SYNTHESIS"] = true, + ["EFFECT_TELEPORT"] = true, + ["EFFECT_THIEF"] = true, + ["EFFECT_THUNDER"] = true, + ["EFFECT_TOXIC"] = true, + ["EFFECT_TRANSFORM"] = true, + ["EFFECT_TRAP_TARGET"] = true, + ["EFFECT_TRIPLE_KICK"] = true, + ["EFFECT_TRI_ATTACK"] = true, + ["EFFECT_TWISTER"] = true, + ["EFFECT_UNUSED_25"] = true, + ["EFFECT_UNUSED_2B"] = true, + ["EFFECT_UNUSED_4E"] = true, + ["EFFECT_UNUSED_6E"] = true, + ["EFFECT_UNUSED_82"] = true, + ["EFFECT_UNUSED_83"] = true, + ["ENCORE"] = true, + ["ENDEAVOR"] = true, + ["ENDURE"] = true, + ["ERUPTION"] = true, + ["EVASION_DOWN"] = true, + ["EVASION_DOWN1_EFFECT"] = true, + ["EVASION_DOWN2_EFFECT"] = true, + ["EVASION_DOWN_2"] = true, + ["EVASION_DOWN_HIT"] = true, + ["EVASION_UP"] = true, + ["EVASION_UP1_EFFECT"] = true, + ["EVASION_UP2_EFFECT"] = true, + ["EVASION_UP_2"] = true, + ["EXPLODE_EFFECT"] = true, + ["EXPLOSION"] = true, + ["FACADE"] = true, + ["FAKE_OUT"] = true, + ["FALSE_SWIPE"] = true, + ["FLAIL"] = true, + ["FLATTER"] = true, + ["FLINCH_HIT"] = true, + ["FLINCH_MINIMIZE_HIT"] = true, + ["FLINCH_SIDE_EFFECT1"] = true, + ["FLINCH_SIDE_EFFECT2"] = true, + ["FLY"] = true, + ["FLY_EFFECT"] = true, + ["FOCUS_ENERGY"] = true, + ["FOCUS_ENERGY_EFFECT"] = true, + ["FOCUS_PUNCH"] = true, + ["FOLLOW_ME"] = true, + ["FORESIGHT"] = true, + ["FREEZE_HIT"] = true, + ["FREEZE_SIDE_EFFECT1"] = true, + ["FREEZE_SIDE_EFFECT2"] = true, + ["FRUSTRATION"] = true, + ["FURY_CUTTER"] = true, + ["FUTURE_SIGHT"] = true, + ["GRUDGE"] = true, + ["GUST"] = true, + ["HAIL"] = true, + ["HAZE"] = true, + ["HAZE_EFFECT"] = true, + ["HEAL_BELL"] = true, + ["HEAL_EFFECT"] = true, + ["HELPING_HAND"] = true, + ["HIDDEN_POWER"] = true, + ["HIGH_CRITICAL"] = true, + ["HIT"] = true, + ["HYPER_BEAM_EFFECT"] = true, + ["IMPRISON"] = true, + ["INGRAIN"] = true, + ["JUMP_KICK_EFFECT"] = true, + ["KNOCK_OFF"] = true, + ["LEECH_SEED"] = true, + ["LEECH_SEED_EFFECT"] = true, + ["LEVEL_DAMAGE"] = true, + ["LIGHT_SCREEN"] = true, + ["LIGHT_SCREEN_EFFECT"] = true, + ["LOCK_ON"] = true, + ["LOW_KICK"] = true, + ["MAGIC_COAT"] = true, + ["MAGNITUDE"] = true, + ["MEAN_LOOK"] = true, + ["MEMENTO"] = true, + ["METRONOME"] = true, + ["METRONOME_EFFECT"] = true, + ["MIMIC"] = true, + ["MIMIC_EFFECT"] = true, + ["MINIMIZE"] = true, + ["MIRROR_COAT"] = true, + ["MIRROR_MOVE"] = true, + ["MIRROR_MOVE_EFFECT"] = true, + ["MIST"] = true, + ["MIST_EFFECT"] = true, + ["MOONLIGHT"] = true, + ["MORNING_SUN"] = true, + ["MUD_SPORT"] = true, + ["MULTI_HIT"] = true, + ["NATURE_POWER"] = true, + ["NIGHTMARE"] = true, + ["NO_ADDITIONAL_EFFECT"] = true, + ["OHKO"] = true, + ["OHKO_EFFECT"] = true, + ["OVERHEAT"] = true, + ["PAIN_SPLIT"] = true, + ["PARALYZE"] = true, + ["PARALYZE_EFFECT"] = true, + ["PARALYZE_HIT"] = true, + ["PARALYZE_SIDE_EFFECT1"] = true, + ["PARALYZE_SIDE_EFFECT2"] = true, + ["PAY_DAY"] = true, + ["PAY_DAY_EFFECT"] = true, + ["PERISH_SONG"] = true, + ["POISON"] = true, + ["POISON_EFFECT"] = true, + ["POISON_FANG"] = true, + ["POISON_HIT"] = true, + ["POISON_SIDE_EFFECT1"] = true, + ["POISON_SIDE_EFFECT2"] = true, + ["POISON_TAIL"] = true, + ["PRESENT"] = true, + ["PROTECT"] = true, + ["PSYCH_UP"] = true, + ["PSYWAVE"] = true, + ["PURSUIT"] = true, + ["QUICK_ATTACK"] = true, + ["RAGE"] = true, + ["RAGE_EFFECT"] = true, + ["RAIN_DANCE"] = true, + ["RAMPAGE"] = true, + ["RAPID_SPIN"] = true, + ["RAZOR_WIND"] = true, + ["RECHARGE"] = true, + ["RECOIL"] = true, + ["RECOIL_25"] = true, + ["RECOIL_EFFECT"] = true, + ["RECOIL_IF_MISS"] = true, + ["RECOVER"] = true, + ["RECYCLE"] = true, + ["REFLECT"] = true, + ["REFLECT_EFFECT"] = true, + ["REFRESH"] = true, + ["REST"] = true, + ["RESTORE_HP"] = true, + ["RETURN"] = true, + ["REVENGE"] = true, + ["ROAR"] = true, + ["ROLE_PLAY"] = true, + ["ROLLOUT"] = true, + ["SAFEGUARD"] = true, + ["SANDSTORM"] = true, + ["SECONDARY"] = true, + ["SECRET_POWER"] = true, + ["SECRET_POWER_BY_TERRAIN"] = true, + ["SEMI_INVULNERABLE"] = true, + ["SKETCH"] = true, + ["SKILL_SWAP"] = true, + ["SKULL_BASH"] = true, + ["SKY_ATTACK"] = true, + ["SKY_UPPERCUT"] = true, + ["SLEEP"] = true, + ["SLEEP_EFFECT"] = true, + ["SLEEP_TALK"] = true, + ["SMELLINGSALT"] = true, + ["SNATCH"] = true, + ["SNORE"] = true, + ["SOFTBOILED"] = true, + ["SOLAR_BEAM"] = true, + ["SONICBOOM"] = true, + ["SPECIAL_ATTACK_DOWN"] = true, + ["SPECIAL_ATTACK_DOWN_2"] = true, + ["SPECIAL_ATTACK_DOWN_HIT"] = true, + ["SPECIAL_ATTACK_UP"] = true, + ["SPECIAL_ATTACK_UP_2"] = true, + ["SPECIAL_DAMAGE_EFFECT"] = true, + ["SPECIAL_DEFENSE_DOWN"] = true, + ["SPECIAL_DEFENSE_DOWN_2"] = true, + ["SPECIAL_DEFENSE_DOWN_HIT"] = true, + ["SPECIAL_DEFENSE_UP"] = true, + ["SPECIAL_DEFENSE_UP_2"] = true, + ["SPECIAL_DOWN1_EFFECT"] = true, + ["SPECIAL_DOWN2_EFFECT"] = true, + ["SPECIAL_DOWN_SIDE_EFFECT"] = true, + ["SPECIAL_UP1_EFFECT"] = true, + ["SPECIAL_UP2_EFFECT"] = true, + ["SPEED_DOWN"] = true, + ["SPEED_DOWN1_EFFECT"] = true, + ["SPEED_DOWN2_EFFECT"] = true, + ["SPEED_DOWN_2"] = true, + ["SPEED_DOWN_HIT"] = true, + ["SPEED_DOWN_SIDE_EFFECT"] = true, + ["SPEED_UP"] = true, + ["SPEED_UP1_EFFECT"] = true, + ["SPEED_UP2_EFFECT"] = true, + ["SPEED_UP_2"] = true, + ["SPIKES"] = true, + ["SPITE"] = true, + ["SPIT_UP"] = true, + ["SPLASH"] = true, + ["SPLASH_EFFECT"] = true, + ["STATUS_SETUP"] = true, + ["STAT_CHANGES"] = true, + ["STOCKPILE"] = true, + ["SUBSTITUTE"] = true, + ["SUBSTITUTE_EFFECT"] = true, + ["SUNNY_DAY"] = true, + ["SUPERPOWER"] = true, + ["SUPER_FANG"] = true, + ["SUPER_FANG_EFFECT"] = true, + ["SWAGGER"] = true, + ["SWALLOW"] = true, + ["SWIFT_EFFECT"] = true, + ["SWITCH_AND_TELEPORT_EFFECT"] = true, + ["SYNTHESIS"] = true, + ["TAUNT"] = true, + ["TEETER_DANCE"] = true, + ["TELEPORT"] = true, + ["THAW_HIT"] = true, + ["THIEF"] = true, + ["THRASH"] = true, + ["THRASH_PETAL_DANCE_EFFECT"] = true, + ["THUNDER"] = true, + ["TICKLE"] = true, + ["TORMENT"] = true, + ["TOXIC"] = true, + ["TRANSFORM"] = true, + ["TRANSFORM_EFFECT"] = true, + ["TRAP"] = true, + ["TRAPPING_EFFECT"] = true, + ["TRICK"] = true, + ["TRIPLE_KICK"] = true, + ["TRI_ATTACK"] = true, + ["TWINEEDLE"] = true, + ["TWINEEDLE_EFFECT"] = true, + ["TWISTER"] = true, + ["TWO_TO_FIVE_ATTACKS_EFFECT"] = true, + ["UPROAR"] = true, + ["VITAL_THROW"] = true, + ["WATER_SPORT"] = true, + ["WEATHER_BALL"] = true, + ["WILL_O_WISP"] = true, + ["WISH"] = true, + ["YAWN"] = true, + }, + ["variants"] = { + ["ACCURACY_DOWN1_EFFECT"] = {["chance"]=0,["semantic"]=23}, + ["ATTACK_DOWN1_EFFECT"] = {["chance"]=0,["semantic"]=18}, + ["ATTACK_DOWN2_EFFECT"] = {["chance"]=0,["semantic"]=58}, + ["ATTACK_DOWN_SIDE_EFFECT"] = {["chance"]=33,["semantic"]=68}, + ["ATTACK_TWICE_EFFECT"] = {["chance"]=0,["semantic"]=44}, + ["ATTACK_UP1_EFFECT"] = {["chance"]=0,["semantic"]=10}, + ["ATTACK_UP2_EFFECT"] = {["chance"]=0,["semantic"]=50}, + ["BIDE_EFFECT"] = {["chance"]=0,["semantic"]=26}, + ["BURN_SIDE_EFFECT1"] = {["chance"]=10,["semantic"]=4}, + ["BURN_SIDE_EFFECT2"] = {["chance"]=30,["semantic"]=4}, + ["CHARGE_EFFECT"] = {["chance"]=0,["semantic"]=39}, + ["CONFUSION_EFFECT"] = {["chance"]=0,["semantic"]=49}, + ["CONFUSION_SIDE_EFFECT"] = {["chance"]=10,["semantic"]=76}, + ["CONVERSION_EFFECT"] = {["chance"]=0,["semantic"]=30}, + ["DEFENSE_DOWN1_EFFECT"] = {["chance"]=0,["semantic"]=19}, + ["DEFENSE_DOWN2_EFFECT"] = {["chance"]=0,["semantic"]=59}, + ["DEFENSE_DOWN_SIDE_EFFECT"] = {["chance"]=33,["semantic"]=69}, + ["DEFENSE_UP1_EFFECT"] = {["chance"]=0,["semantic"]=11}, + ["DEFENSE_UP2_EFFECT"] = {["chance"]=0,["semantic"]=51}, + ["DISABLE_EFFECT"] = {["chance"]=0,["semantic"]=86}, + ["DRAIN_HP_EFFECT"] = {["chance"]=0,["semantic"]=3}, + ["DREAM_EATER_EFFECT"] = {["chance"]=0,["semantic"]=8}, + ["EVASION_DOWN1_EFFECT"] = {["chance"]=0,["semantic"]=24}, + ["EVASION_UP1_EFFECT"] = {["chance"]=0,["semantic"]=16}, + ["EXPLODE_EFFECT"] = {["chance"]=0,["semantic"]=7}, + ["FLINCH_SIDE_EFFECT1"] = {["chance"]=10,["semantic"]=31}, + ["FLINCH_SIDE_EFFECT2"] = {["chance"]=30,["semantic"]=31}, + ["FLY_EFFECT"] = {["chance"]=0,["semantic"]=155}, + ["FOCUS_ENERGY_EFFECT"] = {["chance"]=0,["semantic"]=47}, + ["FREEZE_SIDE_EFFECT1"] = {["chance"]=10,["semantic"]=5}, + ["FREEZE_SIDE_EFFECT2"] = {["chance"]=30,["semantic"]=5}, + ["HAZE_EFFECT"] = {["chance"]=0,["semantic"]=25}, + ["HEAL_EFFECT"] = {["chance"]=0,["semantic"]=32}, + ["HYPER_BEAM_EFFECT"] = {["chance"]=0,["semantic"]=80}, + ["JUMP_KICK_EFFECT"] = {["chance"]=0,["semantic"]=45}, + ["LEECH_SEED_EFFECT"] = {["chance"]=0,["semantic"]=84}, + ["LIGHT_SCREEN_EFFECT"] = {["chance"]=0,["semantic"]=35}, + ["METRONOME_EFFECT"] = {["chance"]=0,["semantic"]=83}, + ["MIMIC_EFFECT"] = {["chance"]=0,["semantic"]=82}, + ["MIRROR_MOVE_EFFECT"] = {["chance"]=0,["semantic"]=9}, + ["MIST_EFFECT"] = {["chance"]=0,["semantic"]=46}, + ["NO_ADDITIONAL_EFFECT"] = {["chance"]=0,["semantic"]=0}, + ["OHKO_EFFECT"] = {["chance"]=0,["semantic"]=38}, + ["PARALYZE_EFFECT"] = {["chance"]=0,["semantic"]=67}, + ["PARALYZE_SIDE_EFFECT1"] = {["chance"]=10,["semantic"]=6}, + ["PARALYZE_SIDE_EFFECT2"] = {["chance"]=30,["semantic"]=6}, + ["PAY_DAY_EFFECT"] = {["chance"]=0,["semantic"]=34}, + ["POISON_EFFECT"] = {["chance"]=0,["semantic"]=66}, + ["POISON_SIDE_EFFECT1"] = {["chance"]=20,["semantic"]=2}, + ["POISON_SIDE_EFFECT2"] = {["chance"]=40,["semantic"]=2}, + ["RAGE_EFFECT"] = {["chance"]=0,["semantic"]=81}, + ["RECOIL_EFFECT"] = {["chance"]=0,["semantic"]=48}, + ["REFLECT_EFFECT"] = {["chance"]=0,["semantic"]=65}, + ["SLEEP_EFFECT"] = {["chance"]=0,["semantic"]=1}, + ["SPECIAL_DAMAGE_EFFECT"] = {["chance"]=0,["semantic"]=87}, + ["SPECIAL_DOWN2_EFFECT"] = {["chance"]=0,["semantic"]=62}, + ["SPECIAL_DOWN_SIDE_EFFECT"] = {["chance"]=33,["semantic"]=72}, + ["SPECIAL_UP1_EFFECT"] = {["chance"]=0,["semantic"]=13}, + ["SPECIAL_UP2_EFFECT"] = {["chance"]=0,["semantic"]=54}, + ["SPEED_DOWN1_EFFECT"] = {["chance"]=0,["semantic"]=20}, + ["SPEED_DOWN2_EFFECT"] = {["chance"]=0,["semantic"]=60}, + ["SPEED_DOWN_SIDE_EFFECT"] = {["chance"]=33,["semantic"]=70}, + ["SPEED_UP2_EFFECT"] = {["chance"]=0,["semantic"]=52}, + ["SPLASH_EFFECT"] = {["chance"]=0,["semantic"]=85}, + ["SUBSTITUTE_EFFECT"] = {["chance"]=0,["semantic"]=79}, + ["SUPER_FANG_EFFECT"] = {["chance"]=0,["semantic"]=40}, + ["SWIFT_EFFECT"] = {["chance"]=0,["semantic"]=17}, + ["SWITCH_AND_TELEPORT_EFFECT"] = {["chance"]=0,["semantic"]=28}, + ["THRASH_PETAL_DANCE_EFFECT"] = {["chance"]=0,["semantic"]=27}, + ["TRANSFORM_EFFECT"] = {["chance"]=0,["semantic"]=57}, + ["TRAPPING_EFFECT"] = {["chance"]=0,["semantic"]=42}, + ["TWINEEDLE_EFFECT"] = {["chance"]=20,["semantic"]=77}, + ["TWO_TO_FIVE_ATTACKS_EFFECT"] = {["chance"]=0,["semantic"]=29}, + }, +} diff --git a/src/mods/PortableScripts.lua b/src/mods/PortableScripts.lua new file mode 100644 index 000000000..bfa6c6181 --- /dev/null +++ b/src/mods/PortableScripts.lua @@ -0,0 +1,543 @@ +-- Per-mod portable dialogue programs. An isolated native runner owns the +-- foreground until completion, then returns the idle runner it borrowed. +local Program = require("src.mods.ScriptProgram") +local Runtime = require("src.mods.Runtime") +local GameVersion = require("src.core.GameVersion") +local Scripts = {} +local runs = setmetatable({}, { __mode = "k" }) +local observers = setmetatable({}, { __mode = "k" }) +local contexts = setmetatable({}, { __mode = "k" }) +local draining = false +local unpack = table.unpack or unpack +local function pack(...) + return { n = select("#", ...), ... } +end +local function engine(fn, ...) + local owner = Runtime.currentMod + Runtime.currentMod = nil + local result = pack(pcall(fn, ...)) + Runtime.currentMod = owner + return unpack(result, 1, result.n) +end +local function current(loader) + if loader.game then + return loader.game + end + local game = package.loaded["src.core.Game"] + return type(game) == "table" and game or nil +end +local function message(err) + local ok, text = pcall(tostring, err) + return ok and text or "unprintable error" +end +local function contains(stack, state) + if type(stack.states) ~= "table" then + return false + end + for _, value in ipairs(stack.states) do + if value == state then + return true + end + end + return false +end +local function remove(stack, state) + if type(stack.states) ~= "table" then + return + end + for i = #stack.states, 1, -1 do + if stack.states[i] == state then + table.remove(stack.states, i) + if Runtime.wants("screen.popped") then + Runtime.emit("screen.popped", { state = state }) + end + return + end + end +end +local LOCKS = { + "transitioning", + "flyAnim", + "teleportOut", + "engaging", + "emote", + "pikaHop", + "healAnim", + "whirlSpin", + "gen4Waterfall", + "gen5SeamPending", + "gen3Decorate", + "gen3Locked", + "frameScriptThisTick", + "_modScriptLock", +} +local function ready(game) + if not (game and game.save) then + return nil, "no save" + end + local stack = game.stack + if not (stack and type(stack.states) == "table" and type(stack.top) == "function") then + return nil, "no overworld" + end + local ow = stack:top() + if not (ow and ow.isOverworld and ow.map and ow.player and ow.runner) then + return nil, "world is busy" + end + if + runs[game] + or game.overworld and game.overworld ~= ow + or game.linkSession + or game.linkNet and not game.linkNet.closed + then + return nil, "world is busy" + end + for _, key in ipairs(LOCKS) do + if ow[key] then + return nil, "world is busy" + end + end + for _, key in ipairs({ "moving", "inputLocked", "stairExit", "spinning", "fieldMove" }) do + if ow.player[key] then + return nil, "world is busy" + end + end + if + ow.runner:isRunning() + or #(ow.scriptMoves or {}) > 0 + or #(ow.pendingScripts or {}) > 0 + or #(ow.parallelRunners or {}) > 0 + or #(ow.parallelQueue or {}) > 0 + then + return nil, "world is busy" + end + return ow +end +local function observe(loader) + local installed = observers[loader] + if installed and installed[1] == loader.hooks then + return + end + -- Weak values too: the hook closure captures its loader, so retaining the + -- hooks here would otherwise keep every unloaded loader alive on LuaJIT. + observers[loader] = setmetatable({ loader.hooks }, { __mode = "v" }) + -- Keep one observer per loader: retiring a hook while its chain dispatches + -- would shift the following mod's index. Per-run cleanup never edits it. + loader.hooks:wrap("input.step", function(nextFn, game, dt) + if next(runs) == nil then + return nextFn(game, dt) + end + local pending = {} + for _, token in pairs(runs) do + if token.loader == loader then + pending[#pending + 1] = token + end + end + for _, token in ipairs(pending) do + local ok, live, why = engine(token.valid) + if not ok or not live then + token.finish(false, ok and why or "script context failed") + elseif token.waitingUI and not contains(token.stack, token.waitingUI) then + token.finish(false, "script UI was removed") + end + end + return nextFn(game, dt) + end, math.huge, "engine:portable_scripts") +end +function Scripts.cancelAll(loader, modId, reason) + if loader and modId then + if contexts[loader] then + contexts[loader].owners[modId] = nil + end + elseif loader then + contexts[loader] = nil + else + for owner in pairs(contexts) do + contexts[owner] = nil + end + end + local wasDraining = draining + draining = true + local pending = {} + for _, token in pairs(runs) do + if (not loader or token.loader == loader) and (not modId or token.modId == modId) then + pending[#pending + 1] = token + end + end + for _, token in ipairs(pending) do + token.finish(false, reason or "script session ended") + end + draining = wasDraining +end +local function facade(loader, modId, native) + local context = contexts[loader] + if not context then + context = { owners = {} } + contexts[loader] = context + end + local lease = {} + context.owners[modId] = lease + local function licensed() + return not draining and contexts[loader] == context and context.owners[modId] == lease + end + -- Install before entry code can subscribe/queue from input.step. Sorting + -- a new observer into a live hook chain could replay its current wrapper. + observe(loader) + local world, active = {}, nil + local function queue(rows, options) + if not licensed() then + return nil, "script facade is retired" + end + local program, why = Program.compile(rows) + if not program then + return nil, why + end + if options ~= nil then + if type(options) ~= "table" or getmetatable(options) ~= nil then + return nil, "options must be a plain table" + end + for key, value in pairs(options) do + if key ~= "onDone" or type(value) ~= "function" then + return nil, "only onDone callback is supported" + end + end + end + local onDone = options and options.onDone + local game = current(loader) + local ow + ow, why = ready(game) + if not ow then + return nil, why + end + local Runner = require("src.script.ScriptRunner") + local runner = Runner.new(game, ow) + local token = { + loader = loader, + modId = modId, + game = game, + world = ow, + runner = runner, + original = ow.runner, + stack = game.stack, + states = game.stack.states, + mapId = ow.map.id, + x = ow.player.cellX, + y = ow.player.cellY, + save = game.save, + data = game.data, + map = ow.map, + player = ow.player, + version = GameVersion.get(), + ui = {}, + steps = 0, + } + function token.valid() + if token.done then + return nil, "script is finished" + end + if + not licensed() + or current(loader) ~= game + or game.save ~= token.save + or game.data ~= token.data + or game.stack ~= token.stack + or game.stack.states ~= token.states + or not contains(token.stack, ow) + or game.overworld and game.overworld ~= ow + or ow.map ~= token.map + or ow.map.id ~= token.mapId + or ow.player ~= token.player + or ow.player.cellX ~= token.x + or ow.player.cellY ~= token.y + or ow.runner ~= runner + or GameVersion.get() ~= token.version + or Runtime.hooks ~= loader.hooks + or Runtime.events ~= loader.events + or game.linkSession + or game.linkNet and not game.linkNet.closed + or ow.transitioning + then + return nil, "script session changed" + end + return true + end + function token.finish(ok, reason) + if token.done then + return + end + token.done = true + runner.co, runner.waitingFrames, runner.waitingCheck = nil, nil, nil + -- Remove only this run's exact native screens, preserving foreign + -- overlays and a replacement world's runner/locks. + for i = #token.ui, 1, -1 do + remove(token.stack, token.ui[i]) + end + if ow.runner == runner then + ow.runner = token.original + end + if runs[game] == token then + runs[game] = nil + end + if active == token then + active = nil + end + if Runtime.wants("script.ended") then + Runtime.emit("script.ended", { ctx = runner.ctx, completed = ok }) + end + if onDone then + local called, err = pcall(onDone, ok, reason) + if not called then + Runtime.reportError(modId, "portable script completion failed: " .. message(err)) + end + end + end + local function check() + local valid, reason = token.valid() + if not valid then + token.finish(false, reason) + end + return valid + end + local function owned(state, step) + token.ui[#token.ui + 1] = state + local update = state.update + function state:update(...) + if not check() or token.step ~= step or game.stack:top() ~= self then + return + end + local ok, err = engine(update, self, ...) + if not ok then + token.finish(false, "native UI failed: " .. message(err)) + end + end + return state + end + local function dialog(ctx, text, asking, step) + local TextBox = require("src.render.TextBox") + local box, answered + local function answer(value) + if answered or token.step ~= step or not check() then + return + end + answered = true + token.waitingUI = nil + ctx.lastCheck = value + runner:resume() + end + box = TextBox.new(game, TextBox.fromProse(text), function() + if not asking and not contains(token.stack, box) then + answer(nil) + end + end) + if asking then + local update, pushed, selected = box.update, false, nil + box.choice = function(yes) + if token.step ~= step or not check() or not selected or contains(token.stack, selected) then + return + end + remove(token.stack, box) + answer(yes) + end + function box:update(dt) + if self.done and (self.holdFrames or 0) <= 0 and not pushed then + pushed, self.choicePushed = true, true + local Choice = require("src.ui.ChoiceBox") + local geometry = self.choiceBox or {} + selected = owned( + Choice.new( + game, + self.choice, + { anchor = "bottom", tx = geometry.tx, ty = geometry.ty, tw = geometry.tw, th = geometry.th } + ), + step + ) + token.waitingUI = selected + game.stack:push(selected) + else + update(self, dt) + end + end + end + owned(box, step) + token.waitingUI = box + game.stack:push(box) + runner:yield() + end + local function choice(ctx, labels, opts, step) + local Menu = require("src.ui.Menu") + local items, menu, answered = {}, nil, false + local function pick(index) + if answered or token.step ~= step or not check() or contains(token.stack, menu) then + return + end + answered = true + token.waitingUI = nil + ctx.lastCheck, ctx.lastChoice = index == 1, { index = index, label = labels[index] } + runner:resume() + end + for i, label in ipairs(labels) do + items[i] = { + label = label, + onSelect = function() + pick(i) + end, + } + end + menu = owned( + Menu.new(game, items, { + onCancel = function() + pick(opts.cancel) + end, + }), + step + ) + menu.index = opts.default + token.waitingUI = menu + game.stack:push(menu) + runner:yield() + end + function runner:exec(compiled, ctx) + local pc, slice = 1, 0 + while pc <= #compiled do + if not check() then + return + end + if slice >= Program.SLICE then + self.waitingFrames = 1 + self:yield() + slice = 0 + if not check() then + return + end + end + token.steps, slice = token.steps + 1, slice + 1 + if token.steps > Program.MAX_STEPS then + error("portable script instruction budget exceeded", 0) + end + local row = compiled[pc] + local verb = row[1] + token.step = {} + self.lastRow = verb .. " (portable row " .. pc .. ")" + ctx.script, ctx.pc = compiled, pc + if verb == "wait" then + self.waitingFrames = row[2] + self:yield() + elseif verb == "show_text" or verb == "ask" then + dialog(ctx, row[2], verb == "ask", token.step) + elseif verb == "choice" then + choice(ctx, row[2], row[3], token.step) + elseif verb == "end" then + return + elseif + verb == "jump" + or verb == "jump_if_true" and ctx.lastCheck + or verb == "jump_if_false" and not ctx.lastCheck + then + pc = row[2] - 1 + end + pc = pc + 1 + end + end + local update = Runner.update + function runner:resume(...) + if not check() or not self.co or coroutine.status(self.co) == "running" then + return + end + local ok, err = coroutine.resume(self.co, ...) + if not ok then + Runtime.reportError(modId, "portable script failed: " .. message(err)) + token.finish(false, "script failed: " .. message(err)) + elseif not token.done and coroutine.status(self.co) == "dead" then + token.finish(true) + end + end + function runner:update(...) + if not check() or game.stack:top() ~= ow then + return + end + local ok, err = engine(update, self, ...) + if not ok then + token.finish(false, "script update failed: " .. message(err)) + end + end + function runner:run() + error("portable script owns this runner", 0) + end + -- Session/argument checks above do not mutate gameplay. Admission starts + -- here; subsequent native failures complete through onDone(false, reason). + active, runs[game], ow.runner = token, token, runner + runner.ctx = runner:makeContext({ + source = { modId = modId, mapId = ow.map.id, hook = "queueScript", strict = true }, + }) + runner.co = coroutine.create(function() + runner:exec(program, runner.ctx) + end) + if Runtime.wants("script.started") then + Runtime.emit("script.started", { ctx = runner.ctx }) + end + local ok, err = engine(runner.resume, runner) + if not ok then + token.finish(false, "script start failed: " .. message(err)) + end + return true + end + function world:queueScript(rows, options) + local ok, result, reason = engine(queue, rows, options) + if not ok then + return nil, "portable script validation failed" + end + return result, reason + end + function world:cancelScript() + if not active or active.done then + return false, "no portable script" + end + active.finish(false, "script cancelled") + return true + end + return setmetatable(world, { + __index = function(_, key) + local value = native() + local member = value and value[key] + if type(member) == "function" then + return function(_, ...) + return member(value, ...) + end + end + return member + end, + }) +end +function Scripts.wrap(mt, api, loader, manifest) + local format = manifest.script_format or "native" + api.scriptFormat = format + if format ~= "unified" then + return mt + end + local index = mt.__index + local world = facade(loader, manifest.id, function() + return index(api, "world") + end) + mt.__index = function(self, key) + if key == "world" then + return world + end + return index(self, key) + end + if api.supports then + local supports = api.supports + function api:supports(surface, name) + if surface == "scripts" then + if Program.supports(name) then + return true + end + return false, "unsupported portable script command" + end + if surface == "world" and (name == "queueScript" or name == "cancelScript") then + return true + end + return supports(self, surface, name) + end + end + return mt +end +return Scripts diff --git a/src/mods/Registry.lua b/src/mods/Registry.lua index 9719673ef..cf200f844 100644 --- a/src/mods/Registry.lua +++ b/src/mods/Registry.lua @@ -28,11 +28,16 @@ function Registry.new(name, spec) }, Registry) end +local function validId(self, id) + return type(id) == "string" and id ~= "" + or self.numericIds and type(id) == "number" and id >= 1 and id < math.huge and id % 1 == 0 +end + local function append(self, id, op, value, owner) if self.frozen then error(self.name .. ": content is frozen after load") end - assert(type(id) == "string" and id ~= "", self.name .. " id is required") + assert(validId(self, id), self.name .. " id is required") local list = self.ops[id] if not list then list = {} @@ -94,6 +99,14 @@ local function fold(self, value, opList, id) if type(entry.value) == "table" then value = Merge.deepMerge(Merge.deepCopy(value == nil and {} or value), entry.value, self.spec.semantics) + -- Adapter-produced list patches include empty replacements. Native + -- patch semantics keep {} as a dictionary/no-op, so this internal + -- path list explicitly distinguishes clearing a list from omitting it. + for _, path in ipairs(entry.replaceLists or {}) do + local into, from = value, entry.value + for i = 1, #path - 1 do into, from = into[path[i]], from[path[i]] end + into[path[#path]] = Merge.deepCopy(from[path[#path]]) + end else value = entry.value end @@ -120,12 +133,14 @@ function Registry:override(id, value, owner) return append(self, id, "override", value, owner) end -function Registry:patch(id, partial, owner) +function Registry:patch(id, partial, owner, replaceLists) assert(partial ~= nil, self.name .. " patch value is required for " .. tostring(id)) if self.spec.semantics == "compose" then error(self.name .. ": patch is not supported on compose registries") end - return append(self, id, "patch", partial, owner) + local result = append(self, id, "patch", partial, owner) + if replaceLists then self.ops[id][#self.ops[id]].replaceLists = Merge.deepCopy(replaceLists) end + return result end -- tombstone: consumers treat the id as absent after the merge @@ -196,7 +211,7 @@ end -- Works after the loader has frozen content, because it appends nothing -- the -- ops are untouched and only the reading of them changes. function Registry:preferOwner(id, owner) - assert(type(id) == "string" and id ~= "", self.name .. " id is required") + assert(validId(self, id), self.name .. " id is required") self.prefer = self.prefer or {} self.prefer[id] = owner self.cache[id] = nil diff --git a/src/mods/Schemas.lua b/src/mods/Schemas.lua index 9014835cc..d9e994c1a 100644 --- a/src/mods/Schemas.lua +++ b/src/mods/Schemas.lua @@ -268,13 +268,14 @@ end -- ------- cross-reference pass +local NUMERIC_REFS = { pokemon = true, moves = true, items = true, trainers = true } local collectRefs collectRefs = function(t, value, path, out) if value == nil or value == Merge.DELETE then return end local kind = t.kind if kind == "opt" then return collectRefs(t.inner, value, path, out) end if kind == "id" then - if type(value) == "string" then + if type(value) == "string" or NUMERIC_REFS[t.registry] and type(value) == "number" and value > 0 then out[#out + 1] = { registry = t.registry, ref = value, path = path } end return @@ -353,7 +354,7 @@ function Schemas.crossValidate(loader, data) end end for name, registry in pairs(loader.content) do - local spec = registry.spec + local spec = registry.referenceSpec or registry.spec for id in pairs(registry.ops) do local value = registry:get(id) if value ~= nil and registry.owners[id] ~= Schemas.ENGINE then diff --git a/src/mods/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/UIActions.lua b/src/mods/UIActions.lua new file mode 100644 index 000000000..63a046979 --- /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 = refs.party or 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..bb0aeb660 --- /dev/null +++ b/src/mods/UnifiedCallbacks.lua @@ -0,0 +1,243 @@ +local Api3Catalog = require("src.mods.Api3Catalog") +local Records = require("src.mods.CallbackRecords") +local EnemyActionContext = require("src.battle.EnemyActionContext") +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.charge_required + held_item.trigger + shiny.roll gender.roll + battle.accuracy battle.catch_exp battle.crit battle.damage battle.enemy_action battle.exp_award + battle.low_health_alarm battle.overlay battle.run battle.status_hud_visible battle.turn_order catch.rate + encounter.fishing encounter.roll encounter.species evolution.check exp.gain fieldmove.eligibility + input.step intro.oak_speech.build link.fingerprint map.palette movement.collision movement.speed + music.select music.volume player.sprite pokemon.icon pokemon.sprite render.compose render.hud + render.letterbox render.zones save.new_game save.write script.command trainer.party transition.style + ui.list_menu ui.naming.grid ui.options.rows ui.party.submenu ui.pc.items ui.start_menu.items + ui.title_menu.items warp.destination world.tod zoom.range +]]) +C.events = names([[ + assets.transformed battle.ball_thrown battle.battler_switched battle.damage_dealt battle.ended + battle.exp_gained battle.fainted battle.move_used battle.started battle.status_inflicted battle.turn_ended + battle.turn_started discord.join_requested flag.changed game.ready intro.birch_speech.finished + intro.birch_speech.started intro.oak_speech.answered intro.oak_speech.finished intro.oak_speech.started + intro.oak_speech.step link.connected link.desync link.ended map.entered map.exited map.reloaded + mod.option_action mod.options_changed mods.loaded music.started music.stopped player.warped + pokemon.before_give pokemon.caught pokemon.egg_hatched pokemon.evolved pokemon.level_up + pokemon.move_learned pokemon.received save.created save.loaded save.loading save.writing screen.popped + screen.pushed script.ended script.started sound.played trade.completed world.blacked_out + world.block_replaced world.blocks_replaced world.boulder_moved world.interacted world.npc_spawned + world.object_toggled world.stepped world.tick world.tod_changed world.trainer_engaged +]]) +local positions = { + ["battle.charge_required"] = { "context" }, + ["shiny.roll"] = { "context" }, + ["gender.roll"] = { "context" }, + ["held_item.trigger"] = { "context" }, + ["battle.accuracy"] = { "context" }, + ["battle.damage"] = { "context" }, + ["battle.crit"] = { "context" }, + ["battle.run"] = { "context" }, + ["battle.catch_exp"] = { "context" }, + ["battle.low_health_alarm"] = { "context" }, + ["battle.exp_award"] = { "context" }, + ["exp.gain"] = { "context" }, + ["battle.turn_order"] = { "battler", "move", "battler", "move", "context" }, + ["battle.enemy_action"] = { false, "battler" }, + ["catch.rate"] = { "items", "mon", "species", "context" }, + ["trainer.party"] = { false, false, "trainerParty" }, + ["evolution.check"] = { false, "mon", "context", "context" }, + ["fieldmove.eligibility"] = { "moves", false }, + ["encounter.fishing"] = { "items", false, "candidates" }, + ["encounter.roll"] = { false, "context" }, + ["encounter.species"] = { "encounter", "context" }, + ["ui.party.submenu"] = { false, false, "mon", false }, + ["pokemon.sprite"] = { false, "context" }, + ["pokemon.icon"] = { false, "context" }, +} +local results = { + ["fieldmove.eligibility"] = "mon", + ["battle.enemy_action"] = "action", + ["trainer.party"] = "trainerParty", + ["battle.turn_order"] = "battler", + ["encounter.fishing"] = "encounter", + ["encounter.roll"] = "encounter", + ["encounter.species"] = "encounter", +} +-- These payloads are engine handles, repair flows or UI objects, not record +-- notifications. In particular, game.ready has a forwarding metatable. +local nativeEvents = names([[ + game.ready save.created save.loaded save.loading save.writing mods.loaded + screen.popped screen.pushed intro.birch_speech.finished intro.birch_speech.started + intro.oak_speech.answered intro.oak_speech.finished intro.oak_speech.started intro.oak_speech.step + mod.option_action mod.options_changed +]]) +local function custom(name) + return type(name) == "string" and name:match("^mod%.[%w_%-]+%..+$") ~= nil +end +function C.install(loader, api, owner, generation) + -- Capture pristine alias identities before Loader merges them into separate + -- records. Each call still builds a fresh index, including later mods' IDs. + local ids = Ids.new(loader) + local api3 = (api.manifest.api or 1) >= 3 + local extra = api3 and Api3Catalog + for _, kind in ipairs({ "pokemon", "moves", "items" }) do + ids:entries(kind) + end + local function check(catalog, name) + assert( + type(name) == "string" + and ( + catalog[name] + or (extra and (catalog == C.events and extra.events[name] or catalog == C.hooks and extra.hooks[name])) + or (catalog == C.events and custom(name)) + ), + "unknown unified callback: " .. tostring(name) + ) + end + local function subscribe(once, name, callback, priority) + check(C.events, name) + assert(type(callback) == "function", "event callback must be a function") + local wrapped = callback + if not custom(name) and not nativeEvents[name] then + wrapped = function(payload) + local boundary = Records.new(loader, name, generation, api3) + local public = boundary.view(payload, name == "pokemon.before_give" and "gift" or "context") + local values = pack(callback(public)) + if name == "pokemon.before_give" then + boundary.commit() + end + return unpack(values, 1, values.n) + end + end + if once then + return loader.events:once(name, wrapped, priority, owner) + end + return loader.events:on(name, wrapped, priority, owner) + end + api.events = { + on = function(_, ...) + return subscribe(false, ...) + end, + once = function(_, ...) + return subscribe(true, ...) + end, + emit = function(_, name, payload) + local prefix = "mod." .. owner .. "." + assert(custom(name) and name:sub(1, #prefix) == prefix, "mods may only emit " .. prefix .. "* events") + return loader.events:emit(name, payload) + end, + } + api.hooks = { + wrap = function(_, name, callback, priority) + check(C.hooks, name) + assert(type(callback) == "function", "hook callback must be a function") + return loader.hooks:wrap(name, function(nextFn, ...) + local boundary = Records.new(loader, name, generation, api3) + local portableEnemy = api3 and generation == 3 and name == "battle.enemy_action" + local roles = portableEnemy and { false, "enemySlot" } or 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 == "enemySlot" then + v = EnemyActionContext.publicId(args[1], v) + elseif 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 == "enemySlot" then + v = EnemyActionContext.nativeActor(args[1], v) + elseif role == "items" or role == "moves" then + v = boundary.native(role, v, original[i]) + elseif role then + local identity = api3 and (name == "shiny.roll" or name == "gender.roll") + v = boundary.restore(v, role, identity and original[i] or nil) + end + out[i] = v + end + if portableEnemy and args.n < 2 then + out.n, out[2] = 2, EnemyActionContext.nativeActor(args[1], nil) + 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 + if portableEnemy then + downstream = pack(pcall(EnemyActionContext.call, raw[1], args[2], nextFn, unpack(raw, 1, raw.n))) + else + downstream = pack(pcall(nextFn, unpack(raw, 1, raw.n))) + end + if downstream[1] then + boundary.refresh() + publicResult = pack(unpack(downstream, 2, downstream.n)) + if results[name] then + publicResult[1] = boundary.view(publicResult[1], results[name]) + end + end + end + if not downstream[1] then + error(downstream[2], 0) + end + return unpack(publicResult, 1, publicResult.n) + end + local invoked = pack(pcall(callback, nextPublic, unpack(public, 1, public.n))) + active = false + if not invoked[1] then + boundary.close() + error(invoked[2], 0) + end + local values = pack(unpack(invoked, 2, invoked.n)) + if called and not downstream[1] then + boundary.close() + error(downstream[2], 0) + end + local ok, err = pcall(boundary.commit, function() + if results[name] then + values[1] = boundary.restore(values[1], results[name]) + end + end) + boundary.close() + if not ok then + error(err, 0) + end + return unpack(values, 1, values.n) + end, priority, owner) + end, + } +end +return C diff --git a/src/mods/UnifiedContent.lua b/src/mods/UnifiedContent.lua new file mode 100644 index 000000000..93a258afe --- /dev/null +++ b/src/mods/UnifiedContent.lua @@ -0,0 +1,541 @@ +-- Explicit opt-in definition-record boundary. Native registries and gameplay +-- stay authoritative; no save, callback, world or battle data is rewritten. +local Merge = require("src.mods.Merge") +local Schemas = require("src.mods.Schemas") +local Shapes = require("src.mods.UnifiedSchemas") +local Ids = require("src.mods.UnifiedIds") +local Effects = require("src.mods.UnifiedEffects") +local U = {} +local copy, DELETE = Merge.deepCopy, Merge.DELETE +local function unsupported(reason) error({ unsupported = reason }, 0) end +local function check(ok, reason) if not ok then error("invalid unified content: " .. reason, 0) end end +local function equal(a, b, seen) + if a == b then return true end + if type(a) ~= "table" or type(b) ~= "table" then return false end + seen = seen or {}; if seen[a] == b then return true end; seen[a] = b + for k, v in pairs(a) do if not equal(v, b[k], seen) then return false end end + for k in pairs(b) do if a[k] == nil then return false end end + return true +end +local function finite(v, seen) + if type(v) == "number" then check(v == v and math.abs(v) < math.huge, "non-finite number") end + if type(v) ~= "table" or v == DELETE then return end + seen = seen or {}; check(not seen[v], "cyclic record"); seen[v] = true + for k, sub in pairs(v) do finite(k, seen); finite(sub, seen) end + seen[v] = nil +end +local function list(value, old, convert) + if value == DELETE then return {} end + check(type(value) == "table", "expected list") + if Merge.isWrapper(value) then + for k in pairs(value) do check(k == "__prepend" or k == "__append", "unknown list extension") end + local out = {} + for _, source in ipairs({ value.__prepend or {}, old or {}, value.__append or {} }) do + for _, row in ipairs(list(source, nil, function(v) return copy(v) end)) do out[#out + 1] = row end + end + return list(out, nil, convert) + end + local count = 0 + for k in pairs(value) do + check(type(k) == "number" and k % 1 == 0 and k >= 1 and k <= #value, "expected contiguous list") + count = count + 1 + end + check(count == #value, "expected contiguous list") + local out = {} + for i, row in ipairs(value) do out[i] = convert(row, i) end + return out +end +local function resolved(ctx, kind, id) + check(type(id) == "string" and id ~= "", "expected string " .. kind .. " ID") + local found, why = ctx.ids:resolve(kind, id) + if not found then unsupported(why) end + return found.key +end +local function readIds(ctx, kind, values) + local out = {} + for _, id in ipairs(values or {}) do + if id ~= 0 then out[#out + 1] = ctx.ids:read(kind, id) end + end + return out +end +local function writeIds(ctx, kind, values, old) + return list(values, old, function(id) return resolved(ctx, kind, id) end) +end +-- Gen 3's seven-byte names also produce these spellings (TypeChart's +-- registrant deliberately keeps them authoritative rather than renaming Data). +local TYPE_ALIASES = { PSYCHIC = "PSYCHIC_TYPE", PSYCHC = "PSYCHIC_TYPE", + MYSTERY = "CURSE_TYPE", FIGHT = "FIGHTING", ELECTR = "ELECTRIC" } +local function typeRead(id) return TYPE_ALIASES[id] or id end +local function typeWrite(ctx, id) + local registry = ctx.loader.content.type_chart + local found = registry:get(id) ~= nil and id or nil + for native, canonical in pairs(TYPE_ALIASES) do + if canonical == id and registry:get(native) ~= nil then + if found then unsupported("ambiguous type ID: " .. tostring(id)) end + found = native + end + end + if found then return found end + unsupported("unknown type ID: " .. tostring(id)) +end +local function reference(ctx, kind, id) + if ctx.loader.content[kind]:get(id) == nil then unsupported("unknown " .. kind .. " ID: " .. tostring(id)) end + return id +end + +local EVO = { + level = "LEVEL", useItem = "ITEM", trade = "TRADE", tradeWithItem = "TRADE", + friendship = "FRIENDSHIP", friendshipDay = "FRIENDSHIP", friendshipNight = "FRIENDSHIP", + HAPPINESS = "FRIENDSHIP", STAT = "LEVEL", + levelAtkGreater = "LEVEL", levelAtkLess = "LEVEL", levelAtkEqual = "LEVEL", +} +local function evolutionRead(ctx, row) + local method = EVO[row.method] or row.method + local out = { method = method, species = ctx.ids:read("pokemon", row.species or row.target) } + if method == "LEVEL" then out.level = row.level or row.param end + if method == "ITEM" then out.item = ctx.ids:read("items", row.item or row.param) end + if method == "TRADE" then out.item = ctx.ids:read("items", row.heldItem or (row.method == "tradeWithItem" and row.param)) end + if method == "FRIENDSHIP" then + out.time = row.timeOfDay or (row.method == "friendshipDay" and "MORNDAY") + or (row.method == "friendshipNight" and "NITE") + end + out.comparison = row.compare or ({ levelAtkGreater = "ATK_GT_DEF", levelAtkLess = "ATK_LT_DEF", + levelAtkEqual = "ATK_EQ_DEF" })[row.method] + return out +end +local function evolutionWrite(ctx, row, old) + if old and equal(row, evolutionRead(ctx, old)) then return copy(old) end + local method = row.method + if method ~= "LEVEL" and method ~= "ITEM" and method ~= "TRADE" and method ~= "FRIENDSHIP" then + unsupported("evolution method has no unified writer: " .. tostring(method)) + end + if ctx.gen == 1 and (method == "FRIENDSHIP" or row.item and method == "TRADE" + or row.time or row.comparison) then unsupported("evolution condition has no Gen 1 representation") end + if ctx.gen >= 4 then + -- This engine implements the native lowercase level method. Other DS + -- evolution conditions retain cartridge parameters and lack a safe writer. + if method ~= "LEVEL" or row.comparison then unsupported("DS evolution condition has no unified writer") end + check(row.level ~= nil and row.item == nil and row.time == nil, "invalid LEVEL condition") + return { method = "level", param = row.level, paramKind = "level", methodId = 4, + target = resolved(ctx, "pokemon", row.species) } + end + local out = { method = method, species = resolved(ctx, "pokemon", row.species) } + if method == "LEVEL" then + check(row.level ~= nil and row.item == nil and row.time == nil, "invalid LEVEL condition") + out.level = row.level + if row.comparison then out.method, out.compare = "STAT", row.comparison end + elseif method == "ITEM" then + check(row.item ~= nil and row.level == nil and row.time == nil and row.comparison == nil, "invalid ITEM condition") + out.item = resolved(ctx, "items", row.item) + elseif method == "TRADE" then + check(row.level == nil and row.time == nil and row.comparison == nil, "invalid TRADE condition") + if row.item then out.heldItem = resolved(ctx, "items", row.item) end + else + check(row.level == nil and row.item == nil and row.comparison == nil, "invalid FRIENDSHIP condition") + out.method, out.timeOfDay = "HAPPINESS", row.time ~= "ANYTIME" and row.time or nil + end + return out +end + +local readRecord +readRecord = function(ctx, kind, id, raw) + local out = { id = id, native = copy(raw) } + for key in pairs(Shapes[kind].fields) do + if key ~= "native" and key ~= "id" then out[key] = copy(raw[key]) end + end + if kind == "pokemon" then + local stats = raw.baseStats or {} + out.baseStats = { hp = stats.hp, attack = stats.attack, defense = stats.defense, speed = stats.speed, + special = stats.special or stats.spatk or stats.spAttack, + specialAttack = ctx.gen > 1 and (stats.spatk or stats.spAttack) or nil, + specialDefense = ctx.gen > 1 and (stats.spdef or stats.spDefense) or nil } + out.types = {}; for i, value in ipairs(raw.types or {}) do out.types[i] = typeRead(value) end + for _, key in ipairs({ "level1Moves", "tmhm", "eggMoves" }) do + if raw[key] then out[key] = readIds(ctx, "moves", raw[key]) end + end + out.level1Moves, out.learnset, out.evolutions = out.level1Moves or {}, {}, {} + for i, row in ipairs(raw.learnset or {}) do + out.learnset[i] = { level = row.level, move = ctx.ids:read("moves", row.move) } + end + for i, row in ipairs(raw.evolutions or {}) do out.evolutions[i] = evolutionRead(ctx, row) end + elseif kind == "moves" then + out.type = typeRead(raw.type) + out.category = raw.category or raw.class + out.effect = Effects.read(raw.effect, ctx.gen, raw) + out.secondaryChance = Effects.chance(raw, ctx.gen) + elseif kind == "items" then + if type(raw.effect) ~= "string" then out.effect = nil end + if raw.machine then out.machine.move = ctx.ids:read("moves", raw.machine.move) end + elseif kind == "trainers" then + out.parties = {} + for i, party in ipairs(raw.parties or (raw.party and { raw.party }) or {}) do + out.parties[i] = {} + for j, mon in ipairs(party) do + out.parties[i][j] = { species = ctx.ids:read("pokemon", mon.species), level = mon.level, + item = ctx.ids:read("items", mon.item), moves = mon.moves and readIds(ctx, "moves", mon.moves) } + end + end + elseif kind == "encounters" then + local function areaRead(area) + if not area then return nil end + local row = { rate = (area.rate or 0) * 256 / (area.rateMax or 256), slots = {} } + for i, slot in ipairs(area.slots or {}) do + row.slots[i] = { species = ctx.ids:read("pokemon", slot.species), level = slot.level, + minLevel = slot.min, maxLevel = slot.max } + end + if area.byTime then + row.times = { DAY = { rate = row.rate, slots = copy(row.slots) } } + for key, native in pairs({ MORN = "morn", NITE = "nite" }) do + if area.byTime[native] then row.times[key] = areaRead(area.byTime[native]) end + end + end + return row + end + out.grass, out.water = areaRead(raw.grass), areaRead(raw.water) + end + return out +end + +local function writeRecord(ctx, kind, id, value, old, mode) + local previous = old and readRecord(ctx, kind, id, old) or {} + if value.native ~= nil and not equal(value.native, old or {}) then unsupported("native diagnostics are read-only") end + if value.id ~= nil then check(value.id == id, "record id differs from operation id") end + if value.native ~= nil and old then + -- Detached read/modify/write records carry inherited fields. Convert only + -- actual edits, so round trips retain native selectors and absent aliases. + value = copy(value) + for key, v in pairs(value) do + if key ~= "id" and key ~= "native" and equal(v, previous[key]) then value[key] = nil end + end + end + local out, replacements = {}, {} + local function replace(key, v) out[key] = v; replacements[#replacements + 1] = { key } end + for key, v in pairs(value) do + if key ~= "native" and key ~= "id" then out[key] = copy(v) end + end + if not old then out.id = id end + if kind == "pokemon" then + if value.baseStats and value.baseStats ~= DELETE then + local stats, native = value.baseStats, {} + for _, stat in pairs(stats) do check(stat ~= DELETE, "cannot delete a native base stat") end + for _, key in ipairs({ "hp", "attack", "defense", "speed" }) do native[key] = stats[key] end + local a, b = stats.specialAttack, stats.specialDefense + if ctx.gen == 1 then + a, b = a or stats.special or (previous.baseStats or {}).special, + b or stats.special or (previous.baseStats or {}).special + if (a or b) and a ~= b then unsupported("Gen 1 cannot split Special") end + if stats.special and a ~= stats.special then check(false, "conflicting Special values") end + native.special = stats.special or stats.specialAttack or stats.specialDefense + else + -- A patch to special intentionally writes both halves. On detached + -- read/modify/write records, unchanged split fields are inherited. + if stats.special ~= nil and (value.native == nil or stats.special ~= (previous.baseStats or {}).special) then + if value.native ~= nil then + if a == (previous.baseStats or {}).specialAttack then a = nil end + if b == (previous.baseStats or {}).specialDefense then b = nil end + end + check(a == nil or a == stats.special, "conflicting Special Attack values") + a, b = a or stats.special, b or stats.special + elseif stats.special ~= nil and not old then a, b = a or stats.special, b or stats.special end + native.spatk, native.spdef = a, b + if a ~= nil then native.special = a end + if old and old.baseStats and old.baseStats.spAttack ~= nil then native.spAttack = a end + if old and old.baseStats and old.baseStats.spDefense ~= nil then native.spDefense = b end + end + out.baseStats = native + end + check(value.types ~= DELETE, "species types cannot be deleted") + if value.types then + local types = list(value.types, previous.types, function(t) return typeWrite(ctx, t) end) + check(#types >= 1 and #types <= 2, "species needs one or two types") + replace("types", types) + out.type1, out.type2 = types[1], types[2] or types[1] + end + for _, key in ipairs({ "level1Moves", "tmhm", "eggMoves" }) do + if value[key] ~= nil then replace(key, writeIds(ctx, "moves", value[key], previous[key])) end + end + if value.learnset ~= nil then + replace("learnset", list(value.learnset, previous.learnset, function(row) + return { level = row.level, move = resolved(ctx, "moves", row.move) } + end)) + end + if value.evolutions ~= nil then + replace("evolutions", list(value.evolutions, previous.evolutions, function(row, i) + return evolutionWrite(ctx, row, old and old.evolutions and old.evolutions[i]) + end)) + end + if value.growthRate and value.growthRate ~= DELETE then reference(ctx, "growth_rates", value.growthRate) end + if ctx.gen == 1 and (value.eggMoves or value.genderRatio or value.eggCycles) then + unsupported("breeding fields have no Gen 1 representation") + end + if not old and ctx.gen >= 3 then unsupported("new species require native EV/ability/storage metadata; patch an existing species") end + elseif kind == "moves" then + if value.type and value.type ~= DELETE then + out.type = typeWrite(ctx, value.type) + if old and old.typeId and out.type ~= old.type then unsupported("changing a DS type requires native type metadata") end + end + local probability = value.secondaryChance + if value.effect and value.effect ~= DELETE then + local plan, changed = Effects.write(value.effect, ctx.gen, old, function(effect) + return ctx.loader.content.move_effects:get(effect) ~= nil + end) + if not plan then unsupported(changed) end + out.effect = plan.effect + if changed then + out._api3EffectId = plan._api3EffectId + if ctx.gen == 3 then out.gen3Effect = plan.gen3Effect or DELETE end + if ctx.gen > 1 and probability == nil then probability = plan.probability or DELETE end + end + end + out.secondaryChance = nil + if probability ~= nil then + if ctx.gen == 1 or ctx.gen > 3 then unsupported("secondaryChance requires a Gen 2/3 native adapter") end + if old and probability ~= DELETE and probability == Effects.chance(old, ctx.gen) + and (probability ~= 0 or old._api3EffectChance or value.native ~= nil) then + -- Detached records preserve the exact original native chance byte. + else + out.effectChance = probability == DELETE and DELETE or math.floor(probability * 256 / 100 + 0.5) + out._api3EffectChance = probability == DELETE and DELETE or true + if ctx.gen == 3 then out.secondaryChance = probability end + end + end + if value.category ~= nil and old and old.class ~= nil then + check(value.category ~= DELETE, "native move class cannot be deleted") + out.class = value.category + if old.classId ~= nil then out.classId = ({ physical = 0, special = 1, status = 2 })[value.category] end + end + elseif kind == "items" then + if value.machine and value.machine ~= DELETE then + out.machine.move = resolved(ctx, "moves", value.machine.move) + end + for key, registry in pairs({ effect = "item_effects", ball = "balls" }) do + if value[key] and value[key] ~= DELETE then + reference(ctx, registry, value[key]) + if ctx.gen > 1 and (not old or value[key] ~= old[key]) then unsupported("changing item behavior requires native metadata") end + end + end + elseif kind == "trainers" and value.parties ~= nil then + local prior = old and (old.parties or (old.party and { old.party })) or {} + local parties = list(value.parties, previous.parties, function(party, i) + return list(party, nil, function(mon, j) + local native = copy(prior[i] and prior[i][j] or {}) + native.species, native.level = resolved(ctx, "pokemon", mon.species), mon.level + if ctx.gen == 1 and mon.item then unsupported("held items have no Gen 1 representation") end + native.item = mon.item and resolved(ctx, "items", mon.item) or nil + native.moves = mon.moves and writeIds(ctx, "moves", mon.moves) or nil + return native + end) + end) + replace("parties", parties) + if old and old.party then + check(#parties == 1, "native trainer has exactly one party") + replace("party", parties[1]) + end + elseif kind == "encounters" then + local function areaWrite(input, native, before, path) + if input == DELETE then return DELETE end + native, before = native or {}, before or {} + check(input.times ~= DELETE, "native time tables cannot be deleted") + if input.times then + input = copy(input) + if equal(input.rate, before.rate) then input.rate = nil end + if equal(input.slots, before.slots) then input.slots = nil end + end + local result = {} + if input.rate ~= nil then + check(input.rate ~= DELETE, "encounter rate cannot be deleted") + local rate = input.rate * (native.rateMax or 256) / 256 + if math.abs(rate - math.floor(rate + 0.5)) > 1e-8 then unsupported("encounter rate is not representable in native units") end + result.rate = math.floor(rate + 0.5) + end + if input.slots ~= nil then + local rows = list(input.slots, before.slots, function(slot, i) + local row = copy((native.slots or {})[i] or {}) + row.species = resolved(ctx, "pokemon", slot.species) + if slot.level then + check(slot.minLevel == nil and slot.maxLevel == nil, "conflicting encounter levels") + row.level, row.min, row.max = slot.level, nil, nil + else + check(slot.minLevel ~= nil and slot.maxLevel ~= nil and slot.minLevel <= slot.maxLevel, "invalid encounter level range") + row.level, row.min, row.max = nil, slot.minLevel, slot.maxLevel + end + if slot.chance then unsupported("changing encounter weights requires native buckets") end + return row + end) + if #rows ~= #(native.slots or {}) then unsupported("changing slot count requires native probability buckets") end + result.slots = rows; local p = copy(path); p[#p + 1] = "slots"; replacements[#replacements + 1] = p + end + if input.times then check(ctx.gen == 2 and native.byTime ~= nil, "time-specific encounters require a native time table") end + if native.byTime then + result.byTime = {} + for time, nativeTime in pairs({ MORN = "morn", DAY = "day", NITE = "nite" }) do + local shared = { rate = input.rate, slots = input.slots } + -- A full detached record contains inherited times: only explicit + -- changes override a shared rate/slot patch. + local specific = input.times and input.times[time] + if specific and before.times and equal(specific, before.times[time]) then specific = nil end + if specific then + for k, v in pairs(specific) do + if not before.times or not before.times[time] or not equal(v, before.times[time][k]) then shared[k] = v end + end + end + if nativeTime == "day" then + local d = areaWrite(shared, { rate = native.rate, rateMax = native.rateMax, slots = native.slots }, before, path) + if d.rate ~= nil then result.rate = d.rate end + if d.slots then result.slots = d.slots end + elseif native.byTime[nativeTime] then + local p = copy(path); p[#p + 1] = "byTime"; p[#p + 1] = nativeTime + result.byTime[nativeTime] = areaWrite(shared, native.byTime[nativeTime], before.times and before.times[time], p) + end + end + end + return result + end + for _, key in ipairs({ "grass", "water" }) do + if value[key] ~= nil then out[key] = areaWrite(value[key], old and old[key], previous[key], { key }) end + end + end + return out, replacements +end + +local function supported(kind, gen) + if kind == "encounters" and gen >= 4 then + return false, "unsupported: DS encounters have seasonal/daily replacement tables; use native content" + end + return true +end +local function protect(fn) + local result = { pcall(fn) } + if not result[1] then + if type(result[2]) == "table" and result[2].unsupported then return nil, "unsupported: " .. result[2].unsupported end + error(result[2], 0) + end + return unpack(result, 2) +end +function U.new(loader, mod, native, gen) + local out = {}; for name, handle in pairs(native) do out[name] = handle end + for kind, shape in pairs(Shapes) do + local registry = loader.content[kind] + if gen >= 4 and kind ~= "encounters" then registry.numericIds = true end + local f = Schemas.f + registry.referenceSpec = copy(registry.spec) + if kind == "pokemon" then + registry.referenceSpec.fields.evolutions = f.list(f.rec { + species = f.opt(f.id("pokemon")), target = f.opt(f.id("pokemon")), + item = f.opt(f.id("items")), heldItem = f.opt(f.id("items")), + }) + elseif kind == "trainers" then + local parties = f.list(f.list(f.rec { species = f.id("pokemon"), + item = f.opt(f.id("items")), moves = f.opt(f.list(f.id("moves"))) })) + registry.referenceSpec.fields.parties = parties + registry.referenceSpec.fields.party = parties.inner + elseif kind == "encounters" then + local slots = f.list(f.rec { species = f.id("pokemon") }) + local area = f.rec { slots = slots, byTime = f.opt(f.map(f.str, f.rec { slots = slots })) } + registry.referenceSpec.fields.grass, registry.referenceSpec.fields.water = area, area + if gen >= 4 then + -- The DS writer is unavailable, but removing a numeric species must + -- still see native encounter references, including seasonal tables. + local fields = {} + for _, key in ipairs({ "grass", "darkGrass", "rustling" }) do fields[key] = slots end + for _, key in ipairs({ "surf", "oldRod", "goodRod", "superRod", "ripplingSurf", "ripplingFishing" }) do + fields[key] = f.rec { slots = slots } + end + fields.timed = f.map(f.str, f.list(f.id("pokemon"))) + fields.radar = f.list(f.id("pokemon")) + fields.dualSlot = f.map(f.str, f.list(f.id("pokemon"))) + fields.swarm = f.union { f.list(f.id("pokemon")), f.map(f.str, f.id("pokemon")) } + fields.seasons = f.map(f.str, f.rec(fields)) + registry.referenceSpec.fields = fields + end + end + local available, why = supported(kind, gen) + local function context() return { loader = loader, gen = gen, ids = Ids.new(loader) } end + local function read(id) + if not available then return nil, why end + local ctx = context() + local found, err = ctx.ids:resolve(kind, id) + if not found then return nil, err end + local raw = registry:get(found.key) + return raw and readRecord(ctx, kind, id, raw) or nil + end + local handle = { available = available, supports = function() return available, why end, + get = function(_, id) return read(id) end, + has = function(_, id) return read(id) ~= nil end, + each = function() + if not available then return function() end, why end + local ctx, keys = context(), {} + for id, found in pairs(ctx.ids:entries(kind)) do + if found == false then error("ambiguous " .. kind .. " ID: " .. id, 0) end + keys[#keys + 1] = id + end + table.sort(keys) + local i = 0 + return function() + i = i + 1; local id = keys[i] + if id == nil then return end + local found = ctx.ids:resolve(kind, id) + return id, readRecord(ctx, kind, id, registry:get(found.key)) + end + end, + } + for _, verb in ipairs({ "register", "override", "patch", "remove" }) do + handle[verb] = function(_, id, value) + if not available then return nil, why end + return protect(function() + check(type(id) == "string" and id ~= "", "operation id must be a non-empty string") + local ctx = context() + local found, err = ctx.ids:resolve(kind, id) + if not found and err:match("^ambiguous") then unsupported(err) end + if not found then + if gen >= 4 then unsupported("new numeric records require native storage allocation") end + found = { key = id, keys = { id } } + end + local old = registry:get(found.key) + if verb == "register" then check(old == nil, kind .. " already registered: " .. id) end + if verb == "remove" then + check(not registry.frozen, kind .. ": content is frozen after load") + loader:_journal(kind) + for _, key in ipairs(found.keys) do registry:remove(key, mod.manifest.id) end + return true + end + finite(value) + check(type(value) == "table" and value ~= DELETE, "expected record") + for key in pairs(value) do check(shape.fields[key] ~= nil, "unknown " .. kind .. " field " .. tostring(key)) end + local function deletes(fields, record) + for key, entry in pairs(record) do + local field = fields[key] + if field and field.kind ~= "opt" then + check(entry ~= DELETE or field.kind == "list", "cannot delete required field " .. key) + if field.kind == "rec" and type(entry) == "table" then deletes(field.fields, entry) end + end + end + end + deletes(shape.fields, value) + local valid, validation = Schemas.check(shape, kind, id, value, verb) + check(valid, validation or "invalid record") + local payload, replacements = writeRecord(ctx, kind, id, value, old, verb) + if verb == "patch" and not old then unsupported("cannot patch absent " .. kind .. " ID: " .. id) end + check(not registry.frozen, kind .. ": content is frozen after load") + loader:_journal(kind) + for _, key in ipairs(found.keys) do + if verb == "patch" then registry:patch(key, payload, mod.manifest.id, replacements) + else + local record = Merge.deepMerge(copy(old or {}), payload) + for _, path in ipairs(replacements) do + local into, from = record, payload + for i = 1, #path - 1 do into, from = into[path[i]], from[path[i]] end + into[path[#path]] = copy(from[path[#path]]) + end + registry[verb](registry, key, record, mod.manifest.id) + end + end + return true + end) + end + end + out[kind] = handle + end + return out +end +return U diff --git a/src/mods/UnifiedEffects.lua b/src/mods/UnifiedEffects.lua new file mode 100644 index 000000000..2083f29a1 --- /dev/null +++ b/src/mods/UnifiedEffects.lua @@ -0,0 +1,135 @@ +-- Canonical effect IDs over this host's shared native battle handlers. +local C = require("src.mods.MoveEffectContract") +local E = {} +local numbers, nativeNames, variants = {}, {}, {} +for number in pairs(C.native3) do + numbers[#numbers + 1] = tonumber(number) +end +table.sort(numbers) +for _, number in ipairs(numbers) do + for _, name in ipairs(C.native3[tostring(number)]) do + if nativeNames[name] == nil then + nativeNames[name] = number + end + end +end +for id in pairs(C.variants) do + variants[#variants + 1] = id +end +table.sort(variants) +local function native(row, gen, chance) + if gen == 1 then + return row.native + end + local names = C.native3[tostring(row.semantic)] + if not names then + return nil + end + return names[2] and (chance or row.chance or 0) > 10 and names[2] or names[1] +end +local function opaque(value, gen) + return "native:" .. gen .. ":" .. tostring(value) +end +function E.chance(raw, gen) + if gen ~= 2 and gen ~= 3 then + return nil + end + if type(raw.effectChance) == "number" then + return math.max(0, math.min(100, math.floor(raw.effectChance * 100 / 256 + 0.5))) + end + return gen == 3 and raw.secondaryChance or nil +end +function E.read(value, gen, raw) + if value == nil then + return nil + end + if gen == 1 or gen > 3 then + return type(value) == "string" and value or nil + end + raw = raw or {} + local probability = E.chance(raw, gen) + local authored = raw._api3EffectId + local binding = type(authored) == "string" and C.bindings[authored] + if binding and binding[tostring(gen)] then + local row = binding[tostring(gen)] + for _, name in ipairs(C.native3[tostring(row.semantic)] or {}) do + if name == value then + return authored + end + end + end + if authored == value and not C.reserved[authored] then + return authored + end + local semantic = nativeNames[value] + if gen == 3 and type(raw.gen3Effect) == "number" then + local names = C.native3[tostring(raw.gen3Effect)] + for _, name in ipairs(names or {}) do + if name == value then + semantic = raw.gen3Effect + end + end + elseif value == "HEAL_EFFECT" and raw.id == "REST" then + semantic = 37 + end + if not semantic then + return opaque(value, gen) + end + for _, id in ipairs(variants) do + local row = C.variants[id] + if row.semantic == semantic and row.chance == probability and C.bindings[id][tostring(gen)] then + return id + end + end + local id = C.read3[tostring(semantic)] + if id and C.bindings[id] and C.bindings[id][tostring(gen)] then + return id + end + return opaque(value, gen) +end +function E.write(id, gen, old, registered) + if type(id) ~= "string" or id == "" then + return nil, "move effect must be a string id" + end + id = ({ HIT = "NO_ADDITIONAL_EFFECT", SLEEP = "SLEEP_EFFECT" })[id] or id + if old and (id == E.read(old.effect, gen, old) or id == opaque(old.effect, gen)) then + return { effect = old.effect }, false + end + if gen > 3 then + return nil, "changing move effects has no native adapter for this generation" + end + if id:match("^native:") then + return nil, "native effect token requires its original record" + end + local binding = C.bindings[id] + local row = binding and binding[tostring(gen)] + if not row then + if binding or C.reserved[id] or not registered(id) then + return nil, "effect has no native handler: " .. id + end + return { effect = id, _api3EffectId = gen > 1 and id or nil }, true + end + if + gen > 1 + and ( + (row.semantic == 37 and (not old or old.id ~= "REST")) + or row.semantic == 32 and old and old.id == "REST" + or row.semantic == 130 and (not old or old.id ~= "SONICBOOM") + or row.semantic == 41 and old and old.id == "SONICBOOM" + ) + then + return nil, "native effect depends on move identity: " .. id + end + local target = native(row, gen) + if not target or not registered(target) then + return nil, "effect has no native handler: " .. id + end + local out = { effect = target } + if gen > 1 then + out._api3EffectId = id + out.probability = row.chance + out.gen3Effect = gen == 3 and row.semantic or nil + end + return out, true +end +return E diff --git a/src/mods/UnifiedIds.lua b/src/mods/UnifiedIds.lua new file mode 100644 index 000000000..50866a636 --- /dev/null +++ b/src/mods/UnifiedIds.lua @@ -0,0 +1,85 @@ +-- Resolve portable names through this boot's own data, never a hard-coded +-- National Dex/item-number table (ROM hacks can assign entirely different IDs). +local Ids = {} +local function slug(name) + if type(name) ~= "string" then return nil end + name = name:gsub("é", "e"):gsub("É", "E"):gsub("♀", "_F"):gsub("♂", "_M") + :gsub("['’]", ""):upper():gsub("[^%w]+", "_"):gsub("^_+", ""):gsub("_+$", "") + name = name:gsub("^(TM)(%d+)$", "%1_%2"):gsub("^(HM)(%d+)$", "%1_%2") + return name ~= "" and name or nil +end +local aliases = { FARFETCH_D = "FARFETCHD", MR__MIME = "MR_MIME" } +local function canonical(key, row, kind) + if kind == "pokemon" or kind == "moves" or kind == "items" then + if type(key) ~= "string" or key:match("^SPECIES_%d+$") + or key:match("^MOVE_%d+$") or key:match("^ITEM_%d+$") then + return aliases[slug(row.name)] or slug(row.name) + end + return aliases[key] or key + end + -- Trainer classes, individual trainers and map IDs are game-specific. + return type(key) == "number" and tostring(key) or key +end + +function Ids.new(loader) + local indexes = {} + local function index(kind) + if indexes[kind] then return indexes[kind] end + local out = { public = {}, native = {} } + indexes[kind] = out + local registry = assert(loader.content[kind], "unknown registry " .. tostring(kind)) + -- Preserve identity/name metadata before the loader merges alias keys + -- into separate result tables. Presentation-name patches cannot rename IDs. + loader._unifiedIdBase = loader._unifiedIdBase or {} + local base = loader._unifiedIdBase[kind] + if not base then + base = {} + for key, row in pairs(registry.base and registry.base() or {}) do + if type(row) == "table" then + base[key] = { identity = row, id = canonical(key, row, kind) } + end + end + loader._unifiedIdBase[kind] = base + end + local groups = {} + for key, row in registry:each() do + if type(row) == "table" and (row.name or kind == "encounters") and key ~= 0 then + -- Aliases pointing at one pristine record form one identity even + -- after an earlier native mod patched only one of its keys. + local identity = base[key] and base[key].identity or row + local group = groups[identity] + if not group then + local id = base[key] and base[key].id or canonical(key, row, kind) + if id then + group = { id = id, key = key, keys = {} } + groups[identity] = group + local previous = out.public[id] + -- false is an ambiguity marker, never last-writer-wins. + if previous ~= nil then out.public[id] = false else out.public[id] = group end + end + end + if group then + group.keys[#group.keys + 1] = key + out.native[key] = group + if type(key) == "number" then group.key = key end + end + end + end + return out + end + return { + resolve = function(_, kind, id) + local found = index(kind).public[id] + if found == false then return nil, "ambiguous " .. kind .. " ID: " .. tostring(id) end + if not found then return nil, "unknown " .. kind .. " ID: " .. tostring(id) end + return found + end, + read = function(_, kind, id) + if id == nil or id == false or id == 0 or id == "" then return nil end + local found = index(kind).native[id] + return found and found.id or tostring(id) + end, + entries = function(_, kind) return index(kind).public end, + } +end +return Ids diff --git a/src/mods/UnifiedSchemas.lua b/src/mods/UnifiedSchemas.lua new file mode 100644 index 000000000..aa9a91cbe --- /dev/null +++ b/src/mods/UnifiedSchemas.lua @@ -0,0 +1,60 @@ +-- The opt-in content format is separate from the legacy/native schemas. +-- These are definition records, not saved Pokemon or callback payloads. +local Schemas = require("src.mods.Schemas") +local f = Schemas.f +local id = f.str +local ids = f.list(id) +local level = f.int(1, 100) +local stats = f.rec { + hp = f.int(1, 255), attack = f.int(1, 255), defense = f.int(1, 255), + speed = f.int(1, 255), special = f.int(1, 255), + specialAttack = f.opt(f.int(1, 255)), specialDefense = f.opt(f.int(1, 255)), +} +local evolution = f.rec { + method = id, species = id, level = f.opt(level), item = f.opt(id), + time = f.opt(f.enum { "ANYTIME", "MORNDAY", "NITE" }), + comparison = f.opt(f.enum { "ATK_GT_DEF", "ATK_LT_DEF", "ATK_EQ_DEF" }), +} +local slot = f.rec { + species = id, level = f.opt(level), minLevel = f.opt(level), maxLevel = f.opt(level), + chance = f.opt(f.numRange(0, 100)), +} +local area = f.rec { rate = f.numRange(0, 256), slots = f.list(slot), + times = f.opt(f.map(f.enum { "MORN", "DAY", "NITE" }, + f.rec { rate = f.opt(f.numRange(0, 256)), slots = f.opt(f.list(slot)) })) } +local U = { + pokemon = { fields = { + id = id, name = f.str, dex = f.opt(f.int(1)), types = ids, baseStats = stats, + catchRate = f.int(0, 255), baseExp = f.int(0), growthRate = id, + level1Moves = ids, learnset = f.list(f.rec { level = level, move = id }), + evolutions = f.list(evolution), tmhm = f.opt(ids), eggMoves = f.opt(ids), + genderRatio = f.opt(f.int(0, 255)), eggCycles = f.opt(f.int(0, 255)), + spriteFront = f.opt(f.path), spriteBack = f.opt(f.path), frontSize = f.opt(f.int(1, 7)), + trueColor = f.opt(f.bool), + } }, + moves = { fields = { + id = id, name = f.str, type = id, power = f.int(0, 255), accuracy = f.int(0, 100), + pp = f.int(0, 64), effect = f.opt(id), secondaryChance = f.opt(f.int(0, 100)), + category = f.opt(f.enum { "physical", "special", "status" }), + priority = f.opt(f.int(-7, 7)), description = f.opt(f.str), + } }, + items = { fields = { + id = id, name = f.str, price = f.int(0), + machine = f.opt(f.rec { kind = f.str, move = id, number = f.int(0) }), + effect = f.opt(id), ball = f.opt(id), tossable = f.opt(f.bool), + needsTarget = f.opt(f.bool), description = f.opt(f.str), + } }, + trainers = { fields = { + id = id, name = f.str, pic = f.opt(f.path), baseMoney = f.opt(f.int(0)), + battleTheme = f.opt(id), partyNames = f.opt(f.list(f.str)), + parties = f.list(f.list(f.rec { + species = id, level = level, item = f.opt(id), moves = f.opt(ids), + })), + } }, + encounters = { fields = { id = f.opt(id), grass = f.opt(area), water = f.opt(area) } }, +} +for _, spec in pairs(U) do + spec.fields.native = f.opt(f.any) + spec.semantics = "record" +end +return U diff --git a/src/mods/WorldOverview.lua b/src/mods/WorldOverview.lua new file mode 100644 index 000000000..491963d48 --- /dev/null +++ b/src/mods/WorldOverview.lua @@ -0,0 +1,153 @@ +-- Read-only world summaries. Do not call timeOfDay(), rollTable(), map loading, +-- or object visibility refreshes here: each can change the live simulation. +local Ids = require("src.mods.UnifiedIds") +local GameVersion = require("src.core.GameVersion") +local Overview = {} + +function Overview.context(loader) + Ids.new(loader):entries("pokemon") + return function() + return Ids.new(loader) + end +end + +function Overview.install(api, loader) + local context = Overview.context(loader) + if api.supports then + local supports = api.supports + function api:supports(surface, name) + if surface == "world" and (name == "mapOverview" or name == "effectiveEncounters") then + return true + end + return supports(self, surface, name) + end + end + return context +end + +local function dataIds(data) + local rows = data.pokemon or {} + return Ids.new({ + content = { + pokemon = { + base = function() + return rows + end, + each = function() + return pairs(rows) + end, + }, + }, + }) +end + +local periods = { MORN = "MORNING", MORNING = "MORNING", DAY = "DAY", NITE = "NITE", NIGHT = "NITE" } +local hours = { MORNING = 6, DAY = 12, NITE = 22 } + +function Overview.encounters(game, ow, context, mapId, terrain, opts) + if terrain ~= "grass" and terrain ~= "water" and terrain ~= "indoor" then + return nil, "invalid terrain: " .. tostring(terrain) + end + if opts ~= nil and type(opts) ~= "table" then + return nil, "opts must be a table" + end + local daytime = opts and opts.daytime + if daytime ~= nil then + daytime = type(daytime) == "string" and periods[daytime:upper()] + if not daytime then + return nil, "invalid daytime" + end + end + local data = game and game.data + if not (data and type(data.encounters) == "table") then + return nil, "encounters data not loaded" + end + local def = data.maps and data.maps[mapId] + if not def then + return nil, "unknown map: " .. tostring(mapId) + end + -- HGSS's Safari table depends on the player's position in a six-area matrix. + -- Encounter.forMap resolves it through the Game singleton and seeds Safari + -- save state. Refuse this exceptional table instead of querying another game. + if def.cameraFamily == "hgss" and require("src.world.HgssSafariZone").isSafariMap(def) then + return nil, "position-dependent Safari encounters unavailable" + end + if GameVersion.get() == "emerald" then + if + require("src.script.Gen3BattlePike").inWildRoom(mapId) + or require("src.script.Gen3BattlePyramid").onFloor(mapId) + then + return nil, "facility encounters unavailable" + end + end + local Encounter = require("src.world.Encounter") + local hour = daytime and hours[daytime] + local enc = Encounter.forMap(data, def, mapId, hour, game.save) + if not daytime then + -- A world.tod hook's most recent effective period is already on the world. + -- Calling it again would execute mod code and can emit/change world state. + local Runtime = require("src.mods.Runtime") + if Runtime.wantsHook("world.tod") and ow then + daytime = ow.tod + end + if GameVersion.isGen2() then + if not daytime then + hour = tonumber(os.date("%H")) or 12 + daytime = hour < 4 and "NITE" or hour < 10 and "MORNING" or hour < 18 and "DAY" or "NITE" + end + else + daytime = daytime or (ow and ow.tod) or "DAY" + end + end + local slots = enc and enc[terrain == "indoor" and "grass" or terrain] + if terrain ~= "water" then + slots = Encounter.atTime(slots, daytime) + end + if not slots then + return { chance = 0, dist = {} } + end + local maximum = tonumber(slots.rateMax) or 256 + local rate = tonumber(slots.rate) or 0 + local span = tonumber(slots.bucketSpan) or 256 + if + maximum <= 0 + or maximum ~= math.floor(maximum) + or maximum == math.huge + or rate ~= rate + or rate == math.huge + or rate == -math.huge + or span <= 0 + or span ~= math.floor(span) + or span == math.huge + then + return nil, "invalid encounter rate/span" + end + local dist, prev = {}, 0 + local weights = slots.buckets or require("src.world.FieldDefaults").constant(data, "encounterBuckets") + if type(weights) ~= "table" or (slots.slots ~= nil and type(slots.slots) ~= "table") then + return nil, "invalid encounter buckets/slots" + end + local ids = context and context() or dataIds(data) + for i, threshold in ipairs(weights or {}) do + if + type(threshold) ~= "number" + or threshold ~= math.floor(threshold) + or threshold < prev + or threshold > span + then + return nil, "invalid encounter buckets" + end + local slot = slots.slots and slots.slots[i] + if rate > 0 and slot and slot.species and threshold > prev then + local id = ids:read("pokemon", slot.species) + if id then + dist[id] = (dist[id] or 0) + threshold - prev + end + end + prev = threshold + end + -- Same independent integer rate gate as rollTable, without drawing a slot. + return { chance = math.max(0, math.min(maximum, math.ceil(rate))) / maximum, dist = dist } +end + +return Overview diff --git a/src/mods/WorldReads.lua b/src/mods/WorldReads.lua new file mode 100644 index 000000000..2ca8f2504 --- /dev/null +++ b/src/mods/WorldReads.lua @@ -0,0 +1,227 @@ +-- Data-only readers shared by mod.world. Nothing here constructs a Pokemon, +-- loads a map, calls gameplay methods or advances either random generator. +local Ids = require("src.mods.UnifiedIds") +local Reads = {} +local METHODS = { party = true, activeBlockAt = true, liveMaps = true } + +function Reads.supports(name) + return type(name) == "string" and METHODS[name] == true +end + +-- Capture native alias identity/name metadata before the loader splits alias +-- rows during merging. Fresh indexes at read time still include later mods. +function Reads.context(loader) + local ids = Ids.new(loader) + for _, kind in ipairs({ "pokemon", "moves", "items" }) do + ids:entries(kind) + end + return { + ids = function() + return Ids.new(loader) + end, + } +end + +-- Engine callers can construct WorldAPI without a mod loader too. The +-- private registry facade only reads data; Ids' bookkeeping stays on it. +local function dataIds(data) + local loader = { content = {} } + for _, kind in ipairs({ "pokemon", "moves", "items" }) do + local rows = type(data[kind]) == "table" and data[kind] or {} + loader.content[kind] = { + base = function() + return rows + end, + each = function() + return pairs(rows) + end, + } + end + return Ids.new(loader) +end + +local function copy(value, seen) + local kind = type(value) + if kind ~= "table" then + if kind == "number" or kind == "string" or kind == "boolean" then + return value + end + return nil + end + seen = seen or {} + if seen[value] then + return seen[value] + end + local out = {} + seen[value] = out + for key, sub in pairs(value) do + if type(key) == "string" or type(key) == "number" then + out[key] = copy(sub, seen) + end + end + return out +end +local STATUS = + { SLP = "sleep", PSN = "poison", BRN = "burn", FRZ = "freeze", PAR = "paralyze", TOX = "toxic" } +local STAT = { + hp = "hp", + attack = "attack", + defense = "defense", + speed = "speed", + special = "special", + specialAttack = "spatk", + specialDefense = "spdef", +} + +-- The same common live-Pokemon shape as the unified callback boundary. +-- Native diagnostics are data-only copies, including unknown save extensions. +function Reads.party(game, context) + local save = game and game.save + if not save then + return nil, "no save" + end + local data = game.data or {} + local ids = context and context.ids() or dataIds(data) + local out = {} + for i, raw in ipairs(save.party or {}) do + local stats, moves = {}, {} + for key, native in pairs(STAT) do + stats[key] = (raw.stats or {})[native] + end + stats.specialAttack = stats.specialAttack or stats.special + stats.specialDefense = stats.specialDefense or stats.special + for slot, move in ipairs(raw.moves or {}) do + local id = type(move) == "table" and move.id or move + local def = (data.moves or {})[id] + local ups = type(move) == "table" and (move.ppUps or 0) or 0 + moves[slot] = { + id = ids:read("moves", id), + pp = type(move) == "table" and move.pp or nil, + maxPp = def and ((def.pp or 0) + ups * math.floor((def.pp or 0) / 5)) or nil, + ppUps = ups, + } + end + out[i] = { + species = ids:read("pokemon", raw.species), + item = ids:read("items", raw.item), + nickname = raw.nickname, + name = raw.name, + level = raw.level, + hp = raw.hp, + maxHp = raw.stats and raw.stats.hp, + status = STATUS[raw.status] or raw.status, + isEgg = raw.isEgg == true, + stats = stats, + moves = moves, + native = copy(raw), + } + end + return out +end + +local function integer(value, low) + return type(value) == "number" + and value == value + and value < math.huge + and value > -math.huge + and value % 1 == 0 + and value >= low +end + +-- Public block coordinates are zero-based, not walk-cell coordinates. Derive +-- geometry from this map's runtime tileset, not the selected game's number. +function Reads.metadata(map) + local def = map and map.def + if + not def + or not integer(def.width, 1) + or not integer(def.height, 1) + or not integer(map.blockCells, 1) + or not integer(map.blockTiles, 1) + or map.blockTiles * 8 ~= map.blockCells * 16 + then + return nil, "block geometry unavailable" + end + return { + tileSize = map.blockTiles * 8, + cellSize = 16, + coordinateBase = 0, + kind = (def.generation == 4 or def.generation == 5 or def.gen5) and "runtime_cell" + or map.blockCells == 1 and "metatile" + or "block", + width = def.width, + height = def.height, + } +end + +function Reads.activeBlockAt(map, mapId, bx, by) + if map.id ~= mapId then + return nil, "map is not active" + end + if not integer(bx, 0) or not integer(by, 0) then + return nil, "invalid block coordinates" + end + local metadata, reason = Reads.metadata(map) + if not metadata then + return nil, reason + end + if bx >= metadata.width or by >= metadata.height then + return nil, "block coordinates out of bounds" + end + local index = by * metadata.width + bx + 1 + local block = map.blockPatch and map.blockPatch[index] + if block == nil then + local blocks = map.def.blocks + if type(blocks) == "table" then + block = blocks[index] + elseif type(blocks) == "string" then + -- Respect a cache already used by Map:blockAt, without populating it. + if map.def._blockArray then + block = map.def._blockArray[index] + else + local lo, hi = blocks:byte(index * 2 - 1, index * 2) + if hi then + block = (lo + hi * 256) % 1024 + end + end + end + end + if not integer(block, 0) then + return nil, "block unavailable" + end + return block, metadata +end + +function Reads.liveMaps(world) + local active = world.map + local out = {} + local seen = {} + local function add(map, x, y, isActive) + if not map then + return + end + -- A connected layout can show one map at several different offsets. + -- Collapse duplicate placements only, retaining the spatial instances. + local key = tostring(map.id) .. ":" .. tostring(x) .. ":" .. tostring(y) + if seen[key] then + return + end + seen[key] = true + local metadata = Reads.metadata(map) + out[#out + 1] = { + mapId = map.id, + x = x, + y = y, + active = isActive, + width = map.widthCells, + height = map.heightCells, + block = metadata, + } + end + add(active, 0, 0, true) + for _, neighbor in ipairs(world.neighbors or {}) do + add(neighbor.map, neighbor.cx or 0, neighbor.cy or 0, false) + end + return out +end +return Reads diff --git a/src/pokemon/DayCare.lua b/src/pokemon/DayCare.lua index e5a1c46c6..b94bb233b 100644 --- a/src/pokemon/DayCare.lua +++ b/src/pokemon/DayCare.lua @@ -231,6 +231,11 @@ local DITTO = "SPECIES_132" -- is compared against, scaled by 16: below it the mon is female. function DayCare.gender(data, mon) if not (data and mon) then return nil end + local generation = require("src.core.GameVersion").generation() + if (generation == 2 or generation == 3) and require("src.mods.Runtime").wantsHook("gender.roll") then + local gender = require("src.pokemon.IdentityRolls").gender(data, mon, generation) + return gender == "unknown" and "none" or gender + end local def = data.pokemon and data.pokemon[mon.species] local ratio = def and def.genderRatio if ratio == nil then return nil end -- Gen1 import: no gender data @@ -247,7 +252,7 @@ function DayCare.gender(data, mon) return (ratio > (mon.personality % 256)) and "female" or "male" end local atk = (mon.dvs and mon.dvs.attack) or 0 - return (atk * 16 < ratio) and "female" or "male" + return (atk * 16 + ((mon.dvs or {}).speed or 0) <= ratio) and "female" or "male" end local function eggGroupsOf(data, mon) diff --git a/src/pokemon/Gen3ConstrainedMon.lua b/src/pokemon/Gen3ConstrainedMon.lua new file mode 100644 index 000000000..8f78e4b45 --- /dev/null +++ b/src/pokemon/Gen3ConstrainedMon.lua @@ -0,0 +1,97 @@ +-- Private native preset builder (CreateMonWithGenderNatureLetter). +-- Unlike an ordinary encounter, this draws only candidate PID words: no +-- random IVs/DVs, learnset moves or shiny-odds roll. Specs use cartridge IDs. +local Stats = require("src.pokemon.Stats") +local Identity = require("src.pokemon.IdentityRolls") +local M = {} + +local function resolve(data, registry, orderName, number) + local rows = data[registry] or {} + local order = (data.constants or {})[orderName] or {} + local id = order[number] + if id ~= nil and rows[id] then + return id, rows[id] + end + local found + for key, row in pairs(rows) do + if tonumber(row.index) == number then + assert(found == nil or rows[found] == row, "ambiguous native " .. registry .. " ID " .. number) + found = key + end + end + assert(found ~= nil, "missing native " .. registry .. " ID " .. number) + return found, rows[found] +end + +function M.new(data, spec, rng) + assert( + type(spec.nature) == "number" and spec.nature % 1 == 0 and spec.nature >= 0 and spec.nature < 25, + "native preset requires a nature ID in 0..24" + ) + assert( + spec.gender == "male" or spec.gender == "female" or spec.gender == "unknown", + "native preset requires a gender" + ) + local species, def = resolve(data, "pokemon", "speciesOrder", spec.species) + assert(Stats.isGen3(def), "native preset requires a Gen-3 species") + local constants = data.constants or {} + local nature = (constants.natureOrder or {})[spec.nature + 1] + if nature == nil then + for id, row in pairs(constants.natures or {}) do + if tonumber(row.index) == spec.nature then + nature = id + break + end + end + end + assert(nature ~= nil, "missing native nature ID " .. tostring(spec.nature)) + local ball = resolve(data, "items", "itemOrder", 4) + local moves = {} + for _, number in ipairs(spec.moves) do + if number ~= 0 then + local id, move = resolve(data, "moves", "moveOrder", number) + moves[#moves + 1] = { id = id, pp = move.pp, maxPp = move.pp } + end + end + rng = rng or love.math.random + local mon = { + species = species, + level = spec.level, + metLevel = spec.level, + nature = nature, + ball = ball, + growthRate = def.growthRate, + catchRate = def.catchRate, + moves = moves, + ivs = {}, + evs = {}, + friendship = def.friendship or 70, + happiness = def.friendship or 70, + } + repeat + -- Random32 consumes the low word first. Gender is evaluated only for + -- matching natures, including the public hook's rejection/override policy. + local lo, hi = rng(0, 65535), rng(0, 65535) + mon.personality = lo + hi * 65536 + until mon.personality % 25 == spec.nature and Identity.gender(data, mon, 3) == spec.gender + mon.gender = spec.gender + local abilities = def.abilities or {} + mon.abilitySlot = abilities[2] and mon.personality % 2 + 1 or 1 + mon.ability = abilities[mon.abilitySlot] + for _, stat in ipairs(Stats.ORDER_GEN3) do + mon.ivs[stat], mon.evs[stat] = 0, 0 + end + -- Native nature effects follow the PID's 0..24 matrix, independently of + -- global preview state or translated nature registry keys. + mon.stats = Stats.calcGen3(def, spec.level, mon.ivs, mon.evs) + local order = { "attack", "defense", "speed", "spatk", "spdef" } + local up, down = math.floor(spec.nature / 5) + 1, spec.nature % 5 + 1 + if up ~= down then + mon.stats[order[up]] = math.floor(mon.stats[order[up]] * 1.1) + mon.stats[order[down]] = math.floor(mon.stats[order[down]] * 0.9) + end + mon.stats.special, mon.hp = mon.stats.spatk, mon.stats.hp + mon.exp = require("src.pokemon.Growth").expForLevel(def.growthRate, spec.level) + return mon +end +return M diff --git a/src/pokemon/IdentityRolls.lua b/src/pokemon/IdentityRolls.lua new file mode 100644 index 000000000..5c8d13967 --- /dev/null +++ b/src/pokemon/IdentityRolls.lua @@ -0,0 +1,107 @@ +-- Shared hook results, with each generation's actual DV/PID predicate. +-- Native predicates draw no RNG; explicit Gen-2 context edits retain native +-- write-back rather than silently changing the saved identity on an override. +local Runtime = require("src.mods.Runtime") +local Stats = require("src.pokemon.Stats") +local bit = require("bit") +local R = {} +local GENDERS = { male = true, female = true, unknown = true } + +function R.data(data) + return data or Runtime.hooks.data +end + +local function definition(data, species, generation) + local def = data and data.pokemon and data.pokemon[species] + if generation ~= 3 or type(def) ~= "table" then + return def + end + -- GBA's species_hook_def is a call-local metadata record. Threshold + -- steering changes this evaluation, not the mounted species table. + local out = {} + for k, v in pairs(def) do + out[k] = v + end + out.id = species + return out +end + +function R.nativeGender(def, dvs, personality, generation) + local ratio = def and def.genderRatio + if ratio == nil or ratio == 255 then + return "unknown" + end + if ratio == 0 then + return "male" + end + if ratio == 254 then + return "female" + end + if generation == 3 then + return ratio > ((tonumber(personality) or 0) % 256) and "female" or "male" + end + local value = (dvs and dvs.attack or 0) * 16 + (dvs and dvs.speed or 0) + return value <= ratio and "female" or "male" +end + +function R.gender(data, mon, generation, level) + data = R.data(data) + local def = definition(data, mon.species, generation) + local fallback = R.nativeGender(def, mon.dvs, mon.personality, generation) + local function vanilla(c) + return R.nativeGender(c.def, c.dvs, c.personality, generation) + end + local ctx = { species = mon.species, def = def, ratio = def and def.genderRatio } + if generation == 2 then + ctx.dvs, ctx.level = mon.dvs, level + else + ctx.personality = mon.personality + end + local result = Runtime.call("gender.roll", vanilla, ctx) + if not GENDERS[result] then + return generation == 3 and fallback or R.nativeGender(def, mon.dvs, mon.personality, generation) + end + return result +end + +function R.gen2Shiny(mon, def) + local result = Runtime.call("shiny.roll", function(c) + return Stats.isShiny(c.dvs) + end, { dvs = mon.dvs, species = mon.species, def = def, level = mon.level }) + return not not result +end + +local function nativeShiny(personality, otId, otSecretId) + local p = (tonumber(personality) or 0) % 4294967296 + local full = (tonumber(otId) or 0) % 4294967296 + local tid = full % 65536 + local sid = (tonumber(otSecretId) or math.floor(full / 65536)) % 65536 + return bit.bxor(tid, sid, math.floor(p / 65536), p % 65536) < 8 +end +R.nativeGen3Shiny = nativeShiny + +function R.gen3Shiny(mon, data) + data = R.data(data) + local def = definition(data, mon.species, 3) + local ivs + if type(mon.ivs) == "table" then + ivs = {} + for k, v in pairs(mon.ivs) do + ivs[k] = v + end + end + local result = Runtime.call("shiny.roll", function(c) + return nativeShiny(c.personality, c.otId, c.otSecretId) + end, { + species = mon.species, + def = def, + level = mon.level, + personality = mon.personality, + ivs = ivs, + otId = mon.otId or mon.trainerId, + otSecretId = mon.otSecretId, + }) + return not not result +end + +return R diff --git a/src/pokemon/Pokemon.lua b/src/pokemon/Pokemon.lua index 79c91e7fe..0663fab24 100644 --- a/src/pokemon/Pokemon.lua +++ b/src/pokemon/Pokemon.lua @@ -173,7 +173,7 @@ function Pokemon.applySeed(data, mon, seed) return true end -function Pokemon.new(data, species, level, rng, form) +function Pokemon.new(data, species, level, rng, form, opts) local nativeForm=(data.constants or {}).gen==4 and (form or 0) or nil local speciesDef=(data.pokemon or {})[species] if require("src.core.GameVersion").get()=="polishedcrystal" and speciesDef and speciesDef.forms then @@ -241,10 +241,42 @@ function Pokemon.new(data, species, level, rng, form) -- meaning "in tall grass only". At the default setting shinyOddsOf answers -- nil and this returns immediately, leaving the cartridge's own 1/8192 -- exactly as it was. + local generation = require("src.core.GameVersion").generation() + local Runtime = require("src.mods.Runtime") + if generation == 2 then + if opts and opts.shiny then mon.shiny = true + elseif Runtime.wantsHook("shiny.roll") then + mon.shiny = require("src.pokemon.IdentityRolls").gen2Shiny(mon, def) + end + if Runtime.wantsHook("gender.roll") then + mon.gender = require("src.pokemon.IdentityRolls").gender(data, mon, 2, level) + end + elseif generation == 3 then + if Runtime.wantsHook("gender.roll") then + mon.gender = require("src.pokemon.IdentityRolls").gender(data, mon, 3) + end + if opts and opts.shiny then mon.isShiny, mon.shiny = true, true end + end Pokemon.rollShiny(data, nil, mon, rng) return mon end +-- Gen 2 refreshes identity at its summary/stat-refresh boundary. A true +-- shiny stays authoritative; a cached false and gender are read again. +function Pokemon.syncIdentity(data, mon) + if type(mon) ~= "table" or require("src.core.GameVersion").generation() ~= 2 then return mon end + local Runtime, Rolls = require("src.mods.Runtime"), require("src.pokemon.IdentityRolls") + local def = data and data.pokemon and data.pokemon[mon.species] + if not def then return mon end + if mon.gender ~= nil or Runtime.wantsHook("gender.roll") then + mon.gender = Rolls.gender(data, mon, 2, mon.level) + end + if mon.shiny ~= nil or Runtime.wantsHook("shiny.roll") then + mon.shiny = mon.shiny or Rolls.gen2Shiny(mon, def) + end + return mon +end + -- LoadEnemyMon .InitDVs (engine/battle/core.asm): a BATTLETYPE_SHINY -- encounter skips the random roll and writes the two fixed DV bytes -- ATKDEFDV_SHINY ($EA) and SPDSPCDV_SHINY ($AA) -- Attack 14, Defense 10, @@ -261,6 +293,9 @@ Pokemon.SHINY_DVS = { attack = 14, defense = 10, speed = 10, special = 10, hp = -- player passes it here and the personality is moved for real at birth. function Pokemon.forceShiny(data, mon, rng, otId) if type(mon) ~= "table" then return mon end + local generation = require("src.core.GameVersion").generation() + if generation == 2 then mon.shiny = true end + if generation == 3 then mon.isShiny = true end -- A GEN 3 POKEMON IS NOT MADE SHINY BY ITS DVs, because it has none. Its -- personality and the trainer's id decide it, and Stats.shinyPersonality -- moves the half of the personality that decides nothing else -- so the @@ -379,6 +414,20 @@ end -- what an editor writes function Pokemon.isShiny(mon) if type(mon) ~= "table" then return false end + local generation = require("src.core.GameVersion").generation() + if generation == 2 and mon.shiny ~= nil then return not not mon.shiny end + if generation == 3 then + if mon.isShiny ~= nil then return not not mon.isShiny end + if require("src.mods.Runtime").wantsHook("shiny.roll") then + return require("src.pokemon.IdentityRolls").gen3Shiny(mon) + end + -- Split trainer IDs and the reference's trainerId alias are explicit + -- identity data too; a delegating hook must not be their only reader. + if mon.otSecretId ~= nil or (mon.otId == nil and mon.trainerId ~= nil) then + return require("src.pokemon.IdentityRolls").nativeGen3Shiny( + mon.personality, mon.otId or mon.trainerId, mon.otSecretId) or mon.shiny == true + end + end -- PERSONALITY FIRST, and this order is the whole correctness of it: -- Pokemon.new rolls DVs for every Pokemon including Hoenn's, so a Gen 3 mon -- has BOTH fields and asking `dvs` first sent it down Johto's branch, where diff --git a/src/render/TextBox.lua b/src/render/TextBox.lua index 07f605295..359964018 100644 --- a/src/render/TextBox.lua +++ b/src/render/TextBox.lua @@ -420,6 +420,44 @@ function TextBox:beginLine() table.insert(self.shown, {}) end +-- Only the manual input branches; typing, automatic text and holds still +-- belong to update(), including the delay after a page/line advance. +function TextBox:advance() + if (self.holdFrames or 0) > 0 or (self.preWait or 0) > 0 then + return + end + if self.done then + if self.stay or self.auto or self.choice then + return + end + require("src.core.Sound").play(self.game.data, "Press_AB") + self.game.stack:pop() + if self.onDone then + self.onDone() + end + elseif self.waiting then + require("src.core.Sound").play(self.game.data, "Press_AB") + self.waiting = false + if self.contAdvance then + -- ContText / ManualTextScroll: keep the box, scroll one line + self.contAdvance = false + self.lineIndex = self.lineIndex + 1 + self:beginLine() + -- ScrollTextUpOneLine is 5 blocking frames and, as its own comment + -- says, is "always called twice in a row" (home/text.asm:280-305) + self.holdFrames = self.scrollHoldFrames or Timing.TEXT_SCROLL_PAIR + else + self.shown = {} + self.pageIndex = self.pageIndex + 1 + self.lineIndex = 1 + self:beginLine() + -- ClearScreenArea then DelayFrames 20: the box sits empty before the + -- next page starts typing (home/text.asm:236-240) + self.holdFrames = Timing.TEXT_PAGE_CLEAR + end + end +end + function TextBox:update(dt) local input = self.game.input self.blink = (self.blink + 1) % 60 @@ -519,9 +557,7 @@ function TextBox:update(dt) return end if input:wasPressed("a") or input:wasPressed("b") then - require("src.core.Sound").play(self.game.data, "Press_AB") - self.game.stack:pop() - if self.onDone then self.onDone() end + self:advance() end return end @@ -534,25 +570,7 @@ function TextBox:update(dt) return end if input:wasPressed("a") or input:wasPressed("b") then - require("src.core.Sound").play(self.game.data, "Press_AB") - self.waiting = false - if self.contAdvance then - -- ContText / ManualTextScroll: keep the box, scroll one line - self.contAdvance = false - self.lineIndex = self.lineIndex + 1 - self:beginLine() - -- ScrollTextUpOneLine is 5 blocking frames and, as its own comment - -- says, is "always called twice in a row" (home/text.asm:280-305) - self.holdFrames = self.scrollHoldFrames or Timing.TEXT_SCROLL_PAIR - else - self.shown = {} - self.pageIndex = self.pageIndex + 1 - self.lineIndex = 1 - self:beginLine() - -- ClearScreenArea then DelayFrames 20: the box sits empty before the - -- next page starts typing (home/text.asm:236-240) - self.holdFrames = Timing.TEXT_PAGE_CLEAR - end + self:advance() end return end diff --git a/src/script/Gen3Commands.lua b/src/script/Gen3Commands.lua index 6c4d921ff..78276f15e 100644 --- a/src/script/Gen3Commands.lua +++ b/src/script/Gen3Commands.lua @@ -3180,10 +3180,8 @@ function Commands.g3_fade_screen(ctx, mode) end end --- sound.c sFanfares: how many frames WaitFanfare holds for each jingle. The --- rendered song can run far past this (its tail is silence), so the frame --- count is the cartridge's answer to "is the fanfare over". Keyed by --- FireRed's song numbers; another cartridge waits for the song itself. +-- Retained legacy export for consumers using FireRed's numeric timers. +-- Native playback and waits resolve per-version metadata through Gen3Fanfare. Gen3Commands.FRLG_FANFARE_FRAMES = { [256] = 160, [257] = 80, [258] = 160, [259] = 220, [260] = 340, [261] = 220, [262] = 120, [268] = 250, [269] = 150, [270] = 180, @@ -3191,40 +3189,40 @@ Gen3Commands.FRLG_FANFARE_FRAMES = { } function Commands.g3_play_fanfare(ctx, song, number) - local Music = require("src.core.Music") - if not Music.playOnce(ctx.game.data, song) then - ctx.g3Fanfare = nil - return - end - local frames - if require("src.core.GameVersion").get() == "firered" then - frames = Gen3Commands.FRLG_FANFARE_FRAMES[tonumber(number) or -1] - end - ctx.g3Fanfare = { started = (ctx.game.save and ctx.game.save.playTime) or 0, - seconds = frames and frames / 60 or nil } + local hex = type(song) == "string" and song:match("^SONG_(%x+)$") + local id = number or hex and tonumber(hex, 16) or song + return require("src.core.Music").nativeFanfare(ctx.game.data, valueOf(ctx, id), ctx) +end + +function Commands.g3_wait_se(ctx) + local Sound = require("src.core.Sound") + if not Sound.isSePlaying(ctx.game.data) then return end + ctx.runner.waitingCheck = Sound.waitSe(ctx.game.data) + ctx.runner:yield() +end + +function Commands.g3_bgm(ctx, operation, song, keep) + return require("src.core.Music").nativeCommand(ctx.game.data, operation, song, keep, ctx) +end + +function Commands.g3_play_cry(ctx, species, mode) + local raw = valueOf(ctx, species) + require("src.core.Sound").playCry(ctx.game.data, Gen3Commands.speciesId(ctx.game.data, raw), + { nativeSpecies = raw, mode = valueOf(ctx, mode), mono = ctx.save.options and ctx.save.options.stereo == false }) +end + +function Commands.g3_wait_cry(ctx) + local Sound = require("src.core.Sound") + if Sound.isCryFinished() then return end + ctx.runner.waitingCheck = Sound.waitCry() + ctx.runner:yield() end function Commands.g3_wait_fanfare(ctx) - local fanfare = ctx.g3Fanfare - if not fanfare then return end - local Music = require("src.core.Music") - local function over() - if not Music.oneShotPlaying() then return true end - -- game time (save.playTime steps 1/60 per logic frame), not wall time - local now = (ctx.game.save and ctx.game.save.playTime) or 0 - if fanfare.seconds and now - fanfare.started >= fanfare.seconds then - -- the jingle's audible part is done; bring the map theme back now - -- rather than after the rendered tail - pcall(Music.restoreMap, ctx.game.data) - return true - end - return false - end - ctx.g3Fanfare = nil - if over() then return end - local runner = ctx.runner - runner.waitingCheck = function() return over() end - runner:yield() + local wait = require("src.core.Music").nativeWaitFanfare() + if not wait then return end + ctx.runner.waitingCheck = wait + ctx.runner:yield() end function Commands.g3_wait_state(ctx) @@ -3756,6 +3754,7 @@ function Gen3Commands.firstBattle(ctx) Commands.start_battle(ctx, "wild", species, level, { -- BATTLE_TYPE_FIRST_BATTLE: there is nowhere to run to and nothing to -- black out to, and the script heals the party two commands later + firstBattle = true, noRun = true, canLose = record.canLose and true or false, }) diff --git a/src/script/Gen3ScriptVM.lua b/src/script/Gen3ScriptVM.lua index d5c709496..51b503628 100644 --- a/src/script/Gen3ScriptVM.lua +++ b/src/script/Gen3ScriptVM.lua @@ -654,30 +654,21 @@ L.playse = function(ir, s) local song = songLabel(ir[2]) if song then emit(s, { "play_sound", song }) end end -L.waitse = L.nop +L.waitse = function(_, s) emit(s, { "g3_wait_se" }) end -- A FANFARE STARTS AND THE SCRIPT KEEPS GOING: `playfanfare / message / -- waitmessage / waitfanfare` prints "received" while the jingle plays. -- Lowered to a blocking play_once, the box only appeared after the whole -- rendered song -- trailing silence included -- had run out. L.playfanfare = function(ir, s) - local song = songLabel(ir[2]) - if song then emit(s, { "g3_play_fanfare", song, tonumber(ir[2]) }) end + emit(s, { "g3_play_fanfare", ir[2] }) end L.waitfanfare = function(_, s) emit(s, { "g3_wait_fanfare" }) end -L.playbgm = function(ir, s) - local song = songLabel(ir[2]) - if song then emit(s, { "play_music", song }) end -end -L.savebgm = L.nop -L.fadedefaultbgm = function(_, s) emit(s, { "play_default_music" }) end -L.fadenewbgm = function(ir, s) - local song = songLabel(ir[2]) - if song then emit(s, { "play_music", song }) end +for _, operation in ipairs({ "playbgm", "savebgm", "fadedefaultbgm", "fadenewbgm", "fadeoutbgm", "fadeinbgm" }) do + local name = operation + L[name] = function(ir, s) emit(s, { "g3_bgm", name, ir[2], ir[3] }) end end -L.fadeoutbgm = function(_, s) emit(s, { "stop_music" }) end -L.fadeinbgm = function(_, s) emit(s, { "play_default_music" }) end -L.playmoncry = function(ir, s) emit(s, { "play_cry", ir[2] }) end -L.waitmoncry = L.nop +L.playmoncry = function(ir, s) emit(s, { "g3_play_cry", ir[2], ir[3] }) end +L.waitmoncry = function(_, s) emit(s, { "g3_wait_cry" }) end -- screen, field, world ----------------------------------------------------- diff --git a/src/ui/BagMenu.lua b/src/ui/BagMenu.lua index 4d229471d..4020375b6 100644 --- a/src/ui/BagMenu.lua +++ b/src/ui/BagMenu.lua @@ -1234,11 +1234,14 @@ function BagMenu.new(game, opts) options[#options + 1] = { label = Strings("QUIT"), onSelect = function() end } end local th = #options * 2 + 1 - game.stack:push(Menu.new(game, options, gen2 + local actions = Menu.new(game, options, gen2 and { tx = 0, ty = 12 - th, tw = 7, th = th } - or { tx = 13, ty = 10, tw = 7, th = th })) + or { tx = 13, ty = 10, tw = 7, th = th }) + actions.publicUIKind = "bag_actions" + game.stack:push(actions) end, }) + list.publicUIKind, list.publicUIBattle = "bag", battle if gen2 then list.sgbPalettes = packPalettes end return list end diff --git a/src/ui/ChoiceBox.lua b/src/ui/ChoiceBox.lua index 1f9f41f81..ba7edbdd0 100644 --- a/src/ui/ChoiceBox.lua +++ b/src/ui/ChoiceBox.lua @@ -30,6 +30,18 @@ function ChoiceBox.new(game, onChoose, opts) return self end +function ChoiceBox:choose(index) + if self.pending ~= nil then + return + end + if not self.noSound then + require("src.core.Sound").play(self.game.data, "Press_AB") + end + self.index = index + self.pending = index == 1 + self.holdFrames = Timing.YES_NO_ANSWER +end + function ChoiceBox:update(dt) local input = self.game.input -- Both branches of DisplayTwoOptionMenu hold 15 frames with the menu still @@ -48,21 +60,9 @@ function ChoiceBox:update(dt) if input:wasPressed("up") or input:wasPressed("down") then self.index = self.index == 1 and 2 or 1 elseif input:wasPressed("a") then - -- HandleMenuInput_ (home/window.asm): SFX_PRESS_AB on A and B alike - if not self.noSound then - require("src.core.Sound").play(self.game.data, "Press_AB") - end - self.pending = (self.index == 1) - self.holdFrames = Timing.YES_NO_ANSWER + self:choose(self.index) elseif input:wasPressed("b") then - if not self.noSound then - require("src.core.Sound").play(self.game.data, "Press_AB") - end - -- .choseSecondMenuItem writes wCurrentMenuItem = 1 before the hold, so - -- the cursor visibly snaps to NO for those 15 frames - self.index = 2 - self.pending = false - self.holdFrames = Timing.YES_NO_ANSWER + self:choose(2) end end diff --git a/src/ui/Gen3PartyMenu.lua b/src/ui/Gen3PartyMenu.lua index 4f3f78b39..b8f6ed64a 100644 --- a/src/ui/Gen3PartyMenu.lua +++ b/src/ui/Gen3PartyMenu.lua @@ -135,6 +135,9 @@ function Gen3PartyMenu.new(game, opts) self.onSwitch = opts.onSwitch self.battle = opts.battle self.forceSwitch = opts.forceSwitch + self.noCancel = opts.noCancel + self.onUpdate = opts.onUpdate + self._party = opts.party self.pickOnly = opts.pickOnly -- FireRed's POKé DUDE drives the ordinary party screen with synthetic -- input. The screen stays the cartridge one; only its input source changes. @@ -168,7 +171,14 @@ function Gen3PartyMenu.new(game, opts) return self end -function Gen3PartyMenu:party() return self.game.save.party or {} end +function Gen3PartyMenu:party() + -- The battle may install or replace its clamped roster after opening the + -- screen. Forced owner-specific choices retain their explicit party. + if self.battle and not self.forceSwitch and self.battle.playerParty then + return self.battle.playerParty + end + return self._party or (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 @@ -179,6 +189,7 @@ function Gen3PartyMenu:slots() return #self:party() + 1 end -- whatever the presentation left on top. Closing twice was always a bug; it -- simply never happened before. function Gen3PartyMenu:close() + if self.noCancel then return end if self.closed then return end self.closed = true self.game.stack:pop() @@ -1015,7 +1026,40 @@ function Gen3PartyMenu:animateTo(mon, fromHP, onDone) self.heal = { mon = mon, from = from, shown = from, onDone = onDone } end +function Gen3PartyMenu:confirm() + if self.index > #self:party() then + if self.hpTransferFrom then + self.hpTransferFrom = nil + return + end + -- the CANCEL button VALIDATES when a team is being chosen; everywhere + -- else it is the way out + if self.chooseOrder then + return self:confirmOrder() + end + return self:close() + end + self:choose() +end + +function Gen3PartyMenu:cancel() + if self.hpTransferFrom then + self.hpTransferFrom = nil + return + end + -- B drops a SWITCH that was half-made before it closes the screen + if self.switchFrom then + self.switchFrom = nil + return + end + if self.chooseOrder then + return self:handOrder(nil) + end + self:close() +end + function Gen3PartyMenu:update(dt) + if self.onUpdate then self.onUpdate(self); if self.closed then return end end -- RECONSTRUCTED: every icon bounces, all the time, on the same beat. On -- the cartridge each one is an OBJ with its own callback; here it is one -- clock, which is indistinguishable on screen and cannot drift. @@ -1073,20 +1117,9 @@ function Gen3PartyMenu:update(dt) self.index = 1 end elseif input:wasPressed("a") then - if self.index > #self:party() then - if self.hpTransferFrom then self.hpTransferFrom=nil;return end - -- the CANCEL button VALIDATES when a team is being chosen; everywhere - -- else it is the way out - if self.chooseOrder then return self:confirmOrder() end - return self:close() - end - self:choose() + self:confirm() elseif input:wasPressed("b") then - if self.hpTransferFrom then self.hpTransferFrom=nil;return end - -- B drops a SWITCH that was half-made before it closes the screen - if self.switchFrom then self.switchFrom = nil return end - if self.chooseOrder then return self:handOrder(nil) end - self:close() + self:cancel() end end diff --git a/src/ui/Gen4PartyMenu.lua b/src/ui/Gen4PartyMenu.lua index ce9163880..851b8ec29 100644 --- a/src/ui/Gen4PartyMenu.lua +++ b/src/ui/Gen4PartyMenu.lua @@ -876,6 +876,28 @@ function Gen4PartyMenu:step(dir) end end +function Gen4PartyMenu:cancel() + if self.switchFrom then + self.switchFrom = nil + elseif self.hpTransfer then + self.hpTransfer = nil + -- B IN A BATTLE IS "BACK TO THE MENU", NOT "I PICK NOTHING". + -- + -- Out of a battle, handing the answer back as nil is how a caller learns + -- the player cancelled, and that is right. IN a battle it crashed: the + -- battle's own onSwitch is written for a Pokemon and went straight into + -- `mon.hp`. The battle already knows what to do when this screen simply + -- closes, so closing is both the safe answer and the correct one. + elseif self.onSwitch and not (self.pickOnly or self.forceSwitch) and not self.battle then + self:handOff(nil) + elseif self.select then + self.index = self:count() + self:choose() + else + self:close() + end +end + function Gen4PartyMenu:update(dt) self.t = (self.t or 0) + (dt or 1 / 60) local input = self.game.input @@ -908,23 +930,7 @@ function Gen4PartyMenu:update(dt) elseif input:wasPressed("a") then self:choose() elseif input:wasPressed("b") or input:wasPressed("start") then - if self.switchFrom then self.switchFrom = nil - elseif self.hpTransfer then self.hpTransfer = nil - -- B IN A BATTLE IS "BACK TO THE MENU", NOT "I PICK NOTHING". - -- - -- Out of a battle, handing the answer back as nil is how a caller learns - -- the player cancelled, and that is right. IN a battle it crashed: the - -- battle's own onSwitch is written for a Pokemon and went straight into - -- `mon.hp`. The battle already knows what to do when this screen simply - -- closes, so closing is both the safe answer and the correct one. - elseif self.onSwitch and not (self.pickOnly or self.forceSwitch) - and not self.battle then - self:handOff(nil) - elseif self.select then - self.index = self:count(); self:choose() - else - self:close() - end + self:cancel() end end diff --git a/src/ui/ListMenu.lua b/src/ui/ListMenu.lua index e93098790..cf42a71f2 100644 --- a/src/ui/ListMenu.lua +++ b/src/ui/ListMenu.lua @@ -172,6 +172,25 @@ local function navPressed(self, dir) return true end +function ListMenu:choose() + if #self.items == 0 then + return self:cancel() + end + beep(self) + local item = self.items[self.index] + if self.onChoose then + self.onChoose(item, self) + end +end + +function ListMenu:cancel() + beep(self) + self.game.stack:pop() + if self.onCancel then + self.onCancel() + end +end + function ListMenu:update(dt) if self.script then self.script(self) @@ -211,16 +230,10 @@ function ListMenu:update(dt) elseif self.onSelectKey and input:wasPressed("select") then self.onSelectKey(self.items[self.index], self) elseif input:wasPressed("b") then - beep(self) - self.game.stack:pop() - if self.onCancel then self.onCancel() end + self:cancel() return elseif input:wasPressed("a") then - beep(self) - local item = self.items[self.index] - if self.onChoose then - self.onChoose(item, self) - end + self:choose() return end diff --git a/src/ui/Menu.lua b/src/ui/Menu.lua index 2415d798b..24b36069d 100644 --- a/src/ui/Menu.lua +++ b/src/ui/Menu.lua @@ -120,6 +120,35 @@ function Menu:clampScroll() end end +function Menu:choose() + -- HandleMenuInput_ (home/window.asm): SFX_PRESS_AB on every A press + if not self.noSound then + require("src.core.Sound").play(self.game.data, "Press_AB") + end + local item = self.items[self.index] + -- keepOpen entries run without closing the menu (e.g. the + -- Pokédex CRY option keeps the side menu up) + if not item.keepOpen then + self.game.stack:pop() + end + if item.onSelect then + item.onSelect() + end +end + +function Menu:cancel() + if not self.cancelable then + return + end + if not self.noSound then + require("src.core.Sound").play(self.game.data, "Press_AB") + end + self.game.stack:pop() + if self.onCancel then + self.onCancel() + end +end + function Menu:update(dt) local input = self.game.input local wasIndex = self.index @@ -128,15 +157,7 @@ function Menu:update(dt) elseif input:wasPressed("down") then self.index = self.index < #self.items and self.index + 1 or 1 elseif input:wasPressed("a") then - -- HandleMenuInput_ (home/window.asm): SFX_PRESS_AB on every A press - if not self.noSound then - require("src.core.Sound").play(self.game.data, "Press_AB") - end - local item = self.items[self.index] - -- keepOpen entries run without closing the menu (e.g. the - -- Pokédex CRY option keeps the side menu up) - if not item.keepOpen then self.game.stack:pop() end - if item.onSelect then item.onSelect() end + self:choose() elseif self.cancelable and (input:wasPressed("b") or (self.startCloses and input:wasPressed("start"))) then -- HandleMenuInput_ returns for any watched key, but only replays diff --git a/src/ui/PartyMenu.lua b/src/ui/PartyMenu.lua index e59d7173b..189442a43 100644 --- a/src/ui/PartyMenu.lua +++ b/src/ui/PartyMenu.lua @@ -437,6 +437,8 @@ function PartyMenu.new(game, opts) -- "Use TM on which POKeMON?" prompt). Set by BagMenu.pickTargetAndUse. #210 self.tmhm = opts.tmhm self.forceSwitch = opts.forceSwitch + self.noCancel = opts.noCancel + self.onUpdate = opts.onUpdate self.battle = opts.battle self.party = opts.party -- link battles pass their clamped copies self.swapFrom = nil @@ -472,7 +474,402 @@ 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 + if self.noCancel then return end + self.game.stack:pop() + if self.onCancel then self.onCancel() end +end + function PartyMenu:update(dt) + if self.onUpdate then self.onUpdate(self); if self.closed then return end end -- icon animation counter; 320 = a whole cycle at every HP speed self.blink = ((self.blink or 0) + 1) % 320 -- The bar fill owns the menu while it runs: UpdateHPBar2 is a blocking @@ -505,245 +902,9 @@ function PartyMenu:update(dt) elseif input:wasPressed("down") then self.subIndex = self.subIndex < n and self.subIndex + 1 or 1 elseif input:wasPressed("b") then - self.submenu = nil + self:cancel() elseif input:wasPressed("a") then - local mon = party[self.index] - local entry = self.subItems[self.subIndex] - local action = entry.action - if not action and entry.onSelect then - -- hook-injected entries carry a callback instead of an action id - entry.onSelect(mon, self.game) - elseif action == "stats" then - -- battle and field alike return to the party list afterwards - -- (core.asm .partyMenuWasSelected) - Screens.push(self.game, "SummaryMenu", mon, { - mons = party, - index = self.index, - onMonChange = function(index) self.index = index end, - }) - elseif action == "battle_switch" then - self.game.stack:pop() - self.onSwitch(mon) - return - elseif action == "cancel" then - self.game.stack:pop() - if self.onCancel then self.onCancel() end - return - elseif action == "switch" then - self.swapFrom = self.index - elseif action == "item" then - self:openItemMenu(mon) - elseif action == "quit" then - self.submenu = nil - elseif action == "fly" then - -- FLY opens the TOWN MAP with a cursor over the visited fly towns, - -- not a plain text list (engine/menus/town_map.asm LoadTownMap_Fly). - -- flyTo (OverworldController) validates the fly-warp + runs the - -- departure/warp, so we just hand it the chosen mapId (#195). - local ow = self.game.overworld - self.game.stack:pop() -- close the party menu - Screens.push(self.game, "TownMap", { fly = true, onFly = function(mapId) - if ow then ow:flyTo(mapId) end - end }) - return - elseif action == "flash" then -- FLASH lights dark tunnels - -- start_sub_menus.asm .flash: PrintText _FlashLightsAreaText runs - -- with the party menu still on screen, and only then - -- GBPalWhiteOutWithDelay3 + jp .goBackToMap. So the message reads - -- over the menu, and the cave is lit when the blink hands the - -- screen back, never under the text (#385). - local ow = self.game.overworld - local TextBox = require("src.render.TextBox") - local Transition = require("src.render.Transition") - -- The other half of CheckUseFlash: using it in the Aerodactyl chamber - -- opens the wall. The chamber's scene script plays the opening the - -- next time the map loads, exactly as the escape-rope one does. - require("src.script.RuinsOfAlph").aerodactylChamber(self.game) - self.game.save.flashLit = true - self.game.stack:push(TextBox.new(self.game, - self.game.data.text._FlashLightsAreaText - or Strings("A blinding FLASH\nlights the area!"), function() - self:close() - -- setDark, not a bare field write: ADVANCED carries the darkness - -- in a baked atlas, so lighting the cave drops every resident map - -- and rebakes this one (#383). It runs HERE, before the blink, - -- because start_sub_menus.asm .flash clears wMapPalOffset before - -- PrintText and blinks last of all: the cave is already lit by the - -- time GBPalWhiteOutWithDelay3 runs. Hanging the rebuild off the - -- blink's completion instead left that rebuild's whole cost -- - -- seconds of per-pixel atlas baking on a phone -- on screen as a - -- solid white frame with nothing under it, which reads as a - -- lockup (#610). - ow:setDark(false) - self.game.stack:push(Transition.whiteFlash(self.game)) - end)) - return - elseif action == "surf" then - -- start_sub_menus.asm .surf: SOULBADGE-gated (checked at list time - -- above), then IsSurfingAllowed (the Cycling Road / Seafoam B4F - -- current refusals, both of which loop back to the submenu), then - -- ItemUseSurfboard: while surfing it tries to dismount instead; - -- otherwise it mounts only if the FACING tile is water, else - -- SurfingAttemptFailed (_NoSurfingHereText) loops back to the - -- submenu. useSurfFieldMove reports which; trySurf does the mount. - local ow = self.game.overworld - local reason = ow:useSurfFieldMove() - local Transition = require("src.render.Transition") - if reason == "ok" then - -- UseItem prints _SurfingGotOnText with the party menu still up; - -- GBPalWhiteOutWithDelay3 + jp .goBackToMap only follow it, so - -- trySurf closes this menu when its text does (#385) - local fx, fy = ow.player:facingCell() - ow:trySurf(fx, fy, function() self:close() end) - return - end - if reason == "dismount" then - -- ItemUseSurfboard .stopSurfing: no text -- the walking state - -- and music return first (PlayDefaultMusic + - -- LoadWalkingPlayerSpriteGraphics), the menu closes with the - -- GBPalWhiteOutWithDelay3 blink, and the simulated pad press - -- steps the player forward onto land (or across a connection - -- strip when the shore is the next map's edge) - self.game.stack:pop() - ow.player.surfing = false - require("src.core.Music").setSurfing(self.game.data, false) - self.game.stack:push(Transition.whiteFlash(self.game, nil, function() - ow:stepForwardOrCrossEdge(ow.player.facing) - end)) - return - end - local TextBox = require("src.render.TextBox") - local def = self.game.data.pokemon[mon.species] - local key = ({ no_badge = "_NewBadgeRequiredText", - forced_bike = "_CyclingIsFunText", - current = "_CurrentTooFastText", - no_place = "_SurfingNoPlaceToGetOffText" })[reason] - or "_NoSurfingHereText" - local txt = (self.game.data.text[key] or Strings("No SURFing here!")) - :gsub("{RAM:wNameBuffer}", mon.nickname or def.name) - if reason == "no_place" then - -- .cannotStopSurfing prints _SurfingNoPlaceToGetOffText but - -- never zeroes wActionResultOrTookBattleTurn, so unlike the - -- other refusals the menu still closes afterwards - -- (GBPalWhiteOutWithDelay3 + .goBackToMap), and the text prints - -- over the still-open menu like every other .loop refusal (#385) - self.game.stack:push(TextBox.new(self.game, txt, function() - self:close() - self.game.stack:push(Transition.whiteFlash(self.game)) - end)) - return - end - self.game.stack:push(TextBox.new(self.game, txt)) - return -- .loop: submenu stays open behind the message - elseif action == "cut" then - -- start_sub_menus.asm .cut -> predef UsedCut (engine/overworld/cut.asm): - -- CASCADEBADGE-gated (list time); _NothingToCutText loops back to the - -- submenu when the FACING tile isn't a cuttable tree. - local ow = self.game.overworld - local reason = ow:useCutFieldMove() - if reason == "ok" then - self.game.stack:pop() -- close the party menu (CloseTextDisplay) - local fx, fy = ow.player:facingCell() - ow:tryCut(fx, fy) - return - end - local TextBox = require("src.render.TextBox") - local def = self.game.data.pokemon[mon.species] - local key = (reason == "no_badge") and "_NewBadgeRequiredText" - or "_NothingToCutText" - local txt = (self.game.data.text[key] or Strings("Nothing to CUT!")) - :gsub("{RAM:wNameBuffer}", mon.nickname or def.name) - self.game.stack:push(TextBox.new(self.game, txt)) - return -- .loop: submenu stays open behind the message - elseif action == "strength" then - -- start_sub_menus.asm .strength: RAINBOWBADGE-gated (list time); - -- predef PrintStrengthText (field_move_messages.asm) sets - -- BIT_STRENGTH_ACTIVE of wStatusFlags1 -- the sole gate - -- push_boulder.asm reads -- then prints _UseStrengthText (no - -- prompt: after the text, the text_asm tail plays the chosen - -- mon's cry, Delay3, and it auto-advances) and - -- _MoveBoulderText (`prompt`: waits for A/B). Back in - -- .strength, GBPalWhiteOutWithDelay3 blinks the screen white - -- before CloseTextDisplay returns to the map. - local ow = self.game.overworld - local TextBox = require("src.render.TextBox") - local Transition = require("src.render.Transition") - local def = self.game.data.pokemon[mon.species] - local name = mon.nickname or (def and def.name) or mon.species - ow.strengthActive = true - -- Gen2 text keys are _UseStrengthText / _MoveBoulderText; - -- Gen1 uses _UsedStrengthText / _CanMoveBouldersText. Try both. - local t1raw = self.game.data.text._UseStrengthText - or self.game.data.text._UsedStrengthText - local t2raw = self.game.data.text._MoveBoulderText - or self.game.data.text._CanMoveBouldersText - local t1 = (t1raw or Strings("{RAM:wNameBuffer} used\nSTRENGTH.")):gsub("{RAM:wNameBuffer}", name) - local t2 = (t2raw or Strings("{RAM:wNameBuffer} can\nmove boulders.")):gsub("{RAM:wNameBuffer}", name) - -- like surf (#320, #385): both texts print with the party menu - -- still on screen, and the blink IS the menu closing afterwards, - -- not a flashbang on the empty map - self.game.stack:push(TextBox.new(self.game, t1, function() - self.game.stack:push(TextBox.new(self.game, t2, function() - self:close() - self.game.stack:push(Transition.whiteFlash(self.game)) - end)) - end, { auto = { sound = function() - return require("src.core.Sound").playCry(self.game.data, mon.species) - end } })) - return - elseif action == "gen2_field" then - -- GSC runs the same handler from the menu as from the overworld - -- A press (SurfFromMenuScript / HeadbuttFromMenuScript), so the - -- tile check lives in one place. - local ow = self.game.overworld - local fx, fy = ow.player:facingCell() - self:close() - if not ow:gen2FieldMoveAt(entry.move, mon, fx, fy) then - local TextBox = require("src.render.TextBox") - -- _CantSurfText is "You can't SURF here", so using it for every - -- field move told a player holding HEADBUTT that the game thought - -- they had picked SURF. Only SURF gets the surf line. - local text = (entry.move == "SURF") - and self.game.data.text._CantSurfText or nil - self.game.stack:push(TextBox.new(self.game, - text or Strings("You can't use that\nhere."))) - end - return - elseif action == "softboiled" then - -- field SOFTBOILED (StartMenu_Pokemon .softboiled): transfer - -- 1/5 of the user's max HP to a chosen teammate - self.softboiledFrom = self.index - elseif action == "dig" then - -- GEN 2 (.DoDig): back out through the recorded entrance, arriving as - -- a DOOR warp so the player steps out of the opening rather than - -- standing in it. GEN 1's DIG is ItemUseEscapeRope and shares - -- TELEPORT's Pokemon Center warp, so it falls through to the same - -- departure with no target. - local ow = self.game.overworld - self.game.stack:pop() - if ow then - local escape = require("src.core.GameVersion").isGen2() - and ow.escapePoint and ow:escapePoint() and true or nil - ow:beginTeleportOut(nil, { escape = escape }) - end - return - elseif action == "escape" then - -- TELEPORT warps to the last Pokémon Center TOWN (wLastBlackoutMap, - -- special_warps.asm escape warp) -- and Gen 2 agrees: TeleportFunction - -- .TryTeleport requires an OUTDOOR map and warps to wLastSpawnMapGroup - -- / wLastSpawnMapNumber, so this destination is right for both - -- generations. Only DIG diverged; it has its own branch above. - -- pokered's .dig/.teleport spin the - -- player up (LeaveMapAnim), white/fade out, then land it; this port - -- lands OUTSIDE the town PC door like Fly (#196). beginTeleportOut - -- centralizes the spin -> fade -> warp so BagMenu's ESCAPE ROPE shares - -- the exact departure; the fade + warp fire when the spin ends. - local ow = self.game.overworld - self.game.stack:pop() - if ow then ow:beginTeleportOut() end - return - end - self.submenu = nil + self:confirm() end return end @@ -753,150 +914,9 @@ function PartyMenu:update(dt) elseif input:wasPressed("down") then self.index = self.index < #party and self.index + 1 or 1 elseif input:wasPressed("b") then - self.game.stack:pop() - if self.onCancel then self.onCancel() end + self:cancel() elseif input:wasPressed("a") and #party > 0 then - local mon = party[self.index] - if self.softboiledFrom then - local user = party[self.softboiledFrom] - local heal = math.floor(user.stats.hp / 5) - if mon == user or mon.hp <= 0 or mon.hp >= mon.stats.hp - or user.hp <= heal then - self.softboiledFrom = nil - local TextBox = require("src.render.TextBox") - self.game.stack:push(TextBox.new(self.game, Strings("It won't have\nany effect."))) - else - user.hp = user.hp - heal - mon.hp = math.min(mon.stats.hp, mon.hp + heal) - self.softboiledFrom = nil - require("src.core.Sound").play(self.game.data, "Heal_HP") - local def = self.game.data.pokemon[mon.species] - local TextBox = require("src.render.TextBox") - self.game.stack:push(TextBox.new(self.game, - Strings("%s's HP\nwas restored!", mon.nickname or def.name))) - end - elseif self.swapFrom then - if self.swapFrom ~= self.index then - party[self.swapFrom], party[self.index] = party[self.index], party[self.swapFrom] - require("src.core.Sound").play(self.game.data, "Swap") - end - self.swapFrom = nil - elseif self.onSwitch and (self.forceSwitch or self.pickOnly or not self.battle) then - -- keepOpen callers (HP medicine) need the menu still drawn while the - -- bar fills and the message prints, and close it themselves; everyone - -- else keeps the old pop-then-call order. Popping first is what made - -- a POTION snap the picker shut before the item had even run (#252). - if not self.keepOpen then self.game.stack:pop() end - self.onSwitch(mon, self) - else - self.submenu = true - self.subIndex = 1 - local items - local ow = self.game.overworld - if self.battle and self.onSwitch then - -- SwitchStatsCancelText (core.asm PartyMenuOrRockOrRun) - items = { { label = Strings("SWITCH"), action = "battle_switch" }, - { label = Strings("STATS"), action = "stats" }, - { label = Strings("CANCEL"), action = "cancel" } } - else - -- STATS/SWITCH plus this mon's field moves (start_sub_menus.asm - -- builds the same dynamic list) - items = { { label = Strings("STATS"), action = "stats" }, - { label = Strings("SWITCH"), action = "switch" } } - -- Field moves (HMs/TMs) are usable out of battle even when the mon - -- is fainted -- Gen 1 does not require HP for Cut/Fly/Surf/etc. - -- Battle still excludes this list via `not self.battle`. Softboiled - -- can appear for a fainted user; its heal transfer then no-ops. - if not self.battle and ow then - -- FLY/TELEPORT: CheckIfInOutsideMap (OVERWORLD + PLATEAU -- - -- Route 23 / Indigo Plateau outdoor), not OVERWORLD alone (#83) - local outside = Map.isOutside(ow.map.def, - FieldDefaults.field(self.game.data, "outsideTilesets")) - -- The badge gate per field move comes from constants.hmBadges and - -- the badge itself through Badges.has: Gen2 gates the same moves on - -- the Johto badges and stores them as engine flags rather than bag - -- items, so the hard-coded R/B item names listed nothing at all. - local gates = FieldDefaults.constant(self.game.data, "hmBadges") or {} - local function badged(moveId) - local gate = gates[moveId] - if not (gate and gate.badge) then return true end - return Badges.has(self.game.save, { id = gate.badge }) - end - for _, mv in ipairs(mon.moves) do - if mv.id == "FLY" and outside and badged("FLY") then - table.insert(items, { label = Strings("FLY"), action = "fly" }) - -- LIGHT is carved on the Aerodactyl chamber wall, and the - -- chamber is not a dark cave -- so CheckUseFlash asks - -- SpecialAerodactylChamber FIRST and lets FLASH be used on its - -- carry, before it ever looks at the palset - -- (engine/events/overworld.asm:285). Without this the move is - -- never even offered there and the wall can never open. - elseif mv.id == "FLASH" and badged("FLASH") - and (ow.dark or (ow.map and ow.map.id - == require("src.script.RuinsOfAlph").MAPS.aerodactyl)) then - table.insert(items, { label = Strings("FLASH"), action = "flash" }) - elseif mv.id == "CUT" and badged("CUT") then - -- CUT/SURF/STRENGTH are party-menu field moves too - -- (start_sub_menus.asm .outOfBattleMovePointers); listed here - -- with the same list-time badge filter this file already uses - -- for FLY/FLASH. The facing-tile/activation check happens on - -- selection (useCutFieldMove/useSurfFieldMove). - table.insert(items, { label = Strings("CUT"), action = "cut" }) - elseif mv.id == "SURF" and badged("SURF") then - table.insert(items, { label = Strings("SURF"), action = "surf" }) - elseif mv.id == "STRENGTH" and badged("STRENGTH") then - table.insert(items, { label = Strings("STRENGTH"), action = "strength" }) - elseif mv.id == "SOFTBOILED" or mv.id == "MILK_DRINK" then - table.insert(items, { label = Strings(mv.id:gsub("_", " ")), - action = "softboiled" }) - elseif mv.id == "TELEPORT" and outside then - -- TELEPORT works only OUTDOORS (start_sub_menus.asm - -- .teleport -> CheckIfInOutsideMap); dark maps don't - -- block it - table.insert(items, { label = Strings("TELEPORT"), action = "escape" }) - elseif mv.id == "DIG" and ow.map.id ~= "AGATHAS_ROOM" - and (DIG_TILESETS[ow.map.def.tileset] - or (GEN2_DIG_ENVIRONMENTS[ow.map.def.environment] - and ow.escapePoint and ow:escapePoint())) then - -- GEN 1: DIG runs ItemUseEscapeRope (.dig sets wCurItem = - -- ESCAPE_ROPE): usable in the dungeon tilesets of - -- escape_rope_tilesets.asm minus Agatha's room, even in - -- the dark (Rock Tunnel). - -- GEN 2: .CheckCanDig instead -- CAVE or DUNGEON, and a - -- recorded entrance to come back out of. Its own action, - -- because Gen 2's DIG and TELEPORT no longer share a - -- destination the way Gen 1's did. - table.insert(items, { label = Strings("DIG"), action = "dig" }) - elseif GEN2_FIELD_MOVES[mv.id] and badged(mv.id) then - -- GSC's own field moves; the tile check happens on selection, - -- exactly like CUT's (start_sub_menus.asm never gates the list - -- on where the player is standing) - table.insert(items, { label = Strings(mv.id:gsub("_", " ")), - action = "gen2_field", move = mv.id }) - end - end - end - if require("src.core.GameVersion").isGen2() then - -- GSC's MonMenu lists the field moves first, then STATS, SWITCH, - -- ITEM and QUIT; Gen 1 has no held items and no QUIT row. - local stats, switch = table.remove(items, 1), table.remove(items, 1) - items[#items + 1] = stats - items[#items + 1] = switch - items[#items + 1] = { label = Strings("ITEM"), action = "item" } - items[#items + 1] = { label = Strings("QUIT"), action = "quit" } - end - end - local ctx = { battle = self.battle, overworld = ow } - local hooked = Runtime.call("ui.party.submenu", sameItems, - self.game, items, mon, ctx) - if type(hooked) == "table" then - items = hooked - else - Logger.error("ui.party.submenu returned %s; keeping the vanilla list", - type(hooked)) - end - self.subItems = items - end + self:confirm() end end diff --git a/src/ui/Screens.lua b/src/ui/Screens.lua index 704c642e3..5c4108fbe 100644 --- a/src/ui/Screens.lua +++ b/src/ui/Screens.lua @@ -115,6 +115,7 @@ local GEN3_ALIASES = { PartyMenu = { id = "Gen3PartyMenu", opts = { onCancel = true, onSwitch = true, battle = true, forceSwitch = true, pickOnly = true, keepOpen = true, + party = true, noCancel = true, onUpdate = true, -- TM/HM teaching. Reported from play: "when teaching a move its falling -- back to the gen1 party menu instead of emerald". It was declined here -- until Emerald's screen could answer ABLE / NOT ABLE off the learnset, diff --git a/src/ui/SummaryMenu.lua b/src/ui/SummaryMenu.lua index 090401b37..b5c20f3aa 100644 --- a/src/ui/SummaryMenu.lua +++ b/src/ui/SummaryMenu.lua @@ -148,6 +148,7 @@ function SummaryMenu.new(game, mon, opts) -- SaveData.validate has run over a loaded save, but this is the site the -- original recomputes at, and it also covers a mon handed in by a mod. Stats.ensure(game.data.pokemon[mon.species], mon) + require("src.pokemon.Pokemon").syncIdentity(game.data, mon) local self = setmetatable({ game = game, mon = mon, diff --git a/src/ui/UICommands.lua b/src/ui/UICommands.lua new file mode 100644 index 000000000..bfc9b7de3 --- /dev/null +++ b/src/ui/UICommands.lua @@ -0,0 +1,348 @@ +-- 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 + if screen.noCancel then out.canBack = false end + local party = kind == "party_gb" and (screen.party or game.save.party) or screen:party() + refs.party = 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 + if not screen.noCancel then row(out, "back", ids) end + end + end + elseif kind == "text" then + out.kind, out.index, out.canBack = "text", nil, false + out.canAdvance = ( + screen.waiting or (screen.done and not screen.auto and not screen.stay and not screen.choice) + ) + and (screen.preWait or 0) <= 0 + and (screen.holdFrames or 0) <= 0 + or false + out.locked = not out.canAdvance + elseif kind == "choice" then + out.kind = "choice" + rows({ "yes", "no" }) + out.locked = screen.pending ~= nil + elseif kind == "battle_bag" then + out.kind, out.page = "bag", screen.screen + local mem = screen.mem or screen.cur + out.pocket = screen.pocket + out.index = screen.cursor and screen.cursor.index + if screen.message then + out.kind, out.canAdvance, out.canBack = "text", true, false + elseif screen.screen == "menu" then + rows({ "pocket:1", "pocket:2", "pocket:3", "pocket:4", "last_item", "back" }) + out.rows[5].enabled = mem.lastItem ~= nil + elseif screen.screen == "pocket" then + for i = 1, 6 do + local item = screen:itemAt(i) + refs["row:" .. i] = item + local r = row(out, item or {}, ids, true) + r.enabled = item ~= nil + end + row(out, "previous_page", ids).enabled = (screen.pageCount[screen.pocket] or 0) > 0 + row(out, "next_page", ids).enabled = (screen.pageCount[screen.pocket] or 0) > 0 + row(out, "back", ids) + else + rows({ "use", "back" }) + out.rows[1].enabled = screen:itemAt((mem.positions[screen.pocket] or 0) + 1) ~= nil + end + out.locked = screen.press ~= nil + elseif kind == "bag_actions" or kind == "item_actions" then + out.kind = "bag_actions" + rows(kind == "bag_actions" and screen.items or screen.entries) + if kind == "item_actions" then + for i, r in ipairs(out.rows) do + r.enabled = screen:pickable(i) + r.action = screen.entries[i].kind + end + else + out.canBack = screen.cancelable + end + else + out.kind = "bag" + out.pocket = screen.pocket or screen.pocketIndex + if screen.message or screen.resultMessage then + out.kind, out.canAdvance, out.canBack = "text", true, false + elseif screen.menu then + out.kind, out.index = screen.menu.kind == "yesno" and "choice" or "bag_actions", screen.menu.index + rows(screen.menu.rows) + else + rows(kind == "bag_gb" and screen.items or kind == "bag_hgss" and screen:items() or screen.rows, true) + out.index = kind == "bag_hgss" and screen.page * 6 + screen.slot + or kind == "bag_bw" and screen.cursor + or screen.index + out.canPocket = not screen.lockPocket + and (kind == "bag_gb" and screen.onPocketSwitch ~= nil or kind ~= "bag_gb" and #(screen.pockets or {}) > 1) + or false + end + out.locked = screen.trash ~= nil and not screen.menu + or screen.moving ~= nil and screen.moving ~= false + or false + end + local battle = screen.battle or screen.publicUIBattle + if + battle and (battle.demo or battle.dudeDemo or battle.pokedudeDemo or battle.spectating or battle.dead) + then + out.locked = true + end + if screen.heal or screen.noInput or screen.script or screen.closed then + out.locked = true + end + if out.locked then + out.canAdvance, out.canPocket, out.canBack = false, false, false + end + return out, refs, kind +end +local function press(screen, ds) + local Sound = require("src.core.Sound") + if ds then + if Sound.playSe and screen.game.data then + Sound.playSe(screen.game.data, "SEQ_SE_DP_SELECT") + end + elseif screen.game.data then + Sound.play(screen.game.data, "Press_AB") + end +end +function Commands.dispatch(screen, kind, view, intent) + if intent.kind == "advance" then + if kind == "text" then + screen:advance() + elseif screen.selectNotice then + screen.selectNotice = nil + else + if screen.resultMessage then + press(screen) + end + screen.message, screen.resultMessage = nil, nil + end + elseif kind == "text" then + error("text only supports advance") + elseif kind == "choice" then + screen:choose(intent.kind == "back" and 2 or intent.index) + elseif kind == "battle_bag" then + if intent.kind == "select" then + screen.cursor.index = intent.index + end + screen:pick(intent.kind == "back" and ({ menu = 6, pocket = 9, use = 2 })[screen.screen] or intent.index) + elseif kind == "party_gb" then + press(screen) + if intent.kind == "back" then + screen:cancel() + else + if screen.submenu then + screen.subIndex = intent.index + else + screen.index = intent.index + end + screen:confirm() + end + elseif kind == "party_gba" or kind == "party_ds" then + if intent.kind == "back" then + if screen.submenu then + if screen.itemMenu then + screen.itemMenu, screen.submenu = nil, 1 + else + screen.submenu = nil + end + else + screen:cancel() + end + elseif screen.submenu then + if kind == "party_ds" then + screen.submenu = intent.index + screen:runAction(screen:actions()[intent.index]) + else + screen.subIndex = intent.index + local r = screen.submenu[intent.index] + screen:runAction(r.action, screen:party()[screen.index], r) + end + else + screen.index = intent.index + if kind == "party_gba" then + screen:confirm() + else + screen:choose() + end + end + elseif kind == "bag_actions" then + if intent.kind == "back" then + screen:cancel() + else + screen.index = intent.index + screen:choose() + end + elseif kind == "item_actions" then + press(screen) + if intent.kind == "select" then + screen.index = intent.index + end + screen:close(intent.kind == "back" and "cancel" or screen.entries[intent.index].kind) + elseif screen.menu then + if intent.kind == "back" then + if kind == "bag_ds" then + press(screen, true) + end + if screen.menu.kind == "yesno" then + screen:resolveTrash(false) + else + screen.menu = nil + end + else + screen.menu.index = intent.index + if kind ~= "bag_ds" then + screen:runMenuRow() + else + press(screen, true) + if screen.menu.kind == "yesno" then + screen:resolveTrash(intent.index == 1) + else + screen:runAction(screen.menu.rows[intent.index]) + end + end + end + elseif intent.kind == "pocket" then + if kind == "bag_gb" then + screen.onPocketSwitch(screen, intent.direction) + elseif kind == "bag_hgss" or kind == "bag_bw" then + screen:setPocket((screen.pocket - 1 + intent.direction) % #screen.pockets + 1) + else + screen.pocketLit = nil + screen:movePocket(intent.direction) + end + elseif intent.kind == "back" then + if kind == "bag_gb" then + screen:cancel() + else + if kind ~= "bag_gba" then + press(screen, true) + end + screen:close() + end + else + if kind == "bag_hgss" then + screen.page, screen.slot = math.floor((intent.index - 1) / 6), (intent.index - 1) % 6 + 1 + elseif kind == "bag_bw" then + screen.cursor = intent.index + screen.top = math.max(0, intent.index - 6) + else + screen.index = intent.index + if kind == "bag_ds" then + screen:clampScroll() + end + end + screen:choose() + end +end +return Commands diff --git a/src/world/MapOverview.lua b/src/world/MapOverview.lua new file mode 100644 index 000000000..adf912a6b --- /dev/null +++ b/src/world/MapOverview.lua @@ -0,0 +1,176 @@ +-- Generation-neutral terrain grid; optional GB tile luminance. All cells are +-- zero based. The scratch Map shares read-only data, but never the cold block +-- decoder's mutable definition or streamed renderer. +local Map = require("src.world.Map") +local Assets = require("src.render.Assets") +local Overview = {} +local shades = {} +Assets.register(function() + shades = {} +end) + +local function digit(sum, count) + return tostring(math.max(0, math.min(3, math.floor((1 - sum / count) * 3 + 0.5)))) +end + +local function visual(map) + local ts = map.tileset + -- GBA metatiles are composed/paletted by their renderer; their opaque tileAt + -- identifiers do not index a GB atlas. DS maps do not have one either. + if + map.blockCells ~= 2 + or not ts.blocks + or type(ts.image) ~= "string" + or type(ts.tilesPerRow) ~= "number" + or ts.tilesPerRow < 1 + or ts.tilesPerRow >= math.huge + or ts.tilesPerRow ~= math.floor(ts.tilesPerRow) + then + return nil + end + local path = Assets.resolve(ts.image) + local cache = shades[path] + if not cache then + if not (love and love.image and love.image.newImageData) then + return nil + end + local ok, pixels = pcall(love.image.newImageData, path) + if not ok or not pixels then + return nil + end + cache = { pixels = pixels, tiles = {} } + shades[path] = cache + end + local rows, details, pixels = {}, {}, cache.pixels + local width, height = pixels:getDimensions() + for y = 0, map.heightCells * 2 - 1 do + local row, top, bottom = {}, {}, {} + for x = 0, map.widthCells * 2 - 1 do + local tile = map:tileAt(x, y) + if type(tile) ~= "number" or tile < 0 or tile ~= math.floor(tile) then + return nil + end + local ox, oy = tile % ts.tilesPerRow * 8, math.floor(tile / ts.tilesPerRow) * 8 + if ox + 8 > width or oy + 8 > height then + return nil + end + local key = ts.tilesPerRow .. ":" .. tile + local s = cache.tiles[key] + if not s then + local sums = { 0, 0, 0, 0 } + for py = 0, 7 do + for px = 0, 7 do + local r, g, b = pixels:getPixel(ox + px, oy + py) + local q = math.floor(py / 4) * 2 + math.floor(px / 4) + 1 + sums[q] = sums[q] + r * 0.2126 + g * 0.7152 + b * 0.0722 + end + end + s = { + digit(sums[1] + sums[2] + sums[3] + sums[4], 64), + digit(sums[1], 16), + digit(sums[2], 16), + digit(sums[3], 16), + digit(sums[4], 16), + } + cache.tiles[key] = s + end + row[#row + 1], top[#top + 1], bottom[#bottom + 1] = s[1], s[2] .. s[3], s[4] .. s[5] + end + rows[#rows + 1] = table.concat(row) + details[#details + 1], details[#details + 2] = table.concat(top), table.concat(bottom) + end + return rows, details +end + +local function integer(n) + return type(n) == "number" and n >= 0 and n < math.huge and n == math.floor(n) +end + +function Overview.build(game, ow) + local map = ow.map + local w, h = map.widthCells, map.heightCells + if not integer(w) or not integer(h) or w == 0 or h == 0 then + return nil, "invalid map dimensions" + end + -- Outdoor DS terrain lives in lazily streamed renderer chunks. Walking that + -- renderer from a query can load/cache chunks. Interior permission grids are + -- already resident and can use the same pure Map classifiers below. + if map.def.generation == 4 and not map.def.behaviorCells then + return nil, "map overview unavailable for streamed terrain" + end + local scratch, def = {}, {} + for k, v in pairs(map) do + scratch[k] = v + end + for k, v in pairs(map.def) do + def[k] = v + end + scratch.def, scratch.renderer = def, nil + setmetatable(scratch, Map) + -- blockArray() writes only into the private definition on a cold GBA map. + -- It may reuse the warm immutable array, and honors the live block patches. + local rows, markers = {}, {} + local function marker(kind, x, y) + if integer(x) and integer(y) and x < w and y < h then + markers[#markers + 1] = { kind = kind, x = x, y = y } + end + end + for _, warp in ipairs(def.warps or {}) do + marker("warp", warp.x, warp.y) + end + -- The active object list is the result of native flag, session, time and + -- scripted spawn filters. Re-running those filters can consult another Game + -- singleton or initialize sprite state. Use the objects actually present. + for _, npc in ipairs(ow.npcs or {}) do + local obj = npc.def or {} + if obj.item and obj.item ~= 0 and obj.item ~= "0" and not npc.hidden and not obj.fruitTree then + marker("item", npc.cellX, npc.cellY) + end + end + local save = game.save or {} + local hidden = game.data and game.data.field and game.data.field.hiddenItems + for _, item in ipairs(hidden and hidden[map.id] or {}) do + if not (save.hiddenTaken and save.hiddenTaken[map.id .. "_" .. item.x .. "_" .. item.y]) then + marker("hidden", item.x, item.y) + end + end + -- Gen2/3 imports also carry hidden-item sign events, with their own taken + -- key and event flag (e.g. Cerulean Gym's Machine Part). + for _, item in ipairs(def.signs or {}) do + if + item.item + and item.item ~= 0 + and item.item ~= "0" + and not (item.eventFlag and save.flags and save.flags[item.eventFlag] == true) + and not (save.hiddenTaken and save.hiddenTaken[map.id .. "_sign_" .. item.x .. "_" .. item.y]) + then + marker("hidden", item.x, item.y) + end + end + for y = 0, h - 1 do + local row = {} + for x = 0, w - 1 do + row[#row + 1] = scratch:isWarpTileCell(x, y) and "+" + or scratch:isWaterCell(x, y) and "~" + or scratch:isWalkableCell(x, y) and "." + or " " + end + rows[#rows + 1] = table.concat(row) + end + local tiles, details = visual(scratch) + return { + mapId = map.id, + width = w, + height = h, + rows = rows, + markers = markers, + tileRows = tiles, + tileWidth = tiles and w * 2, + tileHeight = tiles and h * 2, + tileDetailRows = details, + tileDetailWidth = details and w * 4, + tileDetailHeight = details and h * 4, + } +end + +return Overview diff --git a/src/world/WorldAPI.lua b/src/world/WorldAPI.lua index f21419ae9..3e2ae3080 100644 --- a/src/world/WorldAPI.lua +++ b/src/world/WorldAPI.lua @@ -6,17 +6,21 @@ -- stays unsupported; anything a mod legitimately needs belongs here. local Logger = require("src.core.Logger") +local Actions = require("src.world.WorldActions") local MapLoader = require("src.world.MapLoader") local Gen3Elevation = require("src.world.Gen3Elevation") local Runtime = require("src.mods.Runtime") +local Reads = require("src.mods.WorldReads") +local WorldOverview = require("src.mods.WorldOverview") +local MapOverview = require("src.world.MapOverview") local WorldAPI = {} WorldAPI.__index = WorldAPI local NO_OVERWORLD = "no overworld" -function WorldAPI.new(game, modId) - return setmetatable({ game = game, modId = modId }, WorldAPI) +function WorldAPI.new(game, modId, readContext) + return setmetatable({ game = game, modId = modId, readContext = readContext }, WorldAPI) end -- the live overworld, or nil. Game.overworld is the fast path; the stack @@ -58,6 +62,41 @@ function WorldAPI:current() elevation) } end +-- Detached common Pokemon records. This also works with a save at the title +-- screen: reading the party does not need an active overworld. +function WorldAPI:party() + return Reads.party(self.game, self.readContext) +end + +-- Require the expected active map to reject stale requests after a warp. +-- The second result describes block size/coordinates and the DS proxy grid. +function WorldAPI:activeBlockAt(mapId, bx, by) + local ow = self:overworld() + if not ow or not ow.map then return nil, NO_OVERWORLD end + return Reads.activeBlockAt(ow.map, mapId, bx, by) +end + +-- Loaded maps only, active first and with distinct placements. loadedMaps() +-- retains its legacy shape/order; these records also describe block units. +function WorldAPI:liveMaps() + local ow = self:overworld() + if not ow or not ow.map then return nil, NO_OVERWORLD end + return Reads.liveMaps(ow) +end + +-- Detached, zero-based terrain and marker grids for companion UIs/minimaps. +function WorldAPI:mapOverview() + local ow = self:overworld() + if not ow or not ow.map then return nil, NO_OVERWORLD end + return MapOverview.build(self.game, ow) +end + +-- Table distribution before per-step RNG, repel, roamers and roll hooks. +-- Explicit map IDs also work at the title screen without loading that map. +function WorldAPI:effectiveEncounters(mapId, terrain, opts) + return WorldOverview.encounters(self.game, self:overworld(), self.overviewContext, mapId, terrain, opts) +end + -- opts.arrive = "fly" | "teleport" picks the arrival FX; anything else -- lands the player without one, like a scripted warp. function WorldAPI:warpTo(mapId, x, y, facing, opts) @@ -386,6 +425,14 @@ function WorldAPI:queueScript(rows, extra) return true end +-- Public field mutations are kept behind the same free-roam boundary. +function WorldAPI:canReorderParty() return Actions.canReorderParty(self.game, self:overworld()) end +function WorldAPI:reorderParty(fromSlot, toSlot) return Actions.reorderParty(self.game, self:overworld(), fromSlot, toSlot) end +function WorldAPI:availableFieldActions() return Actions.availableFieldActions(self.game, self:overworld()) end +function WorldAPI:useFieldAction(id, opts) return Actions.useFieldAction(self.game, self:overworld(), id, opts) end +function WorldAPI:canFly() return Actions.canFly(self.game, self:overworld()) end +function WorldAPI:flyTo(mapId) return Actions.flyTo(self.game, self:overworld(), mapId) end + -- drop a map's cached instance so the next load re-reads its record; when -- it is the active map the world reloads around the player in place function WorldAPI:invalidateMap(mapId) diff --git a/src/world/WorldActions.lua b/src/world/WorldActions.lua new file mode 100644 index 000000000..d6172f82c --- /dev/null +++ b/src/world/WorldActions.lua @@ -0,0 +1,469 @@ +-- 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 +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/effects.lua b/tests/api3_contract/effects.lua new file mode 100644 index 000000000..15430e813 --- /dev/null +++ b/tests/api3_contract/effects.lua @@ -0,0 +1,13 @@ +local M = {} +function M.fixture(root, backend, version) + local codeRoot = debug.getinfo(1, "S").source:sub(2):match("^(.*)/effects%.lua$") + return dofile(codeRoot .. "/warp.lua").fixture(root, backend, version, "effects") +end +function M.run(root, backend, version) + local f = M.fixture(root, backend, version) + local report = f.exports.report() + assert(report.initial == "NO_ADDITIONAL_EFFECT" and report.final == "SLEEP_EFFECT") + print("API3_CONTRACT " .. require("src.link.Json").encode(report)) + return report +end +return M diff --git a/tests/api3_contract/mods/effects/main.lua b/tests/api3_contract/mods/effects/main.lua new file mode 100644 index 000000000..a01378e23 --- /dev/null +++ b/tests/api3_contract/mods/effects/main.lua @@ -0,0 +1,33 @@ +local mod = ... +local moves = mod.content.moves +local report = { initial = assert(moves:get("POUND")).effect, effects = {} } +for _, id in ipairs({ + "SLEEP_EFFECT", + "BURN_SIDE_EFFECT1", + "BURN_SIDE_EFFECT2", + "SPECIAL_UP1_EFFECT", + "SPECIAL_DOWN_SIDE_EFFECT", + "NO_ADDITIONAL_EFFECT", +}) do + assert(moves:patch("POUND", { effect = id })) + local row = assert(moves:get("POUND")) + assert(row.effect == id) + report.effects[#report.effects + 1] = row.effect + if mod.generation > 1 then + assert(moves:patch("POUND", { secondaryChance = 25 })) + assert(moves:get("POUND").secondaryChance == 25) + report.chance = 25 + end + local detached = assert(moves:get("POUND")) + assert(moves:override("POUND", detached)) + assert(moves:get("POUND").effect == id) +end +local value, why = moves:patch("POUND", { effect = "native:1:not_original" }) +assert(value == nil and type(why) == "string") +assert(moves:get("POUND").effect == "NO_ADDITIONAL_EFFECT") +assert(moves:patch("POUND", { effect = "SLEEP_EFFECT" })) +report.final = moves:get("POUND").effect +report.preflight = true +mod.exports.report = function() + return report +end diff --git a/tests/api3_contract/mods/effects/manifest.json b/tests/api3_contract/mods/effects/manifest.json new file mode 100644 index 000000000..48e0d38a3 --- /dev/null +++ b/tests/api3_contract/mods/effects/manifest.json @@ -0,0 +1 @@ +{"id":"effects","name":"Portable move effects","version":"1.0.0","api":3,"games":["gen1","gen2","gen3"],"entry":"main.lua"} diff --git a/tests/api3_contract/mods/portable/main.lua b/tests/api3_contract/mods/portable/main.lua new file mode 100644 index 000000000..a428b942f --- /dev/null +++ b/tests/api3_contract/mods/portable/main.lua @@ -0,0 +1,110 @@ +-- This exact entry and manifest run on both engines. No engine imports, +-- format switches or engine-specific branches belong in this fixture. +local mod = ... +local world, battle = mod.world, mod.battle +assert(type(world) == "table" and type(battle) == "table", "early handles") +local function refused(value, reason) + assert(value == nil and type(reason) == "string" and #reason > 0, tostring(reason)) +end +refused(world:party()) +refused(battle:snapshot()) +local no, why = mod:supports("world", "not_a_method") +assert(no == false and type(why) == "string") +assert(mod:supports("content", "pokemon")) +assert(mod:supports("hooks", "evolution.check")) +assert(mod:supports("events", "pokemon.received")) +assert(mod:supports("world", "party")) +-- Known API-3 names are registrable even when this host cannot execute them. +mod.hooks:wrap("render.output", function(next, ctx) + return next(ctx) +end) +mod.events:on("checkpoint.restored", function() end) +local c = mod.content +assert(c.pokemon:has("MEW")) +assert(c.maps:supports() and c.maps.available) +assert(c.maps:has("ABSENT_MAP") == false) +local map = assert(c.maps:get("PROBE")) +map.width = 99 +assert(c.maps:get("PROBE").width == 2) +for id, row in c.maps:each() do + if id == "api3Probe" then + row.value = 88 + end +end +assert(c.maps:get("PROBE").width == 2) +local before = assert(c.pokemon:get("MEW")) +assert(before.id == "MEW" and before.baseStats.hp == 100) +assert(before.level1Moves[1] == "POUND") +assert(before.baseStats.special == 100) +assert(c.moves:get("POUND").effect == "NO_ADDITIONAL_EFFECT") +before.baseStats.hp = 222 +assert(c.pokemon:get("MEW").baseStats.hp == 100, "detached read") +assert(c.pokemon:patch("MEW", { baseStats = { hp = 101 } })) +assert(c.moves:patch("POUND", { power = 45 })) +assert(c.items:patch("POTION", { price = 123 })) +local found = false +for id, row in c.pokemon:each() do + if id == "MEW" then + found = true + assert(row.baseStats.hp == 101) + end +end +assert(found) +local report = { species = "MEW", hp = 101, move = "POUND", power = 45, price = 123 } +local function monCheck(mon) + assert(mon.species == "MEW" and mon.nickname == "Scout" and mon.name == "Scout") + assert(mon.status == "PSN", "short status") + assert(type(mon.shiny) == "boolean", "shiny presentation") + assert(mon.stats.special == 24 and mon.stats.hp == 40, "common stats") + assert(mon.moves[1].id == "POUND" and mon.moves[1].pp == 30 and mon.moves[1].maxPp == 35) +end +mod.events:on("pokemon.received", function(payload) + monCheck(payload.mon) + payload.mon.hp = 1 + report.event = true +end) +mod.hooks:wrap("evolution.check", function(next, game, mon, evolution, trigger) + monCheck(mon) + mon.hp, mon.status = 27, "BRN" + mon.stats.special = 31 + report.hook = true + report.special = 31 + return next(game, mon, evolution, trigger) +end) +mod.events:on("mod.portable.probe", function(payload) + report.custom = payload.value +end) +mod.events:emit("mod.portable.probe", { value = 7 }) +mod.exports.exercise = function() + local first = assert(world:party()) + monCheck(first[1]) + first[1].hp = 1 + assert(world:party()[1].hp == 30, "detached party") + report.party = true + report.status, report.shiny = first[1].status, first[1].shiny + assert(world:getFlag("mod:contract.flag") == false) + assert(world:setFlag("mod:contract.flag", true)) + assert(world:getFlag("mod:contract.flag") == true) + assert(world:setFlag("mod:contract.flag", false)) + assert(world:getFlag("mod:contract.flag") == false) + report.flags = true +end +mod.exports.snapshot = function() + local first = assert(battle:snapshot()) + assert(first.kind == "wild" and first.prompt == "menu") + monCheck(first.player) + assert(first.player.hp == 30 and first.player.maxHp == 40) + assert(type(first.revision) == "number" and first.revision > 0) + first.player.hp = 1 + assert(battle:snapshot().player.hp == 30, "detached battle snapshot") + refused(battle:submit({ id = 1, revision = first.revision, kind = "not_an_intent" })) + report.battle = + { kind = first.kind, prompt = first.prompt, species = first.player.species, status = first.player.status } +end +mod.exports.late = function() + local ok, value, reason = pcall(function() + return c.moves:patch("POUND", { power = 99 }) + end) + assert(not ok or (value == nil and type(reason) == "string"), "frozen content") +end +mod.exports.world, mod.exports.battle, mod.exports.report = world, battle, report diff --git a/tests/api3_contract/mods/portable/manifest.json b/tests/api3_contract/mods/portable/manifest.json new file mode 100644 index 000000000..d98d5f821 --- /dev/null +++ b/tests/api3_contract/mods/portable/manifest.json @@ -0,0 +1 @@ +{"id":"portable","name":"Portable API contract","version":"1.0.0","api":3,"games":["gen1","gen2","gen3"],"entry":"main.lua"} diff --git a/tests/api3_contract/mods/rollback/main.lua b/tests/api3_contract/mods/rollback/main.lua new file mode 100644 index 000000000..daf6abe4a --- /dev/null +++ b/tests/api3_contract/mods/rollback/main.lua @@ -0,0 +1,6 @@ +local mod = ... +assert(mod.content.pokemon:patch("MEW", { baseStats = { hp = 199 } })) +mod.events:on("mod.portable.probe", function() + error("failed subscription leaked") +end) +error("intentional API 3 rollback") diff --git a/tests/api3_contract/mods/rollback/manifest.json b/tests/api3_contract/mods/rollback/manifest.json new file mode 100644 index 000000000..ab532b029 --- /dev/null +++ b/tests/api3_contract/mods/rollback/manifest.json @@ -0,0 +1 @@ +{"id":"rollback","name":"Rollback contract","version":"1.0.0","api":3,"games":["gen1","gen2","gen3"],"entry":"main.lua","dependencies":["portable"]} diff --git a/tests/api3_contract/run.lua b/tests/api3_contract/run.lua new file mode 100644 index 000000000..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..923eaab87 --- /dev/null +++ b/tests/api3_contract/worker.lua @@ -0,0 +1,14 @@ +-- 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 == "effects"), + "invalid worker arguments" +) +local codeRoot = (arg[0]:gsub("\\", "/")):match("^(.*)/worker%.lua$") +if mode == "effects" then + dofile(codeRoot .. "/effects.lua").run(root, backend, version) +else + dofile(codeRoot .. "/run.lua").run(root, backend, version, mode == "rollback", mode == "scaffold") +end diff --git a/tests/beat_up/public_effect.lua b/tests/beat_up/public_effect.lua new file mode 100644 index 000000000..710779ac2 --- /dev/null +++ b/tests/beat_up/public_effect.lua @@ -0,0 +1,52 @@ +-- Composed proof: requires the public canonical effect adapter from #121. +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +require("src.mods.UnifiedEffects") +for _, version in ipairs({ "firered", "emerald" }) do + local entry = [[return function(mod) + assert(mod.content.moves:patch("TACKLE",{effect="EFFECT_BEAT_UP",power=10})) + assert(mod.content.pokemon:patch("CHARMANDER",{baseStats={attack=133,defense=40}})) + mod.exports.effect=assert(mod.content.moves:get("TACKLE")).effect + mod.exports.attack=assert(mod.content.pokemon:get("CHARMANDER")).baseStats.attack + mod.exports.hits,mod.exports.calls={},{crit=0,damage=0} + mod.hooks:wrap("battle.enemy_action",function() return {move="TACKLE",target=0} end) + mod.hooks:wrap("battle.crit",function() mod.exports.calls.crit=mod.exports.calls.crit+1;return true end) + mod.hooks:wrap("battle.damage",function(nextDamage,...) + mod.exports.calls.damage=mod.exports.calls.damage+1;return nextDamage(...) + end) + mod.events:on("battle.damage_dealt",function(e) + mod.exports.hits[#mod.exports.hits+1]={amount=e.damage,crit=e.crit,kind=e.kind} + end) + end]] + local function configure(data, _, ids) + data.moves[ids.TACKLE].target = 0 + end + local f = require("tests.enemy_actions.fixture").new(".", "gen2", version, 3, "unified", configure, entry) + local b = f.fresh("TACKLE", { enemy = true }).battle + T.eq(f.e.effect, "EFFECT_BEAT_UP", "canonical effect roundtrips through the real loader") + T.eq(f.e.attack, 133, "public species base patch loads") + T.eq(f.data.moves[f.ids.TACKLE].gen3Effect, 154, "canonical effect selects native semantic 154") + local second = require("src.mods.Merge").deepCopy(b.enemy.mon) + b.enemy.mon.level, second.level = 10, 30 + b.enemyParty = { b.enemy.mon, second } + b.rng = function(lo, hi) + if lo == 85 and hi == 100 then + return 100 + end + if lo == 1 and hi == 16 then + return 16 + end + return lo or 0 + end + b:executeAction(b.enemy, b.player, b:enemyAction(b.enemy)) + T.same( + f.e.hits, + { { amount = 5, crit = false, kind = "special" }, { amount = 11, crit = false, kind = "special" } }, + "the patched native effect reads each party level and the public species base" + ) + T.same(f.e.calls, { crit = 0, damage = 0 }, "scripted strikes bypass ordinary damage and critical hooks") + T.eq(b.enemy.curMoves[1].pp, 9, "the whole sequence consumes owned PP once") + T.eq(b.player.specialDamageTaken, 11, "counter memory keeps the final strike") + f.close() +end +T.finish("composed public Beat Up effect") diff --git a/tests/charge_required/cases.lua b/tests/charge_required/cases.lua new file mode 100644 index 000000000..b73d4cbff --- /dev/null +++ b/tests/charge_required/cases.lua @@ -0,0 +1,152 @@ +local M = {} +function M.run(f) + local T, e, rows = f.T, f.e, {} + local function observe(label, b, beforeHP, beforePP, calls) + local out = { + label = label, + damaged = b.hp() < beforeHP, + spent = beforePP - b.pp(), + charging = b.charging(), + hidden = b.hidden(), + calls = e.state.calls - calls, + } + rows[#rows + 1] = out + return out + end + for _, name in ipairs({ "SOLARBEAM", "FLY", "DIG", "RAZOR_WIND", "SKULL_BASH", "SKY_ATTACK" }) do + for _, bypass in ipairs({ false, true }) do + e.state.mode = bypass and "bypass" or "native" + local b = f.fresh(name) + local hp, pp, calls = b.hp(), b.pp(), e.state.calls + b.use() + local out = observe(name .. ":" .. tostring(bypass), b, hp, pp, calls) + T.eq(out.calls, 1, "initial charge-capable move invokes callback once") + T.eq(out.charging, not bypass, "public verdict controls native continuation") + T.eq(out.damaged, bypass, "bypass resolves native damage immediately") + T.eq(out.spent, 1, "initial move spends one PP") + T.eq(e.state.charge, true, "default charge context") + T.eq(e.state.isCalled, false, "ordinary initial use is not called") + T.check( + e.state.battle ~= nil and e.state.user ~= nil and e.state.target ~= nil, + "context has native battle and actors" + ) + local expected = f.backend == "gen2" + and f.gen == 3 + and f.api < 3 + and f.format == "native" + and f.ids[name] + or name + T.eq(e.state.move, expected, "native/canonical move ID follows selected callback format") + if not bypass then + local releaseHP, releasePP, releaseCalls = b.hp(), b.pp(), e.state.calls + b.use() + out = observe(name .. ":release", b, releaseHP, releasePP, releaseCalls) + T.eq(out.charging, false, "release clears native continuation") + T.eq(out.hidden, false, "release clears native semi-invulnerability") + T.eq(out.damaged, true, "release resolves native damage") + T.eq(out.spent, 0, "release cannot spend a second PP") + T.eq(out.calls, 0, "release cannot invoke initial-charge callback") + else + T.eq(out.hidden, false, "bypass creates no semi-invulnerable state") + end + end + end + f.nativePolicy = {} + local ordinary = f.fresh("TACKLE", { enemy = true }) + local ordinaryPP = ordinary.pp() + ordinary.use() + f.nativePolicy.enemyNormalPPCost = ordinaryPP - ordinary.pp() + for _, bypass in ipairs({ false, true }) do + e.state.mode = bypass and "bypass" or "native" + local b = f.fresh("FLY", { enemy = true }) + local hp, pp, calls = b.hp(), b.pp(), e.state.calls + b.use() + local out = observe("enemy:" .. tostring(bypass), b, hp, pp, calls) + T.eq(out.calls, 1, "enemy initial charge invokes shared seam") + T.eq(out.charging, not bypass, "enemy verdict controls continuation") + T.eq(out.damaged, bypass, "enemy bypass resolves native damage") + f.nativePolicy[bypass and "enemyBypassPPCost" or "enemyChargePPCost"] = out.spent + T.eq(out.spent, f.nativePolicy.enemyNormalPPCost, "charge decision retains native enemy PP policy") + out.nativePPPreserved, out.spent = out.spent == f.nativePolicy.enemyNormalPPCost, nil + end + for _, mode in ipairs({ "edit", "nil", "truthy" }) do + e.state.mode = mode + local b = f.fresh("SOLARBEAM") + local hp, pp, calls = b.hp(), b.pp(), e.state.calls + b.use() + local out = observe(mode, b, hp, pp, calls) + T.eq(out.charging, mode ~= "edit", "only false bypasses charge") + T.eq(out.damaged, mode == "edit", "edited next verdict reaches native damage") + end + e.state.mode = "native" + local b = f.fresh("TACKLE") + local hp, pp, calls = b.hp(), b.pp(), e.state.calls + b.use() + T.eq(observe("ordinary move", b, hp, pp, calls).calls, 0, "ordinary move bypasses charge seam") + b = f.fresh("FLY") + hp, pp, calls = b.hp(), b.pp(), e.state.calls + b.use(true) + local out = observe("called", b, hp, pp, calls) + T.eq(e.state.isCalled, true, "called-move context") + T.eq(out.spent, 0, "called-move PP semantics preserved") + T.eq(out.charging, true, "called charge move retains native continuation") + e.state.mode = "bypass" + b = f.fresh("FLY") + hp, pp, calls = b.hp(), b.pp(), e.state.calls + b.use(true) + out = observe("called bypass", b, hp, pp, calls) + T.eq(out.spent, 0, "called bypass retains caller-owned PP") + T.eq(out.charging, false, "called bypass stores no continuation") + T.eq(out.damaged, true, "called bypass resolves ordinary damage") + e.state.mode = "native" + if f.gen > 1 then + b = f.fresh("SOLARBEAM", { sun = true }) + hp, pp, calls = b.hp(), b.pp(), e.state.calls + b.use() + out = observe("native sun", b, hp, pp, calls) + T.eq(out.calls, 0, "native sun bypass precedes public seam") + T.eq(out.charging, false, "native sun retains instant SolarBeam") + T.eq(out.damaged, true, "native sun damages immediately") + end + for _, after in ipairs({ false, true }) do + local off = e.api.hooks:wrap("battle.charge_required", function(next, ...) + if after then + next(...) + end + error("intentional charge wrapper failure") + end, 100) + b = f.fresh("FLY") + hp, pp, calls = b.hp(), b.pp(), e.state.calls + b.use() + out = observe("failure:" .. tostring(after), b, hp, pp, calls) + T.eq(out.calls, 1, "wrapper failure executes downstream once") + T.eq(out.spent, 1, "wrapper failure spends exactly one PP") + T.eq(out.charging, true, "wrapper failure retains native verdict") + off() + end + if f.api >= 3 then + local off = e.api.hooks:wrap("battle.charge_required", function(next, ctx) + local a, b = next(ctx), next(ctx) + T.eq(a, b, "replayed next retains charge decision") + return a + end, 100) + b = f.fresh("FLY") + hp, pp, calls = b.hp(), b.pp(), e.state.calls + b.use() + out = observe("replay", b, hp, pp, calls) + T.eq(out.calls, 1, "API-3 replay invokes downstream once") + T.eq(out.spent, 1, "API-3 replay retains one native PP spend") + off() + end + e.stop() + e.stop() + e.state.mode = "bypass" + b = f.fresh("FLY") + hp, pp, calls = b.hp(), b.pp(), e.state.calls + b.use() + out = observe("unsubscribed", b, hp, pp, calls) + T.eq(out.calls, 0, "unsubscribe removes charge callback") + T.eq(out.charging, true, "unsubscribe restores native charge") + return rows +end +return M diff --git a/tests/charge_required/fixture.lua b/tests/charge_required/fixture.lua new file mode 100644 index 000000000..88b0687e5 --- /dev/null +++ b/tests/charge_required/fixture.lua @@ -0,0 +1,364 @@ +local M = {} +function M.new(root, backend, version, api, format, noMod, failing, consumer, prepare, prepareFiles) + consumer = consumer or "portable_charge_required" + local T = require("tests.modkit") + local V = require("src.core.GameVersion") + assert(V.VERSIONS[version], "unregistered version") + V.set(version) + local gen = V.generation() + local rows = { + { "SOLARBEAM", 76, 120, "GRASS", "CHARGE_EFFECT", "EFFECT_SOLARBEAM", 151 }, + { "FLY", 19, 70, "FLYING", "FLY_EFFECT", "EFFECT_FLY", 155 }, + { "DIG", 91, 60, "GROUND", "FLY_EFFECT", "EFFECT_FLY", 155 }, + { "RAZOR_WIND", 13, 80, "NORMAL", "CHARGE_EFFECT", "EFFECT_RAZOR_WIND", 39 }, + { "SKULL_BASH", 130, 100, "NORMAL", "CHARGE_EFFECT", "EFFECT_SKULL_BASH", 145 }, + { "SKY_ATTACK", 143, 140, "FLYING", "CHARGE_EFFECT", "EFFECT_SKY_ATTACK", 75 }, + { "TACKLE", 33, 35, "NORMAL", "NO_ADDITIONAL_EFFECT", "EFFECT_NORMAL_HIT", 0 }, + } + local data = backend == "gen1" and gen == 3 and T.sdk.gen3Data() or T.fixtures.fresh() + if backend == "gen1" and gen == 2 then + data = { + pokemon = { + growthRates = { + GROWTH_MEDIUM_FAST = { numerator = 1, denominator = 1, squared = 0, linear = 0, constant = 0 }, + }, + CHARMANDER = { + id = "CHARMANDER", + index = 4, + dex = 4, + name = "CHARMANDER", + types = { "NORMAL", "NORMAL" }, + baseStats = { + hp = 80, + attack = 50, + defense = 50, + speed = 50, + specialAttack = 50, + specialDefense = 50, + }, + catchRate = 45, + baseExp = 64, + growthRate = "GROWTH_MEDIUM_FAST", + genderRatio = 127, + levelMoves = {}, + evolutions = {}, + }, + }, + moves = {}, + items = {}, + maps = {}, + encounters = {}, + audio = {}, + text = require("tests.gen2_battle_text_fixture")(), + type_chart = { types = {}, matchups = {}, battleRules = require("tests.gen2_battle_rules_fixture")() }, + } + for name, index in pairs({ NORMAL = 0, FLYING = 2, GROUND = 4, GRASS = 22 }) do + data.type_chart.types[name] = + { id = name, index = index, category = name == "GRASS" and "special" or "physical" } + end + elseif not (backend == "gen1" and gen == 3) then + local mon = require("src.mods.Merge").deepCopy(data.pokemon.FIXMON_A) + mon.id, mon.name, mon.dex, mon.index, mon.evolutions = "CHARMANDER", "CHARMANDER", 4, 4, {} + mon.types = { "NORMAL" } + data.pokemon.CHARMANDER = mon + end + local ids, rom, names = {}, {}, {} + for _, row in ipairs(rows) do + local name, number, power, kind, gbEffect, g2Effect, g3Effect = unpack(row) + local id = backend == "gen2" and gen == 3 and ("MOVE_%03d"):format(number) or name + ids[name], names[number] = id, name + local effect = gen > 1 and name == "SOLARBEAM" and "SOLARBEAM_EFFECT" or gbEffect + if backend == "gen1" and gen == 2 then + effect = g2Effect + end + if backend == "gen1" and gen == 3 then + data.gen3Moves._rom[number] = { + effect = g3Effect, + power = power, + type = ({ NORMAL = 0, FLYING = 2, GROUND = 4, GRASS = 12 })[kind], + accuracy = 100, + pp = 10, + secondaryChance = 0, + target = 0, + priority = 0, + flags = 51, + } + data.gen3Pokemon._moveNames[number] = name + rom[number] = data.gen3Moves._rom[number] + else + data.moves[id] = { + id = id, + index = number, + name = name, + power = power, + type = kind, + accuracy = 100, + accuracyRaw = 255, + pp = 10, + effect = effect, + effectChance = name == "SKY_ATTACK" and 30 or 0, + effectChanceRaw = name == "SKY_ATTACK" and 77 or 0, + } + end + end + if prepare then prepare(data, gen, backend, rom, names) end + local files, Json = {}, require("src.link.Json") + for _, name in ipairs({ "main.lua", "manifest.json" }) do + local f = assert(io.open(root .. "/examples/" .. consumer .. "/" .. name, "rb")) + files["mods/" .. consumer .. "/" .. name] = f:read("*a") + f:close() + end + local manifest = Json.decode(files["mods/" .. consumer .. "/manifest.json"]) + manifest.api, manifest.callback_format, manifest.gen2compat = api, format, api < 3 and true or nil + files["mods/" .. consumer .. "/manifest.json"] = Json.encode(manifest) + if failing then + files["mods/" .. consumer .. "/main.lua"] = files["mods/" .. consumer .. "/main.lua"] + .. '\nerror("intentional charge entry failure")' + end + if prepareFiles then prepareFiles(files, data, gen) end + if noMod then + files = {} + end + local result = T.sdk.loadMods( + noMod and {} or { "mods/" .. consumer }, + { data = data, fs = T.sdk.memfs(files), generation = gen, dev = false } + ) + if not failing then + assert(#result.errors == 0, table.concat(result.errors, "\n")) + end + local game = { + data = data, + save = { + party = {}, + options = {}, + player = { name = "PROBE" }, + flags = {}, + inventory = {}, + pokedex = { seen = {}, caught = {} }, + defeatedTrainers = {}, + }, + stack = setmetatable({}, { __index = require("src.core.StateStack") }), + input = { + wasPressed = function() + return false + end, + isDown = function() + return false + end, + }, + } + game.stack:init() + result.loader.game = game + local restores = {} + local function patch(owner, key, value) + local old = owner[key] + restores[#restores + 1] = function() + owner[key] = old + end + owner[key] = value + end + 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, "_romMoveNames", names) + patch(P, "_battleMoves", rom) + local Moves = require("src.core.game3.battle.moves") + patch(Moves, "_rom", rom) + patch(Moves, "_romLoaded", true) + patch(Moves, "BY_NUM", names) + local byName = {} + for number, name in pairs(names) do + byName[name] = number + end + patch(Moves, "_numByName", byName) + patch( + require("src.core.game3.scripting.space"), + "bundle", + { + text = setmetatable({}, { + __index = function(t, key) + local ir = { { t = "text", s = tostring(key) } } + rawset(t, key, ir) + return ir + end, + }), + } + ) + elseif not (backend == "gen1" and gen == 2) then + require("src.render.Font").load(data) + require("src.battle.TypeChart").load(data) + end + local function fresh(name, opts) + opts = opts or {} + local id = ids[name] + local b, user, target, move, use, hp, pp, charging, hidden + if backend == "gen1" and gen == 3 then + local State, Adapter = require("src.core.game3.battle.state"), require("src.core.game3.battle.adapter") + local n = data.gen3Pokemon._moveNames + local number + for k, v in pairs(n) do + if v == name then + number = k + break + end + end + local function mon() + return { + species = 4, + nickname = "PROBE", + level = 30, + hp = 400, + maxHp = 400, + attack = 50, + defense = 50, + speed = 50, + spAtk = 50, + spDef = 50, + ability = 0, + moves = { number }, + pp = { 10 }, + maxPp = { 10 }, + item = 0, + } + end + b = State.new({ + wild = true, + playerParty = { mon() }, + foeMon = mon(), + weather = opts.sun and "SUN" or nil, + }) + b.rng = function(lo) + return lo or 0 + end + user, target = opts.enemy and b.enemy or b.player, opts.enemy and b.player or b.enemy + local ad = Adapter.new(b) + use = function(called) + local slot = 1 + if called then + slot = nil + end + return require("src.core.game3.battle.engine").resolveMove( + user, + target, + number, + slot, + ad, + b, + {}, + { called = called } + ) + end + hp = function() + return ad:hp(target) + end + pp = function() + return user.mon.pp[1] + end + charging = function() + return user.twoTurnMove ~= nil + end + hidden = function() + return user.semiInvulnerable ~= nil + end + elseif backend == "gen1" and gen == 2 then + local Mon = require("src.battle.gen2.Mon") + local p, e = Mon.new(data, "CHARMANDER", 30), Mon.new(data, "CHARMANDER", 30) + p.hp, p.stats.hp, e.hp, e.stats.hp = 400, 400, 400, 400 + p.moves, e.moves = { { id = id, pp = 10 } }, { { id = id, pp = 10 } } + b = require("src.battle.gen2.Battle").new({ + data = data, + party = { p }, + wild = e, + random = function(n) + return math.max(0, (n or 1) - 1) + end, + }) + b.weather = opts.sun and "sun" or nil + user, target = opts.enemy and e or p, opts.enemy and p or e + move = user.moves[1] + use = function(called) + b.copyDepth = called and 1 or 0 + return b:useMove(user, target, id) + end + hp = function() + return target.hp + end + pp = function() + return move.pp + end + charging = function() + return user.volatile.chargeMove ~= nil + end + hidden = function() + return user.volatile.vanished == true + end + else + local P = require("src.pokemon.Pokemon") + local p = P.new(data, "CHARMANDER", 30, function(lo) + return lo or 0 + end) + p.hp, p.stats.hp, p.moves = 400, 400, { { id = id, pp = 10 } } + game.save.party = { p } + b = require("src.battle.BattleState").newWild(game, "CHARMANDER", 30) + b.player.mon.hp, b.player.mon.stats.hp, b.enemy.mon.hp, b.enemy.mon.stats.hp = 400, 400, 400, 400 + b.rng = function(lo) + return lo or 0 + end + if opts.sun then + b.field.weather = "SUN" + end + user, target = opts.enemy and b.enemy or b.player, opts.enemy and b.player or b.enemy + move = { id = id, pp = 10 } + user.mon.moves, user.curMoves = { move }, { move } + use = function(called) + return b:performMove(user, target, move, called) + end + hp = function() + return target.mon.hp + end + pp = function() + return move.pp + end + charging = function() + return user.charging ~= nil + end + hidden = function() + return user.invulnerable == true + end + end + return { + battle = b, + user = user, + target = target, + move = move, + use = use, + hp = hp, + pp = pp, + charging = charging, + hidden = hidden, + } + end + return { + T = T, + e = result.loader.exports[consumer], + result = result, + data = data, + game = game, + gen = gen, + backend = backend, + api = api, + format = format, + ids = ids, + fresh = fresh, + close = function() + for i = #restores, 1, -1 do + restores[i]() + end + result.release() + end, + } +end +return M diff --git a/tests/charge_required/worker.lua b/tests/charge_required/worker.lua new file mode 100644 index 000000000..f98af650c --- /dev/null +++ b/tests/charge_required/worker.lua @@ -0,0 +1,31 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local root, backend, version, api, format = unpack(arg) +local Fixture = dofile(root .. "/tests/charge_required/fixture.lua") +local f = Fixture.new(root, backend, version, tonumber(api), format) +local rows = dofile(root .. "/tests/charge_required/cases.lua").run(f) +f.close() +for _, failing in ipairs({ false, true }) do + local native = Fixture.new(root, backend, version, tonumber(api), format, not failing, failing) + if failing then + native.T.check(#native.result.errors > 0, "failed entry recorded") + else + native.T.eq(native.result.loader.exports.portable_charge_required, nil, "genuine no-mod filesystem") + end + for _, link in ipairs(require("src.mods.Runtime").hooks.chains["battle.charge_required"] or {}) do + native.T.check(link.owner ~= "portable_charge_required", "no-mod/rollback removes owner") + end + local b = native.fresh("FLY") + local pp, hp = b.pp(), b.hp() + b.use() + native.T.eq(b.charging(), true, "no-mod/rollback native charge") + rows[#rows + 1] = { + label = failing and "rollback" or "no mod", + charging = b.charging(), + spent = pp - b.pp(), + damaged = b.hp() < hp, + } + native.close() +end +print("CHARGE_NATIVE_POLICY " .. require("src.link.Json").encode(f.nativePolicy)) +print("CHARGE_REQUIRED_CONTRACT " .. require("src.link.Json").encode(rows)) +f.T.finish("portable native charge decisions") diff --git a/tests/critical_order/cases.lua b/tests/critical_order/cases.lua new file mode 100644 index 000000000..4c67faaae --- /dev/null +++ b/tests/critical_order/cases.lua @@ -0,0 +1,100 @@ +return function(root, backend, version, api) + local native = backend == "gen1" + local f = dofile(root .. "/tests/charge_required/fixture.lua").new( + root, + backend, + version, + api, + "unified", + false, + false, + "portable_critical_order", + function(data) + if not native then + data.type_chart.matchups = { + { attacker = "NORMAL", defender = "ROCK", multiplier = 5 }, + { attacker = "NORMAL", defender = "STEEL", multiplier = 5 }, + { attacker = "NORMAL", defender = "GHOST", multiplier = 0 }, + } + end + end + ) + local T, rows = f.T, {} + f.game.input.wasPressed = function(_, key) + return key == "a" + end + f.game.input.isDown = function(_, key) + return key == "a" + end + T.eq(f.e.available, true, "real public critical producer is available") + for _, enemy in ipairs({ false, true }) do + for _, critical in ipairs({ false, true }) do + for _, stab in ipairs({ false, true }) do + for _, types in ipairs({ { "NORMAL" }, { "STEEL" }, { "ROCK", "STEEL" }, { "GHOST" } }) do + for _, roll in ipairs({ 85, 92, 100 }) do + local s = f.fresh("TACKLE", { enemy = enemy }) + local ids = { NORMAL = 0, ROCK = 5, GHOST = 7, STEEL = 8, FIRE = 10 } + for _, who in ipairs({ s.user, s.target }) do + if native then + for _, key in ipairs({ "attack", "defense", "speed", "spAtk", "spDef" }) do + who.mon[key] = 50 + end + else + who.mon.stats = { + hp = 400, + attack = 50, + defense = 50, + speed = 50, + special = 50, + spatk = 50, + spdef = 50, + } + who.curStats = who.mon.stats + end + end + if native then + s.user.type1, s.user.type2 = stab and 0 or 10, stab and 0 or 10 + s.target.type1, s.target.type2 = ids[types[1]], ids[types[2] or types[1]] + else + s.user.curTypes, s.target.curTypes = { stab and "NORMAL" or "FIRE" }, types + end + s.battle.rng = function(lo, hi) + if lo == 85 and hi == 100 then + return roll + end + return lo or 0 + end + f.e.state.critical, f.e.state.calls = critical, 0 + local before = s.hp() + s.use() + if not native then + local count = 0 + while s.battle:updateQueue() do + count = count + 1 + assert(count < 10000, "native move queue did not settle") + end + end + local amount = before - s.hp() + T.eq(s.pp(), 9, "native move spends one PP, including immunity") + T.eq(f.e.state.calls, 1, "one real public critical decision per move") + if critical and not stab and #types == 1 and types[1] == "STEEL" and roll == 85 then + T.eq(amount, 9, "critical Steel regression through real move pipeline") + end + rows[#rows + 1] = { + enemy = enemy, + critical = critical, + stab = stab, + types = types, + roll = roll, + amount = amount, + pp = s.pp(), + calls = f.e.state.calls, + } + end + end + end + end + end + f.close() + return rows +end diff --git a/tests/critical_order/worker.lua b/tests/critical_order/worker.lua new file mode 100644 index 000000000..4a46d48ad --- /dev/null +++ b/tests/critical_order/worker.lua @@ -0,0 +1,5 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local root, backend, version, api = unpack(arg) +local rows = dofile(root .. "/tests/critical_order/cases.lua")(root, backend, version, tonumber(api)) +print("CRITICAL_ORDER " .. require("src.link.Json").encode(rows)) +require("tests.modkit").finish("native critical/type ordering") diff --git a/tests/drivers/critical_order.lua b/tests/drivers/critical_order.lua new file mode 100644 index 000000000..f6da56076 --- /dev/null +++ b/tests/drivers/critical_order.lua @@ -0,0 +1,99 @@ +-- Actual imported-data FIGHT/move/controller smoke in an isolated identity. +return function(game) + local U, V = require("tests.drivers.util"), require("src.core.GameVersion") + assert(love.filesystem.getIdentity() ~= "Gen2Recomp", "use isolated identity") + assert(os.getenv("POKEPORT_SKIP_LEGACY_MIGRATION") == "1", "disable migration") + assert(os.getenv("POKEPORT_ISOLATED_TESTING") == "1", "use isolated wrapper") + assert(V.get() == os.getenv("POKEPORT_VERSION") and V.isGen3(), "wrong imported game") + local title + for _ = 1, 120 do + local top = game.stack:top() + if top and type(top.onNewGame) == "function" then + title = top + break + end + U.tap(game, "start") + U.wait(5) + end + assert(title, "native title missing") + U.wait(30) + title.onNewGame() + U.wait(60) + local e = assert(game.mods.exports.portable_critical_order, "install critical example") + assert(e.available and game.save and game.overworld, "native mod/New Game missing") + local P, Battle = require("src.pokemon.Pokemon"), require("src.battle.BattleState") + local function named(registry, name) + for id, row in pairs(registry) do + if row.name == name then + return id + end + end + error("missing imported record " .. name) + end + local species, tackle = named(game.data.pokemon, "MEW"), named(game.data.moves, "TACKLE") + assert(game.data.moves[tackle].power == 35, "native cartridge Tackle power") + local function mon(name) + local p = P.new(game.data, species, 30) + p.nickname, p.hp = name, 400 + p.stats = { hp = 400, attack = 50, defense = 50, speed = 50, special = 50, spatk = 50, spdef = 50 } + p.moves = { { id = tackle, pp = 10 } } + return p + end + game.save.options.ruleset = false + game.save.options.textSpeed, game.save.options.animations = 1, false + for _, spec in ipairs({ + { critical = false, types = { "STEEL" }, want = 4 }, + { critical = true, types = { "STEEL" }, want = 9 }, + { critical = true, types = { "ROCK", "STEEL" }, want = 4 }, + { critical = false, stab = true, types = { "STEEL" }, want = 6 }, + { critical = true, stab = true, types = { "STEEL" }, want = 13 }, + { critical = true, stab = true, types = { "ROCK", "STEEL" }, want = 6 }, + }) do + game.save.party = { mon("PLAYER") } + local old = game.data.trainers.PORTABLE_CRITICAL + game.data.trainers.PORTABLE_CRITICAL = { + name = "PORTABLE", + items = {}, + parties = { { { prebuilt = mon("ENEMY") } } }, + } + local b = Battle.newTrainer(game, "PORTABLE_CRITICAL", 1) + game.data.trainers.PORTABLE_CRITICAL = old + b:syncSides() + b.player.curTypes, b.enemy.curTypes = { spec.stab and "NORMAL" or "FIRE" }, spec.types + b.player.curStats.speed = 80 + b.rng = function(lo) + return lo or 0 + end + b.queue, b.phase, b.afterQueue, b.menuIndex = {}, "menu", "menu", 1 + b.introSlide, b.fx = 0, {} + game.stack.states = { game.overworld, b } + e.state.critical, e.state.calls = spec.critical, 0 + U.tap(game, "a") + assert(b.phase == "moveSelect", "native FIGHT opens moves") + U.tap(game, "a") + for _ = 1, 4000 do + if b.phase == "menu" then + break + end + U.tap(game, "a") + end + assert(b.phase == "menu", "native turn did not settle") + assert(b.enemy.mon.hp == 400 - spec.want, "native critical/type rounding") + assert(b.player.curMoves[1].pp == 9 and b.enemy.curMoves[1].pp == 9, "native PP accounting") + assert(e.state.calls == 2, "both native critical decisions use the public consumer") + print( + "PORTABILITY_CRITICAL_CASE " + .. V.get() + .. " " + .. tostring(spec.critical) + .. " " + .. tostring(spec.stab or false) + .. " " + .. table.concat(spec.types, "/") + .. " " + .. spec.want + ) + end + print("PORTABILITY_CRITICAL_ORDER_PASS " .. V.get()) + love.event.quit(0) +end diff --git a/tests/drivers/held_item.lua b/tests/drivers/held_item.lua new file mode 100644 index 000000000..f839e9a97 --- /dev/null +++ b/tests/drivers/held_item.lua @@ -0,0 +1,134 @@ +-- Imported-data native input/held-effect/result-queue smoke. Isolated only. +return function(game) + local U, V = require("tests.drivers.util"), require("src.core.GameVersion") + assert(love.filesystem.getIdentity() ~= "Gen2Recomp", "use isolated identity") + assert(os.getenv("POKEPORT_SKIP_LEGACY_MIGRATION") == "1", "disable migration") + assert(os.getenv("POKEPORT_ISOLATED_TESTING") == "1", "use isolated wrapper") + assert(V.get() == os.getenv("POKEPORT_VERSION"), "wrong imported cartridge") + local title + for _ = 1, 120 do + local top = game.stack:top() + if top and type(top.onNewGame) == "function" then + title = top + break + end + U.tap(game, "start") + U.wait(5) + end + assert(title, "native title missing") + U.wait(30) + title.onNewGame() + U.wait(60) + assert(game.save and game.overworld, "native New Game missing") + local e = assert(game.mods.exports.portable_held_item, "install held-item example") + local P, Battle = require("src.pokemon.Pokemon"), require("src.battle.BattleState") + local species, tackle, leftovers + for id, def in pairs(game.data.pokemon) do + if def.name == "MEW" then + species = id + break + end + end + for id, def in pairs(game.data.moves) do + if def.name == "TACKLE" then + tackle = id + break + end + end + for id, def in pairs(game.data.items) do + if def.name == "LEFTOVERS" then + leftovers = id + break + end + end + assert(species and tackle and leftovers, "imported native metadata missing") + game.save.options.ruleset = false + game.save.options.textSpeed, game.save.options.animations = 1, false + local world = game.overworld + local function fresh(hp) + local p = P.new(game.data, species, 30) + p.hp, p.stats.hp, p.nickname, p.item = hp or 300, 500, "PLAYER", leftovers + p.moves = { { id = tackle, pp = 10 } } + game.save.party = { p } + local b = Battle.newWild(game, species, 30) + b.enemy.mon.hp, b.enemy.mon.stats.hp, b.enemy.mon.nickname = 500, 500, "ENEMY" + b.enemy.mon.item, b.enemy.mon.heldItem = leftovers, nil + b.enemy.mon.moves = { { id = tackle, pp = 10 } } + b.enemy.curMoves = b.enemy.mon.moves + b.rng = function(lo) + return lo or 0 + end + b.queue, b.phase, b.afterQueue, b.menuIndex = {}, "menu", "menu", 1 + b.introSlide, b.fx = 0, {} + game.stack.states = { world, b } + e.state.mode, e.state.trigger, e.state.holder, e.state.requiresItem = "delegate", nil, nil, true + e.state.calls, e.state.seen, e.state.views, e.state.selected = 0, {}, {}, nil + return b + end + local function drain(b) + for _ = 1, 4000 do + if b.phase == "menu" then + return + end + U.tap(game, "a") + end + error("native held-item result queue did not settle") + end + for _, mode in ipairs({ "suppress", "override" }) do + local b = fresh(240) + e.state.mode, e.state.trigger, e.state.holder = mode, "residual", "PLAYER" + e.state.effect, e.state.parameter = "HELD_BERRY", 7 + local lost = 0 + local off = e.api.events:on("battle.damage_dealt", function(ctx) + if ctx.target.mon.nickname == "PLAYER" then lost = lost + ctx.damage end + end) + U.tap(game, "a") + assert(b.phase == "moveSelect", "native FIGHT did not open move selection") + U.tap(game, "a") + drain(b) + off() + local ctx = assert(e.state.selected, "native player's held read missing") + assert(ctx.mon.species == "MEW" and ctx.item == "LEFTOVERS", "portable imported held IDs") + assert( + b.player.mon.hp == 240 - lost + (mode == "override" and 7 or 0), + "native input/HP follows held decision" + ) + assert((b.player.mon.item ~= nil) == (mode ~= "override"), "native held consumption") + end + local b = fresh() + b:endOfTurn() + b.phase = "messages" + drain(b) + assert(b.player.mon.hp == 331 and b.player.mon.item == leftovers, "native delegated Leftovers") + b = fresh() + b.enemy.mon.hp = 1 + e.state.mode, e.state.trigger, e.state.holder = "override", "endure", "ENEMY" + e.state.effect, e.state.parameter = "HELD_FOCUS_BAND", 256 + b:performMove(b.player, b.enemy, b.player.mon.moves[1]) + b.phase = "messages" + drain(b) + assert(b.enemy.mon.hp == 1 and e.state.seen.endure, "native lethal hit honors held endurance") + if V.generation() == 3 then + b = fresh() + b.player.stages.attack = -2 + e.state.mode, e.state.trigger, e.state.holder = "override", "check", "PLAYER" + e.state.effect = "HELD_RESTORE_STATS" + require("src.battle.HeldItems").onEntry(b, b.player) + b.phase = "messages" + drain(b) + assert(b.player.stages.attack == 0 and b.player.mon.item == nil, "native entry White Herb substitution") + b = fresh() + e.state.mode, e.state.trigger, e.state.holder = "override", "run", "PLAYER" + e.state.effect = "HELD_ESCAPE" + b.menuIndex = 4 + U.tap(game, "a") + assert(b.result == "run" and e.state.seen.run, "native RUN menu uses public held escape") + -- Stop at the in-battle result before finish/afterBattle/save processing. + end + b = fresh() + e.stop() + b:endOfTurn() + assert(e.state.calls == 0 and b.player.mon.hp == 331, "native held unsubscribe") + print("PORTABILITY_HELD_ITEM_PASS " .. V.get()) + love.event.quit(0) +end diff --git a/tests/drivers/held_item_scheduling.lua b/tests/drivers/held_item_scheduling.lua new file mode 100644 index 000000000..ec085f2f0 --- /dev/null +++ b/tests/drivers/held_item_scheduling.lua @@ -0,0 +1,113 @@ +-- Imported-data native input/held-effect/result-queue smoke. Isolated only. +return function(game) + local U, V = require("tests.drivers.util"), require("src.core.GameVersion") + assert(love.filesystem.getIdentity() ~= "Gen2Recomp", "use isolated identity") + assert(os.getenv("POKEPORT_SKIP_LEGACY_MIGRATION") == "1", "disable migration") + assert(os.getenv("POKEPORT_ISOLATED_TESTING") == "1", "use isolated wrapper") + assert(V.get() == os.getenv("POKEPORT_VERSION"), "wrong imported cartridge") + local title + for _ = 1, 120 do + local top = game.stack:top() + if top and type(top.onNewGame) == "function" then + title = top + break + end + U.tap(game, "start") + U.wait(5) + end + assert(title, "native title missing") + U.wait(30) + title.onNewGame() + U.wait(60) + assert(game.save and game.overworld, "native New Game missing") + local e = assert(game.mods.exports.portable_held_item, "install held-item example") + local P, Battle = require("src.pokemon.Pokemon"), require("src.battle.BattleState") + local species, tackle, leftovers, splash + for id, def in pairs(game.data.pokemon) do + if def.name == "MEW" then + species = id + break + end + end + for id, def in pairs(game.data.moves) do + if def.name == "TACKLE" then + tackle = id + end + if def.name == "SPLASH" then + splash = id + end + end + for id, def in pairs(game.data.items) do + if def.name == "LEFTOVERS" then + leftovers = id + break + end + end + assert(species and tackle and leftovers and splash, "imported native metadata missing") + game.save.options.ruleset = false + game.save.options.textSpeed, game.save.options.animations = 1, false + local world = game.overworld + local function fresh(hp) + local p = P.new(game.data, species, 30) + p.hp, p.stats.hp, p.nickname, p.item = hp or 300, 500, "PLAYER", leftovers + p.moves = { { id = tackle, pp = 10 } } + game.save.party = { p } + local b = Battle.newWild(game, species, 30) + b.enemy.mon.hp, b.enemy.mon.stats.hp, b.enemy.mon.nickname = 500, 500, "ENEMY" + b.enemy.mon.item, b.enemy.mon.heldItem = leftovers, nil + b.enemy.mon.moves = { { id = tackle, pp = 10 } } + b.enemy.curMoves = b.enemy.mon.moves + b.rng = function(lo) + return lo or 0 + end + b.queue, b.phase, b.afterQueue, b.menuIndex = {}, "menu", "menu", 1 + b.introSlide, b.fx = 0, {} + game.stack.states = { world, b } + e.state.mode, e.state.trigger, e.state.holder, e.state.requiresItem = "delegate", nil, nil, true + e.state.calls, e.state.seen, e.state.views, e.state.selected = 0, {}, {}, nil + return b + end + local function drain(b) + for _ = 1, 4000 do + if b.phase == "menu" then + return + end + U.tap(game, "a") + end + error("native held-item result queue did not settle") + end + assert(V.generation() == 3, "GBA scheduling smoke only") + local b = fresh(100) + b.player.mon.item, b.enemy.mon.item = nil, nil + b.enemy.mon.moves = { { id = splash, pp = 10 } } + b.enemy.curMoves = b.enemy.mon.moves + e.state.mode, e.state.trigger, e.state.holder = "override", "residual", "PLAYER" + e.state.effect, e.state.parameter, e.state.requiresItem = "HELD_BERRY", 7, false + U.tap(game, "a") + assert(b.phase == "moveSelect", "native FIGHT missing") + U.tap(game, "a") + drain(b) + assert(b.turnCount == 1, "exactly one native turn") + -- Four scans after each action; normal turn check and four post-turn scans. + assert(b.player.mon.hp == 191, "native action/residual grants must heal 13*7") + assert(e.state.seen.residual >= 13, "native held reads missing") + + b = fresh(20) + b.player.mon.status = "PSN" + e.state.mode, e.state.trigger, e.state.holder = "override", "residual", "PLAYER" + e.state.effect, e.state.parameter = "HELD_HEAL_POISON", 0 + b:endOfTurn() + b.phase = "messages" + drain(b) + assert(b.player.mon.hp == 20 and not b.player.mon.status, "cure must precede lethal poison") + assert(not b.player.faintQueued and not b.result, "cured holder must remain in battle") + assert(not b.player.mon.item, "native item consumed") + + b = fresh(100) + b.player.mon.item, b.enemy.mon.item = nil, nil + e.stop() + b:endOfTurn() + assert(b.player.mon.hp == 100 and e.state.calls == 0, "unsubscribe restores native empty hands") + print("PORTABILITY_HELD_ITEM_SCHEDULING_PASS " .. V.get()) + love.event.quit(0) +end diff --git a/tests/drivers/pokemon_rolls.lua b/tests/drivers/pokemon_rolls.lua new file mode 100644 index 000000000..bc0675d46 --- /dev/null +++ b/tests/drivers/pokemon_rolls.lua @@ -0,0 +1,117 @@ +-- Imported-data smoke: real constructors, summary screens and forced wild. +return function(game) + local U, V = require("tests.drivers.util"), require("src.core.GameVersion") + assert(love.filesystem.getIdentity() ~= "Gen2Recomp", "use isolated identity") + assert(os.getenv("POKEPORT_SKIP_LEGACY_MIGRATION") == "1", "disable migration") + assert(os.getenv("POKEPORT_ISOLATED_TESTING") == "1", "use isolated wrapper") + assert(V.get() == os.getenv("POKEPORT_VERSION"), "wrong imported version") + local gen = V.generation() + assert(gen == 2 or gen == 3, "identity hook smoke requires Gen 2/3") + local title + for _ = 1, 120 do + local top = game.stack:top() + if top and type(top.onNewGame) == "function" then + title = top + break + end + U.tap(game, "start") + U.wait(5) + end + assert(title, "native title missing") + title.onNewGame() + U.wait(60) + local world = assert(game.overworld, "native New Game missing") + local e = assert(game.mods.exports.portable_pokemon_rolls, "install public identity example") + assert(e.available, "identity hooks unavailable") + local P, DayCare = require("src.pokemon.Pokemon"), require("src.pokemon.DayCare") + local Copy = require("src.mods.Merge").deepCopy + local species + for id, def in pairs(game.data.pokemon) do + if def.name == "CHARMANDER" then + species = id + break + end + end + assert(species, "imported Charmander missing") + game.save.options.shinyOdds = false + e.mode = "force" + local sequence = { 15, 0, 0, 0, 1, 255 } + local i = 0 + local mon = P.new(game.data, species, 5, function(lo) + i = i + 1 + return sequence[i] or lo or 0 + end) + if gen == 3 then + mon.otId, mon.otSecretId = 0, 0 + end + local genes = { dvs = Copy(mon.dvs), ivs = Copy(mon.ivs), pid = mon.personality, stats = Copy(mon.stats) } + game.save.party = { mon } + assert(P.isShiny(mon), "native shiny reader ignored hook") + assert(P.genderOf(game.data, mon) == "female", "native birth gender ignored hook") + assert(e.observations["gender.roll"].species == "CHARMANDER", "species did not cross API-3 boundary") + assert(e.observations["gender.roll"].defId == "CHARMANDER", "definition ID is not portable") + local Screens = require("src.ui.Screens") + game.stack.states = { world } + Screens.push(game, "SummaryMenu", mon, {}) + U.wait(15) + assert(game.stack:top() ~= world, "native summary did not open") + assert(P.isShiny(mon), "summary lost shiny result") + assert(P.genderOf(game.data, mon) == "female", "summary lost birth gender") + U.tap(game, "b") + U.wait(10) + e.mode = "suppress" + if gen == 2 then + game.stack.states = { world } + Screens.push(game, "SummaryMenu", mon, {}) + U.wait(15) + assert(mon.gender == "male", "Gen-2 summary did not refresh gender") + assert(P.isShiny(mon), "Gen-2 summary lost authoritative true shiny") + U.tap(game, "b") + U.wait(10) + else + assert(P.genderOf(game.data, mon) == "female", "GBA display lost native birth cache") + assert(DayCare.gender(game.data, mon) == "male", "GBA native query did not evaluate hook") + assert(not P.isShiny(mon), "GBA native shiny query did not suppress") + end + local actual = { dvs = mon.dvs, ivs = mon.ivs, pid = mon.personality, stats = mon.stats } + 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 + assert(equal(actual, genes), "plain overrides/summary changed genetics or stats") + local explicit = mon.isShiny + if gen == 3 then + mon.isShiny = true + end + local before = e.calls["shiny.roll"] or 0 + local battle = require("src.battle.BattleState").newWild(game, species, 5, { shiny = true }) + assert(P.isShiny(battle.enemy.mon), "native forced wild lost shiny precedence") + assert((e.calls["shiny.roll"] or 0) == before, "forced wild invoked ordinary shiny callback") + mon.isShiny = explicit + e.stopShiny() + e.stopGender() + local calls = Copy(e.calls) + if gen == 2 then + P.syncIdentity(game.data, mon) + end + P.isShiny(mon) + DayCare.gender(game.data, mon) + assert(equal(calls, e.calls), "retired native hooks still dispatched") + print("PORTABILITY_POKEMON_ROLL_PASS " .. V.get()) + love.event.quit(0) +end diff --git a/tests/enemy_actions/accuracy.lua b/tests/enemy_actions/accuracy.lua new file mode 100644 index 000000000..2abfc5ab1 --- /dev/null +++ b/tests/enemy_actions/accuracy.lua @@ -0,0 +1,369 @@ +-- Exercise the real native accuracy command and the public API-3 hook. +local M = {} +local STAGE = { + [-6] = 33, + [-5] = 36, + [-4] = 43, + [-3] = 50, + [-2] = 60, + [-1] = 75, + [0] = 100, + 133, + 166, + 200, + 233, + 266, + 300, +} +function M.run(root, backend, version, execution) + local native = backend == "gen1" + local function configure(data, engine, ids, rom, names) + for _, row in ipairs({ { 129, "SWIFT" }, { 87, "THUNDER" }, { 327, "SKY_UPPERCUT" } }) do + local number, name = unpack(row) + local id = engine == "gen1" and name or ("MOVE_%03d"):format(number) + ids[name], names[number] = id, name + if engine == "gen1" then + local d = require("src.mods.Merge").deepCopy(data.gen3Moves._rom[33]) + data.gen3Moves._rom[number], rom[number], data.gen3Pokemon._moveNames[number] = d, d, name + else + local d = require("src.mods.Merge").deepCopy(data.moves[ids.TACKLE]) + d.id, d.name, d.index = id, name, number + data.moves[id] = d + end + end + if engine == "gen2" then + data.items.BRIGHTPOWDER = + { index = 179, name = "BRIGHTPOWDER", holdEffect = "EVASION_UP", holdEffectParam = 10 } + data.items.LAX_INCENSE = + { index = 221, name = "LAX INCENSE", holdEffect = "EVASION_UP", holdEffectParam = 5 } + end + end + local f = + dofile(root .. "/tests/enemy_actions/fixture.lua").new(root, backend, version, 3, "unified", configure) + local T, rows, calls, draws, policy = f.T, {}, {}, {}, "delegate" + local stop + local damageCalls = 0 + local stopCrit, stopDamage + if execution then + stopCrit = f.e.api.hooks:wrap("battle.crit", function() + return false + end) + stopDamage = f.e.api.hooks:wrap("battle.damage", function(nextDamage, ...) + damageCalls = damageCalls + 1 + return nextDamage(...) + end) + end + local function hook(nextAccuracy, c) + calls[#calls + 1] = { + move = c.moveId, + number = c.moveNum, + accuracy = c.accuracy, + user = c.user.mon.nickname, + target = c.target.mon.nickname, + battle = c.battle ~= nil, + } + if policy == "force" then + return true + end + if policy == "suppress" then + return false + end + if policy == "threshold" then + c.accuracy = 37 + end + if policy == "rng" then + c.rng = function(lo, hi) + T.eq(lo, 1, "replacement accuracy RNG lower bound") + T.eq(hi, 100, "replacement accuracy RNG upper bound") + return 1 + end + end + return nextAccuracy(c) + end + local function at(b, id) + return native and b.battlers[id] or b:battlerAt(id) + end + local abilities = { + NONE = 0, + COMPOUND_EYES = 14, + HUSTLE = 55, + SAND_VEIL = 8, + CLOUD_NINE = 13, + VOLT_ABSORB = 10, + WATER_ABSORB = 11, + FLASH_FIRE = 18, + } + local cases = {} + for acc = -6, 6 do + for eva = -6, 6 do + for _, word in ipairs({ 0, 49, 82, 83, 99 }) do + cases[#cases + 1] = + { name = "stages", accuracy = 83, acc = acc, eva = eva, word = word, user = (acc - eva) % 4 } + end + end + end + for _, spec in ipairs({ + { name = "swift", accuracy = 1, move = "SWIFT", number = 129, effect = 17 }, + { name = "swift-air", accuracy = 1, move = "SWIFT", number = 129, effect = 17, semi = "ON_AIR" }, + { name = "thunder-rain", accuracy = 70, move = "THUNDER", number = 87, effect = 152, sky = "RAIN" }, + { name = "thunder-sun", accuracy = 70, move = "THUNDER", number = 87, effect = 152, sky = "SUN" }, + { + name = "thunder-air", + accuracy = 70, + move = "THUNDER", + number = 87, + effect = 152, + semi = "ON_AIR", + executionOnly = true, + }, + { + name = "sky-uppercut-air", + accuracy = 90, + move = "SKY_UPPERCUT", + number = 327, + effect = 207, + semi = "ON_AIR", + executionOnly = true, + }, + { name = "full-accuracy", accuracy = 100 }, + { name = "zero-accuracy", accuracy = 0 }, + { name = "compound-eyes", mine = "COMPOUND_EYES", accuracy = 73, acc = 1 }, + { name = "hustle", mine = "HUSTLE", accuracy = 83, acc = -1 }, + { name = "hustle-special", mine = "HUSTLE", kind = 13, accuracy = 83 }, + { name = "sand-veil", theirs = "SAND_VEIL", sky = "SAND", accuracy = 83, acc = 1 }, + { name = "sand-veil-suppressed", theirs = "SAND_VEIL", sky = "SAND", suppressSky = true, accuracy = 83 }, + { name = "brightpowder", item = "BRIGHTPOWDER", accuracy = 83, acc = 1 }, + { name = "lax-incense", item = "LAX_INCENSE", accuracy = 83, acc = 1 }, + { + name = "composed-modifiers", + mine = "COMPOUND_EYES", + theirs = "SAND_VEIL", + sky = "SAND", + item = "LAX_INCENSE", + accuracy = 73, + acc = 1, + }, + { name = "identified", identified = true, accuracy = 83, acc = -1, eva = 6 }, + { name = "locked-on", locked = true, accuracy = 1 }, + { name = "semi-air", semi = "ON_AIR", accuracy = 100 }, + { name = "semi-ground", semi = "UNDERGROUND", accuracy = 100 }, + { name = "semi-water", semi = "UNDERWATER", accuracy = 100 }, + { name = "protected", protect = true, accuracy = 1 }, + { name = "unaffected-by-protect", protect = true, noProtect = true, accuracy = 83 }, + { name = "pokedude", demo = true, accuracy = 1 }, + { name = "pokedude-air", demo = true, semi = "ON_AIR", accuracy = 1 }, + { name = "pokedude-protect", demo = true, protect = true, accuracy = 1 }, + { name = "oak-damage-pending", oak = true, flags = 0, accuracy = 1 }, + { name = "oak-damage-done", oak = true, flags = 1, accuracy = 1 }, + { name = "oak-stat-pending", oak = true, flags = 1, power = 0, accuracy = 1 }, + { name = "oak-stat-done", oak = true, flags = 2, power = 0, accuracy = 1 }, + { name = "oak-separate-flags", oak = true, flags = 2, accuracy = 1 }, + { name = "oak-protected", oak = true, flags = 0, protect = true, accuracy = 1 }, + { name = "absorb-electric", theirs = "VOLT_ABSORB", kind = 13, accuracy = 83 }, + { name = "absorb-water", theirs = "WATER_ABSORB", kind = 11, accuracy = 83 }, + { name = "absorb-fire", theirs = "FLASH_FIRE", kind = 10, accuracy = 83 }, + { name = "pokedude-absorb", demo = true, theirs = "VOLT_ABSORB", kind = 13, accuracy = 1 }, + }) do + for id = 0, 3 do + local copy = require("src.mods.Merge").deepCopy(spec) + copy.user = id + copy.word = 49 + cases[#cases + 1] = copy + end + end + for _, mode in ipairs({ "none", "delegate", "force", "suppress", "threshold", "rng" }) do + policy = mode + if mode ~= "none" then + stop = f.e.api.hooks:wrap("battle.accuracy", hook) + end + for _, spec in ipairs(cases) do + if + (spec.name ~= "stages" or not execution and (mode == "none" or mode == "delegate")) + and (execution or not spec.executionOnly) + then + calls, draws, damageCalls = {}, {}, 0 + local b = f.fresh("TACKLE", { enemy = true }).battle + b.double = true + if native then + local S = require("src.core.game3.battle.state") + b.battlers[2] = + S.makeBattler(require("src.mods.Merge").deepCopy(b.player.mon), "player", { id = 2 }) + b.battlers[3] = S.makeBattler(require("src.mods.Merge").deepCopy(b.enemy.mon), "enemy", { id = 3 }) + else + local S = require("src.battle.BattleState") + b:syncSides() + b:placeBattler(2, S.makeBattler(f.data, require("src.mods.Merge").deepCopy(b.player.mon), true)) + b:placeBattler(3, S.makeBattler(f.data, require("src.mods.Merge").deepCopy(b.enemy.mon), false)) + b:syncSides() + end + for id = 0, 3 do + local a = at(b, id) + a.mon.nickname, a.mon.hp = "P" .. id, 100 + if native then + a.ability, a.mon.ability, a.mon.maxHp, a.type1, a.type2 = 0, 0, 400, 0, 0 + for _, key in ipairs({ "attack", "defense", "speed", "spAtk", "spDef" }) do + a.mon[key] = 50 + end + else + a.abilityOverride, a.mon.stats.hp, a.curTypes = false, 400, { "NORMAL" } + for _, key in ipairs({ "attack", "defense", "speed", "spatk", "spdef" }) do + a.curStats[key] = 50 + end + end + end + local user, target = at(b, spec.user), at(b, spec.user % 2 == 0 and 1 or 0) + local function ability(a, name) + if native then + a.ability, a.mon.ability = abilities[name or "NONE"], abilities[name or "NONE"] + else + a.abilityOverride = name and name:gsub("COMPOUND_EYES", "COMPOUNDEYES") or false + end + end + ability(user, spec.mine) + ability(target, spec.theirs) + if spec.suppressSky then + ability(at(b, 3 - spec.user % 2), "CLOUD_NINE") + end + user.stages, target.stages = { accuracy = spec.acc or 0 }, { evasion = spec.eva or 0 } + b.firstBattle, b.oakMsgFlags = spec.oak, spec.flags or 0 + local move + local number, moveName = spec.number or 33, spec.move or "TACKLE" + if native then + user.mon.moves = { number } + else + user.curMoves = { { id = f.ids[moveName], pp = 10 } } + user.mon.moves = user.curMoves + end + if native then + b.pokedude, b.weather = spec.demo, spec.sky + target.expProtected, target.expIdentified, target.semiInvulnerable = + spec.protect, spec.identified, spec.semi + target.expLockedOn = spec.locked and 2 or nil + target.expLockedOnById = spec.locked and spec.user or nil + target.item = spec.item == "BRIGHTPOWDER" and 179 or spec.item == "LAX_INCENSE" and 221 or 0 + move = require("src.core.game3.battle.moves")._rom[number] + move.effect = spec.effect or (spec.power == 0 and 19 or 0) + move.accuracy, move.power, move.type, move.flags = + spec.accuracy, spec.power or 35, spec.kind or 0, spec.noProtect and 0 or 2 + else + b.pokedudeDemo, b.field.weather = spec.demo and {}, spec.sky == "SAND" and "SANDSTORM" or spec.sky + target.protecting, target.identified, target.invulnerable = + spec.protect, spec.identified, spec.semi ~= nil + target.hiddenAs = ({ ON_AIR = "air", UNDERGROUND = "underground", UNDERWATER = "underwater" })[spec.semi] + user.lockedOn = spec.locked and target or nil + target.mon.item = spec.item + move = f.data.moves[f.ids[moveName]] + move.effect = ({ [17] = "SWIFT_EFFECT", [152] = "THUNDER_EFFECT", [207] = "SKY_UPPERCUT_EFFECT" })[spec.effect] + or (spec.power == 0 and "DEFENSE_DOWN1_EFFECT" or "NO_ADDITIONAL_EFFECT") + move.gen3Effect = spec.effect or (spec.power == 0 and 19 or 0) + move.accuracy, move.power, move.type, move.protectAffected = + spec.accuracy, + spec.power or 35, + ({ [0] = "NORMAL", [13] = "ELECTRIC", [11] = "WATER", [10] = "FIRE" })[spec.kind or 0], + not spec.noProtect + end + b.rng = function(lo, hi) + local value = lo + spec.word % (hi - lo + 1) + draws[#draws + 1] = { lo = lo, hi = hi, word = spec.word, value = value } + return value + end + local result + if execution then + if native then + require("src.core.game3.battle.engine").resolveMove( + user, + target, + number, + 1, + require("src.core.game3.battle.adapter").new(b), + b, + {} + ) + else + b:executeAction(user, target, user.curMoves[1]) + end + result = "execution" + elseif native then + local context = require("src.core.game3.battle.engine").newContext( + user, + target, + number, + 1, + require("src.core.game3.battle.adapter").new(b), + b, + {}, + {} + ) + result = context:accuracyCheck("normal", false) + else + result = b:accuracyRoll(move, user, target) + end + local buff = math.max(-6, math.min(6, (spec.acc or 0) - (spec.identified and 0 or spec.eva or 0))) + local threshold = + math.floor(STAGE[buff] * (spec.effect == 152 and spec.sky == "SUN" and 50 or spec.accuracy) / 100) + if spec.mine == "COMPOUND_EYES" then + threshold = math.floor(threshold * 130 / 100) + end + if spec.theirs == "SAND_VEIL" and spec.sky == "SAND" and not spec.suppressSky then + threshold = math.floor(threshold * 80 / 100) + end + if spec.mine == "HUSTLE" and not spec.kind then + threshold = math.floor(threshold * 80 / 100) + end + if spec.item then + threshold = math.floor(threshold * (spec.item == "BRIGHTPOWDER" and 90 or 95) / 100) + end + local guaranteed = spec.demo + or spec.locked + or spec.oak + and spec.user % 2 == 0 + and math.floor((spec.flags or 0) / (spec.power == 0 and 2 or 1)) % 2 == 0 + local specialAir = execution and (spec.effect == 152 or spec.effect == 207) and spec.semi == "ON_AIR" + local guard = spec.protect and not spec.noProtect or spec.semi and not guaranteed and not specialAir + guaranteed = guaranteed + or spec.effect == 17 + or spec.effect == 78 + or spec.effect == 152 and spec.sky == "RAIN" + local callback = not guard and not guaranteed and mode ~= "none" + T.eq(#calls, callback and 1 or 0, spec.name .. " native callback boundary") + if not execution then + T.eq( + #draws, + not guard and not guaranteed and not (mode == "force" or mode == "suppress") and 1 or 0, + spec.name .. " exact native accuracy draw count" + ) + end + if callback then + T.eq(calls[1] and calls[1].accuracy, threshold, "public accuracy threshold uses native arithmetic") + end + rows[#rows + 1] = { + case = spec.name, + mode = mode, + user = spec.user, + acc = spec.acc, + eva = spec.eva, + word = spec.word, + result = result, + calls = calls, + draws = draws, + damageCalls = execution and damageCalls or nil, + pp = execution and (native and user.mon.pp[1] or user.curMoves[1].pp) or nil, + defense = target.stages.defense or 0, + hp = target.mon.hp, + flashFire = (native and target.expFlashFire or target.flashFire) or false, + } + end + end + if stop then + stop() + stop = nil + end + end + if stopCrit then + stopCrit() + stopDamage() + end + f.close() + return rows +end +return M diff --git a/tests/enemy_actions/arguments.lua b/tests/enemy_actions/arguments.lua new file mode 100644 index 000000000..c56479837 --- /dev/null +++ b/tests/enemy_actions/arguments.lua @@ -0,0 +1,218 @@ +local M = {} +function M.run(root, backend, version) + local f = dofile(root .. "/tests/enemy_actions/fixture.lua").new(root, backend, version, 3, "unified") + local T, observations = f.T, {} + local function setup(double) + local c = f.fresh("TACKLE", { enemy = true }) + local b = c.battle + b.double = double + if backend == "gen1" then + if double then + local State = require("src.core.game3.battle.state") + b.battlers[2] = + State.makeBattler(require("src.mods.Merge").deepCopy(b.player.mon), "player", { id = 2 }) + b.battlers[3] = + State.makeBattler(require("src.mods.Merge").deepCopy(b.enemy.mon), "enemy", { id = 3 }) + end + elseif double then + local Battle = require("src.battle.BattleState") + b:placeBattler(2, Battle.makeBattler(f.data, require("src.mods.Merge").deepCopy(b.player.mon), true)) + b:placeBattler(3, Battle.makeBattler(f.data, require("src.mods.Merge").deepCopy(b.enemy.mon), false)) + end + if backend == "gen2" then + b:syncSides() + end + for id = 0, double and 3 or 1 do + local actor = backend == "gen1" and b.battlers[id] or b:battlerAt(id) + if backend == "gen1" then + actor.mon.moves, actor.mon.pp = { 33, 10 }, { 10, 7 } + else + actor.curMoves = { { id = f.ids.TACKLE, pp = 10 }, { id = f.ids.SCRATCH, pp = 7 } } + actor.mon.moves = actor.curMoves + end + end + return c, b + end + local function actorAt(b, id) + return backend == "gen1" and b.battlers[id] or b:battlerAt(id) + end + local function choose(b, id) + if backend == "gen1" then + return require("src.core.game3.battle.commands").enemyAction(b, id) + end + return b:enemyAction(id ~= nil and actorAt(b, id) or nil) + end + local function number(a) + if backend == "gen1" then + return require("src.mods.Gen3Compat").moveId(a.move) + end + return f.data.moves[a.id].index + end + local function pp(actor) + if backend == "gen1" then + return { unpack(actor.mon.pp) } + end + local out = {} + for i, move in ipairs(actor.curMoves) do + out[i] = move.pp + end + return out + end + local function execute(b, id, a) + local actor, target = actorAt(b, id), actorAt(b, 0) + if backend == "gen1" then + require("src.core.game3.battle.engine").resolveMove( + actor, + target, + a.move, + a.slot, + require("src.core.game3.battle.adapter").new(b), + b, + {} + ) + else + b:executeAction(actor, target, a) + end + end + for _, double in ipairs({ false, true }) do + for _, spec in ipairs({ + { name = "default" }, + { name = "explicit-left", id = 1 }, + { name = "explicit-right", id = 3 }, + }) do + if double or spec.id ~= 3 then + for _, forwarding in ipairs({ "replace", "by-position", "forward", "zero-next", "omit-battler" }) do + local c, b = setup(double) + local seen = {} + f.e.choose(function(nextAction, battle, id, ...) + T.eq(select("#", ...), 0, "native hook arity is preserved") + T.eq(battle, b, "opaque battle handle is preserved") + seen[#seen + 1] = id == nil and "nil" or type(id) == "number" and id or type(id) + if forwarding == "forward" then + return nextAction(battle, id) + end + if forwarding == "zero-next" then + return nextAction() + end + if forwarding == "omit-battler" then + return nextAction(battle) + end + return { move = forwarding == "by-position" and id ~= 3 and "TACKLE" or "SCRATCH" } + end) + local action = choose(b, spec.id) + local label = backend + .. "/" + .. version + .. "/" + .. tostring(double) + .. "/" + .. spec.name + .. "/" + .. forwarding + T.same(seen, { spec.id == nil and "nil" or spec.id }, label .. " publishes numeric/nil request") + local scratch = forwarding == "replace" or (forwarding == "by-position" and spec.id == 3) + T.eq(number(action), scratch and 10 or 33, label .. " returns actual choice") + local id = spec.id or 1 + execute(b, id, action) + local expected = scratch and { 10, 6 } or { 9, 7 } + T.same(pp(actorAt(b, id)), expected, label .. " spends caller PP") + local other = double and actorAt(b, id == 1 and 3 or 1) + if other then + T.same(pp(other), { 10, 7 }, label .. " preserves other flank PP") + end + observations[#observations + 1] = { + double = double, + case = spec.name, + forwarding = forwarding, + seen = seen, + move = number(action), + pp = pp(actorAt(b, id)), + other = other and pp(other) or nil, + } + end + end + end + end + for _, spec in ipairs({ + { name = "explicit-left", initial = 3, forwarded = 1 }, + { name = "explicit-right", initial = 1, forwarded = 3 }, + { name = "omit", initial = 3 }, + { name = "explicit-from-default", forwarded = 1 }, + }) do + local _, b = setup(true) + local trace = {} + f.e.choose(function(_, battle, id) + T.eq(battle, b, "downstream public callback retains opaque handle") + trace[#trace + 1] = id == nil and "nil" or id + return { move = id == 3 and "SCRATCH" or "TACKLE" } + end) + local stop = f.e.api.hooks:wrap("battle.enemy_action", function(nextAction, battle, id) + trace[#trace + 1] = id == nil and "nil" or id + return nextAction(battle, spec.forwarded) + end, 100) + local action = choose(b, spec.initial) + stop() + T.same( + trace, + { spec.initial == nil and "nil" or spec.initial, spec.forwarded == nil and "nil" or spec.forwarded }, + "nested next preserves explicit/default selector" + ) + local scratch = spec.forwarded == 3 + T.eq(number(action), scratch and 10 or 33, "nested numeric selector affects actual move") + execute(b, spec.initial or 1, action) + T.same( + pp(actorAt(b, spec.initial or 1)), + scratch and { 10, 6 } or { 9, 7 }, + "forwarded choice spends the caller's actual PP" + ) + observations[#observations + 1] = { + case = "nested-" .. spec.name, + seen = trace, + move = number(action), + pp = pp(actorAt(b, spec.initial or 1)), + } + end + -- Query native AI for the other flank, then execute as the original caller. + -- Native source planning keeps the caller while using the delegated slot. + local _, foreign = setup(true) + local right = actorAt(foreign, 3) + if backend == "gen1" then + right.mon.moves, right.mon.pp = { 10 }, { 5 } + else + right.curMoves = { { id = f.ids.SCRATCH, pp = 5 } } + right.mon.moves = right.curMoves + end + f.e.choose(function(nextAction, battle) + return nextAction(battle, 3) + end) + local choice = choose(foreign, 1) + T.eq(number(choice), 10, "forwarded native AI selects the right flank's actual move") + execute(foreign, 1, choice) + T.same(pp(actorAt(foreign, 1)), { 9, 7 }, "delegated native slot spends caller PP") + T.same(pp(right), { 5 }, "delegated native query does not spend queried actor PP") + observations[#observations + 1] = + { case = "foreign-native", move = number(choice), pp = pp(actorAt(foreign, 1)), other = pp(right) } + + local _, reentrant = setup(true) + local trace, active = {}, false + f.e.choose(function(nextAction, battle, id) + trace[#trace + 1] = id == nil and "nil" or id + if not active then + active = true + local inner = choose(battle, 3) + T.eq(number(inner), 33, "reentrant request completes native selection") + active = false + end + return nextAction(battle, id) + end) + local result = choose(reentrant) + T.same(trace, { "nil", 3 }, "reentrant native requests preserve both public selectors") + execute(reentrant, 1, result) + T.same(pp(actorAt(reentrant, 1)), { 9, 7 }, "outer request retains caller PP after reentry") + T.same(pp(actorAt(reentrant, 3)), { 10, 7 }, "nested query is read-only until execution") + observations[#observations + 1] = + { case = "reentry", seen = trace, pp = pp(actorAt(reentrant, 1)), other = pp(actorAt(reentrant, 3)) } + f.close() + return observations +end +return M diff --git a/tests/enemy_actions/assist.lua b/tests/enemy_actions/assist.lua new file mode 100644 index 000000000..526ea9edd --- /dev/null +++ b/tests/enemy_actions/assist.lua @@ -0,0 +1,547 @@ +-- Owned party/move definitions drive the actual native Assist dispatch. +local M = {} +local BLOCKED = + { 118, 165, 166, 102, 68, 243, 182, 197, 203, 194, 214, 168, 266, 289, 270, 343, 271, 264, 119, 274 } +local NAMES = { + [118] = "METRONOME", + [165] = "STRUGGLE", + [166] = "SKETCH", + [102] = "MIMIC", + [68] = "COUNTER", + [243] = "MIRROR_COAT", + [182] = "PROTECT", + [197] = "DETECT", + [203] = "ENDURE", + [194] = "DESTINY_BOND", + [214] = "SLEEP_TALK", + [168] = "THIEF", + [266] = "FOLLOW_ME", + [289] = "SNATCH", + [270] = "HELPING_HAND", + [343] = "COVET", + [271] = "TRICK", + [264] = "FOCUS_PUNCH", + [119] = "MIRROR_MOVE", + [274] = "ASSIST", +} +function M.fixture(root, backend, version, options) + local native = backend == "gen1" + local f = dofile(root .. "/tests/enemy_actions/nature_power.lua").fixture(root, backend, version, options) + local function mount(n, name, effect, binding, power, target) + if native then + local def = { + effect = effect, + power = power, + type = 0, + accuracy = 100, + pp = 10, + target = target, + flags = n == 274 and 0 or 51, + secondaryChance = 0, + priority = 0, + } + local moves = require("src.core.game3.battle.moves") + moves._rom[n], moves.BY_NUM[n], moves._numByName[name] = def, name, n + f.data.gen3Moves._rom[n], f.data.gen3Pokemon._moveNames[n] = def, name + else + local id = ("MOVE_%03d"):format(n) + f.data.moves[id] = { + id = id, + index = n, + name = name, + gen3Effect = effect, + effect = binding, + power = power, + type = "NORMAL", + accuracy = 100, + pp = 10, + target = target, + flags = n == 274 and 0 or 51, + secondaryChance = 0, + } + end + end + mount(274, "ASSIST", 180, "ASSIST_EFFECT", 0, 1) + mount(10, "SCRATCH", 0, "NO_ADDITIONAL_EFFECT", 35, 0) + for _, n in ipairs(BLOCKED) do + if n ~= 274 and n ~= 214 then + mount( + n, + NAMES[n], + n == 118 and 83 or n == 119 and 9 or 0, + n == 118 and "METRONOME_EFFECT" or n == 119 and "MIRROR_MOVE_EFFECT" or "NO_ADDITIONAL_EFFECT", + 0, + 0 + ) + end + end + local field = f.field + f.field = function(double) + local b = field(double) + local player, enemy = { f.actor(0).mon }, { f.actor(1).mon } + if double then + player[2], enemy[2] = f.actor(2).mon, f.actor(3).mon + end + b.enemyPartyB = nil + b.playerParty = player + if native then + b.foeParty = enemy + for id = 0, double and 3 or 1 do + f.actor(id).partyIndex = id < 2 and 1 or 2 + end + else + b.enemyParty = enemy + end + return b + end + f.setMoves = function(mon, numbers, pp) + if native then + mon.moves, mon.pp, mon.maxPp = {}, {}, {} + for slot, n in pairs(numbers) do + mon.moves[slot], mon.pp[slot], mon.maxPp[slot] = n, pp or 10, 10 + end + else + mon.moves = {} + for slot, n in pairs(numbers) do + mon.moves[slot] = + { id = type(n) == "number" and (n == 0 and 0 or ("MOVE_%03d"):format(n)) or n, pp = pp or 10 } + end + end + end + f.party = function(b, user) + if native then + return user.side == "player" and b.playerParty or b.foeParty + end + if user.isPlayer then + b.playerParty = b.playerParty or b.game.save.party + return b.playerParty + end + b.enemyParty = b.enemyParty or { user.mon } + return b.enemyParty + end + return f +end +local function cases() + local out = { + { name = "empty" }, + { name = "own-only", own = true }, + { name = "alive", moves = { 33 } }, + { name = "fainted", moves = { 33 }, hp = 0 }, + { name = "egg", moves = { 33 }, egg = true }, + { name = "zero-species", moves = { 33 }, species = 0 }, + { name = "missing-species", moves = { 33 }, missing = true }, + { name = "zero-pp", moves = { 33 }, pp = 0 }, + { name = "duplicate-slots", moves = { 33, 10, 33, 89 } }, + { name = "sparse-slots", moves = { [4] = 33 } }, + { name = "fifth-slot", moves = { [5] = 33 } }, + { name = "duplicates-members", moves = { 33, 10, 33 }, extra = { 33, 89 } }, + { name = "native-caller", moves = { 267 } }, + { name = "active-partner", partner = true }, + { name = "split-owner", moves = { 33 }, extra = { 10 }, split = true }, + { name = "split-partner", partner = true, extra = { 89 }, split = true }, + } + for _, n in ipairs(BLOCKED) do + out[#out + 1] = { name = "blocked-" .. n, moves = { n } } + out[#out + 1] = { name = "filtered-" .. n, moves = { n, 33, n, 10 } } + end + return out +end +function M.run(root, backend, version, options) + local native = backend == "gen1" + options = options or {} + local f = M.fixture(root, backend, version, options) + local caller = native and 274 or "MOVE_274" + local custom + if options.public then + if native then + local g, m = require("src.mods.Gen3Compat"), require("src.core.game3.battle.moves") + caller, custom = assert(g.moveId("PORTABLE_ASSIST")), assert(g.moveId("PORTABLE_STRIKE")) + for _, name in ipairs({ "PORTABLE_ASSIST", "PORTABLE_STRIKE" }) do + local n = assert(g.moveId(name)) + m._rom[n] = assert(f.data.gen3Moves._rom[n]) + m.BY_NUM[n], m._numByName[name] = name, n + end + else + caller, custom = "PORTABLE_ASSIST", "PORTABLE_STRIKE" + end + end + local function history(value) + if not options.public or value == nil then + return value + end + if native then + return require("src.mods.Gen3Compat").moveName(value) + end + local def = f.data.moves[value] + if def then + return def.name + end + for _, row in pairs(f.data.moves) do + if type(row) == "table" and row.index == value then + return row.name + end + end + return value + end + local rows = {} + for _, double in ipairs({ false, true }) do + for actor = 0, double and 3 or 1 do + for _, case in + ipairs((function() + local list = cases() + if options.held then + while #list > 16 do + table.remove(list) + end + end + if options.public then + list[#list + 1] = { name = "custom-bench", moves = { custom } } + end + return list + end)()) + do + for _, kind in ipairs({ "ordinary", "pressure", "zero-caller-pp", "parent-bench-change" }) do + for _, pick in ipairs({ 1, 2, 3, 4, 5 }) do + local b = f.field(double) + b.terrain = 8 + if options.item then + f.put(f.actor(actor), options.item) + end + for id = 0, double and 3 or 1 do + local a = f.actor(id) + f.setMoves(a.mon, { 274 }) + if not native then + a.curMoves = a.mon.moves + end + end + local user, targetId = f.actor(actor), actor % 2 == 0 and actor + 1 or actor - 1 + local target = f.actor(targetId) + local party = f.party(b, user) + local ownIndex + for i, mon in ipairs(party) do + if mon == user.mon then + ownIndex = i + end + end + assert(ownIndex, "party fixture preserves the actual active owner") + if native then + user.partyIndex = ownIndex + end + f.setMoves( + user.mon, + case.own and { caller, 33 } or { caller }, + kind == "zero-caller-pp" and 0 or 10 + ) + if not native then + user.curMoves = user.mon.moves + end + local function bench(numbers) + local mon = require("src.mods.Merge").deepCopy(user.mon) + mon.nickname = "BENCH" .. (#party + 1) + mon.hp = case.hp or 400 + mon.isEgg = case.egg or nil + if case.missing then + mon.species = nil + elseif case.species ~= nil then + mon.species = case.species + end + f.setMoves(mon, numbers, case.pp or 10) + party[#party + 1] = mon + return mon + end + if case.moves then + bench(case.moves) + end + if case.extra then + bench(case.extra) + end + if case.partner then + if double then + local ally = f.actor(actor < 2 and actor + 2 or actor - 2) + f.setMoves(ally.mon, { 33 }) + if not native then + ally.curMoves = ally.mon.moves + end + else + bench({ 33 }) + end + end + if case.split and not native and not user.isPlayer then + b.enemyParty, b.enemyPartyB = { party[1] }, {} + for i = 2, #party do + b.enemyPartyB[#b.enemyPartyB + 1] = party[i] + end + end + if kind == "pressure" then + for id = 0, double and 3 or 1 do + local a = f.actor(id) + if a ~= user then + if native then + a.ability = "PRESSURE" + else + a.abilityOverride = "PRESSURE" + end + end + end + end + local trace, events, damage, draws = {}, {}, {}, {} + local offHeld = f.e.api.hooks:wrap("held_item.trigger", function(nextRead, c) + trace[#trace + 1] = { + holder = c.mon.nickname, + item = c.item, + definition = c.def and c.def.id, + effect = c.effect, + parameter = c.parameter, + trigger = c.trigger, + actualBattle = c.battle == b, + } + return nextRead(c) + end) + local offCrit = f.e.api.hooks:wrap("battle.crit", function() + return false + end) + local function pp() + if native then + return user.mon.pp[1] + end + return user.curMoves[1].pp + end + local offMove = f.e.api.events:on("battle.move_used", function(e) + events[#events + 1] = { + move = e.move.id, + called = e.isCalled or false, + target = e.target.mon.nickname, + user = e.user.mon.nickname, + pp = pp(), + publicPP = e.user.mon.moves[1].pp, + } + if not e.isCalled and kind == "parent-bench-change" then + if #party == ownIndex then + bench({ 10 }) + else + f.setMoves(party[#party], { 10 }) + end + end + end) + local offDamage = f.e.api.events:on("battle.damage_dealt", function(e) + damage[#damage + 1] = { + move = e.move.id, + user = e.user.mon.nickname, + target = e.target.mon.nickname, + damage = e.damage, + critical = e.crit or false, + substitute = e.substitute or false, + typeMult = e.typeMult, + } + end) + local selector = 0 + local picked = false + b.rng = function(lo, hi) + local value = lo == 1 and hi == 100 and 1 or hi + if lo == 1 and hi <= 24 and not picked then + value = math.min(pick, hi) + picked = true + elseif lo == 0 and hi == 3 then + value = selector % 4 + selector = selector + 1 + end + draws[#draws + 1] = { lo, hi, value, pp = pp() } + return value + end + local adapter = native and require("src.core.game3.battle.adapter").new(b) + if native then + require("src.core.game3.battle.engine").resolveMove(actor, targetId, caller, 1, adapter, b, {}) + else + b:executeAction(user, target, user.curMoves[1]) + end + local animations = {} + for _, event in ipairs(native and adapter:eventsSince(0) or b.queue) do + if native and event.kind == "move" or not native and event.anim then + local move = native and event.moveId + or f.data.moves[event.anim] and f.data.moves[event.anim].index + or event.anim + animations[#animations + 1] = { + move = history(move), + user = native and event.attackerId or event.attackerPosition, + target = native and event.targetId or event.targetPosition, + turn = native and event.turn or event.animContext and event.animContext.moveTurn or 0, + } + end + end + offHeld() + offCrit() + offMove() + offDamage() + local state = {} + for id = 0, double and 3 or 1 do + local a = f.actor(id) + local last = native and a.lastMoveId or a.lastMove + if not native and last then + last = f.data.moves[last] and f.data.moves[last].index or last + end + state[#state + 1] = { + hp = a.mon.hp, + status = a.mon.status, + last = history(last), + choice = history(a.choicedMove), + flinched = a.flinched or false, + } + end + local partyState = {} + for _, mon in ipairs(party) do + local pps = {} + for slot = 1, 5 do + local value + if native then + value = mon.pp[slot] + else + value = mon.moves[slot] and mon.moves[slot].pp + end + pps[slot] = value == nil and "absent" or value + end + partyState[#partyState + 1] = + { name = mon.nickname, hp = mon.hp, egg = mon.isEgg or false, pp = pps } + end + f.T.eq(#f.result.errors, 0, "Assist public callbacks stay error-free") + rows[#rows + 1] = { + double = double, + actor = actor, + case = case.name, + kind = kind, + pick = pick, + item = options.item, + events = events, + trace = trace, + draws = draws, + damage = damage, + animations = animations, + state = state, + party = partyState, + } + end + end + end + end + end + f.close() + return rows +end +function M.content(root, backend, version) + return M.run(root, backend, version, { + public = true, + entry = [=[return function(mod) + assert(mod.content.moves:register("PORTABLE_ASSIST",{id="PORTABLE_ASSIST",name="PORTABLE_ASSIST",type="NORMAL",power=0,accuracy=100,pp=10,effect="ASSIST"})) + assert(mod.content.moves:register("PORTABLE_STRIKE",{id="PORTABLE_STRIKE",name="PORTABLE_STRIKE",type="NORMAL",power=35,accuracy=100,pp=10,effect="NO_ADDITIONAL_EFFECT"})) + local record=assert(mod.content.moves:get("PORTABLE_ASSIST"));assert(record.effect=="ASSIST") + mod.exports.api=mod + end]=], + }) +end +function M.held(root, backend, version) + local rows = {} + for _, item in ipairs({ 186, 187, 219 }) do + for _, row in ipairs(M.run(root, backend, version, { held = true, item = item })) do + rows[#rows + 1] = row + end + end + return rows +end +-- A native caller that returns before move-end must retain the old history, +-- including when a real outer Metronome dispatch selected that failed caller. +function M.failedCallers(root, backend, version) + local native = backend == "gen1" + local f = M.fixture(root, backend, version) + local rows = {} + for _, double in ipairs({ false, true }) do + for actor = 0, double and 3 or 1 do + for _, previous in ipairs({ 0, 33, 10 }) do + for _, child in ipairs({ 274, 119 }) do + local b = f.field(double) + local user, targetId = f.actor(actor), actor % 2 == 0 and actor + 1 or actor - 1 + local target = f.actor(targetId) + for id = 0, double and 3 or 1 do + f.setMoves(f.actor(id).mon, { 274 }) + end + f.setMoves(user.mon, { 118 }) + if native then + user.lastMoveId, user.lastMove = + previous ~= 0 and previous or nil, previous ~= 0 and previous or nil + b.moveMax, b.moveExcluded = child, {} + for n = 1, child do + if n ~= child then + b.moveExcluded[n] = true + end + end + else + user.curMoves = user.mon.moves + user.lastMove = previous ~= 0 and ("MOVE_%03d"):format(previous) or nil + f.data.constants.moveOrder = { ("MOVE_%03d"):format(child) } + end + local events, trace, draws, damage = {}, {}, {}, {} + local offMove = f.e.api.events:on("battle.move_used", function(e) + events[#events + 1] = { + move = e.move.id, + called = e.isCalled or false, + user = e.user.mon.nickname, + target = e.target.mon.nickname, + pp = e.user.mon.moves[1].pp, + } + end) + local offHeld = f.e.api.hooks:wrap("held_item.trigger", function(nextRead, c) + trace[#trace + 1] = { + trigger = c.trigger, + holder = c.mon.nickname, + item = c.item, + effect = c.effect, + parameter = c.parameter, + } + return nextRead(c) + end) + local offDamage = f.e.api.events:on("battle.damage_dealt", function(e) + damage[#damage + 1] = { move = e.move.id, damage = e.damage } + end) + b.rng = function(lo, hi) + draws[#draws + 1] = { lo, hi, lo } + return lo + end + if native then + require("src.core.game3.battle.engine").resolveMove( + actor, + targetId, + 118, + 1, + require("src.core.game3.battle.adapter").new(b), + b, + {} + ) + else + b:executeAction(user, target, user.curMoves[1]) + end + offMove() + offHeld() + offDamage() + local last = native and user.lastMoveId or user.lastMove + if not native then + last = last and f.data.moves[last].index + end + rows[#rows + 1] = { + double = double, + actor = actor, + previous = previous, + child = child, + last = last or 0, + pp = native and user.mon.pp[1] or user.curMoves[1].pp, + events = events, + trace = trace, + draws = draws, + damage = damage, + } + f.T.eq(#f.result.errors, 0, "failed native caller callbacks have no errors") + end + end + end + end + f.close() + return rows +end +return M diff --git a/tests/enemy_actions/baton_pass.lua b/tests/enemy_actions/baton_pass.lua new file mode 100644 index 000000000..e3fbdcdc8 --- /dev/null +++ b/tests/enemy_actions/baton_pass.lua @@ -0,0 +1,441 @@ +local M = {} +local STATS = { "attack", "defense", "speed", "spatk", "spdef", "accuracy", "evasion" } +local FLAGS = { + { "confusionTurns", "confusedTurns" }, + { "focusEnergy", "focusEnergy" }, + { "substituteHP", "substituteHP" }, + { "expSeeded", "leechSeeded" }, + { "expIngrain", "ingrained" }, + { "expPerishTurns", "perishTurns" }, + { "expCursed", "cursed" }, + { "expTrapped", "trapped" }, + { "escapePrevention", "escapePrevention" }, + { "expLockedOn", "lockOnTurns" }, + { "expLockedOnById", "lockOnByPos" }, +} +function M.fixture(root, backend, version) + local native = backend == "gen1" + local function configure(data, _, ids, rom, names) + local id = native and "BATON_PASS" or "MOVE_226" + ids.BATON_PASS, names[226] = id, "BATON_PASS" + if native then + local move = { + effect = 127, + type = 0, + power = 0, + accuracy = 0, + pp = 20, + target = 16, + flags = 0, + secondaryChance = 0, + priority = 0, + } + data.gen3Moves._rom[226], rom[226], data.gen3Pokemon._moveNames[226] = move, move, "BATON_PASS" + data.gen3Pokemon._types[4] = { 0, 0 } + data.gen3Pokemon._abilities[4] = { 0, 0 } + else + data.moves[id] = { + id = id, + index = 226, + name = "BATON_PASS", + effect = "BATON_PASS_EFFECT", + gen3Effect = 127, + power = 0, + type = "NORMAL", + accuracy = 0, + pp = 20, + target = 16, + protectAffected = false, + } + data.pokemon.CHARMANDER.types = { "NORMAL" } + data.pokemon.CHARMANDER.abilities = {} + end + local Copy = require("src.mods.Merge").deepCopy + for _, row in ipairs({ { 1, "BULBASAUR", "GRASS", 12 }, { 7, "SQUIRTLE", "WATER", 11 } }) do + if native then + data.gen3Pokemon._types[row[1]] = { row[4], row[4] } + data.gen3Pokemon._abilities[row[1]] = { 0, 0 } + else + local mon = Copy(data.pokemon.CHARMANDER) + mon.id, mon.index, mon.dex, mon.types = row[2], row[1], row[1], { row[3] } + data.pokemon[row[2]] = mon + end + end + for _, row in ipairs({ + { 52, "EMBER", "FIRE", 10, 40 }, + { 55, "WATER_GUN", "WATER", 11, 40 }, + { 75, "RAZOR_LEAF", "GRASS", 12, 55 }, + { 49, "SONICBOOM", "NORMAL", 0, 1 }, + }) do + local moveId = native and row[2] or ("MOVE_%03d"):format(row[1]) + ids[row[2]], names[row[1]] = moveId, row[2] + if native then + local move = { + type = row[4], + power = row[5], + effect = 0, + accuracy = 100, + pp = 10, + secondaryChance = 0, + target = 0, + priority = 0, + flags = 0, + } + data.gen3Moves._rom[row[1]], rom[row[1]], data.gen3Pokemon._moveNames[row[1]] = move, move, row[2] + else + data.moves[moveId] = { + id = moveId, + index = row[1], + name = row[2], + type = row[3], + power = row[5], + pp = 10, + accuracy = 100, + effect = "NO_ADDITIONAL_EFFECT", + } + end + end + if not native then + for _, kind in ipairs({ "FIRE", "WATER", "GRASS" }) do + data.type_chart.matchups[#data.type_chart.matchups + 1] = + { attacker = kind, defender = kind, multiplier = 5 } + end + end + end + local f = + dofile(root .. "/tests/enemy_actions/fixture.lua").new(root, backend, version, 3, "unified", configure) + return f +end +function M.run(root, backend, version, options) + options = options or {} + local native = backend == "gen1" + local f = M.fixture(root, backend, version) + if options.choiceBand and not native then + f.data.items.ITEM_186 = { + id = "ITEM_186", + index = 186, + name = "CHOICE BAND", + holdEffect = "CHOICE_BAND", + holdEffectParam = 0, + } + end + local T, Copy, rows = f.T, require("src.mods.Merge").deepCopy, {} + local events, draws = {}, {} + local stop = f.e.api.events:on("battle.battler_switched", function(e) + events[#events + 1] = { name = e.battler.mon.nickname, old = e.previous.mon.nickname, reason = e.reason } + end) + local function at(b, id) + return native and b.battlers[id] or b:battlerAt(id) + end + local function actor(mon, id) + if native then + return require("src.core.game3.battle.state").makeBattler( + mon, + id % 2 == 0 and "player" or "enemy", + { id = id, partyIndex = id < 2 and 1 or 2 } + ) + end + return require("src.battle.BattleState").makeBattler(f.data, mon, id % 2 == 0) + end + local function fresh(double) + local b = f.fresh("TACKLE", { enemy = true }).battle + b.double = double + if not native then + b:syncSides() + end + if double then + for id = 2, 3 do + local a = actor(Copy(at(b, id % 2).mon), id) + if native then + b.battlers[id] = a + else + b:placeBattler(id, a) + end + end + end + if not native then + b:syncSides() + end + for id = 0, double and 3 or 1 do + local a = at(b, id) + a.mon.nickname = "P" .. id + if options.choiceBand then + a.mon.item = native and 186 or "ITEM_186" + if native then + a.item = 186 + end + end + if native then + a.mon.moves, a.mon.pp, a.mon.maxPp = { 226, 33 }, { 20, 10 }, { 20, 10 } + a.type1, a.type2, a.ability, a.mon.ability = 0, nil, 0, 0 + else + a.mon.moves = { { id = f.ids.BATON_PASS, pp = 20 }, { id = f.ids.TACKLE, pp = 10 } } + a.curMoves, a.curTypes, a.abilityOverride = a.mon.moves, { "NORMAL" }, false + end + end + local function party(side) + local out = { at(b, side).mon } + if double then + out[2] = at(b, side + 2).mon + end + for i = 1, 2 do + local m = Copy(out[1]) + m.nickname = "BENCH_" .. side .. "_" .. i + out[#out + 1] = m + end + return out + end + if native then + b.playerParty, b.foeParty = party(0), party(1) + else + b.game.save.party, b.enemyParty = party(0), party(1) + end + b.rng = function(lo, hi) + draws[#draws + 1] = { lo = lo, hi = hi, value = hi } + return hi + end + return b + end + local function view(a) + local stages, flags = {}, {} + for _, k in ipairs(STATS) do + local key = native and ({ spatk = "spAtk", spdef = "spDef" })[k] or k + stages[k] = (a.stages or {})[key or k] or 0 + end + for _, pair in ipairs(FLAGS) do + flags[pair[2]] = a[native and pair[1] or pair[2]] + end + return { + name = a.mon.nickname, + stages = stages, + flags = flags, + hp = a.mon.hp, + last = a.lastMove and (native and a.lastMove or f.data.moves[a.lastMove].index), + disabled = a.disabledTurns or a.expDisabledTurns, + taunt = a.tauntTurns or a.expTauntedTurns, + choice = options.choiceBand and a.choicedMove or nil, + } + end + for _, double in ipairs({ false, true }) do + for id = 0, double and 3 or 1 do + for _, kind in ipairs({ + "first", + "last", + "invalid", + "confusion", + "volatiles", + "none", + "interactive", + "egg", + "reserved", + "bindings", + "spikes1", + "spikes2", + "spikes3", + "called", + }) do + events, draws = {}, {} + local b = fresh(double) + local user = at(b, id) + local old = user + local party = native and (id % 2 == 0 and b.playerParty or b.foeParty) + or (id % 2 == 0 and b.game.save.party or b.enemyParty) + local offset = double and 2 or 1 + user.stages.attack, user.stages.defense = 2, -1 + user.lastMove = native and 33 or f.ids.TACKLE + if kind == "confusion" then + user[native and "confusionTurns" or "confusedTurns"] = 3 + end + if kind == "volatiles" then + for _, pair in ipairs(FLAGS) do + if pair[2] ~= "confusedTurns" and pair[2] ~= "lockOnByPos" then + user[native and pair[1] or pair[2]] = pair[2] == "perishTurns" and 3 + or pair[2] == "substituteHP" and 25 + or true + end + end + user[native and "expLockedOn" or "lockOnTurns"] = 2 + user[native and "expLockedOnBy" or "lockOnBy"] = native and "enemy" or false + user[native and "expLockedOnById" or "lockOnByPos"] = 1 + end + if kind == "none" then + for i = offset + 1, #party do + party[i].hp = 0 + end + end + if kind == "egg" then + party[offset + 1].isEgg = true + end + if kind == "reserved" and double then + b.monToSwitchInto = { [id < 2 and id + 2 or id - 2] = offset + 1 } + end + if kind == "bindings" then + local foe = at(b, id % 2 == 0 and 1 or 0) + user[native and "expSeeded" or "leechSeeded"] = true + user[native and "expSeedSource" or "leechSeedSource"] = foe + foe[native and "expSeedSource" or "leechSeedSource"] = user + foe[native and "expSeeded" or "leechSeeded"] = true + foe[native and "expInfatuatedWith" or "infatuated"] = user + if native then + foe.expInfatuated = true + end + end + local layers = tonumber(kind:match("^spikes(%d)$")) + if layers then + local side = native and (id % 2 == 0 and b.playerSide or b.enemySide) or b:sideOf(user) + side.spikes = layers + end + local selected = kind == "last" and #party or kind == "invalid" and 99 or offset + 1 + local interactive = kind == "interactive" + b.interactiveChoices = interactive + if not interactive then + b.batonPassChooser = function() + return selected + end + end + if native then + require("src.core.game3.battle.engine").resolveMove( + id, + id, + 226, + kind ~= "called" and 1 or nil, + require("src.core.game3.battle.adapter").new(b), + b, + { called = kind == "called" } + ) + else + if kind == "called" then + b:performMove(user, user, user.curMoves[1], true) + else + b:executeAction(user, user, user.curMoves[1]) + end + end + local before = { actor = view(at(b, id)), pp = native and old.mon.pp[1] or old.mon.moves[1].pp } + if interactive and id % 2 == 0 then + if native then + require("src.core.game3.battle.engine").resumeChoice( + b, + require("src.core.game3.battle.adapter").new(b), + #party + ) + else + local pending = b._gen3BatonPending + T.check(pending ~= nil, "native move pauses for party choice") + T.eq(at(b, id), old, "no replacement before the choice") + T.check( + require("src.battle.Gen3BatonPass").select(b, pending, #party), + "explicit last eligible member is accepted" + ) + require("src.battle.Gen3BatonPass").await(b, pending) + end + end + T.eq( + native and old.mon.pp[1] or old.mon.moves[1].pp, + kind == "called" and 20 or 19, + "Baton Pass spends donor PP once; called moves do not spend their own PP" + ) + if kind ~= "none" then + T.check(at(b, id) ~= old, "native Baton Pass replaces its own slot") + T.eq(at(b, id).stages.attack, 2, "attack stage is inherited") + T.eq(at(b, id).stages.defense, -1, "defense stage is inherited") + end + if options.choiceBand then + T.eq( + at(b, id).choicedMove, + kind == "none" and 226 or nil, + "actual successful Baton Pass skips binding; failed Baton Pass binds its own move" + ) + end + rows[#rows + 1] = { + double = double, + id = id, + kind = kind, + before = before, + after = view(at(b, id)), + events = Copy(events), + draws = Copy(draws), + oldLast = view(old).last, + seedFrom = ((at(b, id)[native and "expSeedSource" or "leechSeedSource"] or {}).mon or {}).nickname, + foeSeedFrom = (( + at(b, id % 2 == 0 and 1 or 0)[native and "expSeedSource" or "leechSeedSource"] or {} + ).mon or {}).nickname, + foeInfatuated = at(b, id % 2 == 0 and 1 or 0)[native and "expInfatuated" or "infatuated"] ~= nil, + } + end + end + end + local typeIds = { NORMAL = 0, FIRE = 10, WATER = 11, GRASS = 12 } + for _, double in ipairs({ false, true }) do + for _, id in ipairs(double and { 1, 3 } or { 1 }) do + for _, types in ipairs({ + { "NORMAL" }, + { "FIRE" }, + { "WATER" }, + { "GRASS" }, + { "FIRE", "GRASS" }, + { "WATER", "GRASS" }, + }) do + for _, moves in ipairs({ + { "EMBER", "WATER_GUN" }, + { "RAZOR_LEAF", "EMBER" }, + { "SONICBOOM", "TACKLE" }, + { "BATON_PASS", "TACKLE" }, + }) do + events, draws = {}, {} + local b, selected = fresh(double) + local user = at(b, id) + for pos = 0, double and 2 or 0, 2 do + local foe = at(b, pos) + if native then + foe.type1, foe.type2 = typeIds[types[1]], types[2] and typeIds[types[2]] + else + foe.curTypes = Copy(types) + end + end + local party = native and b.foeParty or b.enemyParty + local offset = double and 2 or 1 + for j = 1, 2 do + local mon = party[offset + j] + mon.species = native and (j == 1 and 1 or 7) or (j == 1 and "BULBASAUR" or "SQUIRTLE") + if native then + local number = ({ + EMBER = 52, + WATER_GUN = 55, + RAZOR_LEAF = 75, + SONICBOOM = 49, + TACKLE = 33, + BATON_PASS = 226, + })[moves[j]] + mon.moves, mon.pp, mon.maxPp = { number }, { 0 }, { 10 } + else + mon.moves = { { id = f.ids[moves[j]], pp = 0 } } + end + end + if native then + selected = require("src.core.game3.battle.engine").mostSuitableMon( + b, + require("src.core.game3.battle.adapter").new(b), + id + ) + else + local candidates, ownParty = require("src.battle.Gen3BatonPass").candidates(b, user) + selected = require("src.battle.Gen3BatonPass").enemyChoice(b, user, candidates, ownParty) + end + rows[#rows + 1] = { + kind = "native_ai", + double = double, + id = id, + types = types, + moves = moves, + selected = selected, + draws = Copy(draws), + } + end + end + end + end + stop() + f.close() + return rows +end +return M diff --git a/tests/enemy_actions/battle_effect_presentation.lua b/tests/enemy_actions/battle_effect_presentation.lua new file mode 100644 index 000000000..60eaa6549 --- /dev/null +++ b/tests/enemy_actions/battle_effect_presentation.lua @@ -0,0 +1,295 @@ +-- Real native VM / host queue, complete tick states and measured sound controls. +local M = {} +function M.run(root, backend, version, options) + options = options or {} + local native = backend == "gen1" + local f = dofile(root .. "/tests/enemy_actions/assist.lua").fixture(root, backend, version) + local hardware = dofile(root .. "/tests/native_sound/fixture.lua").install(root, native, f) + local Anim, Seq, Runtime, picPath, savedPic + if native then + Anim, Seq = require("src.core.game3.battle.anim"), require("src.core.game3.battle.anim_seq") + Runtime = require("src.core.game3.runtime") + Runtime.session = { version = version } + local Pic = require("src.core.game3.battle.pic_coords") + picPath = require("src.core.GameVersion").cachePrefix() .. Pic.PACK + savedPic = love.filesystem.read(picPath) + love.filesystem.write(picPath, "return {front={[4]={y=13}},back={[4]={y=8}},elevation={[4]=0}}") + Pic.reset() + assert(Pic.active().front[4] == 13, "owned native pic coordinates must be readable") + assert( + Pic.active().back[4] == (version == "firered" and 10 or 8), + "native back coordinates must be readable" + ) + local generalNames = {} + for i = 0, 8 do + generalNames[i] = "UNUSED_" .. i + end + generalNames[9] = "FOCUS_BAND" + generalNames[2] = "SUBSTITUTE_FADE" + Anim.loadPack({ + version = 5, + tags = {}, + generalNames = generalNames, + general = { + [2] = { + { op = "monbg", battler = "attacker" }, + { op = "createvisualtask", task = "SubstituteFadeToInvisible", priority = 5, args = {} }, + { + op = "createvisualtask", + task = "BlendBattleAnimPal", + priority = 10, + args = { 2, 0, 0, 16, 32767 }, + }, + { op = "waitforvisualfinish" }, + { op = "delay", frames = 1 }, + { op = "clearmonbg", battler = "attacker" }, + { op = "delay", frames = 2 }, + { op = "blendoff" }, + { + op = "createvisualtask", + task = "BlendBattleAnimPal", + priority = 10, + args = { 2, 0, 0, 0, 32767 }, + }, + { op = "createvisualtask", task = "SwapMonSpriteToFromSubstitute", priority = 2, args = { 1 } }, + { op = "end" }, + }, + [9] = { + { + op = "createsprite", + template = "gSimplePaletteBlendSpriteTemplate", + callback = "SimplePaletteBlend", + animBattler = "attacker", + args = { 2, 7, 0, 9, 31 }, + noGfx = true, + w = 8, + h = 8, + subpriority = 0, + }, + { op = "playsewithpan", pan = -64, se = version == "firered" and 164 or 171 }, + { + op = "createvisualtask", + task = "SlideMonForFocusBand", + priority = 5, + args = { 30, 128, 0, 1, 2, 0, 1 }, + }, + { op = "waitforvisualfinish" }, + { + op = "createsprite", + template = "gSimplePaletteBlendSpriteTemplate", + callback = "SimplePaletteBlend", + animBattler = "attacker", + args = { 2, 4, 9, 0, 31 }, + noGfx = true, + w = 8, + h = 8, + subpriority = 0, + }, + { op = "waitforvisualfinish" }, + { op = "delay", frames = 6 }, + { + op = "createsprite", + template = "gSlideMonToOriginalPosSpriteTemplate", + callback = "SlideMonToOriginalPos", + animBattler = "attacker", + args = { 0, 0, 15 }, + noGfx = true, + w = 8, + h = 8, + subpriority = 0, + }, + { op = "end" }, + }, + }, + statusNames = { [0] = "STATUS_PSN" }, + status = { + [0] = { + { op = "loopsewithpan", pan = 63, se = version == "firered" and 141 or 148, times = 6, wait = 13 }, + { op = "createvisualtask", task = "ShakeMon2", priority = 2, args = { 0, 1, 0, 18, 2 } }, + { + op = "createvisualtask", + task = "BlendColorCycle", + priority = 2, + args = { 2, 2, 2, 0, 12, 31774 }, + }, + { op = "end" }, + }, + }, + }) + end + local rows = {} + for _, double in ipairs({ false, true }) do + for actor = 0, double and 3 or 1 do + for _, scene in ipairs({ false, true }) do + for _, stereo in ipairs({ false, true }) do + for _, name in ipairs(options.names or { "POISON", "FOCUS_BAND" }) do + for _, kind in + ipairs(options.kinds or { "ordinary", "substitute", "lowered-substitute", "hidden" }) + do + local b = f.field(double) + local user, draws = f.actor(actor), 0 + user.substituteHP = (kind == "substitute" or kind == "lowered-substitute") and 100 or nil + user.mon.status = "PSN" + local fading = name == "SUBSTITUTE_FADE" + local showing = kind == "substitute" or fading and (kind == "ordinary" or kind == "hidden") + local hidden = kind == "hidden" or kind == "hidden-mon" + b.rng = function() + draws = draws + 1 + error("presentation cannot draw battle RNG") + end + hardware.reset() + hardware.stereo(stereo) + if native then + require("src.core.game3.battle")._st = b + user.status = "PSN" + user.semiInvulnerable = hidden and "ON_AIR" or nil + Runtime.session = { version = version, options = { battleScene = scene and 0 or 1 } } + Anim.reset({ headless = false, double = double }) + for id = 0, double and 3 or 1 do + Anim.present(id).visible = true + end + Anim.setSubstitute(actor, showing) + if hidden then + Anim.present(actor).visible = false + end + Seq.beginEvents({ + { + kind = "anim", + anim = name == "POISON" and "status" or "general", + name = name, + attackerId = actor, + targetId = actor, + }, + }, function() end) + else + f.game.save.options.animations = scene + user.invulnerable = hidden or nil + b.picFx = hidden and { [user] = { hidden = true } } or nil + f.data.constants.gen3BattlerCoords = { + singles = { { x = 72, y = 80 }, { x = 176, y = 40 } }, + doubles = { + { x = 32, y = 80 }, + { x = 200, y = 40 }, + { x = 90, y = 88 }, + { x = 152, y = 32 }, + }, + } + b.current, b.waitFrames, b.nextInsert = nil, nil, 0 + b._gbaEffectPresentation = nil + b._gbaSubstitutePresentation = kind == "lowered-substitute" + and { [user] = { substitute = false, lowered = true, x = 0 } } + or nil + if fading then + b._gbaSubstitutePresentation = { [user] = { substitute = showing, x = 0 } } + end + b.queue = { + { + anim = name, + gbaNativeEffect = true, + attackerPosition = actor, + targetPosition = actor, + attackerIsPlayer = user.isPlayer, + }, + } + end + local frames, done = {}, false + for tick = 0, 200 do + hardware.tick(tick) + if native then + if tick > 0 then + Anim.update(1 / 60) + end + if not Anim.busy() then + done = Seq.update() + end + else + done = not b:updateQueue() + end + local states = {} + for id = 0, double and 3 or 1 do + local a = f.actor(id) + local state + if native then + local p = Anim.present(id) + state = { + x = p.ox or 0, + y = p.oy or 0, + alpha = p.alpha or 1, + visible = p.visible ~= false, + substitute = p.substitute or false, + monbg = p.monbg or false, + coeff = p.blendCoeff or 0, + color = p.blendColor or { 0, 0, 0 }, + } + else + local x, y = b:gen3AnimShake(a) + local r, g, blue, coeff = b:gen3AnimTint(a) + local special = b:gen3AnimSpecialMonState(a) + state = { + x = x, + y = y, + alpha = special and special.alpha or 1, + visible = not (b.picFx and b.picFx[a] and b.picFx[a].hidden), + substitute = b:substituteVisible(a), + monbg = require("src.battle.Gen3EffectPresentation").monBg(b, a), + coeff = coeff or 0, + color = { r or 0, g or 0, blue or 0 }, + } + end + state.hp, state.substituteHP = a.mon.hp, a.substituteHP or 0 + state.status = a.mon.status + states[#states + 1] = state + end + frames[#frames + 1] = { tick = tick, done = done, states = states, rng = draws } + if done then + break + end + end + assert( + done, + ("native %s must finish: actor %d, scene %s, stereo %s, %s"):format( + name, + actor, + tostring(scene), + tostring(stereo), + kind + ) + ) + f.T.eq( + #hardware.trace(), + name ~= "SUBSTITUTE_FADE" + and scene + and (kind == "ordinary" or name == "FOCUS_BAND" and kind == "lowered-substitute") + and (name == "POISON" and 6 or 1) + or 0, + "actual native effect sound sequence" + ) + rows[#rows + 1] = { + double = double, + actor = actor, + scene = scene, + stereo = stereo, + case = kind, + name = name, + frames = frames, + sounds = hardware.trace(), + } + end + end + end + end + end + end + hardware.close() + if native then + if savedPic then + love.filesystem.write(picPath, savedPic) + else + love.filesystem.remove(picPath) + end + require("src.core.game3.battle.pic_coords").reset() + end + f.close() + return rows +end +return M diff --git a/tests/enemy_actions/beat_up.lua b/tests/enemy_actions/beat_up.lua new file mode 100644 index 000000000..b3a09fef6 --- /dev/null +++ b/tests/enemy_actions/beat_up.lua @@ -0,0 +1,884 @@ +local M = {} +function M.run(root, backend, version, patchStats, options) + options = options or {} + local native = backend == "gen1" + local species = { + { "CHARMANDER", 4, 60, 40 }, + { "CHARMELEON", 5, 90, 70 }, + { "PIDGEY", 16, 110, 30 }, + { "NIDORAN_F", 29, 45, 50 }, + { "MEW", 151, 100, 100 }, + } + local function configure(data, engine, ids, rom, names) + for _, r in ipairs({ + { "BEAT_UP", 251, 154, "BEAT_UP_EFFECT", 10, 0, 0 }, + { "HELPING_HAND", 270, 176, "HELPING_HAND_EFFECT", 0, 16, 5 }, + }) do + local name, num, effect, handler, power, target, priority = unpack(r) + local key = engine == "gen1" and name or ("MOVE_%03d"):format(num) + ids[name], names[num] = key, name + if engine == "gen1" then + local d = { + effect = effect, + power = power, + type = 17, + target = target, + priority = priority, + accuracy = 100, + pp = 20, + flags = power == 0 and 0 or 2, + secondaryChance = 0, + } + data.gen3Moves._rom[num], rom[num], data.gen3Pokemon._moveNames[num] = d, d, name + else + data.moves[key] = { + id = key, + index = num, + name = name, + gen3Effect = effect, + effect = handler, + power = power, + type = "DARK", + target = target, + priority = priority, + accuracy = 100, + accuracyRaw = 255, + pp = 20, + protectAffected = power ~= 0, + makesContact = false, + } + end + end + for _, r in ipairs(species) do + local name, num, attack, defense = unpack(r) + if engine == "gen1" then + data.gen3Pokemon._stats[num] = { hp = 50, atk = attack, def = defense, spe = 50, spa = 50, spd = 50 } + data.gen3Pokemon._types[num] = { 0, 0 } + else + local d = require("src.mods.Merge").deepCopy(data.pokemon.CHARMANDER) + d.id, d.name, d.index, d.dex, d.evolutions, d.types = name, name, num, num, {}, { "NORMAL" } + d.baseStats = + { hp = 50, attack = attack, defense = defense, speed = 50, special = 50, spatk = 50, spdef = 50 } + d.evYield = { attack = 1 } + data.pokemon[name] = d + end + end + if engine == "gen2" then + data.items.FOCUS_BAND = { + index = 1196, + name = "FOCUS BAND", + holdEffect = "FOCUS_BAND", + gen3HoldEffect = 39, + holdEffectParam = 10, + } + data.items.NATIVE_FOCUS_BAND = { + index = 196, + name = "FOCUS BAND", + holdEffect = "FOCUS_BAND", + gen3HoldEffect = 39, + holdEffectParam = 10, + } + data.type_chart.types = { + NORMAL = { id = "NORMAL", index = 0, category = "physical" }, + DARK = { id = "DARK", index = 17, category = "special" }, + GHOST = { id = "GHOST", index = 7, category = "physical" }, + FIGHTING = { id = "FIGHTING", index = 1, category = "physical" }, + } + data.type_chart.matchups = { + { attacker = "NORMAL", defender = "GHOST", multiplier = 0 }, + { attacker = "DARK", defender = "FIGHTING", multiplier = 5 }, + { attacker = "DARK", defender = "GHOST", multiplier = 20 }, + } + end + end + local entry + if patchStats then + entry = [[return function(mod) + assert(mod.content.pokemon:patch("MEW", {baseStats={attack=133}})) + mod.exports.baseAttack=assert(mod.content.pokemon:get("MEW")).baseStats.attack + local choice + mod.hooks:wrap("battle.enemy_action",function(nextAction,...) + if choice then return choice(nextAction,...) end + return nextAction(...) + end) + mod.exports.choose=function(callback) choice=callback end + mod.exports.api=mod + end]] + end + local f = dofile(root .. "/tests/enemy_actions/fixture.lua").new( + root, + backend, + version, + 3, + "unified", + configure, + entry + ) + local T, rows, moves, hits, calls, trace = f.T, {}, {}, {}, { crit = 0, damage = 0 }, {} + if patchStats then + T.eq(f.e.baseAttack, 133, "the identical API-3 species patch loads through both real SDKs") + end + local sourceItems, restoreItems + local bandDef = { name = "FOCUS BAND", pocket = "ITEMS", holdEffect = 39, holdEffectParam = 10 } + if native and options.adjustment then + sourceItems = require("src.core.game3.items_data") + restoreItems = { sourceItems._byId, sourceItems._byName, sourceItems._pack } + sourceItems._byId, sourceItems._byName, sourceItems._pack = + { [1196] = bandDef }, {}, { items = { [1196] = bandDef } } + end + local mutate + local stopCrit = f.e.api.hooks:wrap("battle.crit", function() + calls.crit = calls.crit + 1 + return true + end) + local stopDamage = f.e.api.hooks:wrap("battle.damage", function(nextDamage, ...) + calls.damage = calls.damage + 1 + return nextDamage(...) + end) + local stopMove = f.e.api.events:on("battle.move_used", function(e) + moves[#moves + 1] = { user = e.user.mon.nickname, target = e.target.mon.nickname, move = e.move.id } + end) + local stopHit = f.e.api.events:on("battle.damage_dealt", function(e) + hits[#hits + 1] = { + user = e.user.mon.nickname, + target = e.target.mon.nickname, + move = e.move.id, + amount = e.damage, + crit = e.crit, + typeMult = e.typeMult, + kind = e.kind, + substitute = e.substitute or false, + } + if mutate then + mutate(#hits) + end + end) + local function at(b, id) + if native then + return b.battlers[id] + end + return b:battlerAt(id) + end + local function put(b, id, a) + if native then + b.battlers[id] = a + if id == 0 then + b.player = a + elseif id == 1 then + b.enemy = a + end + else + b:placeBattler(id, a) + end + end + local function make(mon, id, index) + if native then + return require("src.core.game3.battle.state").makeBattler( + mon, + id % 2 == 0 and "player" or "enemy", + { id = id, partyIndex = index } + ) + end + local a = require("src.battle.BattleState").makeBattler(f.data, mon, id % 2 == 0) + a.partyIndex = index + return a + end + local function fresh(spec, userId) + moves, hits, calls, trace, mutate = {}, {}, { crit = 0, damage = 0 }, {}, nil + local b, copy = f.fresh("TACKLE", { enemy = true }).battle, require("src.mods.Merge").deepCopy + local template = copy(b.player.mon) + local function party(prefix) + local p = {} + for i = 1, 6 do + local mon = copy(template) + local r = species[(i - 1) % #species + 1] + mon.species, mon.level, mon.hp, mon.nickname = native and r[2] or r[1], 5 + i * 5, 10000, prefix .. i + if native then + mon.maxHp, mon.ability, mon.moves, mon.pp, mon.maxPp = + 10000, 0, { 251, 270 }, { 20, 20 }, { 20, 20 } + for _, k in ipairs({ "attack", "defense", "speed", "spAtk", "spDef" }) do + mon[k] = 50 + end + else + mon.moves = { { id = f.ids.BEAT_UP, pp = 20 }, { id = f.ids.HELPING_HAND, pp = 20 } } + mon.stats = + { hp = 10000, attack = 50, defense = 50, speed = 50, special = 50, spatk = 50, spdef = 50 } + end + if spec.filter and i >= 3 then + if i == 3 then + mon.status = "PSN" + elseif i == 4 then + mon.hp = 0 + elseif i == 5 then + mon.isEgg = true + else + mon.status = 0 + end + end + if spec.allBlocked then + mon.status = "PSN" + end + if spec.onlyOne and i > 1 then + mon.status = "PSN" + end + p[i] = mon + end + return p + end + local player, enemy = party("A"), party("B") + local split = spec.split or 3 + if options.sideParty then + for _, p in ipairs({ player, enemy }) do + if spec.five then + p[6] = nil + end + if spec.sideFilter then + p[2].status, p[3].hp, p[5].isEgg = "PSN", 0, true + end + if spec.sideChange == "wake" then + p[5].status = "PSN" + end + if spec.primaryBlocked then + for i = 1, split do + p[i].status = "PSN" + end + end + if spec.secondaryBlocked then + for i = split + 1, #p do + p[i].status = "PSN" + end + end + end + if native then + b = require("src.core.game3.battle.state").new({ + playerParty = player, + foeParty = enemy, + double = true, + playerHalf = split, + foeHalf = split, + partnerIndex = split + 1, + foePartnerIndex = split + 1, + }) + local State = require("src.core.game3.battle.state") + T.eq(State.ownsSlot(b, 0, split + 1), false, "player cannot replace from the ally's party") + T.eq(State.ownsSlot(b, 2, 1), false, "ally cannot replace from the player's party") + T.eq(State.ownsSlot(b, 1, split + 1), false, "first trainer cannot take the second trainer's bench") + T.eq(State.ownsSlot(b, 3, 1), false, "second trainer cannot take the first trainer's bench") + else + local function half(p, first, last) + local out = {} + for i = first, last do + out[#out + 1] = p[i] + end + return out + end + local primary, ally = half(player, 1, split), half(player, split + 1, #player) + local function trainer(id, p, first, last) + local slots = {} + for i = first, last do + slots[#slots + 1] = { prebuilt = p[i] } + end + f.data.trainers[id] = { id = id, name = id, baseMoney = 0, parties = { slots } } + end + f.data.trainers = f.data.trainers or {} + trainer("OPP_CONTRACT_A", enemy, 1, split) + trainer("OPP_CONTRACT_B", enemy, split + 1, #enemy) + f.game.save.party = primary + b = require("src.battle.BattleState").newTrainer(f.game, "OPP_CONTRACT_A", 1) + b:setAlly({ name = "ALLY", party = ally }) + T.eq(b:addOpponentTrainer("OPP_CONTRACT_B"), true, "native second trainer is installed") + b:sendOutSecondPair() + T.eq(b:benchFor(true, 0), primary, "player replacements remain in the owning party") + T.eq(b:benchFor(true, 2), ally, "ally replacements remain in the owning party") + T.eq(b:benchFor(false, 1), b.enemyParty, "first trainer retains their replacement bench") + T.eq(b:benchFor(false, 3), b.enemyPartyB, "second trainer retains their replacement bench") + if spec.battleScope then + b.playerParty = primary + f.game.save.party = party("FOREIGN") + end + end + else + b.double = true + if native then + b.playerParty, b.foeParty = player, enemy + else + f.game.save.party, b.enemyParty = player, enemy + end + end + for id = 0, 3 do + local p = id % 2 == 0 and player or enemy + local index = id < 2 and 1 or options.sideParty and split + 1 or 2 + if not options.sideParty then + put(b, id, make(p[index], id, index)) + end + local a = at(b, id) + if options.sideParty then + T.eq(a.mon, p[index], "native constructors preserve slot ownership") + end + if native then + a.ability, a.mon.ability = 0, 0 + else + a.abilityOverride = false + end + end + if not native then + b:syncSides() + end + if spec.cloneUser then + local old = at(b, userId) + local index = userId < 2 and 1 or split + 1 + put(b, userId, make(copy(old.mon), userId, index)) + end + local user = at(b, userId) + local target = at(b, userId % 2 == 0 and 1 or 0) + local p = userId % 2 == 0 and player or enemy + if spec.empty then + if native then + if userId % 2 == 0 then + b.playerParty = {} + else + b.foeParty = {} + end + else + if userId % 2 == 0 then + f.game.save.party = {} + else + b.enemyParty = {} + end + end + end + if spec.typeName then + local ids = { NORMAL = 0, DARK = 17, GHOST = 7, FIGHTING = 1 } + if native then + target.type1, target.type2 = ids[spec.typeName], ids[spec.typeName] + else + target.curTypes = { spec.typeName } + end + end + if spec.normalMove then + if native then + require("src.core.game3.battle.moves")._rom[251].type = 0 + else + f.data.moves[f.ids.BEAT_UP].type = "NORMAL" + end + else + if native then + require("src.core.game3.battle.moves")._rom[251].type = 17 + else + f.data.moves[f.ids.BEAT_UP].type = "DARK" + end + end + if spec.targetSpecies then + target.species = native and 151 or "MEW" + end + if spec.userSpecies then + user.species = native and 151 or "MEW" + end + if spec.battleStats then + if native then + user.mon.attack, target.mon.defense = 999, 1 + else + user.curStats.attack, target.curStats.defense = 999, 1 + end + user.stages.attack, target.stages.defense = 6, -6 + end + if spec.battleParty and not native and user.isPlayer then + b.playerParty = player + f.game.save.party = {} + end + if spec.defAbility then + if native then + local ab = require("src.core.game3.battle.abilities").id(spec.defAbility) + target.ability, target.mon.ability = ab, ab + else + target.abilityOverride = spec.defAbility + end + end + if spec.dynamic == "wake" then + p[3].status = "PSN" + end + if spec.sleep then + user.mon.status, user.sleepTurns = "SLP", 5 + end + if spec.protected then + if native then + target.expProtected = true + else + target.protecting = true + end + end + if spec.hidden then + if native then + target.semiInvulnerable = "ON_AIR" + else + target.invulnerable = true + end + end + if spec.substitute then + target.substituteHP = spec.substitute + end + if options.adjustment then + bandDef.holdEffectParam = spec.chance or 10 + if native then + target.item, target.mon.item = + spec.noItem and 0 or spec.item or 1196, spec.noItem and 0 or spec.item or 1196 + else + f.data.items.FOCUS_BAND.holdEffectParam = spec.chance or 10 + target.items, target.mon.item = f.data.items, spec.item == 196 and "NATIVE_FOCUS_BAND" or "FOCUS_BAND" + if spec.noItem then + target.mon.item = nil + end + end + end + if spec.endure then + target.mon.hp = 4 + if native then + target.expEnduring = true + else + target.enduring = true + end + end + if spec.lowHP then + target.mon.hp = spec.lowHP + end + local phase, number, bandNumber = "critical", 0, 0 + b.rng = function(lo, hi) + if lo == 0 and hi == 99 then + bandNumber = bandNumber + 1 + local values = spec.bands or { 99 } + local value = values[math.min(bandNumber, #values)] + trace[#trace + 1] = { kind = "focusBand", value = value } + return value + end + if lo == 1 and hi == 100 then + return spec.miss and 100 or 1 + end + if lo == 0 and hi == 255 then + return spec.miss and 255 or 0 + end + if + phase == "critical" + and ( + (lo == 0 and (hi == 15 or hi == 7 or hi == 3 or hi == 2 or hi == 1)) + or (lo == 1 and (hi == 16 or hi == 8 or hi == 4 or hi == 3 or hi == 2)) + ) + then + number = number + 1 + local choices = spec.critical or { false } + local critical = choices[(number - 1) % #choices + 1] + phase = "variation" + trace[#trace + 1] = { kind = "critical", value = critical } + return critical and lo or hi + end + if (lo == 85 and hi == 100) or (phase == "variation" and lo == 0 and hi == 15) then + local variation = spec.variation or 100 + phase = "critical" + trace[#trace + 1] = { kind = "variation", value = variation } + return lo == 85 and variation or 100 - variation + end + return lo or 0 + end + return b, user, target, p + end + local function pp(a) + return native and a.mon.pp[1] or a.curMoves[1].pp + end + local function use(b, id) + local target = id % 2 == 0 and 1 or 0 + f.e.choose(function() + return { move = "BEAT_UP", target = target } + end) + if native then + local C = require("src.core.game3.battle.commands") + local a = id % 2 == 0 and C.playerAction(b, 1, 1, id, target) or C.enemyAction(b, id) + require("src.core.game3.battle.engine").resolveMove( + id, + a.target, + a.move, + a.slot, + require("src.core.game3.battle.adapter").new(b), + b, + {} + ) + else + if id % 2 == 0 then + b.chosenTargets = { [id] = at(b, target) } + end + local a = id % 2 == 0 and at(b, id).curMoves[1] or b:enemyAction(at(b, id)) + b:executeAction(at(b, id), at(b, target), a) + end + end + local specs = { + { name = "mixed-team" }, + { name = "filtered", filter = true }, + { name = "all-blocked", allBlocked = true }, + { name = "one", onlyOne = true }, + { name = "empty", empty = true }, + { name = "resist", typeName = "FIGHTING" }, + { name = "super", typeName = "GHOST" }, + { name = "authored-normal-ghost", normalMove = true, typeName = "GHOST" }, + { name = "ignores-battle-stats", battleStats = true }, + { name = "target-active-species", targetSpecies = true }, + { name = "user-active-species", userSpecies = true }, + { name = "critical-each", critical = { true } }, + { name = "critical-mixed", critical = { false, true, false, true } }, + { name = "variation", variation = 85 }, + { name = "protected", protected = true }, + { name = "sleep", sleep = true }, + { name = "hidden", hidden = true }, + { name = "sub-break", substitute = 1 }, + { name = "sub-survive", substitute = 1000 }, + { name = "ko-stop", lowHP = 1 }, + { name = "endure", endure = true }, + { name = "miss", miss = true }, + { name = "helped", helped = true }, + { name = "helped-critical", helped = true, critical = { true } }, + { name = "armor-critical", defAbility = "BATTLE_ARMOR", critical = { true } }, + { name = "battle-scoped-party", battleParty = true }, + { name = "callback-wake", dynamic = "wake" }, + { name = "callback-block", dynamic = "block" }, + { name = "callback-faint", dynamic = "faint" }, + { name = "callback-species", dynamic = "species" }, + { name = "callback-user-hp", dynamic = "userHP" }, + { name = "accuracy-wake", allBlocked = true, dynamic = "accuracy" }, + } + if options.adjustment then + specs = { + { name = "no-band", noItem = true, lowHP = 4 }, + { name = "native-band-nonlethal", item = 196, bands = { 0, 99 }, lowHP = 4, firstProc = true }, + { name = "native-band-lethal", item = 196, bands = { 0 }, lowHP = 1 }, + { name = "nonlethal-proc", bands = { 0, 99 }, lowHP = 1000 }, + { name = "failed-procs", bands = { 99 }, lowHP = 4 }, + { name = "always-proc", bands = { 0 }, lowHP = 1 }, + { name = "nonlethal-proc-does-not-stick", bands = { 0, 99 }, lowHP = 4, firstProc = true }, + { + name = "substitute-proc-does-not-stick", + bands = { 0, 99 }, + lowHP = 4, + substitute = 1, + firstProc = true, + }, + { name = "substitute-survives", bands = { 0, 99 }, lowHP = 4, substitute = 1000 }, + { name = "endure-with-band", bands = { 0, 99 }, lowHP = 4, endure = true }, + { name = "zero-chance", chance = 0, bands = { 0 }, lowHP = 4 }, + { name = "chance-boundary-win", bands = { 9, 10 }, lowHP = 4, firstProc = true }, + { name = "chance-boundary-loss", bands = { 10 }, lowHP = 4 }, + { name = "late-proc", bands = { 99, 0, 99 }, lowHP = 4, firstProc = true, late = true }, + { name = "remove-after-proc", bands = { 0, 99 }, lowHP = 4, firstProc = true, remove = true }, + { name = "equip-after-hit", noItem = true, bands = { 0 }, lowHP = 4, equip = true }, + { name = "next-move-resets", bands = { 0 }, lowHP = 1, repeatMove = true }, + } + end + if options.sideParty then + specs = { + { name = "joined-six" }, + { name = "joined-filtered", sideFilter = true }, + { name = "joined-primary-blocked", primaryBlocked = true }, + { name = "joined-secondary-blocked", secondaryBlocked = true }, + { name = "joined-all-blocked", allBlocked = true }, + { name = "joined-accuracy-wake", allBlocked = true, sideAccuracy = true }, + { name = "joined-five", five = true, split = 2 }, + { name = "joined-battle-scope", battleScope = true }, + { name = "joined-wake", sideChange = "wake" }, + { name = "joined-block", sideChange = "block" }, + { name = "joined-replace", sideChange = "replace" }, + { name = "joined-array-reassigned", sideChange = "array" }, + { name = "joined-user-hp", sideChange = "userHP" }, + { name = "joined-clone-hp", sideChange = "userHP", cloneUser = true }, + { name = "joined-sub-break", substitute = 1 }, + { name = "joined-ko-stop", lowHP = 1 }, + { name = "joined-critical", critical = { true } }, + { name = "joined-variation", variation = 85 }, + } + end + for id = 0, 3 do + for _, spec in ipairs(specs) do + local b, user, target, party = fresh(spec, id) + if spec.miss then + local m = native and require("src.core.game3.battle.moves")._rom[251] or f.data.moves[f.ids.BEAT_UP] + m.accuracy, m.accuracyRaw = 1, 1 + end + if spec.helped then + local helperId = (id + 2) % 4 + if native then + require("src.core.game3.battle.engine").resolveMove( + helperId, + helperId, + 270, + 2, + require("src.core.game3.battle.adapter").new(b), + b, + {} + ) + else + b:executeAction(at(b, helperId), at(b, helperId), at(b, helperId).curMoves[2]) + end + moves, hits, calls, trace = {}, {}, { crit = 0, damage = 0 }, {} + end + if spec.dynamic then + mutate = function(number) + if number ~= 1 then + return + end + if spec.dynamic == "wake" then + party[3].status = nil + elseif spec.dynamic == "block" then + party[3].status = "PSN" + elseif spec.dynamic == "faint" then + party[3].hp = 0 + elseif spec.dynamic == "species" then + party[2].species = native and 151 or "MEW" + elseif spec.dynamic == "userHP" then + user.mon.hp = 0 + end + end + end + if spec.remove or spec.equip then + mutate = function(number) + if number ~= 1 then + return + end + if native then + target.item, target.mon.item = spec.remove and 0 or 1196, spec.remove and 0 or 1196 + elseif spec.remove then + target.mon.item = nil + else + target.mon.item = "FOCUS_BAND" + end + end + end + if spec.sideChange then + mutate = function(number) + if number ~= 1 then + return + end + if spec.sideChange == "wake" then + party[5].status = nil + elseif spec.sideChange == "block" then + party[5].status = "PSN" + elseif spec.sideChange == "userHP" then + user.mon.hp = 0 + elseif spec.sideChange == "array" then + local blocked = require("src.mods.Merge").deepCopy(party) + for _, mon in ipairs(blocked) do + mon.status = "PSN" + end + if native then + if user.isPlayer then + b.playerParty = blocked + else + b.foeParty = blocked + end + else + local first, second = {}, {} + for i, mon in ipairs(blocked) do + if i <= (spec.split or 3) then + first[#first + 1] = mon + else + second[#second + 1] = mon + end + end + if user.isPlayer then + b.playerParty, b.ally.party = first, second + else + b.enemyParty, b.enemyPartyB = first, second + end + end + elseif spec.sideChange == "replace" then + local replacement = require("src.mods.Merge").deepCopy(party[5]) + replacement.species = native and 5 or "CHARMELEON" + party[5] = replacement + if not native then + local second = user.isPlayer and b.ally.party or b.enemyPartyB + second[5 - (spec.split or 3)] = replacement + end + end + end + end + local stopAccuracy + if spec.dynamic == "accuracy" or spec.sideAccuracy then + stopAccuracy = f.e.api.hooks:wrap("battle.accuracy", function(nextAccuracy, ...) + party[spec.sideAccuracy and 5 or 3].status = nil + return nextAccuracy(...) + end) + end + local before = target.mon.hp + use(b, id) + if spec.repeatMove then + T.eq(#hits, 6, "the first move completes six held-item saves") + bandDef.holdEffectParam = 0 + if not native then + f.data.items.FOCUS_BAND.holdEffectParam = 0 + end + use(b, id) + end + if stopAccuracy then + stopAccuracy() + end + T.eq( + pp(user), + spec.repeatMove and 18 or spec.sleep and 20 or 19, + "native Beat Up spends PP once, not per participant" + ) + T.same( + calls, + { crit = 0, damage = 0 }, + "native Beat Up bypasses ordinary damage and public critical callbacks" + ) + if spec.name == "mixed-team" then + T.eq(#hits, 6, "six healthy members each contribute a real strike") + end + if + spec.name == "mixed-team" + or spec.name == "critical-each" + or spec.name == "variation" + or spec.name == "target-active-species" + or spec.name == "user-active-species" + then + local amounts = {} + for _, h in ipairs(hits) do + amounts[#amounts + 1] = h.amount + end + local expected = spec.name == "target-active-species" and { 2, 3, 4, 3, patchStats and 5 or 4, 3 } + or spec.name == "critical-each" and { 6, 10, 14, 8, patchStats and 22 or 18, 12 } + or spec.name == "variation" and { 2, 4, 5, 3, patchStats and 9 or 7, 5 } + or { 3, 5, 7, 4, patchStats and 11 or 9, 6 } + T.same(amounts, expected, "native per-member level/base/critical/variation rounding") + end + if spec.dynamic == "accuracy" then + T.eq(#hits, 1, "eligibility is read after the public accuracy callback") + end + if spec.dynamic == "wake" then + T.eq(#hits, 6, "later participants can become eligible after a hit") + end + if spec.dynamic == "block" or spec.dynamic == "faint" then + T.eq(#hits, 5, "later participant conditions are read live") + end + if spec.name == "filtered" then + T.eq(#hits, 3, "poisoned, fainted and egg entries are excluded, numeric-zero status is healthy") + end + if spec.name == "all-blocked" or spec.name == "empty" then + T.eq(#hits, 0, "no eligible members produces no hit") + end + if spec.name == "sub-break" then + T.eq(#hits, 6, "later members continue after the Substitute breaks") + end + if spec.name == "ko-stop" then + T.eq(#hits, 1, "remaining strikes stop when the target faints") + end + if options.adjustment then + local expectedHits, expectedHP = 6, 1 + if + spec.noItem and not spec.equip + or spec.name == "failed-procs" + or spec.chance == 0 + or spec.name == "chance-boundary-loss" + then + expectedHits, expectedHP = 2, 0 + elseif spec.firstProc then + expectedHits, expectedHP = spec.late and 3 or 2, 0 + elseif spec.name == "nonlethal-proc" then + expectedHP = 966 + elseif spec.name == "substitute-survives" then + expectedHP = 4 + elseif spec.repeatMove then + expectedHits, expectedHP = 7, 0 + end + T.eq(#hits, expectedHits, spec.name .. " native strike count") + T.eq(target.mon.hp, expectedHP, spec.name .. " native target HP") + local bands = 0 + for _, draw in ipairs(trace) do + if draw.kind == "focusBand" then + bands = bands + 1 + end + end + local expectedBands = spec.remove and 1 + or spec.equip and expectedHits - 1 + or spec.noItem and 0 + or expectedHits + T.eq(bands, expectedBands, "every reached strike adjusts exactly once, including Substitute/Endure") + for k = 1, #trace do + if trace[k].kind == "focusBand" then + T.eq(trace[k - 1] and trace[k - 1].kind, "variation", "Focus Band follows that strike's variance") + T.eq(trace[k - 2] and trace[k - 2].kind, "critical", "critical precedes that strike's adjustment") + end + end + end + if options.sideParty then + local expected = { 3, 5, 7, 4, 9, 6 } + if spec.five then + expected[6] = nil + end + if spec.primaryBlocked then + expected = { 4, 9, 6 } + end + if spec.secondaryBlocked then + expected = { 3, 5, 7 } + end + if spec.allBlocked then + expected = spec.sideAccuracy and { 9 } or {} + end + if spec.sideFilter then + expected = { 3, 4, 6 } + end + if spec.sideChange == "block" then + expected = { 3, 5, 7, 4, 6 } + end + if spec.sideChange == "replace" then + expected[5] = 8 + end + if spec.sideChange == "userHP" and id >= 2 then + expected = { 3, 5, 7, 9, 6 } + end + if spec.critical then + expected = { 6, 10, 14, 8, 18, 12 } + end + if spec.variation then + expected = { 2, 4, 5, 3, 7, 5 } + end + if spec.substitute then + expected[1] = 1 + end + if spec.lowHP then + expected = { 1 } + end + local amounts = {} + for _, h in ipairs(hits) do + amounts[#amounts + 1] = h.amount + end + T.same(amounts, expected, spec.name .. " uses the entire live side party in native order") + end + local memory = native and (target.lastSpecialDamageTaken or 0) or (target.specialDamageTaken or 0) + if spec.endure then + T.eq(target.mon.hp, 1, "Endure retains one HP through the remaining strikes") + T.eq(#hits, 6, "zero-damage Endure strikes still execute") + T.eq(memory, 0, "the final zero-damage strike replaces earlier special counter memory") + end + if spec.name == "mixed-team" then + T.eq(memory, 6, "special counter memory keeps the final strike, not their sum") + end + rows[#rows + 1] = { + case = spec.name, + id = id, + hits = hits, + moves = moves, + trace = trace, + calls = calls, + amount = before - target.mon.hp, + pp = pp(user), + specialMemory = memory, + substitute = target.substituteHP or 0, + } + end + end + stopHit() + stopMove() + stopDamage() + stopCrit() + if sourceItems then + sourceItems._byId, sourceItems._byName, sourceItems._pack = unpack(restoreItems, 1, 3) + end + f.close() + return rows +end +return M diff --git a/tests/enemy_actions/cases.lua b/tests/enemy_actions/cases.lua new file mode 100644 index 000000000..d1aaf6270 --- /dev/null +++ b/tests/enemy_actions/cases.lua @@ -0,0 +1,353 @@ +local M = {} +function M.run(root, backend, version, api, format) + local f = dofile(root .. "/tests/enemy_actions/fixture.lua").new(root, backend, version, api, format) + local T, observations = f.T, {} + local function fresh() + local c = f.fresh("TACKLE", { enemy = true }) + local u = c.battle.enemy + if backend == "gen1" then + u.mon.moves, u.mon.pp, u.mon.maxPp = { 33, 10, 10 }, { 10, 7, 5 }, { 10, 10, 10 } + else + u.curMoves = { + { id = f.ids.TACKLE, pp = 10 }, + { id = f.ids.SCRATCH, pp = 7 }, + { id = f.ids.SCRATCH, pp = 5 }, + } + u.mon.moves = u.curMoves + end + return c, u + end + local function pp(u) + if backend == "gen1" then + return { unpack(u.mon.pp) } + end + local out = {} + for i, row in ipairs(u.curMoves) do + out[i] = row.pp + end + return out + end + local function number(action) + if backend == "gen1" then + return require("src.mods.Gen3Compat").moveId(action.move) + end + local row = f.data.moves[action.id] + return row and row.index + end + local function execute(b, a) + if backend == "gen1" then + require("src.core.game3.battle.engine").resolveMove( + b.enemy, + b.player, + a.move, + a.slot, + require("src.core.game3.battle.adapter").new(b), + b, + {} + ) + else + b:executeAction(b.enemy, b.player, a) + end + end + local function choose(b) + if backend == "gen1" then + return require("src.core.game3.battle.commands").enemyAction(b, 1) + end + return b:enemyAction(b.enemy) + end + local specs = { + { + "name", + function() + return "SCRATCH" + end, + }, + { + "number", + function() + return 10 + end, + }, + { + "numeric-string", + function() + return "10" + end, + }, + { + "move-record", + function() + return { kind = "move", move = "SCRATCH" } + end, + }, + { + "numeric-record", + function() + return { move = 10 } + end, + }, + { + "numeric-string-record", + function() + return { move = "10" } + end, + }, + { + "lowercase-name", + function() + return "scratch" + end, + }, + { + "lowercase-record", + function() + return { move = "scratch" } + end, + }, + { + "numeric-id", + function() + return { id = 10 } + end, + }, + { + "id-record", + function() + return { id = "SCRATCH" } + end, + }, + { + "wrong-slot", + function() + return { move = "SCRATCH", slot = 1 } + end, + }, + { + "duplicate-slot", + function() + return { move = "SCRATCH", slot = 3 } + end, + 3, + }, + { + "fractional-slot", + function() + return { move = "SCRATCH", slot = 1.5 } + end, + }, + { + "forged-pp", + function() + return { id = "SCRATCH", pp = 900 } + end, + }, + { + "nil", + function() + return nil + end, + 1, + }, + { + "false", + function() + return false + end, + 1, + }, + { + "unknown", + function() + return "NO_SUCH_MOVE" + end, + 1, + }, + { + "forward", + function(nextAction, ...) + return nextAction(...) + end, + 1, + }, + { + "omitted-battler", + function(nextAction, battle) + return nextAction(battle) + end, + 1, + }, + { + "delegate-nil", + function(nextAction, ...) + nextAction(...) + return nil + end, + 1, + }, + { + "delegate-false", + function(nextAction, ...) + nextAction(...) + return false + end, + 1, + }, + { + "delegate-move", + function(nextAction, ...) + local action = nextAction(...) + action.move = "SCRATCH" + return action + end, + 1, + }, + } + for _, spec in ipairs(specs) do + local label = backend .. "/" .. version .. "/" .. api .. "/" .. spec[1] + local c, u = fresh() + local oldHp = c.hp() + f.e.choose(spec[2]) + local ok, action = pcall(choose, c.battle) + T.check(ok and type(action) == "table", label .. " produces an executable record") + if ok and type(action) == "table" then + local slot = spec[3] or 2 + local expected = slot == 1 and spec[1] ~= "delegate-move" and 33 or 10 + T.eq(number(action), expected, label .. " selects the requested move rather than fallback") + local ran, err = pcall(execute, c.battle, action) + T.check(ran, label .. " executes: " .. tostring(err)) + T.check(c.hp() < oldHp, label .. " damages the real opponent") + local want = { 10, 7, 5 } + want[slot] = want[slot] - 1 + T.same(pp(u), want, label .. " decrements only the owned slot") + local learned = backend == "gen1" and u.mon.moves[1] or f.data.moves[u.curMoves[1].id].index + T.eq(learned, 33, label .. " does not rewrite learned moves") + observations[#observations + 1] = + { case = spec[1], move = number(action), pp = pp(u), hit = c.hp() < oldHp, learned = learned } + end + end + -- Two hooks exercise translation of a scalar through next(), not only the final return. + local c, u = fresh() + f.e.choose(function() + return 10 + end) + local stop = f.e.api.hooks:wrap("battle.enemy_action", function(nextAction, ...) + return nextAction(...) + end, 100) + local a = choose(c.battle) + T.eq(number(a), 10, "nested hook retains numeric move choice") + execute(c.battle, a) + T.same(pp(u), { 10, 6, 5 }, "nested hook retains actual PP ownership") + stop() + local unlearned, actor = fresh() + if backend == "gen1" then + actor.mon.moves, actor.mon.pp = { 33 }, { 10 } + else + actor.curMoves = { actor.curMoves[1] } + actor.mon.moves = actor.curMoves + end + f.e.choose(function() + return { move = "SCRATCH" } + end) + local outside = choose(unlearned.battle) + T.eq(number(outside), 10, "declared unlearned move is selected") + execute(unlearned.battle, outside) + T.eq(unlearned.pp(), 10, "unlearned move cannot consume a different learned slot") + T.check(unlearned.hp() < 400, "unlearned declared move actually executes") + observations[#observations + 1] = { case = "unlearned", pp = pp(actor), hit = unlearned.hp() < 400 } + + -- A hook-created charge action must survive the native continuation by identity. + local charging = f.fresh("FLY", { enemy = true }) + f.e.choose(function() + return { move = "FLY" } + end) + local first = choose(charging.battle) + execute(charging.battle, first) + T.check(charging.charging(), "public charge choice enters native charge state") + T.eq(charging.pp(), 9, "charge spends owned PP once") + T.eq(charging.hp(), 400, "charge turn does not damage the target") + if backend == "gen2" then + charging.battle.enemy.chargeReady = true + end + f.e.choose(nil) + local second = choose(charging.battle) + if backend == "gen2" then + T.eq(first, second, "native charge continuation retains identity") + end + execute(charging.battle, second) + T.check(not charging.charging(), "native continuation releases the charge") + T.eq(charging.pp(), 9, "charge continuation does not spend PP twice") + T.check(charging.hp() < 400, "native continuation lands the move") + observations[#observations + 1] = + { case = "charge", pp = charging.pp(), hit = charging.hp() < 400, charging = charging.charging() } + local exhausted, exhaustedActor = fresh() + if backend == "gen1" then + exhaustedActor.mon.pp[2] = 0 + else + exhaustedActor.curMoves[2].pp = 0 + end + f.e.choose(function() + return { move = "SCRATCH", slot = 2, pp = 100 } + end) + execute(exhausted.battle, choose(exhausted.battle)) + T.eq(exhausted.hp(), 400, "forged PP cannot execute an exhausted learned move") + T.same(pp(exhaustedActor), { 10, 0, 5 }, "exhausted choice does not spend another slot") + observations[#observations + 1] = + { case = "exhausted", pp = pp(exhaustedActor), hit = exhausted.hp() < 400 } + if backend == "gen2" then + local pressure, actor = fresh() + pressure.battle.player.abilityOverride = "PRESSURE" + f.e.choose(function() + return { move = "SCRATCH" } + end) + execute(pressure.battle, choose(pressure.battle)) + T.same(pp(actor), { 10, 5, 5 }, "Pressure charges the actual slot twice") + + local legacy, legacyActor = fresh() + f.e.choose(function(nextAction, ...) + local result = nextAction(...) + result.id = format == "native" and f.ids.SCRATCH or "SCRATCH" + return result + end) + execute(legacy.battle, choose(legacy.battle)) + T.same(pp(legacyActor), { 9, 7, 5 }, "legacy delegated id edit retains its selected PP slot") + T.eq(legacyActor.curMoves[1].id, f.ids.TACKLE, "delegated id edit preserves learned move") + + local mimic, mimicActor = fresh() + mimicActor.curMoves[3] = { id = f.ids.MIMIC, pp = 5 } + f.e.choose(function() + return { move = "MIMIC" } + end) + execute(mimic.battle, choose(mimic.battle)) + T.eq(mimicActor.curMoves[3].id, f.ids.TACKLE, "Mimic replaces the actual selected slot") + T.eq(mimicActor.curMoves[3].pp, 4, "Mimic keeps PP on its actual slot") + T.check(mimicActor.curMoves[3].mimic, "native Mimic marks its owned slot") + T.eq(mimicActor.curMoves[1].mimic, nil, "Mimic does not overwrite the first slot") + + local double, left = fresh() + local b = double.battle + local Battle = require("src.battle.BattleState") + local mon = require("src.mods.Merge").deepCopy(left.mon) + local right = Battle.makeBattler(f.data, mon, false) + b.double = true + b:placeBattler(3, right) + b:syncSides() + f.e.choose(function() + return { move = "SCRATCH" } + end) + local rightAction = b:enemyAction(right) + b:executeAction(right, b.player, rightAction) + T.same(pp(right), { 10, 6, 5 }, "right-flank choice owns right-flank PP") + T.same(pp(left), { 10, 7, 5 }, "right-flank action leaves left-flank PP intact") + if format == "native" then + local raw = right.curMoves[2] + f.e.choose(function() + return raw + end) + T.eq(b:enemyAction(right), raw, "explicit native move handles retain identity") + end + end + T.eq(#f.result.errors, 0, "public consumer completes without swallowed hook failures") + f.close() + return observations +end +return M diff --git a/tests/enemy_actions/choice_band.lua b/tests/enemy_actions/choice_band.lua new file mode 100644 index 000000000..dc28f2189 --- /dev/null +++ b/tests/enemy_actions/choice_band.lua @@ -0,0 +1,292 @@ +-- Controlled native-shaped inputs. The source engine and its handlers remain +-- unchanged; all mounted item/move tables belong to this fixture. +local M = {} +local NUMBERS = { TACKLE = 33, SCRATCH = 10, FLY = 19, STRUGGLE = 165, SLEEP_TALK = 214, BATON_PASS = 226 } +function M.fixture(root, backend, version, options) + options = options or {} + local native = backend == "gen1" + local f = dofile(root .. "/tests/enemy_actions/fixture.lua").new( + root, + backend, + version, + options.api or 3, + options.format or "unified", + function(data, _, ids, rom, names) + for _, row in ipairs({ + { 214, "SLEEP_TALK", 97, "SLEEP_TALK_EFFECT", 0, 1 }, + { 226, "BATON_PASS", 127, "BATON_PASS_EFFECT", 0, 16 }, + }) do + local number, name, effect, binding, power, target = unpack(row) + ids[name] = native and name or ("MOVE_%03d"):format(number) + names[number] = name + if native then + local def = { + effect = effect, + power = power, + type = 0, + accuracy = 100, + pp = 10, + target = target, + flags = 51, + secondaryChance = 0, + priority = 0, + } + data.gen3Moves._rom[number], rom[number], data.gen3Pokemon._moveNames[number] = def, def, name + else + data.moves[ids[name]] = { + id = ids[name], + index = number, + name = name, + gen3Effect = effect, + effect = binding, + power = power, + type = "NORMAL", + accuracy = 100, + pp = 10, + target = target, + flags = 51, + secondaryChance = 0, + } + end + end + for _, row in ipairs({ + { 186, "CHOICE_BAND", "CHOICE_BAND", 29 }, + { 529, "IMITATION_BAND", "CHOICE_BAND", 29 }, + { 500, "PLAIN_TOKEN", nil, 0 }, + }) do + local number, name, effect, byte = unpack(row, 1, 4) + if native then + data.gen3Items._byId[number] = { + name = name:gsub("_", " "), + pocket = "ITEMS", + price = 10, + holdEffect = byte, + holdEffectParam = 0, + } + else + local id = ("ITEM_%03d"):format(number) + data.items[id] = { + id = id, + index = number, + name = name:gsub("_", " "), + price = 10, + holdEffect = effect, + gen3HoldEffect = byte, + holdEffectParam = 0, + } + end + end + if native then + data.gen3Pokemon._types[4], data.gen3Pokemon._abilities[4] = { 0, 0 }, { 0, 0 } + else + data.constants.generation = 3 + data.pokemon.CHARMANDER.types, data.pokemon.CHARMANDER.abilities = { "NORMAL" }, {} + end + end, + options.entry + ) + if native then + local Items = require("src.core.game3.items_data") + local previous, close = Items._byId, f.close + Items._byId = f.data.gen3Items._byId + f.close = function() + Items._byId = previous + close() + end + end + function f.hold(user, number) + user.mon.item = native and (number or 0) or number and ("ITEM_%03d"):format(number) or nil + if native then + user.item = number or 0 + end + end + function f.moves(user, names, pp) + if native then + user.mon.moves, user.mon.pp, user.mon.maxPp = {}, {}, {} + for slot, name in ipairs(names) do + user.mon.moves[slot], user.mon.pp[slot], user.mon.maxPp[slot] = NUMBERS[name], pp or 10, 10 + end + else + user.mon.moves = {} + for slot, name in ipairs(names) do + user.mon.moves[slot] = { id = f.ids[name], pp = pp or 10 } + end + user.curMoves = user.mon.moves + end + end + function f.moveId(name) + return native and NUMBERS[name] or f.ids[name] + end + function f.at(b, id) + return native and b.battlers[id] or b:battlerAt(id) + end + function f.field(double) + local b = f.fresh("TACKLE").battle + b.double = double + if not native then + b:syncSides() + end + if double then + local Copy = require("src.mods.Merge").deepCopy + for id = 2, 3 do + local mon = Copy(f.at(b, id % 2).mon) + if native then + b.battlers[id] = require("src.core.game3.battle.state").makeBattler( + mon, + id % 2 == 0 and "player" or "enemy", + { id = id, partyIndex = 2 } + ) + else + b:placeBattler(id, require("src.battle.BattleState").makeBattler(f.data, mon, id % 2 == 0)) + end + end + end + if not native then + b:syncSides() + end + for id = 0, double and 3 or 1 do + local a = f.at(b, id) + a.mon.nickname, a.mon.hp = "P" .. id, 400 + f.moves(a, { "TACKLE", "SCRATCH", "SLEEP_TALK", "STRUGGLE" }) + f.hold(a, nil) + if native then + a.mon.maxHp, a.type1, a.type2, a.ability, a.mon.ability = 400, 0, 0, 0, 0 + a.mon.attack, a.mon.defense, a.mon.spAtk, a.mon.spDef, a.mon.speed = 50, 50, 50, 50, 100 - id * 10 + else + a.curTypes, a.abilityOverride, a.mon.stats.hp = { "NORMAL" }, false, 400 + a.curStats.attack, a.curStats.defense, a.curStats.spatk, a.curStats.spdef = 50, 50, 50, 50 + a.curStats.speed = 100 - id * 10 + end + end + b.phase, b.queue, b.current = "moveSelect", {}, nil + return b + end + return f +end +function M.run(root, backend, version) + local native, f = backend == "gen1", M.fixture(root, backend, version) + local T, rows = f.T, {} + local cases = { + { name = "first-choice", move = "TACKLE", expected = 33 }, + { name = "second-slot", move = "SCRATCH", expected = 10 }, + { name = "existing-choice", move = "SCRATCH", previous = 33, expected = 33 }, + { name = "empty-hand", move = "TACKLE", item = false }, + { name = "ordinary-item", move = "TACKLE", item = 500 }, + { name = "effect-on-other-item", move = "TACKLE", item = 529, expected = 33 }, + { name = "empty-hand-substitution", move = "TACKLE", item = false, force = true, expected = 33 }, + { name = "suppressed-effect", move = "TACKLE", suppress = true }, + { name = "suppressed-existing", move = "SCRATCH", suppress = true, previous = 33, expected = 33 }, + { name = "lost-item-keeps-choice", move = "SCRATCH", item = false, previous = 33, expected = 33 }, + { name = "lost-move-clears-after-read", move = "TACKLE", previous = 10, learned = { "TACKLE" } }, + { name = "lost-move-without-item", move = "TACKLE", previous = 10, learned = { "TACKLE" }, item = false }, + { name = "struggle-does-not-bind", move = "STRUGGLE" }, + { name = "struggle-keeps-choice", move = "STRUGGLE", previous = 33, expected = 33 }, + { name = "successful-baton", move = "BATON_PASS", learned = { "BATON_PASS", "TACKLE" } }, + { + name = "failed-baton", + move = "BATON_PASS", + failed = true, + learned = { "BATON_PASS", "TACKLE" }, + expected = 226, + }, + { + name = "native-called-provenance", + move = "TACKLE", + caller = "SLEEP_TALK", + learned = { "SLEEP_TALK", "SCRATCH" }, + expected = 214, + }, + { + name = "native-called-baton", + move = "BATON_PASS", + caller = "SLEEP_TALK", + learned = { "SLEEP_TALK", "SCRATCH" }, + expected = 214, + }, + { name = "custom-called-without-native-caller", move = "TACKLE", called = true, expected = 33 }, + { name = "disobedience-command-guard", move = "TACKLE", notObeyed = true }, + } + for _, case in ipairs(cases) do + for _, enemy in ipairs({ false, true }) do + local c = f.fresh("TACKLE", { enemy = enemy }) + local b, user, target = c.battle, c.user, c.target + if not native then + b:syncSides() + end + user.mon.nickname, target.mon.nickname = "USER", "TARGET" + f.moves(user, case.learned or { "TACKLE", "SCRATCH" }) + f.hold(user, case.item == nil and 186 or case.item or nil) + f.hold(target, nil) + user.choicedMove = case.previous + local trace, draws = {}, {} + local off = f.e.api.hooks:wrap("held_item.trigger", function(nextRead, ctx) + trace[#trace + 1] = { + holder = ctx.mon.nickname, + item = ctx.item, + definition = ctx.def and ctx.def.id, + effect = ctx.effect, + parameter = ctx.parameter, + trigger = ctx.trigger, + actualBattle = ctx.battle == b, + } + if ctx.trigger == "check" and ctx.mon.nickname == "USER" then + if case.suppress then + return nil, 0 + end + if case.force then + return "HELD_CHOICE_BAND", 0 + end + end + return nextRead(ctx) + end) + b.rng = function(lo, hi) + draws[#draws + 1] = { lo, hi, hi } + return hi + end + if native then + local Moves = require("src.core.game3.battle.moves") + local id = f.moveId(case.move) + require("src.core.game3.battle.engine").moveEndEffects({ + st = b, + adapter = require("src.core.game3.battle.adapter").new(b), + user = user, + target = target, + moveId = id, + move = Moves.get(id), + opts = { calledBy = case.caller and f.moveId(case.caller), called = case.called }, + firstDmg = 0, + failed = case.failed, + notObeyed = case.notObeyed, + }) + else + b._gbaMoveFrame = { + user = user, + moveId = f.moveId(case.move), + move = b.data.moves[f.moveId(case.move)], + calledBy = case.caller and f.moveId(case.caller), + failed = case.failed, + notObeyed = case.notObeyed, + } + require("src.battle.GbaItemSchedule").moveEnd(b) + b._gbaMoveFrame = nil + end + off() + T.eq(user.choicedMove, case.expected, "native move-end binds or cleans the learned choice") + T.eq(#trace, case.notObeyed and 2 or 3, "complete zero-damage move-end held read sequence") + T.eq(#draws, 0, "choice command consumes no RNG") + T.eq(#f.result.errors, 0, "public hook chain remains error-free") + rows[#rows + 1] = { + case = case.name, + enemy = enemy, + choice = user.choicedMove, + trace = trace, + draws = draws, + userHp = user.mon.hp, + targetHp = target.mon.hp, + } + end + end + f.close() + return rows +end +return M diff --git a/tests/enemy_actions/choice_call_failure.lua b/tests/enemy_actions/choice_call_failure.lua new file mode 100644 index 000000000..ecc67ad03 --- /dev/null +++ b/tests/enemy_actions/choice_call_failure.lua @@ -0,0 +1,118 @@ +-- A failed native Mirror Move returns before its move-end script. Observe +-- its complete held sequence, rather than accepting only unchanged HP. +local M = {} +function M.run(root, backend, version) + local native = backend == "gen1" + local f = dofile(root .. "/tests/enemy_actions/choice_band.lua").fixture(root, backend, version) + if native then + local row = { + effect = 9, + power = 0, + type = 0, + accuracy = 100, + pp = 10, + target = 0, + flags = 0, + priority = 0, + secondaryChance = 0, + } + local moves = require("src.core.game3.battle.moves") + moves._rom[119], moves.BY_NUM[119], moves._numByName.MIRROR_MOVE = row, "MIRROR_MOVE", 119 + f.data.gen3Moves._rom[119], f.data.gen3Pokemon._moveNames[119] = row, "MIRROR_MOVE" + else + f.data.moves.MOVE_119 = { + id = "MOVE_119", + index = 119, + name = "MIRROR_MOVE", + gen3Effect = 9, + effect = "MIRROR_MOVE_EFFECT", + power = 0, + type = "NORMAL", + accuracy = 100, + pp = 10, + target = 0, + flags = 0, + mirrorMoveAffected = false, + } + end + local rows = {} + for _, double in ipairs({ false, true }) do + for id = 0, double and 3 or 1 do + for _, bound in ipairs({ false, true }) do + local b = f.field(double) + local user, target = f.at(b, id), f.at(b, id % 2 == 0 and id + 1 or id - 1) + f.hold(user, 186) + user.choicedMove = bound and 33 or nil + if native then + user.mon.moves, user.mon.pp, user.mon.maxPp = { 119, 33 }, { 10, 10 }, { 10, 10 } + else + user.mon.moves = { { id = "MOVE_119", pp = 10 }, { id = f.ids.TACKLE, pp = 10 } } + user.curMoves = user.mon.moves + end + local trace, draws, events = {}, {}, {} + local off = f.e.api.hooks:wrap("held_item.trigger", function(nextRead, ctx) + trace[#trace + 1] = { + holder = ctx.mon.nickname, + item = ctx.item, + definition = ctx.def and ctx.def.id, + effect = ctx.effect, + parameter = ctx.parameter, + trigger = ctx.trigger, + actualBattle = ctx.battle == b, + } + return nextRead(ctx) + end) + local stop = f.e.api.events:on("battle.move_used", function(e) + events[#events + 1] = { + user = e.user.mon.nickname, + target = e.target.mon.nickname, + move = e.move.id, + called = e.isCalled or false, + } + end) + b.rng = function(lo, hi) + draws[#draws + 1] = { lo, hi, hi } + return hi + end + if native then + require("src.core.game3.battle.engine").resolveMove( + id, + id % 2 == 0 and id + 1 or id - 1, + 119, + 1, + require("src.core.game3.battle.adapter").new(b), + b, + {} + ) + else + b:executeAction(user, target, user.curMoves[1]) + end + off() + stop() + f.T.eq( + user.choicedMove, + bound and 33 or nil, + "failed native caller does not enter the choice command" + ) + f.T.eq(#trace, double and 4 or 2, "only the separate after-action item sweep remains") + f.T.eq(#draws, 0, "failed native caller consumes no selection or damage RNG") + f.T.eq(#f.result.errors, 0, "failed caller callbacks remain error-free") + rows[#rows + 1] = { + double = double, + actor = id, + bound = bound, + choice = user.choicedMove, + pp = native and user.mon.pp[1] or user.curMoves[1].pp, + userHp = user.mon.hp, + targetHp = target.mon.hp, + trace = trace, + draws = draws, + events = events, + } + end + end + end + f.close() + return rows +end +return M diff --git a/tests/enemy_actions/choice_content.lua b/tests/enemy_actions/choice_content.lua new file mode 100644 index 000000000..60b2b2356 --- /dev/null +++ b/tests/enemy_actions/choice_content.lua @@ -0,0 +1,119 @@ +-- Same authored API-3 caller on both engines. This observes actual dispatch, +-- events, RNG, HP and choice/PP ownership, not the complete held-script trace. +local M = {} +function M.run(root, backend, version) + local native, rows = backend == "gen1", {} + local entry = [=[return function(mod) + assert(mod.content.move_effects:register("PORTABLE_CHOOSER", {kind="primary",run=function(ctx) + ctx.callMove("TACKLE");return {} + end})) + assert(mod.content.moves:register("PORTABLE_CALLER", {id="PORTABLE_CALLER",name="PORTABLE_CALLER", + type="NORMAL",power=0,accuracy=100,pp=10,effect="PORTABLE_CHOOSER"})) + mod.exports.api=mod + end]=] + for _, learned in ipairs({ false, true }) do + local f = dofile(root .. "/tests/enemy_actions/choice_band.lua").fixture( + root, + backend, + version, + { entry = entry } + ) + local number, registries, previousRegistryData + if native then + number = assert(require("src.mods.Gen3Compat").moveId("PORTABLE_CALLER")) + local moves = require("src.core.game3.battle.moves") + moves._rom[number] = assert(f.data.gen3Moves._rom[number]) + moves.BY_NUM[number], moves._numByName.PORTABLE_CALLER = "PORTABLE_CALLER", number + registries = require("src.core.game3.battle.mod_registries") + previousRegistryData = registries.data() + registries.bind(f.data) + end + local function name(value) + if not value then + return nil + end + if native then + return require("src.mods.Gen3Compat").moveName(value) + end + local def = f.data.moves[value] + if def then + return def.name + end + for _, row in pairs(f.data.moves) do + if type(row) == "table" and tonumber(row.index) == value then + return row.name + end + end + end + for id = 0, 1 do + local b = f.field(false) + local user, target = f.at(b, id), f.at(b, 1 - id) + f.hold(user, 186) + if native then + user.mon.moves = { number, learned and 33 or 10 } + user.mon.pp, user.mon.maxPp = { 10, 10 }, { 10, 10 } + else + user.mon.moves = + { { id = "PORTABLE_CALLER", pp = 10 }, { id = learned and f.ids.TACKLE or f.ids.SCRATCH, pp = 10 } } + user.curMoves = user.mon.moves + end + local draws, events = {}, {} + b.rng = function(lo, hi) + draws[#draws + 1] = { lo, hi, hi } + return hi + end + local noCrit = f.e.api.hooks:wrap("battle.crit", function() + return false + end) + local off = f.e.api.events:on("battle.move_used", function(e) + events[#events + 1] = { + user = e.user.mon.nickname, + target = e.target.mon.nickname, + move = e.move.id, + called = e.isCalled or false, + } + end) + if native then + require("src.core.game3.battle.engine").resolveMove( + id, + 1 - id, + number, + 1, + require("src.core.game3.battle.adapter").new(b), + b, + {} + ) + else + b:executeAction(user, target, user.curMoves[1]) + end + off() + noCrit() + local pp = native and user.mon.pp or { user.curMoves[1].pp, user.curMoves[2].pp } + f.T.eq( + name(user.choicedMove), + learned and "TACKLE" or "PORTABLE_CALLER", + "custom ctx.callMove retains its own child/outer move-end boundaries" + ) + f.T.same(pp, { 9, 10 }, "actual authored caller spends only its learned PP") + f.T.eq(#events, 2, "actual authored caller and child both emit their public move event") + f.T.eq(#f.result.errors, 0, "authored callback completes without swallowed errors") + rows[#rows + 1] = { + learned = learned, + actor = id, + choice = name(user.choicedMove), + last = name(user[native and "lastMoveId" or "lastMove"]), + pp = pp, + userHp = user.mon.hp, + targetHp = target.mon.hp, + draws = draws, + events = events, + } + end + if registries then + registries.bind(previousRegistryData) + end + f.close() + end + return rows +end +return M diff --git a/tests/enemy_actions/choice_planning.lua b/tests/enemy_actions/choice_planning.lua new file mode 100644 index 000000000..1eb530345 --- /dev/null +++ b/tests/enemy_actions/choice_planning.lua @@ -0,0 +1,270 @@ +-- A one-action native plan isolates forced-move resolution without recreating +-- its formula or consuming the separate whole-turn ordering word. +local M = {} +function M.run(root, backend, version) + local native = backend == "gen1" + local f = dofile(root .. "/tests/enemy_actions/choice_band.lua").fixture(root, backend, version) + local T, Copy, rows = f.T, require("src.mods.Merge").deepCopy, {} + local function number(value) + if native then + return tonumber(value) + end + local def = f.data.moves[value] + return def and tonumber(def.index or def.numId) + end + local function view(user) + local pp, learned = {}, {} + for slot = 1, 4 do + if native then + pp[slot] = user.mon.pp[slot] + learned[slot] = number(user.mon.moves[slot]) + else + local move = user.curMoves[slot] + pp[slot] = move and move.pp + learned[slot] = move and number(move.id) + end + end + return { + hp = user.mon.hp, + choice = user.choicedMove, + pp = pp, + learned = learned, + last = number(user[native and "lastMoveId" or "lastMove"]), + } + end + for _, double in ipairs({ false, true }) do + for id = 0, double and 3 or 1 do + for _, kind in ipairs({ + "bound", + "forced_status", + "unbound", + "same_move", + "empty_item", + "effect_only_item", + "empty_override", + "suppressed", + "metadata_cleared", + "struggle", + "struggle_ghost", + "struggle_rock", + "first_duplicate_empty", + "duplicate_selected", + "missing_choice", + "locked_pp_empty", + "encore", + "charging", + "replace_moves", + "replace_reordered_moves", + "replace_removed_slot", + "replace_moves_pressure", + "replace_removed_slot_pressure", + }) do + local removedSlot = kind == "replace_removed_slot" or kind == "replace_removed_slot_pressure" + local pressure = kind == "replace_moves_pressure" or kind == "replace_removed_slot_pressure" + local b = f.field(double) + local user, targetId = f.at(b, id), id % 2 == 0 and id + 1 or id - 1 + local target = f.at(b, targetId) + if kind ~= "unbound" then + user.choicedMove = kind == "forced_status" and 214 or 33 + end + if removedSlot then + user.choicedMove = 10 + end + if pressure then + if native then + target.ability, target.mon.ability = 46, 46 + else + target.abilityOverride = "PRESSURE" + end + end + local item = 186 + if kind == "empty_item" or kind == "empty_override" then + item = nil + end + if kind == "effect_only_item" then + item = 529 + end + f.hold(user, item) + if native then + f.data.gen3Items._byId[186].holdEffect = kind == "metadata_cleared" and 0 or 29 + else + f.data.items.ITEM_186.holdEffect = kind == "metadata_cleared" and "NONE" or "CHOICE_BAND" + end + local struggle = kind:match("^struggle") ~= nil + local selected = kind == "same_move" and 1 or struggle and 4 or 2 + if removedSlot then + selected = 1 + end + if kind == "struggle_ghost" or kind == "struggle_rock" then + if native then + target.type1 = kind == "struggle_ghost" and 7 or 5 + target.type2 = target.type1 + else + target.curTypes = { kind == "struggle_ghost" and "GHOST" or "ROCK" } + end + end + if kind == "first_duplicate_empty" or kind == "duplicate_selected" then + f.moves(user, { "TACKLE", "SCRATCH", "TACKLE", "STRUGGLE" }) + if native then + user.mon.pp[1] = 0 + else + user.curMoves[1].pp = 0 + end + if kind == "duplicate_selected" then + selected = 3 + end + end + if kind == "locked_pp_empty" then + if native then + user.mon.pp[1] = 0 + else + user.curMoves[1].pp = 0 + end + end + if kind == "missing_choice" then + f.moves(user, { "SCRATCH", "SCRATCH", "SLEEP_TALK", "STRUGGLE" }) + end + if kind == "encore" then + user[native and "expEncoreMove" or "encoreMove"] = f.moveId("TACKLE") + user[native and "expEncoreSlot" or "encoreSlot"] = 1 + user[native and "expEncoreTurns" or "encoreTurns"] = 3 + end + if kind == "charging" then + if native then + user.expLockedMove = 19 + else + user.charging = { id = f.ids.FLY, pp = 1 } + end + end + local trace, draws, events = {}, {}, {} + local off = f.e.api.hooks:wrap("held_item.trigger", function(nextRead, ctx) + trace[#trace + 1] = { + holder = ctx.mon.nickname, + item = ctx.item, + definition = ctx.def and ctx.def.id, + effect = ctx.effect, + parameter = ctx.parameter, + trigger = ctx.trigger, + actualBattle = ctx.battle == b, + } + if kind == "suppressed" then + return nil, 0 + end + if kind == "empty_override" then + return "HELD_CHOICE_BAND", 0 + end + return nextRead(ctx) + end) + b.rng = function(lo, hi) + draws[#draws + 1] = { lo, hi, hi } + return hi + end + local plan, plannedMove, plannedSlot, targetType, plannedTarget + if native then + local action = + { kind = "move", move = user.mon.moves[selected], slot = selected, target = targetId } + local sourcePlan = require("src.core.game3.battle.engine").planTurnActions( + b, + require("src.core.game3.battle.adapter").new(b), + { [id] = action } + ) + plan = sourcePlan[1] + plannedMove, plannedSlot, targetType, plannedTarget = + plan.move, plan.slot, plan.targetType, plan.target + else + local slot = user.curMoves[selected] + local action = require("src.battle.EnemyMoveChoice").withMove(slot, slot.id, slot, selected) + action.kind, action.target = "move", targetId + action.targetType = tonumber(b.data.moves[slot.id].target) or 0 + plan = require("src.battle.Gen3ChoiceBand").plan(b, user, action) + plannedMove, plannedSlot, targetType, plannedTarget = + plan.id, plan.slot, plan.targetType, plan.target + end + off() + T.eq(#draws, 0, "one-action native forced planning consumes no whole-turn RNG") + T.eq( + #trace, + (kind == "unbound" or kind == "encore" or kind == "charging") and 0 or 1, + "only an applicable Choice Band planner performs the held check" + ) + local plannerTrace = Copy(trace) + local oldPP, oldSlot + if kind == "replace_moves" or kind == "replace_reordered_moves" or removedSlot or pressure then + oldSlot = native and user.mon.pp or user.curMoves + oldPP = native and oldSlot[plannedSlot] or oldSlot[plannedSlot].pp + f.moves( + user, + removedSlot and { "TACKLE" } + or (kind == "replace_moves" or pressure) and { "TACKLE", "SCRATCH", "SLEEP_TALK", "STRUGGLE" } + or { "SCRATCH", "TACKLE", "SLEEP_TALK", "STRUGGLE" }, + 7 + ) + end + -- Charging is a planner-only input here. Its actual release needs the + -- real charge setup; all ordinary and Encore plans execute normally. + if kind ~= "charging" then + local noCrit = f.e.api.hooks:wrap("battle.crit", function() + return false + end) + local stop = f.e.api.events:on("battle.move_used", function(e) + events[#events + 1] = + { user = e.user.mon.nickname, target = e.target.mon.nickname, move = e.move.id } + end) + if native then + require("src.core.game3.battle.engine").resolveMove( + id, + targetId, + plan.move, + plan.slot, + require("src.core.game3.battle.adapter").new(b), + b, + {}, + {} + ) + else + b:executeAction(user, target, plan) + end + stop() + noCrit() + end + local retainedPP = oldSlot and (native and oldSlot[plannedSlot] or oldSlot[plannedSlot].pp) + if oldSlot then + T.eq(retainedPP, oldPP, "replaced learned records do not spend old PP") + end + if removedSlot then + T.check(target.mon.hp < 400, "removing the PP slot does not cancel the already planned move") + T.eq( + native and user.mon.pp[1] or user.curMoves[1].pp, + 7, + "removing the planned slot does not spend another current move's PP" + ) + elseif pressure then + T.eq( + native and user.mon.pp[1] or user.curMoves[1].pp, + 5, + "Pressure spends two PP from the replacement slot when it exists" + ) + end + T.eq(#f.result.errors, 0, "planner and actual move callbacks stay error-free") + rows[#rows + 1] = { + case = kind, + double = double, + actor = id, + move = number(plannedMove), + slot = plannedSlot, + targetType = targetType, + target = type(plannedTarget) == "table" and plannedTarget.id or plannedTarget, + plannerTrace = plannerTrace, + draws = draws, + events = events, + user = view(user), + targetState = view(target), + replacedOldPP = retainedPP, + } + end + end + end + f.close() + return rows +end +return M diff --git a/tests/enemy_actions/choice_selection.lua b/tests/enemy_actions/choice_selection.lua new file mode 100644 index 000000000..59cb565da --- /dev/null +++ b/tests/enemy_actions/choice_selection.lua @@ -0,0 +1,189 @@ +local M = {} +function M.run(root, backend, version) + local native = backend == "gen1" + local f = dofile(root .. "/tests/enemy_actions/choice_band.lua").fixture(root, backend, version) + local T, Copy, rows = f.T, require("src.mods.Merge").deepCopy, {} + local function at(b, id) + return native and b.battlers[id] or b:battlerAt(id) + end + local function fresh(double) + local b = f.fresh("TACKLE").battle + b.double = double + if not native then + b:syncSides() + end + if double then + for id = 2, 3 do + local mon = Copy(at(b, id % 2).mon) + if native then + b.battlers[id] = require("src.core.game3.battle.state").makeBattler( + mon, + id % 2 == 0 and "player" or "enemy", + { id = id, partyIndex = 2 } + ) + else + b:placeBattler(id, require("src.battle.BattleState").makeBattler(f.data, mon, id % 2 == 0)) + end + end + end + if not native then + b:syncSides() + end + for id = 0, double and 3 or 1 do + local user = at(b, id) + user.mon.nickname, user.mon.hp = "P" .. id, 400 + f.moves(user, { "TACKLE", "SCRATCH", "SLEEP_TALK", "STRUGGLE" }) + f.hold(user, nil) + end + b.phase, b.queue, b.current = "moveSelect", {}, nil + return b + end + local function pp(user, slot, value) + if native then + user.mon.pp[slot] = value + else + user.curMoves[slot].pp = value + end + end + for _, double in ipairs({ false, true }) do + for id = 0, double and 3 or 1 do + for _, kind in ipairs({ + "unbound", + "bound", + "effect_only_item", + "empty_override", + "suppressed", + "metadata_cleared", + "locked_pp_empty", + "all_pp_empty", + "missing_locked_move", + "disabled_locked", + "encore_other", + "taunted_status_lock", + "tormented_lock", + "tormented_struggle", + "imprisoned_lock", + }) do + local b = fresh(double) + local user, foe = at(b, id), at(b, id % 2 == 0 and id + 1 or id - 1) + f.hold(user, kind == "effect_only_item" and 529 or kind ~= "empty_override" and 186 or nil) + user.choicedMove = kind ~= "unbound" and 33 or nil + if native then + f.data.gen3Items._byId[186].holdEffect = kind == "metadata_cleared" and 0 or 29 + else + f.data.items.ITEM_186.holdEffect = kind == "metadata_cleared" and "NONE" or "CHOICE_BAND" + end + if kind == "locked_pp_empty" then + pp(user, 1, 0) + end + if kind == "all_pp_empty" then + for slot = 1, 4 do + pp(user, slot, 0) + end + end + if kind == "missing_locked_move" then + f.moves(user, { "SCRATCH", "SLEEP_TALK", "BATON_PASS", "STRUGGLE" }) + end + if kind == "disabled_locked" then + user[native and "expDisabledMove" or "disabledMove"] = f.moveId("TACKLE") + user[native and "expDisableTurns" or "disabledTurns"] = 3 + if not native then + user.disabledSlot = 1 + end + end + if kind == "encore_other" then + user[native and "expEncoreMove" or "encoreMove"] = f.moveId("SCRATCH") + user[native and "expEncoreTurns" or "encoreTurns"] = 3 + user[native and "expEncoreSlot" or "encoreSlot"] = 2 + end + if kind == "taunted_status_lock" then + user.choicedMove = 214 + user[native and "expTauntedTurns" or "tauntTurns"] = 2 + end + if kind == "tormented_lock" or kind == "tormented_struggle" then + user[native and "expTormented" or "tormented"] = true + user[native and "lastMoveId" or "lastMove"] = + f.moveId(kind == "tormented_struggle" and "STRUGGLE" or "TACKLE") + if kind == "tormented_struggle" then + user.choicedMove = 165 + f.hold(user, 529) + end + end + if kind == "imprisoned_lock" then + foe[native and "expImprison" or "imprison"] = true + f.moves(foe, { "TACKLE" }) + end + local trace, draws = {}, {} + local off = f.e.api.hooks:wrap("held_item.trigger", function(nextRead, ctx) + trace[#trace + 1] = { + holder = ctx.mon.nickname, + item = ctx.item, + definition = ctx.def and ctx.def.id, + effect = ctx.effect, + parameter = ctx.parameter, + trigger = ctx.trigger, + actualBattle = ctx.battle == b, + } + if ctx.mon.nickname == user.mon.nickname then + if kind == "empty_override" then + return "HELD_CHOICE_BAND", 0 + end + if kind == "suppressed" then + return nil, 0 + end + end + return nextRead(ctx) + end) + b.rng = function(lo, hi) + draws[#draws + 1] = { lo, hi, hi } + return hi + end + local mask + if native then + mask = require("src.core.game3.battle.engine").moveLimitations( + user, + require("src.core.game3.battle.adapter").new(b) + ) + else + mask = require("src.battle.Gen3MoveSelection").aiMask(b, user) + end + local aiAllowed, manualAllowed = {}, {} + for slot = 1, 4 do + aiAllowed[slot] = not mask[slot] + if native then + manualAllowed[slot] = require("src.core.game3.battle.commands").moveUsable(b, slot, id) + else + manualAllowed[slot] = require("src.battle.Gen3MoveSelection").manualReason( + b, + user, + user.curMoves[slot], + slot + ) == nil + end + end + off() + T.eq(#trace, 1, "AI limitations read the holder once; manual admission does not read the held effect") + T.eq(#draws, 0, "read-only native limitations consume no RNG") + T.eq(#f.result.errors, 0, "complete held query has no swallowed callback failures") + if kind == "tormented_struggle" then + T.eq(manualAllowed[4], true, "manual Torment keeps its Struggle exception") + end + rows[#rows + 1] = { + case = kind, + double = double, + actor = id, + aiAllowed = aiAllowed, + manualAllowed = manualAllowed, + choice = user.choicedMove, + trace = trace, + draws = draws, + hp = user.mon.hp, + foeHp = foe.mon.hp, + } + end + end + end + f.close() + return rows +end +return M diff --git a/tests/enemy_actions/choice_wild.lua b/tests/enemy_actions/choice_wild.lua new file mode 100644 index 000000000..56b7cfda5 --- /dev/null +++ b/tests/enemy_actions/choice_wild.lua @@ -0,0 +1,150 @@ +-- Exercise the actual ordinary-wild controller, including its deliberately +-- unfiltered nonempty-slot draw before forced-move planning. +local M = {} +function M.run(root, backend, version) + local native = backend == "gen1" + local f = dofile(root .. "/tests/enemy_actions/choice_band.lua").fixture(root, backend, version) + local T, rows = f.T, {} + local function number(id) + if native then + return require("src.mods.Gen3Compat").moveId(id) + end + local def = f.data.moves[id] + return def and tonumber(def.index or def.numId) + end + for _, double in ipairs({ false, true }) do + for id = 1, double and 3 or 1, 2 do + for _, kind in ipairs({ + "bound", + "unbound", + "all_pp_empty", + "locked_pp_empty", + "effect_only_item", + "empty_override", + "suppressed", + "encore", + "taunt", + "torment", + "retry_empty", + "bounded_empty_fallback", + }) do + for pick = 0, 3 do + local b = f.field(double) + local user = f.at(b, id) + f.hold(user, kind == "effect_only_item" and 529 or 186) + if kind == "empty_override" then + f.hold(user, nil) + end + if kind ~= "unbound" then + user.choicedMove = 33 + end + local function setPP(slot, value) + if native then + user.mon.pp[slot] = value + else + user.curMoves[slot].pp = value + end + end + if kind == "all_pp_empty" then + for slot = 1, 4 do + setPP(slot, 0) + end + elseif kind == "locked_pp_empty" then + setPP(1, 0) + end + if kind == "encore" then + user[native and "expEncoreMove" or "encoreMove"] = f.moveId("SCRATCH") + user[native and "expEncoreSlot" or "encoreSlot"] = 2 + user[native and "expEncoreTurns" or "encoreTurns"] = 3 + elseif kind == "taunt" then + user[native and "expTauntedTurns" or "tauntTurns"] = 3 + elseif kind == "torment" then + user[native and "expTormented" or "tormented"] = true + user[native and "lastMoveId" or "lastMove"] = f.moveId("TACKLE") + elseif kind == "retry_empty" or kind == "bounded_empty_fallback" then + if native then + user.mon.moves[pick + 1] = 0 + else + user.curMoves[pick + 1] = { id = 0, pp = 0 } + end + user.choicedMove = nil + end + local trace, draws, selections = {}, {}, 0 + local off = f.e.api.hooks:wrap("held_item.trigger", function(nextRead, ctx) + trace[#trace + 1] = { + holder = ctx.mon.nickname, + item = ctx.item, + definition = ctx.def and ctx.def.id, + effect = ctx.effect, + parameter = ctx.parameter, + trigger = ctx.trigger, + actualBattle = ctx.battle == b, + } + if kind == "suppressed" then + return nil, 0 + end + if kind == "empty_override" then + return "HELD_CHOICE_BAND", 0 + end + return nextRead(ctx) + end) + b.rng = function(lo, hi) + local value = hi + if lo == 0 and hi == 3 then + selections = selections + 1 + value = pick + if kind == "retry_empty" and selections > 1 then + value = (pick + 1) % 4 + end + end + draws[#draws + 1] = { lo, hi, value } + return value + end + local action + if native then + action = require("src.core.game3.battle.ai").chooseAction(b, id) + else + action = b:vanillaEnemyAction(user) + end + local selected = { + move = number(native and action.move or action.id), + slot = action.slot, + target = action.target, + scores = action.scores, + } + local plan + if native then + plan = require("src.core.game3.battle.engine").planTurnActions( + b, + require("src.core.game3.battle.adapter").new(b), + { [id] = action } + )[1] + else + plan = require("src.battle.Gen3ChoiceBand").plan(b, user, action) + end + off() + T.eq(#f.result.errors, 0, "ordinary wild callbacks stay error-free") + rows[#rows + 1] = { + case = kind, + double = double, + actor = id, + pick = pick, + selected = selected, + planned = { + move = number(native and plan.move or plan.id), + slot = plan.slot, + target = plan.target, + }, + trace = trace, + draws = draws, + hp = user.mon.hp, + choice = user.choicedMove, + } + end + end + end + end + f.close() + return rows +end +return M diff --git a/tests/enemy_actions/constrained_party.lua b/tests/enemy_actions/constrained_party.lua new file mode 100644 index 000000000..4d7480e54 --- /dev/null +++ b/tests/enemy_actions/constrained_party.lua @@ -0,0 +1,349 @@ +-- Run the actual native builders with ROM-shaped data, never patched formulas. +local M = {} +local SPECIES = { + { 19, "RATTATA", { 30, 56, 35, 72, 25, 35 }, 127, 50, 62, 70, 0 }, + { 16, "PIDGEY", { 40, 45, 40, 56, 35, 35 }, 127, 51, 0, 70, 3 }, + { 43, "ODDISH", { 45, 50, 55, 30, 75, 65 }, 127, 34, 0, 70, 3 }, + { 60, "POLIWAG", { 40, 50, 40, 90, 40, 40 }, 127, 11, 6, 70, 3 }, + { 12, "BUTTERFREE", { 60, 45, 50, 70, 80, 80 }, 127, 14, 0, 70, 0 }, + { 39, "JIGGLYPUFF", { 115, 45, 20, 20, 45, 25 }, 191, 56, 0, 35, 4 }, +} +local MOVES = { + { 33, "TACKLE", 35 }, + { 39, "TAIL_WHIP", 30 }, + { 158, "HYPER_FANG", 15 }, + { 98, "QUICK_ATTACK", 30 }, + { 28, "SAND_ATTACK", 15 }, + { 16, "GUST", 35 }, + { 71, "ABSORB", 25 }, + { 230, "SWEET_SCENT", 20 }, + { 77, "POISONPOWDER", 35 }, + { 55, "WATER_GUN", 25 }, + { 95, "HYPNOSIS", 20 }, + { 145, "BUBBLE", 30 }, + { 93, "CONFUSION", 25 }, + { 78, "STUN_SPORE", 30 }, + { 79, "SLEEP_POWDER", 15 }, + { 47, "SING", 15 }, + { 111, "DEFENSE_CURL", 40 }, + { 1, "POUND", 35 }, +} +local LESSONS = { "battle", "status", "matchups", "catch" } +local STATS = { "hp", "attack", "defense", "speed", "spatk", "spdef" } +local SHORT = { "hp", "atk", "def", "spe", "spa", "spd" } +local SOURCE_STATS = { "maxHp", "attack", "defense", "speed", "spAtk", "spDef" } +local NATURES = { [1] = "LONELY", [4] = "NAUGHTY", [19] = "RASH", [23] = "CAREFUL" } +local GROWTH = { [0] = "MEDIUM_FAST", [3] = "MEDIUM_SLOW", [4] = "FAST" } +local GROWTH_IDS = {} +for n, id in pairs(GROWTH) do + GROWTH_IDS[id] = n +end +local function key(format, prefix, n, name) + if format == "raw" then + return (prefix .. "_%03d"):format(n) + end + return format == "translated" and ("DE_" .. name) or name +end +function M.run(root, backend, version) + local native, all = backend == "gen1", {} + for _, format in ipairs({ "canonical", "raw", "translated" }) do + local speciesKeys, moveKeys, expectedPp, natureIds, abilityIds = {}, {}, {}, {}, {} + local function configure(data, engine, ids, rom, names) + data.constants = data.constants or {} + data.constants.speciesOrder, data.constants.moveOrder = {}, {} + data.constants.natureOrder, data.constants.natures = {}, {} + data.abilities = data.abilities or {} + if not native then + local id = key(format, "ITEM", 4, "POKE_BALL") + data.items[id] = { index = 4, name = "POKE BALL", ball = "POKE_BALL" } + data.constants.itemOrder = { [4] = id } + if format == "raw" then + data.items.FIX_BADGE_1.index = nil + data.constants.itemOrder = nil + end + end + for n, name in pairs(NATURES) do + local id = key(format, "NATURE", n, name) + data.constants.natureOrder[n + 1] = id + data.constants.natures[id] = { index = n, modifiers = {} } + natureIds[id] = n + end + for _, s in ipairs(SPECIES) do + local n, name, stats, ratio, a1, a2, friendship, growth = unpack(s) + local id = key(format, "SPECIES", n, name) + speciesKeys[n] = id + local display = format == "translated" and ("DE " .. name) or name + if native then + local p = data.gen3Pokemon + p._names[n], p._types[n] = display, { 0, 0 } + p._stats[n] = {} + for i, k in ipairs(SHORT) do + p._stats[n][k] = stats[i] + end + p._speciesMeta[n] = { + genderRatio = ratio, + friendship = friendship, + growthRate = growth, + catchRate = 45, + expYield = 50, + } + p._abilities[n] = { a1, a2 } + p._learnsets[n] = { { 1, 33 } } + else + local def = require("src.mods.Merge").deepCopy(data.pokemon.CHARMANDER) + def.id, def.name, def.index, def.dex = id, display, n, n + def.baseStats, def.evYield, def.genderRatio = {}, {}, ratio + for i, k in ipairs(STATS) do + def.baseStats[k] = stats[i] + end + def.baseStats.special = def.baseStats.spatk + def.friendship, def.growthRate, def.evolutions = friendship, GROWTH[growth], {} + def.abilities = {} + for i, a in ipairs({ a1, a2 }) do + if a ~= 0 then + local aid = "ABILITY_" .. a + def.abilities[i], abilityIds[aid] = aid, a + data.abilities[aid] = { index = a, name = aid } + end + end + data.pokemon[id] = def + data.constants.speciesOrder[n] = id + end + end + for _, m in ipairs(MOVES) do + local n, name, pp = unpack(m) + local id = key(format, "MOVE", n, name) + moveKeys[id], expectedPp[n] = n, pp + local display = format == "translated" and ("DE " .. name) or name + names[n] = display + if native then + local d = { + effect = 0, + power = 35, + type = 0, + target = 0, + priority = 0, + accuracy = 100, + pp = pp, + flags = 0, + } + data.gen3Moves._rom[n], rom[n], data.gen3Pokemon._moveNames[n] = d, d, display + else + -- Remove fixture aliases: translated/raw rows are the only valid IDs. + for k, d in pairs(data.moves) do + if d.index == n then + data.moves[k] = nil + end + end + data.moves[id] = { + id = id, + name = display, + index = n, + pp = pp, + power = 35, + accuracy = 100, + type = "NORMAL", + effect = "NO_ADDITIONAL_EFFECT", + } + data.constants.moveOrder[n] = id + end + end + if not native then + if format == "raw" then + data.constants.speciesOrder, data.constants.moveOrder, data.constants.natureOrder = nil, nil, nil + end + for _, s in ipairs(SPECIES) do + if speciesKeys[s[1]] ~= s[2] then + data.pokemon[s[2]] = nil + end + end + end + end + local f = + dofile(root .. "/tests/enemy_actions/fixture.lua").new(root, backend, version, 3, "unified", configure) + local T, calls, policy = f.T, {}, "none" + local stop + local function hook(nextGender, c) + calls[#calls + 1] = + { species = c.species, def = c.def.id, ratio = c.ratio, personality = c.personality } + if policy == "reject-first" and #calls == 1 then + return "female" + end + if policy == "invalid" then + return "not-a-gender" + end + if policy == "threshold" then + c.def.genderRatio = 0 + end + return nextGender(c) + end + local sourceRng, oldRandom, oldState + if native then + sourceRng = require("src.core.game3.rng") + oldRandom, oldState = sourceRng.Random, sourceRng.getState() + end + for _, mode in ipairs({ "none", "delegate", "reject-first", "invalid", "threshold" }) do + policy = mode + if mode ~= "none" then + stop = f.e.api.hooks:wrap("gender.roll", hook) + end + for _, seed in ipairs({ 0, 1, 65535 }) do + for lesson, name in ipairs(LESSONS) do + calls = {} + local words, state = {}, seed + -- Independent input generator; the source still executes its original Random(). + local function word(lo, hi) + T.eq(lo, 0, "constructor requests a complete low/high word") + T.eq(hi, 65535, "constructor requests a complete low/high word") + local low, high = state % 65536, math.floor(state / 65536) + state = (low * 20077 + ((low * 16838 + high * 20077) % 65536) * 65536 + 24691) % 4294967296 + local value = math.floor(state / 65536) + words[#words + 1] = value + return value + end + local original = { { species = "REAL", hp = 17, nested = { marker = seed } } } + f.game.save.party = original + local before = require("src.link.Json").encode(original) + local party, foes, b + if native then + sourceRng.SeedRng(seed) + sourceRng.Random = function() + local result = oldRandom() + words[#words + 1] = result + return result + end + party, foes = require("src.core.game3.battle.pokedude").build(lesson - 1) + state = sourceRng.getState().value + sourceRng.Random = oldRandom + else + b = require("src.battle.BattleState").newPokedudeDemo(f.game, name, word) + party, foes = f.game.save.party, b.enemyParty + end + T.eq(#party, lesson == 3 and 2 or 1, "native player party size") + T.eq(#foes, 1, "native opposing party size") + local observed = {} + for _, side in ipairs({ party, foes }) do + local out = {} + for _, mon in ipairs(side) do + local n = native and mon.species or f.data.pokemon[mon.species].index + local stats, ivs, evs, moves = {}, {}, {}, {} + for i, k in ipairs(STATS) do + stats[i] = native and mon[SOURCE_STATS[i]] or mon.stats[k] + ivs[i], evs[i] = mon.ivs[native and SHORT[i] or k], mon.evs[native and SHORT[i] or k] + T.eq(ivs[i], 0, "native preset IV is zero") + T.eq(evs[i], 0, "native preset EV is zero") + end + for j, m in ipairs(mon.moves) do + local mn = native and m or moveKeys[m.id] + local pp, maxPp = native and mon.pp[j] or m.pp, native and mon.maxPp[j] or m.maxPp + T.eq(pp, expectedPp[mn], "move PP follows its native definition") + T.eq(maxPp, pp, "native slots start with full PP") + moves[#moves + 1] = { mn, pp, maxPp } + end + local nature = native and mon.nature or natureIds[mon.nature] + T.eq(mon.personality % 25, nature, "stored nature agrees with the native PID") + T.eq(mon.gender, native and "M" or "male", "native requested gender is stored") + T.eq(mon.hp, stats[1], "preset starts at full HP") + local ball = native and mon.pokeball or (f.data.items[mon.ball] or {}).index + T.eq(ball, 4, "native preset records its Poke Ball") + out[#out + 1] = { + species = n, + level = mon.level, + metLevel = mon.metLevel, + exp = mon.exp, + personality = mon.personality, + nature = nature, + gender = "male", + stats = stats, + ivs = ivs, + evs = evs, + moves = moves, + ability = native and mon.ability or abilityIds[mon.ability], + ball = ball, + growthRate = native and mon.growthRate or GROWTH_IDS[mon.growthRate], + happiness = mon.happiness, + friendship = mon.friendship, + } + end + observed[#observed + 1] = out + end + if lesson == 4 then + local ids = {} + for _, m in ipairs(observed[1][1].moves) do + ids[#ids + 1] = m[1] + end + T.same(ids, { 93, 77, 79, 78 }, "catching lesson preserves native move-slot order") + end + if b then + T.eq(b.player.mon, party[1], "native player battler uses the created party member") + T.eq(b.enemy.mon, foes[1], "native opposing battler uses the created foe") + b:restorePokedudeParty() + T.eq(f.game.save.party, original, "demo restores the original party reference") + b:restorePokedudeParty() + T.eq(f.game.save.party, original, "restoration is idempotent") + end + T.eq( + require("src.link.Json").encode(original), + before, + "native preset leaves real party bytes unchanged" + ) + all[#all + 1] = { + format = format, + mode = mode, + seed = seed, + lesson = name, + parties = observed, + words = words, + finalState = state, + genderCalls = calls, + } + end + end + if stop then + stop() + stop = nil + end + end + if native then + sourceRng.Random = oldRandom + sourceRng.setState(oldState) + else + local B, Preset = require("src.battle.BattleState"), require("src.pokemon.Gen3ConstrainedMon") + local original = f.game.save.party + local missing = f.data.moves[key(format, "MOVE", 16, "GUST")] + f.data.moves[key(format, "MOVE", 16, "GUST")] = nil + T.raises( + function() + B.newPokedudeDemo(f.game, "battle") + end, + "missing native moves ID 16", + "incomplete preset data fails explicitly before party ownership changes" + ) + T.eq(f.game.save.party, original, "failed opposing preset leaves the real party selected") + T.eq(f.game.save.partyAside, nil, "failed preset does not leave a borrowed-party stash") + f.data.moves[key(format, "MOVE", 16, "GUST")] = missing + local shinyCalls, wordCalls = 0, 0 + local stopShiny = f.e.api.hooks:wrap("shiny.roll", function(nextShiny, ...) + shinyCalls = shinyCalls + 1 + return nextShiny(...) + end) + local oldRandom = love.math.random + love.math.random = function(lo, hi) + wordCalls = wordCalls + 1 + T.eq(lo, 0, "default preset source requests a low/high word") + T.eq(hi, 65535, "default preset source requests a low/high word") + return wordCalls % 2 == 1 and 151 or 0 + end + local mon = + Preset.new(f.data, { species = 19, level = 15, nature = 1, gender = "male", moves = { 33 } }) + love.math.random = oldRandom + stopShiny() + T.eq(mon.personality, 151, "default word source follows native low-word-first identity") + T.eq(wordCalls, 2, "native preset draws no generic IV/DV or shiny-odds randomness") + T.eq(shinyCalls, 0, "private native creation does not call the shiny predicate") + end + f.close() + end + return all +end +return M diff --git a/tests/enemy_actions/critical_kinds.lua b/tests/enemy_actions/critical_kinds.lua new file mode 100644 index 000000000..c3f0fd60b --- /dev/null +++ b/tests/enemy_actions/critical_kinds.lua @@ -0,0 +1,469 @@ +local M = {} +function M.run(root, backend, version) + local native = backend == "gen1" + local function configure(data, engine, ids, rom, names) + ids.BEAT_UP, names[251] = engine == "gen1" and "BEAT_UP" or "MOVE_251", "BEAT_UP" + if engine == "gen1" then + local d = + { effect = 154, power = 10, type = 17, target = 0, priority = 0, accuracy = 100, pp = 20, flags = 2 } + data.gen3Moves._rom[251], rom[251], data.gen3Pokemon._moveNames[251] = d, d, "BEAT_UP" + data.gen3Pokemon._stats[4] = { hp = 50, atk = 60, def = 40, spe = 50, spa = 50, spd = 50 } + else + data.moves.MOVE_251 = { + id = "MOVE_251", + index = 251, + name = "BEAT_UP", + gen3Effect = 154, + effect = "BEAT_UP_EFFECT", + power = 10, + type = "DARK", + target = 0, + priority = 0, + accuracy = 100, + accuracyRaw = 255, + pp = 20, + protectAffected = true, + } + data.pokemon.CHARMANDER.baseStats.attack, data.pokemon.CHARMANDER.baseStats.defense = 60, 40 + data.pokemon.CHARMANDER.baseStats.spatk, data.pokemon.CHARMANDER.baseStats.spdef = 50, 50 + data.pokemon.CHARMANDER.evYield = { attack = 1 } + data.type_chart.types = data.type_chart.types or {} + data.type_chart.types.DARK = { id = "DARK", index = 17, category = "special" } + for id, row in pairs({ + SCOPE_LENS = { 198, "SCOPE_LENS" }, + CUSTOM_SCOPE = { 1198, "SCOPE_LENS" }, + LUCKY_PUNCH = { 222, "LUCKY_PUNCH" }, + STICK = { 225, "STICK" }, + LANSAT_BERRY = { 173, "CRITICAL_UP" }, + }) do + data.items[id] = { index = row[1], name = id, holdEffect = row[2], holdEffectParam = 4 } + end + data.constants.itemOrder = { [4] = "POKE_BALL" } + data.items.POKE_BALL = { index = 4, name = "POKE BALL", ball = "POKE_BALL" } + data.constants.natureOrder = { [2] = "LONELY", [5] = "NAUGHTY" } + data.pokemon.CHARMANDER.genderRatio = 127 + for _, number in ipairs({ 39, 158, 98, 28, 16 }) do + data.moves["MOVE_" .. number] = { index = number, pp = 30 } + end + for name, index in pairs({ RATTATA = 19, PIDGEY = 16, CHANSEY = 113, FARFETCH_D = 83 }) do + local d = require("src.mods.Merge").deepCopy(data.pokemon.CHARMANDER) + d.id, d.name, d.index, d.dex, d.evolutions = name, name, index, index, {} + data.pokemon[name] = d + end + end + end + local f = + dofile(root .. "/tests/enemy_actions/fixture.lua").new(root, backend, version, 3, "unified", configure) + local T = f.T + local sourceItems, oldItems + if native then + sourceItems = require("src.core.game3.items_data") + oldItems = { sourceItems._byId, sourceItems._byName, sourceItems._pack } + local raw = { [1198] = { name = "CUSTOM SCOPE", pocket = "ITEMS", holdEffect = 41, holdEffectParam = 0 } } + sourceItems._byId, sourceItems._byName, sourceItems._pack = raw, {}, { items = raw } + end + local rows, hits, draws, calls, contexts = {}, {}, {}, {}, {} + local policy, liveBattle + local function criticalHook(nextCrit, context) + calls.crit = calls.crit + 1 + contexts[#contexts + 1] = { + battle = context.battle ~= nil, + target = context.target and context.target.mon.nickname, + move = context.moveId, + number = context.moveNum, + stage = context.stage, + } + if policy == "force" then + return true + end + local result = nextCrit(context) + if policy == "after" then + return true + end + if policy == "suppress" then + return false + end + return result + end + local stopCrit = f.e.api.hooks:wrap("battle.crit", criticalHook) + local stopDamage = f.e.api.hooks:wrap("battle.damage", function(nextDamage, ...) + calls.damage = calls.damage + 1 + return nextDamage(...) + end) + local stopHit = f.e.api.events:on("battle.damage_dealt", function(e) + hits[#hits + 1] = { + damage = e.damage, + crit = e.crit, + user = e.user.mon.nickname, + target = e.target.mon.nickname, + kind = e.kind, + substitute = e.substitute or false, + oakFlags = liveBattle.oakMsgFlags or 0, + } + end) + local function at(b, id) + return native and b.battlers[id] or b:battlerAt(id) + end + local function put(b, id, a) + if native then + b.battlers[id] = a + else + b:placeBattler(id, a) + end + end + local specs = { + { name = "ordinary", kind = "normal" }, + { name = "ordinary-unhooked", kind = "normal", noHook = true }, + { name = "ordinary-special", kind = "normal", special = true }, + { name = "scope-lens", kind = "normal", item = "SCOPE_LENS", stage = 1 }, + { name = "scope-native-id", kind = "normal", item = "SCOPE_LENS", noEffect = true, stage = 1 }, + { name = "custom-scope", kind = "normal", item = "CUSTOM_SCOPE", stage = 0 }, + { + name = "lucky-punch-current-species", + kind = "normal", + item = "LUCKY_PUNCH", + species = "CHANSEY", + speciesNum = 113, + stage = 2, + }, + { + name = "stick-current-species", + kind = "normal", + item = "STICK", + species = "FARFETCH_D", + speciesNum = 83, + stage = 2, + }, + { name = "high-critical", kind = "normal", high = true, stage = 1 }, + { name = "stage-cap", kind = "normal", high = true, focus = true, item = "SCOPE_LENS", stage = 4 }, + { name = "lansat", kind = "normal", item = "LANSAT_BERRY", berry = true, stage = 2 }, + { + name = "lansat-focus-energy", + kind = "normal", + item = "LANSAT_BERRY", + berry = true, + focus = true, + stage = 2, + }, + { name = "focus-energy", kind = "normal", focus = true }, + { name = "armor", kind = "normal", armor = true }, + { name = "forced-hook", kind = "normal", hook = "force" }, + { name = "suppressed-hook", kind = "normal", hook = "suppress" }, + { name = "after-hook", kind = "normal", hook = "after" }, + } + local extra = version == "firered" + and { + { name = "pokedude", kind = "pokedude" }, + { name = "pokedude-unhooked", kind = "pokedude", noHook = true }, + { name = "pokedude-focus", kind = "pokedude", focus = true }, + { name = "pokedude-forced", kind = "pokedude", hook = "force" }, + { name = "pokedude-after", kind = "pokedude", hook = "after" }, + { name = "pokedude-armor", kind = "pokedude", armor = true }, + { name = "old-man-control", kind = "oldman" }, + { name = "oak-before", kind = "oak" }, + { name = "oak-after", kind = "oak", flags = 1 }, + { name = "oak-substitute", kind = "oak", substitute = true }, + } + or { + { name = "first-battle", kind = "first" }, + { name = "first-unhooked", kind = "first", noHook = true }, + { name = "first-battle-after-damage", kind = "first", flags = 1 }, + { name = "first-forced", kind = "first", hook = "force" }, + { name = "first-after", kind = "first", hook = "after" }, + { name = "first-armor", kind = "first", armor = true }, + { name = "first-kind-only", kind = "firstKind" }, + { name = "wally", kind = "wally" }, + { name = "wally-unhooked", kind = "wally", noHook = true }, + { name = "wally-forced", kind = "wally", hook = "force" }, + { name = "wally-after", kind = "wally", hook = "after" }, + { name = "wally-focus", kind = "wally", focus = true }, + } + for _, s in ipairs(extra) do + specs[#specs + 1] = s + end + for _, spec in ipairs(specs) do + for id = 0, 3 do + for _, name in ipairs({ "TACKLE", "BEAT_UP" }) do + hits, draws, calls, contexts, policy = {}, {}, { crit = 0, damage = 0 }, {}, spec.hook + local b = f.fresh("TACKLE", { enemy = true }).battle + if native then + if spec.kind == "first" or spec.kind == "oak" then + b.firstBattle = true + end + if spec.kind == "pokedude" then + b.pokedude = true + end + if spec.kind == "oldman" then + b.oldManTutorial = true + end + b.kinds = {} + if spec.kind == "first" or spec.kind == "firstKind" then + b.kinds.firstBattle = "birch" + end + if spec.kind == "wally" then + b.kinds.tutorial = "wally" + end + elseif spec.kind == "first" then + f.data.constants.gen3FirstBattle = { species = "CHARMANDER", level = 30, canLose = true } + local yielded = 0 + local ctx = { + game = f.game, + save = f.game.save, + runner = { + yield = function() + yielded = yielded + 1 + end, + }, + overworld = { + pushBattle = function(_, battle) + b = battle + end, + }, + } + T.eq(require("src.script.Gen3Commands").firstBattle(ctx), true, "native first-battle script starts") + T.eq(yielded, 1, "native script yields after creating its battle") + T.eq(b.firstBattle, true, "first-battle metadata survives the native script and constructor") + elseif spec.kind == "firstKind" then + b = + require("src.battle.BattleState").newWild(f.game, "CHARMANDER", 30, { firstBattleKind = "birch" }) + elseif spec.kind == "pokedude" then + b = require("src.battle.BattleState").newPokedudeDemo(f.game, "battle") + T.check(b.pokedudeDemo ~= nil, "native Teachy TV constructor supplies its controller state") + elseif spec.kind == "wally" then + b:makeDudeDemo("WALLY") + elseif spec.kind == "oldman" then + b:makeOldManDemo("OLD MAN") + elseif spec.kind == "oak" then + b.firstBattle = true + end + b.oakMsgFlags = spec.flags or 0 + b.double = true + if not native then + b:syncSides() + end + for pos = 2, 3 do + local mon = require("src.mods.Merge").deepCopy(at(b, pos - 2).mon) + local a = native + and require("src.core.game3.battle.state").makeBattler( + mon, + pos == 2 and "player" or "enemy", + { id = pos, partyIndex = 2 } + ) + or require("src.battle.BattleState").makeBattler(f.data, mon, pos == 2) + put(b, pos, a) + end + if not native then + b:syncSides() + end + for pos = 0, 3 do + local a = at(b, pos) + a.mon.nickname, a.mon.hp, a.mon.level = "P" .. pos, 10000, 30 + if native then + a.species, a.mon.species, a.mon.maxHp = 4, 4, 10000 + a.type1, a.type2, a.ability, a.mon.ability = 0, 0, 0, 0 + a.mon.moves, a.mon.pp, a.mon.maxPp = { 33, 251 }, { 10, 20 }, { 10, 20 } + for _, k in ipairs({ "attack", "defense", "speed", "spAtk", "spDef" }) do + a.mon[k] = 50 + end + else + a.species, a.mon.species = "CHARMANDER", "CHARMANDER" + a.curStats = + { hp = 10000, attack = 50, defense = 50, speed = 50, special = 50, spatk = 50, spdef = 50 } + a.mon.stats, a.curTypes, a.abilityOverride = a.curStats, { "NORMAL" }, false + a.curMoves = { { id = f.ids.TACKLE, pp = 10 }, { id = f.ids.BEAT_UP, pp = 20 } } + a.mon.moves = a.curMoves + end + end + if native then + b.playerParty, b.foeParty = { at(b, 0).mon, at(b, 2).mon }, { at(b, 1).mon, at(b, 3).mon } + else + f.game.save.party, b.enemyParty = { at(b, 0).mon, at(b, 2).mon }, { at(b, 1).mon, at(b, 3).mon } + end + local user, target = at(b, id), at(b, id % 2 == 0 and 1 or 0) + liveBattle = b + if native then + require("src.core.game3.battle.moves")._rom[33].type = spec.special and 17 or 0 + require("src.core.game3.battle.moves")._rom[33].effect = spec.high and 43 or 0 + if spec.special then + user.type1, user.type2 = 17, 17 + end + else + f.data.moves[f.ids.TACKLE].type = spec.special and "DARK" or "NORMAL" + local move = f.data.moves[f.ids.TACKLE] + move.effect, move.gen3Effect, move.highCrit = + spec.high and "HIGH_CRITICAL_EFFECT" or "NO_ADDITIONAL_EFFECT", spec.high and 43 or 0, spec.high + f.data.items.SCOPE_LENS.holdEffect = spec.noEffect and "NONE" or "SCOPE_LENS" + if spec.special then + user.curTypes = { "DARK" } + end + end + user.focusEnergy = spec.focus + if spec.item then + if native then + local item = ({ + SCOPE_LENS = 198, + CUSTOM_SCOPE = 1198, + LUCKY_PUNCH = 222, + STICK = 225, + LANSAT_BERRY = 173, + })[spec.item] + user.item, user.mon.item = item, item + else + user.items, user.mon.item = f.data.items, spec.item + end + end + if spec.species then + user.species = native and spec.speciesNum or spec.species + end + if spec.berry then + user.mon.hp = 2500 + local active + if native then + active = require("src.core.game3.battle.held_items").normal( + require("src.core.game3.battle.adapter").new(b), + user, + false + ) + else + active = b:holdItemTrigger(user, "turn") + end + T.eq(active, spec.focus ~= true, "Lansat activates only without existing Focus Energy") + local held = native and user.item == 173 or not native and user.mon.item == "LANSAT_BERRY" + T.eq(held, spec.focus == true, "already focused users keep their unconsumed berry") + T.eq(user.focusEnergy, true, "the native berry producer uses the Focus Energy marker") + end + if spec.armor then + if native then + target.ability, target.mon.ability = 4, 4 + else + target.abilityOverride = "BATTLE_ARMOR" + end + end + if spec.substitute then + target.substituteHP = 1 + end + b.rng = function(lo, hi) + if lo == 85 and hi == 100 then + draws[#draws + 1] = { kind = "variation" } + return 100 + end + if + (lo == 0 and (hi == 15 or hi == 7 or hi == 3 or hi == 2 or hi == 1)) + or (lo == 1 and (hi == 16 or hi == 8 or hi == 4 or hi == 3 or hi == 2)) + then + draws[#draws + 1] = { kind = "critical", den = hi - lo + 1 } + return lo + end + return lo or 0 + end + f.e.choose(function() + return { move = name, target = id % 2 == 0 and 1 or 0 } + end) + local slot = name == "TACKLE" and 1 or 2 + if spec.noHook then + stopCrit() + end + if native then + local C = require("src.core.game3.battle.commands") + local action = id % 2 == 0 and C.playerAction(b, 1, slot, id, id % 2 == 0 and 1 or 0) + or C.enemyAction(b, id) + require("src.core.game3.battle.engine").resolveMove( + id, + action.target, + action.move, + action.slot, + require("src.core.game3.battle.adapter").new(b), + b, + {} + ) + else + if id % 2 == 0 then + b.chosenTargets = { [id] = target } + end + local action = id % 2 == 0 and user.curMoves[slot] or b:enemyAction(user) + b:executeAction(user, target, action) + end + if spec.noHook then + stopCrit = f.e.api.hooks:wrap("battle.crit", criticalHook) + end + local ordinary = name == "TACKLE" + local guarded = spec.kind == "first" or spec.kind == "firstKind" or spec.kind == "wally" + local oakPending = spec.kind == "oak" and not spec.flags + local expected, criticalDraws = {}, 0 + local force = ordinary and (spec.hook == "force" or spec.hook == "after") + for hit = 1, ordinary and 1 or 2 do + local suppressed = guarded or (oakPending and (id % 2 == 1 or hit == 1 or spec.substitute)) + local critical = force + or not ( + suppressed + or spec.kind == "pokedude" + or (ordinary and (spec.armor or spec.hook == "suppress")) + ) + expected[hit] = ordinary and (critical and 33 or 16) or (critical and 12 or 6) + if spec.substitute and hit == 1 then + expected[hit] = 1 + end + if not suppressed and not (ordinary and (spec.armor or spec.hook == "force")) then + criticalDraws = criticalDraws + 1 + end + end + local amounts = {} + local actualDraws = 0 + for _, h in ipairs(hits) do + amounts[#amounts + 1] = h.damage + T.eq( + h.kind, + ordinary and not spec.special and "physical" or "special", + "native event carries the damage kind" + ) + end + for _, r in ipairs(draws) do + if r.kind == "critical" then + actualDraws = actualDraws + 1 + end + end + T.same(amounts, expected, spec.name .. " native critical damage") + T.eq(actualDraws, criticalDraws, spec.name .. " native critical draw count") + T.eq( + calls.crit, + ordinary and not spec.armor and not spec.noHook and 1 or 0, + "only ordinary unarmored hits enter the public critical hook" + ) + T.eq(calls.damage, ordinary and 1 or 0, "scripted Beat Up bypasses ordinary damage hooks") + for _, c in ipairs(contexts) do + T.same(c, { + battle = true, + target = target.mon.nickname, + move = "TACKLE", + number = 33, + stage = spec.stage or (spec.focus and 2 or 0), + }, "public critical context carries the native battle/target/move/stage") + end + T.eq( + native and user.mon.pp[slot] or user.curMoves[slot].pp, + ordinary and 9 or 19, + "owned PP is spent once" + ) + rows[#rows + 1] = { + case = spec.name, + user = id, + move = name, + hits = hits, + draws = draws, + calls = calls, + contexts = contexts, + oakFlags = b.oakMsgFlags or 0, + } + end + end + end + stopHit() + stopDamage() + stopCrit() + if sourceItems then + sourceItems._byId, sourceItems._byName, sourceItems._pack = unpack(oldItems, 1, 3) + end + f.close() + return rows +end +return M diff --git a/tests/enemy_actions/disable.lua b/tests/enemy_actions/disable.lua new file mode 100644 index 000000000..21349afee --- /dev/null +++ b/tests/enemy_actions/disable.lua @@ -0,0 +1,431 @@ +local M = {} +function M.fixture(root, backend, version, entry) + local native = backend == "gen1" + return dofile(root .. "/tests/enemy_actions/fixture.lua").new( + root, + backend, + version, + 3, + "unified", + function(data, _, ids, rom, names) + for _, row in ipairs({ + { 50, "DISABLE", 86 }, + { 114, "HAZE", 25 }, + { 144, "TRANSFORM", 57 }, + { 117, "BIDE", 26 }, + { 700, "MOD_CUSTOM", 0 }, + }) do + local number, name, effect = unpack(row) + local id = native and name or ("MOVE_%03d"):format(number) + ids[name], names[number] = id, name + if native then + local def = { + effect = effect, + power = 0, + type = 0, + accuracy = 100, + pp = 10, + target = 0, + flags = 2, + secondaryChance = 0, + priority = 0, + } + data.gen3Moves._rom[number], rom[number], data.gen3Pokemon._moveNames[number] = def, def, name + data.gen3Pokemon._types[4], data.gen3Pokemon._abilities[4] = { 0, 0 }, { 0, 0 } + else + data.moves[id] = { + id = id, + index = number, + name = name, + effect = name .. "_EFFECT", + gen3Effect = effect, + power = 0, + type = "NORMAL", + accuracy = 100, + pp = 10, + target = 0, + flags = 2, + protectAffected = true, + } + if name == "MOD_CUSTOM" then + data.moves[id].index = nil + end + data.pokemon.CHARMANDER.types, data.pokemon.CHARMANDER.abilities = { "NORMAL" }, {} + end + end + end, + entry + ) +end +function M.run(root, backend, version) + local source = backend == "gen1" + local f = M.fixture(root, backend, version) + local T, Copy, rows = f.T, require("src.mods.Merge").deepCopy, {} + local draws, events = {}, {} + local stop = f.e.api.events:on("battle.move_used", function(e) + events[#events + 1] = + { user = e.user.mon.nickname, target = e.target and e.target.mon.nickname, move = e.move.id } + end) + local noCrit = f.e.api.hooks:wrap("battle.crit", function() + return false + end) + local function at(b, id) + return source and b.battlers[id] or b:battlerAt(id) + end + local function fresh(double, word) + local b = f.fresh("TACKLE", { enemy = true }).battle + b.double = double + if not source then + b:syncSides() + end + if double then + for id = 2, 3 do + local mon = Copy(at(b, id % 2).mon) + if source then + b.battlers[id] = require("src.core.game3.battle.state").makeBattler( + mon, + id % 2 == 0 and "player" or "enemy", + { id = id, partyIndex = 2 } + ) + else + b:placeBattler(id, require("src.battle.BattleState").makeBattler(f.data, mon, id % 2 == 0)) + end + end + end + if not source then + b:syncSides() + end + for id = 0, double and 3 or 1 do + local a = at(b, id) + a.mon.nickname = "P" .. id + if source then + a.mon.moves, a.mon.pp, a.mon.maxPp = { 33, 50, 10, 114 }, { 10, 10, 10, 10 }, { 10, 10, 10, 10 } + a.mon.maxHp, a.ability, a.mon.ability, a.type1, a.type2 = 400, 0, 0, 0, 0 + a.mon.speed = 100 - id * 10 + else + a.mon.moves = { + { id = f.ids.TACKLE, pp = 10 }, + { id = f.ids.DISABLE, pp = 10 }, + { id = f.ids.SCRATCH, pp = 10 }, + { id = f.ids.HAZE, pp = 10 }, + } + a.curMoves, a.curTypes, a.abilityOverride = a.mon.moves, { "NORMAL" }, false + a.mon.stats.hp = 400 + for _, stat in ipairs({ "attack", "defense", "spatk", "spdef" }) do + a.curStats[stat] = 50 + end + a.curStats.speed = 100 - id * 10 + end + a.mon.hp = 400 + end + b.rng = function(lo, hi) + local value = lo == 0 and hi == 3 and word or hi + draws[#draws + 1] = { lo = lo, hi = hi, value = value } + return value + end + return b + end + local nums = { + [33] = f.ids.TACKLE, + [10] = f.ids.SCRATCH, + [50] = f.ids.DISABLE, + [114] = f.ids.HAZE, + [144] = f.ids.TRANSFORM, + [700] = f.ids.MOD_CUSTOM, + [999] = "UNKNOWN", + [117] = f.ids.BIDE, + } + local function number(id) + if source then + return tonumber(id) + end + for num, name in pairs(nums) do + if name == id then + return num + end + end + end + local function last(a, num) + a[source and "lastMoveId" or "lastMove"] = source and num or nums[num] + end + local function setMove(a, slot, num) + if source then + a.mon.moves[slot] = num + else + a.curMoves[slot].id = nums[num] + end + end + local function setDisabled(a, num, turns) + a[source and "expDisabledMove" or "disabledMove"] = source and num or nums[num] + a[source and "expDisableTurns" or "disabledTurns"] = turns + end + local function view(a) + local pp, learned = {}, {} + for i = 1, 4 do + pp[i] = source and a.mon.pp[i] or a.curMoves[i].pp + learned[i] = number(source and a.mon.moves[i] or a.curMoves[i].id) + end + return { + turns = a[source and "expDisableTurns" or "disabledTurns"], + move = number(a[source and "expDisabledMove" or "disabledMove"]), + hp = a.mon.hp, + pp = pp, + learned = learned, + last = number(a[source and "lastMoveId" or "lastMove"]), + confusion = a[source and "confusionTurns" or "confusedTurns"], + bideTurns = a.bideTurns, + bideDamage = a.bideDamage, + } + end + local function cast(b, uid, tid, slot, called) + local user, target = at(b, uid), at(b, tid) + if source then + local nativeSlot = slot + if called then + nativeSlot = nil + end + require("src.core.game3.battle.engine").resolveMove( + uid, + tid, + user.mon.moves[slot], + nativeSlot, + require("src.core.game3.battle.adapter").new(b), + b, + {}, + { called = called } + ) + elseif called then + b:performMove(user, target, user.curMoves[slot], true) + else + b:executeAction(user, target, user.curMoves[slot]) + end + end + local function tick(b) + if source then + require("src.core.game3.battle.engine").collectResidualEvents( + b, + require("src.core.game3.battle.adapter").new(b) + ) + else + b:endOfTurn() + end + end + local function record(b, label) + local field = {} + for id = 0, b.double and 3 or 1 do + field[#field + 1] = view(at(b, id)) + end + -- Compare the real read-only native admission checks, not a duplicate + -- restriction formula or Gen1's snapshot (which omits disabled here). + local admission, phase, choosing = {}, b.phase, b.choosingSlot + b.phase, b.choosingSlot = "moveSelect", 0 + for slot = 1, 4 do + if source then + admission[slot] = require("src.core.game3.battle.commands").selectionError(b, slot, 0) == nil + else + admission[slot] = require("src.battle.BattleCommands").reason(b, { kind = "move", slot = slot }) + == nil + end + end + b.phase, b.choosingSlot = phase, choosing + rows[#rows + 1] = + { label = label, field = field, admission = admission, draws = Copy(draws), events = Copy(events) } + end + for _, double in ipairs({ false, true }) do + for uid = 0, double and 3 or 1 do + local tid = uid % 2 == 0 and uid + 1 or uid - 1 + for word = 0, 3 do + for _, kind in ipairs({ + "ordinary", + "no_last", + "unknown", + "empty_pp", + "already", + "zero_counter", + "protected", + "substitute", + "hidden", + "custom", + "duplicate", + }) do + draws, events = {}, {} + local b = fresh(double, word) + local target = at(b, tid) + last(target, 33) + if kind == "no_last" then + last(target, nil) + end + if kind == "unknown" then + last(target, 999) + end + if kind == "empty_pp" then + if source then + target.mon.pp[1] = 0 + else + target.curMoves[1].pp = 0 + end + end + if kind == "custom" then + setMove(target, 1, 700) + last(target, 700) + end + if kind == "duplicate" then + setMove(target, 3, 33) + end + if kind == "already" or kind == "zero_counter" then + setDisabled(target, 10, kind == "already" and 4 or 0) + end + if kind == "protected" then + target[source and "expProtected" or "protecting"] = true + end + if kind == "substitute" then + target.substituteHP = 25 + end + if kind == "hidden" then + if source then + target.semiInvulnerable = "ON_AIR" + else + target.invulnerable, target.hiddenAs = true, "air" + end + end + cast(b, uid, tid, 2) + if kind == "ordinary" or kind == "substitute" or kind == "custom" or kind == "duplicate" then + T.eq( + target[source and "expDisableTurns" or "disabledTurns"], + word + 2, + "native Disable draws two to five residual phases" + ) + T.eq( + number(target[source and "expDisabledMove" or "disabledMove"]), + kind == "custom" and 700 or 33, + "Disable owns the last used learned move" + ) + end + record(b, (double and "double" or "single") .. "/" .. uid .. "/" .. word .. "/" .. kind) + end + end + for _, kind in ipairs({ + "expiry", + "pp_depletion", + "removed", + "swapped", + "blocked", + "confused", + "different", + "called", + "duplicate_blocked", + "haze", + "transform", + "bide", + "locked", + }) do + draws, events = {}, {} + local b = fresh(double, 3) + local target = at(b, tid) + last(target, 33) + if kind == "bide" then + setMove(target, 1, 117) + last(target, 117) + target.bideTurns, target.bideDamage = 2, 73 + end + cast(b, uid, tid, 2) + record(b, kind .. "/applied/" .. uid) + if kind == "pp_depletion" then + if source then + target.mon.pp[1] = 0 + else + target.curMoves[1].pp = 0 + end + elseif kind == "removed" then + setMove(target, 1, 10) + elseif kind == "swapped" then + if source then + target.mon.moves[1], target.mon.moves[3] = target.mon.moves[3], target.mon.moves[1] + else + target.curMoves[1], target.curMoves[3] = target.curMoves[3], target.curMoves[1] + end + elseif kind == "haze" then + cast(b, tid, uid, 4) + elseif kind == "transform" then + setMove(target, 4, 144) + cast(b, tid, uid, 4) + elseif kind == "bide" then + if source then + cast(b, tid, uid, 1) + else + b:executeAction(target, at(b, uid), { special = "bide" }) + end + elseif + kind == "blocked" + or kind == "confused" + or kind == "called" + or kind == "different" + or kind == "duplicate_blocked" + or kind == "locked" + then + local slot = kind == "different" and 3 or 1 + if kind == "confused" then + target[source and "confusionTurns" or "confusedTurns"] = 4 + end + if kind == "duplicate_blocked" then + setMove(target, 3, 33) + slot = 3 + end + if kind == "locked" then + target.bideTurns, target.bideDamage = 2, 73 + if source then + target.twoTurnMove, target.expRolloutTimer, target.expFuryCutter, target.expRampageTurns = + 33, 3, 4, 2 + target.expLockedMove, target.expLockedSlot, target.expUproarTurns, target.uproar = + 33, 1, 2, true + else + target.charging, target.rampMove, target.rampRun = target.curMoves[1], f.ids.TACKLE, 3 + target.thrashMove, target.thrashTurns, target.uproarTurns = target.curMoves[1], 2, 2 + end + end + cast(b, tid, uid, slot, kind == "called") + if kind == "locked" then + for _, key in + ipairs( + source + and { + "twoTurnMove", + "expRolloutTimer", + "expFuryCutter", + "expRampageTurns", + "expLockedMove", + "expLockedSlot", + "expUproarTurns", + "uproar", + } + or { "charging", "rampMove", "rampRun", "thrashMove", "thrashTurns", "uproarTurns" } + ) + do + T.eq(target[key], nil, "native disabled interruption cancels multi-turn state: " .. key) + end + end + end + record(b, kind .. "/executed/" .. uid) + for step = 1, 5 do + tick(b) + record(b, kind .. "/residual/" .. step .. "/" .. uid) + end + T.eq( + target[source and "expDisabledMove" or "disabledMove"], + nil, + "native Disable clears through actual lifecycle" + ) + T.eq( + target[source and "expDisableTurns" or "disabledTurns"], + nil, + "native Disable clears its timer too" + ) + end + end + end + stop() + noCrit() + f.close() + return rows +end +return M diff --git a/tests/enemy_actions/encore.lua b/tests/enemy_actions/encore.lua new file mode 100644 index 000000000..36b88b2b7 --- /dev/null +++ b/tests/enemy_actions/encore.lua @@ -0,0 +1,443 @@ +local M = {} +function M.fixture(root, backend, version) + local native = backend == "gen1" + return dofile(root .. "/tests/enemy_actions/fixture.lua").new( + root, + backend, + version, + 3, + "unified", + function(data, _, ids, rom, names) + for _, row in ipairs({ { 227, "ENCORE", 90 }, { 119, "MIRROR_MOVE", 9 }, { 700, "MOD_CUSTOM", 0 } }) do + local number, name, effect = unpack(row) + local id = native and name or ("MOVE_%03d"):format(number) + ids[name], names[number] = id, name + if native then + local def = { + effect = effect, + power = 0, + type = 0, + accuracy = 100, + pp = 10, + target = 0, + flags = 2, + secondaryChance = 0, + priority = 0, + } + data.gen3Moves._rom[number], rom[number], data.gen3Pokemon._moveNames[number] = def, def, name + data.gen3Pokemon._types[4], data.gen3Pokemon._abilities[4] = { 0, 0 }, { 0, 0 } + else + data.moves[id] = { + id = id, + index = number, + name = name, + effect = name .. "_EFFECT", + gen3Effect = effect, + power = 0, + type = "NORMAL", + accuracy = 100, + pp = 10, + target = 0, + flags = 2, + protectAffected = true, + } + if name == "MOD_CUSTOM" then + data.moves[id].index = nil + end + data.pokemon.CHARMANDER.types, data.pokemon.CHARMANDER.abilities = { "NORMAL" }, {} + end + end + end + ) +end +function M.run(root, backend, version) + local source = backend == "gen1" + local f = M.fixture(root, backend, version) + local T, Copy, rows = f.T, require("src.mods.Merge").deepCopy, {} + local draws, events = {}, {} + local stop = f.e.api.events:on("battle.move_used", function(e) + events[#events + 1] = + { user = e.user.mon.nickname, target = e.target and e.target.mon.nickname, move = e.move.id } + end) + local noCrit = f.e.api.hooks:wrap("battle.crit", function() + return false + end) + local function at(b, id) + return source and b.battlers[id] or b:battlerAt(id) + end + local function fresh(double, word) + local b = f.fresh("TACKLE", { enemy = true }).battle + b.double = double + if not source then + b:syncSides() + end + if double then + for id = 2, 3 do + local mon = Copy(at(b, id % 2).mon) + if source then + b.battlers[id] = require("src.core.game3.battle.state").makeBattler( + mon, + id % 2 == 0 and "player" or "enemy", + { id = id, partyIndex = 2 } + ) + else + b:placeBattler(id, require("src.battle.BattleState").makeBattler(f.data, mon, id % 2 == 0)) + end + end + end + if not source then + b:syncSides() + end + for id = 0, double and 3 or 1 do + local a = at(b, id) + a.mon.nickname = "P" .. id + if source then + a.mon.moves, a.mon.pp, a.mon.maxPp = { 33, 227, 10, 119 }, { 10, 10, 10, 10 }, { 10, 10, 10, 10 } + a.mon.maxHp, a.ability, a.mon.ability, a.type1, a.type2 = 400, 0, 0, 0, 0 + a.mon.speed = 100 - id * 10 + else + a.mon.moves = { + { id = f.ids.TACKLE, pp = 10 }, + { id = f.ids.ENCORE, pp = 10 }, + { id = f.ids.SCRATCH, pp = 10 }, + { id = f.ids.MIRROR_MOVE, pp = 10 }, + } + a.curMoves, a.curTypes, a.abilityOverride = a.mon.moves, { "NORMAL" }, false + a.mon.stats.hp = 400 + for _, stat in ipairs({ "attack", "defense", "spatk", "spdef" }) do + a.curStats[stat] = 50 + end + a.curStats.speed = 100 - id * 10 + end + a.mon.hp = 400 + end + b.rng = function(lo, hi) + local value = lo == 0 and hi == 3 and word or hi + draws[#draws + 1] = { lo = lo, hi = hi, value = value } + return value + end + return b + end + local function number(id) + if source then + return tonumber(id) + end + return id == "UNKNOWN" and 999 + or id == f.ids.MOD_CUSTOM and 700 + or id and f.data.moves[id] and f.data.moves[id].index + end + local function view(a) + local pp = {} + for i = 1, 4 do + pp[i] = source and a.mon.pp[i] or a.mon.moves[i].pp + end + return { + turns = a[source and "expEncoreTurns" or "encoreTurns"], + move = number(a[source and "expEncoreMove" or "encoreMove"]), + slot = a[source and "expEncoreSlot" or "encoreSlot"], + hp = a.mon.hp, + pp = pp, + last = number(a[source and "lastMoveId" or "lastMove"]), + } + end + local function last(a, n) + a[source and "lastMoveId" or "lastMove"] = source and n + or n + and ({ + [33] = f.ids.TACKLE, + [10] = f.ids.SCRATCH, + [227] = f.ids.ENCORE, + [119] = f.ids.MIRROR_MOVE, + [165] = f.ids.STRUGGLE, + [999] = "UNKNOWN", + [700] = f.ids.MOD_CUSTOM, + })[n] + end + local function cast(b, uid, tid, slot, called) + local user, target = at(b, uid), at(b, tid) + if source then + local nativeSlot = slot + if called then + nativeSlot = nil + end + require("src.core.game3.battle.engine").resolveMove( + uid, + tid, + user.mon.moves[slot], + nativeSlot, + require("src.core.game3.battle.adapter").new(b), + b, + {}, + { called = called } + ) + elseif called then + b:performMove(user, target, user.curMoves[slot], true) + else + b:executeAction(user, target, user.curMoves[slot]) + end + end + local function tick(b) + if source then + require("src.core.game3.battle.engine").collectResidualEvents( + b, + require("src.core.game3.battle.adapter").new(b) + ) + else + b:endOfTurn() + end + end + local function record(b, label, uid, tid) + local field = {} + for id = 0, b.double and 3 or 1 do + field[#field + 1] = { name = at(b, id).mon.nickname, hp = at(b, id).mon.hp } + end + rows[#rows + 1] = { + label = label, + user = view(at(b, uid)), + target = view(at(b, tid)), + draws = Copy(draws), + events = Copy(events), + field = field, + } + end + for _, double in ipairs({ false, true }) do + for uid = 0, double and 3 or 1 do + local tid = uid % 2 == 0 and uid + 1 or uid - 1 + for word = 0, 3 do + for _, kind in ipairs({ + "ordinary", + "no_last", + "unknown", + "empty_pp", + "already", + "zero_counter", + "mirror", + "struggle", + "encore", + "protected", + "substitute", + "hidden", + "custom", + }) do + draws, events = {}, {} + local b = fresh(double, word) + local target = at(b, tid) + last(target, 33) + if kind == "no_last" then + last(target, nil) + end + if kind == "unknown" then + last(target, 999) + end + if kind == "mirror" then + last(target, 119) + end + if kind == "struggle" then + last(target, 165) + end + if kind == "encore" then + last(target, 227) + end + if kind == "empty_pp" then + if source then + target.mon.pp[1] = 0 + else + target.curMoves[1].pp = 0 + end + end + if kind == "custom" then + last(target, 700) + if source then + target.mon.moves[1] = 700 + else + target.curMoves[1].id = f.ids.MOD_CUSTOM + end + end + if kind == "already" or kind == "zero_counter" then + target[source and "expEncoreTurns" or "encoreTurns"] = kind == "already" and 4 or 0 + end + if kind == "protected" then + target[source and "expProtected" or "protecting"] = true + end + if kind == "substitute" then + target.substituteHP = 25 + end + if kind == "hidden" then + if source then + target.semiInvulnerable = "ON_AIR" + else + target.invulnerable, target.hiddenAs = true, "air" + end + end + cast(b, uid, tid, 2) + if kind == "ordinary" or kind == "substitute" or kind == "zero_counter" or kind == "custom" then + T.eq( + target[source and "expEncoreTurns" or "encoreTurns"], + word + 3, + "native Encore duration consumes its own four-value roll" + ) + T.eq( + target[source and "expEncoreSlot" or "encoreSlot"], + 1, + "Encore records the original move slot" + ) + end + record(b, (double and "double" or "single") .. "/" .. uid .. "/" .. word .. "/" .. kind, uid, tid) + end + end + for _, kind in ipairs({ "expiry", "pp_depletion", "slot_replaced", "slot_swapped", "forced", "called" }) do + draws, events = {}, {} + local b = fresh(double, 3) + local target = at(b, tid) + last(target, 33) + cast(b, uid, tid, 2) + record(b, kind .. "/applied/" .. uid, uid, tid) + if kind == "pp_depletion" then + if source then + target.mon.pp[1] = 0 + else + target.curMoves[1].pp = 0 + end + elseif kind == "slot_replaced" then + if source then + target.mon.moves[1] = 10 + else + target.curMoves[1].id = f.ids.SCRATCH + end + elseif kind == "slot_swapped" then + if source then + target.mon.moves[1], target.mon.moves[3] = target.mon.moves[3], target.mon.moves[1] + else + target.curMoves[1], target.curMoves[3] = target.curMoves[3], target.curMoves[1] + end + elseif kind == "forced" or kind == "called" then + cast(b, tid, uid, 3, kind == "called") + record(b, kind .. "/executed/" .. uid, uid, tid) + end + for step = 1, 6 do + tick(b) + record(b, kind .. "/residual/" .. step .. "/" .. uid, uid, tid) + end + T.eq( + target[source and "expEncoreTurns" or "encoreTurns"], + nil, + "Encore eventually clears through actual residuals" + ) + end + end + end + for uid = 0, 3 do + for _, kind in ipairs({ + "selected", + "follow_me", + "lightning_rod", + "self", + "random", + "chosen_random", + "absent_left", + "depends", + }) do + draws, events = {}, {} + local b = fresh(true, 0) + local foe = uid % 2 == 0 and uid + 1 or uid - 1 + local user = at(b, uid) + last(user, 33) + cast(b, foe, uid, 2) + local tackle = source and f.data.gen3Moves._rom[33] or f.data.moves[f.ids.TACKLE] + local scratch = source and f.data.gen3Moves._rom[10] or f.data.moves[f.ids.SCRATCH] + local oldType, oldTarget, oldChosen = tackle.type, tackle.target, scratch.target + tackle.target = kind == "self" and 16 or kind == "random" and 4 or kind == "depends" and 1 or 0 + if kind == "chosen_random" then + scratch.target = 4 + end + local opposite = uid % 2 == 0 and 1 or 0 + local right = opposite + 2 + if kind == "follow_me" then + if source then + (uid % 2 == 0 and b.enemySide or b.playerSide).expFollowMeId = right + else + b.sides[uid % 2 == 0 and 2 or 1]._gen3FollowMePosition = right + end + end + if kind == "lightning_rod" then + tackle.type = source and 13 or "ELECTRIC" + local holder = at(b, right) + if source then + holder.ability, holder.mon.ability = 31, 31 + else + holder.abilityOverride = "LIGHTNING_ROD" + end + end + if kind == "absent_left" then + at(b, opposite).mon.hp = 0 + b.absent = b.absent or {} + b.absent[opposite] = true + end + local count = 0 + b.rng = function(lo, hi) + local value = hi + if lo == 0 and hi == 3 then + count = count + 1 + value = count == 1 and uid or count == 2 and ((uid + 2) % 4) or right + end + draws[#draws + 1] = { lo = lo, hi = hi, value = value } + return value + end + cast(b, uid, foe, 3) + record(b, "retarget/" .. uid .. "/" .. kind, uid, foe) + tackle.type, tackle.target, scratch.target = oldType, oldTarget, oldChosen + end + end + for _, uid in ipairs({ 1, 3 }) do + for _, state in ipairs({ "active", "cleared_after_planning" }) do + draws, events = {}, {} + local b = fresh(true, 3) + last(at(b, uid), 33) + cast(b, 0, uid, 2) + -- This contract starts at the prepared selection boundary. The combined + -- fork owns an earlier cartridge-faithful Quick Claw word; Gen1's standalone + -- command does not prepare it. Give both engines the same explicit input. + b.randomTurnNumber, b._nativeOrderReady = 65535, true + local function cancel() + if state == "cleared_after_planning" then + local actor = at(b, uid) + actor[source and "expEncoreTurns" or "encoreTurns"] = nil + actor[source and "expEncoreMove" or "encoreMove"] = nil + actor[source and "expEncoreSlot" or "encoreSlot"] = nil + end + end + f.e.choose(function() + return { move = "SCRATCH", slot = 3, target = 2 } + end) + if source then + local action = require("src.core.game3.battle.commands").enemyAction(b, uid) + T.eq(action.move, 10, "public mod supplies the requested alternative before native planning") + local Engine = require("src.core.game3.battle.engine") + local ad = require("src.core.game3.battle.adapter").new(b) + local planned = Engine.planTurnActions(b, ad, { [uid] = action }) + cancel() + for _, row in ipairs(planned) do + if row.kind == "move" then + Engine.resolveMove(row.user, row.target, row.move, row.slot, ad, b, {}) + end + end + else + local action = b:enemyAction(at(b, uid)) + T.eq(action.id, f.ids.SCRATCH, "public mod supplies the requested alternative before native planning") + action = require("src.battle.Gen3Encore").plan(at(b, uid), action) + cancel() + b:executeAction(at(b, uid), at(b, 0), action) + end + f.e.choose(nil) + T.eq(events[#events].move, "TACKLE", "native planning enforces Encore after the public choice") + T.eq(events[#events].target, "P2", "early Encore planning retains the explicitly chosen target") + record(b, "planned_public/" .. state .. "/" .. uid, uid, 0) + rows[#rows].preparedTurnWord = b.randomTurnNumber + end + end + noCrit() + stop() + f.close() + return rows +end +return M diff --git a/tests/enemy_actions/fixture.lua b/tests/enemy_actions/fixture.lua new file mode 100644 index 000000000..378a5a1e8 --- /dev/null +++ b/tests/enemy_actions/fixture.lua @@ -0,0 +1,281 @@ +local M = {} +function M.new(root, backend, version, api, format, configure, entryOverride) + local consumer = "portable_enemy_action" + local T = require("tests.modkit") + local V = require("src.core.GameVersion") + assert(V.VERSIONS[version], "unregistered version") + V.set(version) + local gen = V.generation() + local rows = { + { "STRUGGLE", 165, 50, "NORMAL", "RECOIL_EFFECT", "EFFECT_RECOIL_HIT", 48 }, + { "MIMIC", 102, 0, "NORMAL", "MIMIC_EFFECT", "EFFECT_MIMIC", 82 }, + { "SCRATCH", 10, 40, "NORMAL", "NO_ADDITIONAL_EFFECT", "EFFECT_NORMAL_HIT", 0 }, + { "FLY", 19, 70, "FLYING", "FLY_EFFECT", "EFFECT_FLY", 155 }, + { "TACKLE", 33, 35, "NORMAL", "NO_ADDITIONAL_EFFECT", "EFFECT_NORMAL_HIT", 0 }, + } + local data = backend == "gen1" and gen == 3 and T.sdk.gen3Data() or T.fixtures.fresh() + assert(gen == 3, "enemy action fixture requires GBA") + if backend ~= "gen1" then + local mon = require("src.mods.Merge").deepCopy(data.pokemon.FIXMON_A) + mon.id, mon.name, mon.dex, mon.index, mon.evolutions = "CHARMANDER", "CHARMANDER", 4, 4, {} + mon.types = { "NORMAL" } + data.pokemon.CHARMANDER = mon + end + local ids, rom, names = {}, {}, {} + for _, row in ipairs(rows) do + local name, number, power, kind, gbEffect, g2Effect, g3Effect = unpack(row) + local id = backend == "gen2" and gen == 3 and ("MOVE_%03d"):format(number) or name + ids[name], names[number] = id, name + local effect = gen > 1 and name == "SOLARBEAM" and "SOLARBEAM_EFFECT" or gbEffect + if backend == "gen1" and gen == 3 then + data.gen3Moves._rom[number] = { + effect = g3Effect, + power = power, + type = ({ NORMAL = 0, FLYING = 2, GROUND = 4, GRASS = 12 })[kind], + accuracy = 100, + pp = 10, + secondaryChance = 0, + target = 0, + priority = 0, + flags = 51, + } + data.gen3Pokemon._moveNames[number] = name + rom[number] = data.gen3Moves._rom[number] + else + data.moves[id] = { + id = id, + index = number, + name = name, + power = power, + type = kind, + accuracy = 100, + accuracyRaw = 255, + pp = 10, + effect = effect, + effectChance = name == "SKY_ATTACK" and 30 or 0, + effectChanceRaw = name == "SKY_ATTACK" and 77 or 0, + } + end + end + if backend == "gen2" then + data.moves.STRUGGLE = data.moves[ids.STRUGGLE] + end + if configure then + configure(data, backend, ids, rom, names) + end + local files, Json = {}, require("src.link.Json") + for _, name in ipairs({ "main.lua", "manifest.json" }) do + local f = assert(io.open(root .. "/examples/" .. consumer .. "/" .. name, "rb")) + files["mods/" .. consumer .. "/" .. name] = f:read("*a") + f:close() + end + -- Effect-composition probes must patch content during this same load. + if entryOverride then files["mods/" .. consumer .. "/main.lua"] = entryOverride end + local manifest = Json.decode(files["mods/" .. consumer .. "/manifest.json"]) + manifest.api, manifest.callback_format, manifest.gen2compat = api, format, api < 3 and true or nil + files["mods/" .. consumer .. "/manifest.json"] = Json.encode(manifest) + local result = T.sdk.loadMods( + { "mods/" .. consumer }, + { data = data, fs = T.sdk.memfs(files), generation = gen, dev = false } + ) + assert(#result.errors == 0, table.concat(result.errors, "\n")) + local game = { + data = data, + save = { + party = {}, + options = {}, + player = { name = "PROBE" }, + flags = {}, + inventory = {}, + pokedex = { seen = {}, caught = {} }, + defeatedTrainers = {}, + }, + stack = setmetatable({}, { __index = require("src.core.StateStack") }), + input = { + wasPressed = function() + return false + end, + isDown = function() + return false + end, + }, + } + game.stack:init() + result.loader.game = game + local restores = {} + local function patch(owner, key, value) + local old = owner[key] + restores[#restores + 1] = function() + owner[key] = old + end + owner[key] = value + end + 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, "_romMoveNames", names) + patch(P, "_battleMoves", rom) + local Moves = require("src.core.game3.battle.moves") + patch(Moves, "_rom", rom) + patch(Moves, "_romLoaded", true) + patch(Moves, "BY_NUM", names) + local byName = {} + for number, name in pairs(names) do + byName[name] = number + end + patch(Moves, "_numByName", byName) + patch(require("src.core.game3.scripting.space"), "bundle", { + text = setmetatable({}, { + __index = function(t, key) + local ir = { { t = "text", s = tostring(key) } } + rawset(t, key, ir) + return ir + end, + }), + }) + elseif not (backend == "gen1" and gen == 2) then + require("src.render.Font").load(data) + require("src.battle.TypeChart").load(data) + end + local function fresh(name, opts) + opts = opts or {} + local id = ids[name] + local b, user, target, move, use, hp, pp, charging, hidden + if backend == "gen1" and gen == 3 then + local State, Adapter = require("src.core.game3.battle.state"), require("src.core.game3.battle.adapter") + local n = data.gen3Pokemon._moveNames + local number + for k, v in pairs(n) do + if v == name then + number = k + break + end + end + local function mon() + return { + species = 4, + nickname = "PROBE", + level = 30, + hp = 400, + maxHp = 400, + attack = 50, + defense = 50, + speed = 50, + spAtk = 50, + spDef = 50, + ability = 0, + moves = { number }, + pp = { 10 }, + maxPp = { 10 }, + item = 0, + } + end + b = State.new({ + wild = true, + playerParty = { mon() }, + foeMon = mon(), + weather = opts.sun and "SUN" or nil, + }) + b.rng = function(lo) + return lo or 0 + end + user, target = opts.enemy and b.enemy or b.player, opts.enemy and b.player or b.enemy + local ad = Adapter.new(b) + use = function(called) + local slot = 1 + if called then + slot = nil + end + return require("src.core.game3.battle.engine").resolveMove( + user, + target, + number, + slot, + ad, + b, + {}, + { called = called } + ) + end + hp = function() + return ad:hp(target) + end + pp = function() + return user.mon.pp[1] + end + charging = function() + return user.twoTurnMove ~= nil + end + hidden = function() + return user.semiInvulnerable ~= nil + end + else + local P = require("src.pokemon.Pokemon") + local p = P.new(data, "CHARMANDER", 30, function(lo) + return lo or 0 + end) + p.hp, p.stats.hp, p.moves = 400, 400, { { id = id, pp = 10 } } + game.save.party = { p } + b = require("src.battle.BattleState").newWild(game, "CHARMANDER", 30) + b.player.mon.hp, b.player.mon.stats.hp, b.enemy.mon.hp, b.enemy.mon.stats.hp = 400, 400, 400, 400 + b.rng = function(lo) + return lo or 0 + end + if opts.sun then + b.field.weather = "SUN" + end + user, target = opts.enemy and b.enemy or b.player, opts.enemy and b.player or b.enemy + move = { id = id, pp = 10 } + user.mon.moves, user.curMoves = { move }, { move } + use = function(called) + return b:performMove(user, target, move, called) + end + hp = function() + return target.mon.hp + end + pp = function() + return move.pp + end + charging = function() + return user.charging ~= nil + end + hidden = function() + return user.invulnerable == true + end + end + return { + battle = b, + user = user, + target = target, + move = move, + use = use, + hp = hp, + pp = pp, + charging = charging, + hidden = hidden, + } + end + return { + T = T, + e = result.loader.exports[consumer], + result = result, + data = data, + game = game, + gen = gen, + backend = backend, + api = api, + format = format, + ids = ids, + fresh = fresh, + close = function() + for i = #restores, 1, -1 do + restores[i]() + end + result.release() + end, + } +end +return M diff --git a/tests/enemy_actions/follow_me.lua b/tests/enemy_actions/follow_me.lua new file mode 100644 index 000000000..6386e32fc --- /dev/null +++ b/tests/enemy_actions/follow_me.lua @@ -0,0 +1,362 @@ +local M = {} +function M.run(root, backend, version) + local function configure(data, engine, ids, rom, names) + for _, row in ipairs({ + { "FOLLOW_ME", 266, 172, 0, 16, 3 }, + { "THUNDERSHOCK", 84, 0, 40, 0, 0 }, + { "TARGET_BOTH", 600, 0, 40, 8, 0 }, + }) do + local name, number, effect, power, target, priority = unpack(row) + local key = engine == "gen1" and name or ("MOVE_%03d"):format(number) + ids[name], names[number] = key, name + if engine == "gen1" then + local def = { + effect = effect, + power = power, + target = target, + priority = priority, + type = number == 84 and 13 or 0, + accuracy = 100, + pp = 20, + flags = 0, + secondaryChance = 0, + } + data.gen3Moves._rom[number], rom[number], data.gen3Pokemon._moveNames[number] = def, def, name + else + data.moves[key] = { + id = key, + name = name, + index = number, + gen3Effect = effect, + protectAffected = false, + effect = number == 266 and "FOLLOW_ME_EFFECT" or "NO_ADDITIONAL_EFFECT", + power = power, + target = target, + priority = priority, + type = number == 84 and "ELECTRIC" or "NORMAL", + accuracy = 100, + accuracyRaw = 255, + pp = 20, + } + end + end + end + local f = + dofile(root .. "/tests/enemy_actions/fixture.lua").new(root, backend, version, 3, "unified", configure) + local T, rows, moves, hits = f.T, {}, {}, {} + local stopMove = f.e.api.events:on("battle.move_used", function(e) + moves[#moves + 1] = { user = e.user.mon.nickname, target = e.target.mon.nickname, move = e.move.id } + end) + local stopHit = f.e.api.events:on("battle.damage_dealt", function(e) + hits[#hits + 1] = e.target.mon.nickname + end) + local function at(b, id) + return backend == "gen1" and b.battlers[id] or b:battlerAt(id) + end + local function actor(mon, id) + if backend == "gen1" then + return require("src.core.game3.battle.state").makeBattler( + mon, + id % 2 == 0 and "player" or "enemy", + { id = id } + ) + end + return require("src.battle.BattleState").makeBattler(f.data, mon, id % 2 == 0) + end + local function put(b, id, who) + if backend == "gen1" then + b.battlers[id] = who + else + b:placeBattler(id, who) + end + end + local function fresh(double) + local b, copy = f.fresh("TACKLE", { enemy = true }).battle, require("src.mods.Merge").deepCopy + b.double = double + if double then + put(b, 2, actor(copy(b.player.mon), 2)) + put(b, 3, actor(copy(b.enemy.mon), 3)) + end + if backend == "gen2" then + b:syncSides() + end + for id = 0, double and 3 or 1 do + local a = at(b, id) + a.mon.nickname, a.mon.hp = "P" .. id, 400 + if backend == "gen1" then + a.mon.moves, a.mon.pp, a.mon.maxPp = { 266, 33, 84, 600 }, { 20, 10, 10, 10 }, { 20, 10, 10, 10 } + a.ability, a.mon.ability, a.mon.speed = 0, 0, 400 - id * 50 + else + a.curMoves = { + { id = f.ids.FOLLOW_ME, pp = 20 }, + { id = f.ids.TACKLE, pp = 10 }, + { id = f.ids.THUNDERSHOCK, pp = 10 }, + { id = f.ids.TARGET_BOTH, pp = 10 }, + } + a.mon.moves, a.abilityOverride = a.curMoves, false + a.curStats.speed = 400 - id * 50 + end + end + return b + end + local function pp(b, id, slot) + local a = at(b, id) + return backend == "gen1" and a.mon.pp[slot] or a.curMoves[slot].pp + end + local function rod(b, id) + local a = at(b, id) + if backend == "gen1" then + a.ability, a.mon.ability = 31, 31 + else + a.abilityOverride = "LIGHTNING_ROD" + end + end + local function cast(b, id) + if backend == "gen1" then + require("src.core.game3.battle.engine").resolveMove( + id, + id, + 266, + 1, + require("src.core.game3.battle.adapter").new(b), + b, + {} + ) + else + b:executeAction(at(b, id), at(b, id), at(b, id).curMoves[1]) + end + end + local function attack(b, id, target, name) + name = name or "TACKLE" + local slot = name == "FOLLOW_ME" and 1 or name == "THUNDERSHOCK" and 3 or name == "TARGET_BOTH" and 4 or 2 + f.e.choose(function() + return { move = name, target = target } + end) + if backend == "gen1" then + local C = require("src.core.game3.battle.commands") + local action = id % 2 == 0 and C.playerAction(b, 1, slot, id, target) or C.enemyAction(b, id) + require("src.core.game3.battle.engine").resolveMove( + id, + action.target, + action.move, + action.slot, + require("src.core.game3.battle.adapter").new(b), + b, + {} + ) + else + local action = id % 2 == 0 and at(b, id).curMoves[slot] or b:enemyAction(at(b, id)) + if id % 2 == 0 then + b.chosenTargets = { [id] = target and at(b, target) } + end + b:executeAction(at(b, id), at(b, target or (id % 2 == 0 and 1 or 0)), action) + end + end + local specs = { + { name = "left", casts = { 0 }, user = 1, target = 2, expected = "P0" }, + { name = "right", casts = { 2 }, user = 1, target = 0, expected = "P2" }, + { name = "right-attacker", casts = { 0 }, user = 3, target = 2, expected = "P0" }, + { name = "default-target", casts = { 2 }, user = 1, expected = "P2" }, + { name = "last-caster", casts = { 0, 2 }, user = 1, target = 0, expected = "P2" }, + { name = "last-caster-reversed", casts = { 2, 0 }, user = 1, target = 2, expected = "P0" }, + { + name = "lightning-priority", + casts = { 0 }, + user = 1, + target = 2, + move = "THUNDERSHOCK", + rod = 2, + expected = "P0", + }, + { name = "enemy-center-left", casts = { 1 }, user = 0, target = 3, expected = "P1" }, + { name = "enemy-center-right", casts = { 3 }, user = 2, target = 1, expected = "P3" }, + { + name = "self-not-redirected", + casts = { 0 }, + user = 1, + target = 1, + move = "FOLLOW_ME", + expected = "P1", + status = true, + }, + { name = "both-sides", casts = { 0, 3 }, user = 1, target = 2, expected = "P0" }, + { name = "fainted-caster", casts = { 0 }, user = 1, target = 2, faint = 0, expected = "P2" }, + { + name = "fainted-timer-before-rod", + casts = { 0 }, + user = 1, + target = 3, + faint = 0, + move = "THUNDERSHOCK", + rod = 2, + expected = "P3", + }, + { name = "replaced-caster-slot", casts = { 0 }, user = 1, target = 2, replace = 0, expected = "P0_NEXT" }, + { + name = "spread-not-redirected", + casts = { 0 }, + user = 1, + target = 2, + move = "TARGET_BOTH", + expected = "P2", + spread = true, + }, + { name = "sleep-refusal", casts = { 0 }, user = 1, target = 2, expected = "P2", sleep = true }, + { name = "substitute-protect", casts = { 0 }, user = 1, target = 2, expected = "P0", protected = true }, + { name = "single", casts = { 0 }, user = 1, target = 0, expected = "P0", single = true }, + } + for _, spec in ipairs(specs) do + local b = fresh(not spec.single) + moves, hits = {}, {} + if spec.sleep then + b.player.mon.status, b.player.sleepTurns = "SLP", 5 + end + if spec.protected then + b.player.substituteHP, b.player.protecting = 40, true + b.enemy.protecting = true + end + for _, id in ipairs(spec.casts) do + cast(b, id) + T.eq( + pp(b, id, 1), + spec.sleep and 20 or 19, + backend .. "/" .. spec.name .. " actual Follow Me PP follows move execution" + ) + end + if spec.protected then + b.player.substituteHP, b.player.protecting, b.enemy.protecting = nil, nil, nil + end + if spec.rod then + rod(b, spec.rod) + end + if spec.faint then + at(b, spec.faint).mon.hp = 0 + if backend == "gen1" then + b.absent[spec.faint] = true + end + end + if spec.replace then + local mon = require("src.mods.Merge").deepCopy(at(b, spec.replace).mon) + mon.nickname = "P0_NEXT" + put(b, spec.replace, actor(mon, spec.replace)) + end + moves, hits = {}, {} + attack(b, spec.user, spec.target, spec.move) + local label = backend .. "/" .. version .. "/" .. spec.name + T.eq(moves[1] and moves[1].target, spec.expected, label .. " native target honors Follow Me") + T.same( + hits, + spec.status and {} or spec.spread and { "P2", "P0" } or { spec.expected }, + label .. " actual damage reaches native targets" + ) + local slot = spec.move == "FOLLOW_ME" and 1 or spec.move == "THUNDERSHOCK" and 3 or spec.spread and 4 or 2 + T.eq( + pp(b, spec.user, slot), + spec.move == "FOLLOW_ME" and 19 or 9, + label .. " actual attack spends own PP once" + ) + rows[#rows + 1] = { case = spec.name, moves = moves, hits = hits, pp = pp(b, spec.user, slot) } + end + for _, residual in ipairs({ false, true }) do + local b = fresh(true) + moves, hits = {}, {} + cast(b, 0) + if residual then + if backend == "gen1" then + require("src.core.game3.battle.engine").collectResidualEvents( + b, + require("src.core.game3.battle.adapter").new(b) + ) + else + b:endOfTurn() + end + end + moves, hits = {}, {} + attack(b, 1, 2) + T.same(hits, { "P0" }, "Follow Me remains active through the current round's residuals") + local first = moves + if backend == "gen1" then + require("src.core.game3.battle.engine").planTurnActions( + b, + require("src.core.game3.battle.adapter").new(b), + { + [0] = { kind = "watch" }, + [1] = { kind = "watch" }, + [2] = { kind = "watch" }, + [3] = { kind = "watch" }, + } + ) + else + f.e.choose(function(_, _, id) + return { move = "TACKLE", target = id == 1 and 2 or 0 } + end) + b.queue, b.pendingActions = {}, { [2] = b:battlerAt(2).curMoves[2] } + b:resolveTurn(b.player.curMoves[2]) + end + moves, hits = {}, {} + attack(b, 1, 2) + T.same(hits, { "P2" }, "native next-turn planning clears the expired Follow Me position") + T.eq(pp(b, 1, 2), 8, "two real attacks consume two PP across round boundaries") + rows[#rows + 1] = { + case = residual and "residual-expiry" or "turn-expiry", + before = first, + after = moves, + hits = hits, + pp = pp(b, 1, 2), + } + end + + local b = fresh(true) + moves, hits = {}, {} + if backend == "gen1" then + b.player.mon.speed, b.enemy.mon.speed, at(b, 3).mon.speed = 50, 900, 800 + else + b.player.curStats.speed, b.enemy.curStats.speed, at(b, 3).curStats.speed = 50, 900, 800 + end + f.e.choose(function() + return { move = "TACKLE", target = 2 } + end) + moves, hits = {}, {} + if backend == "gen1" then + local C, E, A = + require("src.core.game3.battle.commands"), + require("src.core.game3.battle.engine"), + require("src.core.game3.battle.adapter") + local ad = A.new(b) + local actions = E.planTurnActions(b, ad, { + [0] = C.playerAction(b, 1, 1, 0, 0), + [2] = { kind = "watch" }, + [1] = C.enemyAction(b, 1), + [3] = C.enemyAction(b, 3), + }) + for _, a in ipairs(actions) do + if a.kind == "move" then + E.resolveMove(a.user, a.target, a.move, a.slot, ad, b, {}) + end + end + else + b.queue, b.pendingActions = {}, { [2] = { special = "recharge" } } + b:resolveTurn(b.player.curMoves[1]) + local count = 0 + while b.queue[1] and count < 1000 do + count = count + 1 + local row = table.remove(b.queue, 1) + if row.fn then + b.nextInsert = 0 + row.fn() + end + end + T.check(count < 1000, "real Follow Me turn queue completes") + end + T.same(hits, { "P0", "P0" }, "native Follow Me priority beats both faster foes") + T.eq(moves[1] and moves[1].move, "FOLLOW_ME", "native priority executes the real Follow Me producer first") + T.eq(pp(b, 0, 1), 19, "queued Follow Me spends one caster PP") + T.same({ pp(b, 1, 2), pp(b, 3, 2) }, { 9, 9 }, "both redirected queued attacks spend owned PP") + rows[#rows + 1] = + { case = "priority-queue", moves = moves, hits = hits, pp = { pp(b, 0, 1), pp(b, 1, 2), pp(b, 3, 2) } } + stopHit() + stopMove() + f.close() + return rows +end +return M diff --git a/tests/enemy_actions/helping_hand.lua b/tests/enemy_actions/helping_hand.lua new file mode 100644 index 000000000..68947106c --- /dev/null +++ b/tests/enemy_actions/helping_hand.lua @@ -0,0 +1,440 @@ +local M = {} +function M.run(root, backend, version) + local native = backend == "gen1" + local extra = { + { "HELPING_HAND", 270, 176, "HELPING_HAND_EFFECT", 0, 16, 5, "NORMAL", 0 }, + { "THUNDERSHOCK", 84, 0, "NO_ADDITIONAL_EFFECT", 40, 0, 0, "ELECTRIC", 13 }, + { "TARGET_BOTH", 600, 0, "NO_ADDITIONAL_EFFECT", 35, 8, 0, "NORMAL", 0 }, + { "DRAGON_RAGE", 82, 41, "SPECIAL_DAMAGE_EFFECT", 1, 0, 0, "DRAGON", 16 }, + } + local function configure(data, engine, ids, rom, names) + for _, row in ipairs(extra) do + local name, number, effect, handler, power, target, priority, kind, typeId = unpack(row) + local key = engine == "gen1" and name or ("MOVE_%03d"):format(number) + ids[name], names[number] = key, name + if engine == "gen1" then + local def = { + effect = effect, + power = power, + target = target, + priority = priority, + type = typeId, + accuracy = 100, + pp = 20, + flags = power == 0 and 0 or 51, + secondaryChance = 0, + } + data.gen3Moves._rom[number], rom[number], data.gen3Pokemon._moveNames[number] = def, def, name + else + data.moves[key] = { + id = key, + index = number, + name = name, + gen3Effect = effect, + effect = handler, + power = power, + target = target, + priority = priority, + type = kind, + accuracy = 100, + accuracyRaw = 255, + pp = 20, + protectAffected = power ~= 0, + fixedDamage = name == "DRAGON_RAGE" and 40 or nil, + } + end + end + if engine == "gen2" then + data.type_chart.types = {} + data.type_chart.matchups = { + { attacker = "NORMAL", defender = "STEEL", multiplier = 5 }, + { attacker = "NORMAL", defender = "GHOST", multiplier = 0 }, + } + for name, id in pairs({ NORMAL = 0, GHOST = 7, STEEL = 8, FIRE = 10, ELECTRIC = 13, DRAGON = 16 }) do + data.type_chart.types[name] = + { id = name, index = id, category = id <= 8 and "physical" or "special" } + end + end + end + local f = + dofile(root .. "/tests/enemy_actions/fixture.lua").new(root, backend, version, 3, "unified", configure) + local T, rows, moves, hits, critical, roll = f.T, {}, {}, {}, false, 100 + local stopCrit = f.e.api.hooks:wrap("battle.crit", function() + return critical + end) + local stopMove = f.e.api.events:on("battle.move_used", function(e) + moves[#moves + 1] = { user = e.user.mon.nickname, target = e.target.mon.nickname, move = e.move.id } + end) + local stopHit = f.e.api.events:on("battle.damage_dealt", function(e) + hits[#hits + 1] = { target = e.target.mon.nickname, amount = e.damage } + end) + local function at(b, id) + if native then + return b.battlers[id] + end + return b:battlerAt(id) + end + local function actor(mon, id) + if native then + return require("src.core.game3.battle.state").makeBattler( + mon, + id % 2 == 0 and "player" or "enemy", + { id = id } + ) + end + return require("src.battle.BattleState").makeBattler(f.data, mon, id % 2 == 0) + end + local function put(b, id, who) + if native then + b.battlers[id] = who + else + b:placeBattler(id, who) + end + end + local function fresh(double) + local b, copy = f.fresh("TACKLE", { enemy = true }).battle, require("src.mods.Merge").deepCopy + b.double = double ~= false + if b.double then + put(b, 2, actor(copy(b.player.mon), 2)) + put(b, 3, actor(copy(b.enemy.mon), 3)) + end + if not native then + b:syncSides() + end + for id = 0, b.double and 3 or 1 do + local a = at(b, id) + a.mon.nickname, a.mon.hp, a.mon.level = "P" .. id, 10000, 30 + if native then + a.mon.maxHp, a.mon.moves, a.mon.pp, a.mon.maxPp = + 10000, { 270, 33, 84, 600 }, { 20, 10, 10, 10 }, { 20, 10, 10, 10 } + for _, key in ipairs({ "attack", "defense", "speed", "spAtk", "spDef" }) do + a.mon[key] = 50 + end + a.ability, a.mon.ability, a.type1, a.type2 = 0, 0, 0, 0 + else + a.curStats = + { hp = 10000, attack = 50, defense = 50, speed = 50, special = 50, spatk = 50, spdef = 50 } + a.mon.stats, a.curTypes, a.abilityOverride = a.curStats, { "NORMAL" }, false + a.curMoves = { + { id = f.ids.HELPING_HAND, pp = 20 }, + { id = f.ids.TACKLE, pp = 10 }, + { id = f.ids.THUNDERSHOCK, pp = 10 }, + { id = f.ids.TARGET_BOTH, pp = 10 }, + } + a.mon.moves = a.curMoves + end + end + b.rng = function(lo, hi) + if lo == 85 and hi == 100 then + return roll + end + return lo or 0 + end + moves, hits = {}, {} + return b + end + local function typed(a, kind) + if native then + a.type1, a.type2 = + ({ NORMAL = 0, STEEL = 8, GHOST = 7, FIRE = 10, ELECTRIC = 13 })[kind], + ({ NORMAL = 0, STEEL = 8, GHOST = 7, FIRE = 10, ELECTRIC = 13 })[kind] + else + a.curTypes = { kind } + end + end + local function pp(b, id, slot) + return native and at(b, id).mon.pp[slot] or at(b, id).curMoves[slot].pp + end + local function active(b, id) + return native and at(b, id).expHelpingHand == true or not native and at(b, id)._gen3HelpingHand == true + end + local function cast(b, id) + if native then + require("src.core.game3.battle.engine").resolveMove( + id, + id, + 270, + 1, + require("src.core.game3.battle.adapter").new(b), + b, + {} + ) + else + b:executeAction(at(b, id), at(b, id), at(b, id).curMoves[1]) + end + end + local slots, numbers = + { TACKLE = 2, THUNDERSHOCK = 3, TARGET_BOTH = 4, DRAGON_RAGE = 4 }, + { TACKLE = 33, THUNDERSHOCK = 84, TARGET_BOTH = 600, DRAGON_RAGE = 82 } + local function attack(b, id, target, name) + name = name or "TACKLE" + if name == "DRAGON_RAGE" then + if native then + at(b, id).mon.moves[4] = 82 + else + at(b, id).curMoves[4].id = f.ids.DRAGON_RAGE + end + end + f.e.choose(function() + return { move = name, target = target } + end) + if native then + local C = require("src.core.game3.battle.commands") + local action = id % 2 == 0 and C.playerAction(b, 1, slots[name], id, target) or C.enemyAction(b, id) + require("src.core.game3.battle.engine").resolveMove( + id, + action.target, + action.move, + action.slot, + require("src.core.game3.battle.adapter").new(b), + b, + {} + ) + else + local action = id % 2 == 0 and at(b, id).curMoves[slots[name]] or b:enemyAction(at(b, id)) + if id % 2 == 0 then + b.chosenTargets = { [id] = at(b, target) } + end + b:executeAction(at(b, id), at(b, target), action) + end + end + for caster = 0, 3 do + local partner, target = (caster + 2) % 4, caster % 2 == 0 and 1 or 0 + for _, crit in ipairs({ false, true }) do + critical = crit + for _, stab in ipairs({ false, true }) do + for _, kind in ipairs({ "NORMAL", "STEEL", "GHOST" }) do + for _, variation in ipairs({ 85, 100 }) do + roll = variation + for _, name in ipairs({ "TACKLE", "THUNDERSHOCK", "TARGET_BOTH", "DRAGON_RAGE" }) do + local amounts = {} + for _, helped in ipairs({ false, true }) do + local b = fresh() + typed(at(b, partner), stab and (name == "THUNDERSHOCK" and "ELECTRIC" or "NORMAL") or "FIRE") + typed(at(b, target), kind) + if helped then + cast(b, caster) + end + T.eq(active(b, partner), helped, "actual native effect only supports the partner") + local before = at(b, target).mon.hp + moves, hits = {}, {} + attack(b, partner, target, name) + local amount = before - at(b, target).mon.hp + T.eq( + pp(b, partner, slots[name]), + 9, + "actual supported move spends owned PP once: " .. caster .. "/" .. name .. "/" .. kind + ) + T.eq(pp(b, caster, 1), helped and 19 or 20, "actual helper spends PP only when cast") + amounts[#amounts + 1] = amount + rows[#rows + 1] = { + case = "damage", + caster = caster, + critical = crit, + stab = stab, + kind = kind, + roll = variation, + move = name, + helped = helped, + amount = amount, + moves = moves, + hits = hits, + pp = pp(b, partner, slots[name]), + } + end + if name == "DRAGON_RAGE" then + T.same(amounts, { 40, 40 }, "fixed damage is never boosted") + elseif kind ~= "GHOST" or name == "THUNDERSHOCK" then + T.check(amounts[2] >= amounts[1], "Helping Hand never reduces an ordinary hit") + end + end + end + end + end + end + end + critical, roll = false, 100 + for _, spec in ipairs({ + { name = "single", single = true }, + { name = "absent", absent = true }, + { name = "fainted", fainted = true }, + { name = "sleep", sleep = true }, + { name = "protected-substitute", protected = true, supported = true }, + { name = "repeat", repeatCast = true, supported = true }, + { name = "reverse", reverse = true, supported = true }, + }) do + local b = fresh(not spec.single) + if spec.absent then + put(b, 2, nil) + if native then + b.absent[2] = true + end + end + if spec.fainted then + at(b, 2).mon.hp = 0 + if native then + b.absent[2] = true + end + end + if spec.sleep then + b.player.mon.status, b.player.sleepTurns = "SLP", 5 + end + if spec.protected then + b.player.protecting, b.player.substituteHP, at(b, 2).protecting, at(b, 2).substituteHP = + true, 40, true, 40 + end + cast(b, 0) + if spec.repeatCast then + cast(b, 0) + end + if spec.reverse then + cast(b, 2) + end + local flags = {} + for id = 0, b.double and 3 or 1 do + flags[#flags + 1] = at(b, id) and active(b, id) or false + end + T.eq(flags[3] or false, spec.supported or false, spec.name .. " native effect success gate") + T.eq(flags[1], false, spec.name .. " never boosts the caster") + T.eq( + pp(b, 0, 1), + spec.sleep and 20 or spec.repeatCast and 18 or 19, + spec.name .. " native PP on refusal or repetition" + ) + rows[#rows + 1] = { case = spec.name, flags = flags, helperPP = pp(b, 0, 1), moves = moves, hits = hits } + end + for _, residual in ipairs({ false, true }) do + local b = fresh() + cast(b, 0) + if residual then + if native then + require("src.core.game3.battle.engine").collectResidualEvents( + b, + require("src.core.game3.battle.adapter").new(b) + ) + else + b:endOfTurn() + end + end + T.eq(active(b, 2), true, "support remains through the round and residual collection") + if native then + require("src.core.game3.battle.engine").planTurnActions( + b, + require("src.core.game3.battle.adapter").new(b), + { + [0] = { kind = "watch" }, + [1] = { kind = "watch" }, + [2] = { kind = "watch" }, + [3] = { kind = "watch" }, + } + ) + else + f.e.choose(function() + return { move = "TACKLE", target = 0 } + end) + b.queue, b.pendingActions = {}, { [2] = at(b, 2).curMoves[2] } + b:resolveTurn(b.player.curMoves[2]) + end + T.eq(active(b, 2), false, "next native planning clears support before execution") + moves, hits = {}, {} + attack(b, 2, 1) + rows[#rows + 1] = + { case = residual and "residual-expiry" or "turn-expiry", hits = hits, pp = pp(b, 2, 2) } + end + do + local b = fresh() + cast(b, 0) + if native then + at(b, 2).confusionTurns = 3 + else + at(b, 2).confusedTurns = 3 + end + local before = at(b, 2).mon.hp + moves, hits = {}, {} + attack(b, 2, 1) + local amount = before - at(b, 2).mon.hp + T.eq(amount, 13, "Helping Hand does not boost a native confusion self-hit") + T.eq(pp(b, 2, 2), 10, "confusion refusal spends no move PP") + rows[#rows + 1] = { case = "confusion", amount = amount, pp = pp(b, 2, 2), moves = moves, hits = hits } + end + do + local b = fresh() + cast(b, 0) + moves, hits = {}, {} + attack(b, 2, 1) + attack(b, 2, 1) + T.same( + hits, + { { target = "P1", amount = 24 }, { target = "P1", amount = 24 } }, + "support applies to every native attack until the next planning boundary" + ) + T.eq(pp(b, 2, 2), 8, "two supported actions spend two PP") + rows[#rows + 1] = { case = "two-actions", hits = hits, pp = pp(b, 2, 2) } + end + do + local b = fresh() + if native then + b.player.mon.speed, b.enemy.mon.speed, at(b, 3).mon.speed = 1, 900, 800 + else + b.player.curStats.speed, b.enemy.curStats.speed, at(b, 3).curStats.speed = 1, 900, 800 + end + f.e.choose(function() + return { move = "TACKLE", target = 0 } + end) + moves, hits = {}, {} + if native then + local C, E, A = + require("src.core.game3.battle.commands"), + require("src.core.game3.battle.engine"), + require("src.core.game3.battle.adapter") + local ad = A.new(b) + local actions = E.planTurnActions(b, ad, { + [0] = C.playerAction(b, 1, 1, 0, 0), + [2] = C.playerAction(b, 1, 2, 2, 1), + [1] = C.enemyAction(b, 1), + [3] = C.enemyAction(b, 3), + }) + for _, a in ipairs(actions) do + if a.kind == "move" then + E.resolveMove(a.user, a.target, a.move, a.slot, ad, b, {}) + end + end + else + b.queue, b.pendingActions = {}, { [2] = at(b, 2).curMoves[2] } + b:resolveTurn(b.player.curMoves[1]) + local count = 0 + while b.queue[1] and count < 1000 do + count = count + 1 + local row = table.remove(b.queue, 1) + if row.fn then + b.nextInsert = 0 + row.fn() + end + end + T.check(count < 1000, "actual native Helping Hand priority queue settles") + end + T.eq( + moves[1] and moves[1].move, + "HELPING_HAND", + "priority five supports the partner before faster opponents" + ) + T.same( + hits, + { { target = "P0", amount = 16 }, { target = "P0", amount = 16 }, { target = "P1", amount = 24 } }, + "real queue applies support exactly once at native damage calculation" + ) + rows[#rows + 1] = { case = "priority-queue", moves = moves, hits = hits, pp = pp(b, 2, 2) } + end + local b = fresh() + cast(b, 0) + local mon = require("src.mods.Merge").deepCopy(at(b, 2).mon) + put(b, 2, actor(mon, 2)) + T.eq(active(b, 2), false, "replacement battler does not inherit support") + rows[#rows + 1] = { case = "replacement", active = active(b, 2) } + stopHit() + stopMove() + stopCrit() + f.close() + return rows +end +return M diff --git a/tests/enemy_actions/lock_on.lua b/tests/enemy_actions/lock_on.lua new file mode 100644 index 000000000..6ded2c731 --- /dev/null +++ b/tests/enemy_actions/lock_on.lua @@ -0,0 +1,429 @@ +-- Real move producers, repeated accuracy commands, residual sweeps and switches. +local M = {} +function M.fixture(root, backend, version) + local native = backend == "gen1" + local function configure(data, _, ids, rom, names) + for _, row in ipairs({ { 199, "LOCK_ON", 94 }, { 170, "MIND_READER", 94 }, { 90, "FISSURE", 38 } }) do + local n, name, effect = unpack(row) + local id = native and name or ("MOVE_%03d"):format(n) + ids[name], names[n] = id, name + if native then + local d = { + effect = effect, + type = n == 90 and 4 or 0, + accuracy = n == 90 and 30 or 100, + power = 0, + pp = 20, + target = 0, + flags = 2, + priority = 0, + secondaryChance = 0, + } + data.gen3Moves._rom[n], rom[n], data.gen3Pokemon._moveNames[n] = d, d, name + else + data.moves[id] = { + id = id, + index = n, + name = name, + gen3Effect = effect, + power = 0, + type = n == 90 and "GROUND" or "NORMAL", + accuracy = n == 90 and 30 or 100, + pp = 20, + target = 0, + protectAffected = true, + effect = n == 90 and "OHKO_EFFECT" or "LOCK_ON_EFFECT", + } + end + end + end + local f = + dofile(root .. "/tests/enemy_actions/fixture.lua").new(root, backend, version, 3, "unified", configure) + return f +end +function M.run(root, backend, version) + local native = backend == "gen1" + local f = M.fixture(root, backend, version) + local T, Copy, rows = f.T, require("src.mods.Merge").deepCopy, {} + local calls, draws, moves, policy = {}, {}, {}, "delegate" + local damageCalls = 0 + local stopDamage = f.e.api.hooks:wrap("battle.damage", function(nextDamage, ...) + damageCalls = damageCalls + 1 + return nextDamage(...) + end) + local stop = f.e.api.hooks:wrap("battle.accuracy", function(nextAccuracy, c) + calls[#calls + 1] = { + move = c.moveId, + number = c.moveNum, + user = c.user.mon.nickname, + target = c.target.mon.nickname, + accuracy = c.accuracy, + } + if policy == "deny" then + return false + end + if policy == "force" then + return true + end + return nextAccuracy(c) + end) + local stopCrit = f.e.api.hooks:wrap("battle.crit", function() + return false + end) + local stopMoves = f.e.api.events:on("battle.move_used", function(e) + moves[#moves + 1] = { user = e.user.mon.nickname, target = e.target.mon.nickname, move = e.move.id } + end) + local function at(b, id) + return native and b.battlers[id] or b:battlerAt(id) + end + local function actor(mon, id) + if native then + return require("src.core.game3.battle.state").makeBattler( + mon, + id % 2 == 0 and "player" or "enemy", + { id = id } + ) + end + return require("src.battle.BattleState").makeBattler(f.data, mon, id % 2 == 0) + end + local function put(b, id, a) + if native then + b.battlers[id] = a + else + b:placeBattler(id, a) + end + end + local function fresh(double) + damageCalls = 0 + local b = f.fresh("TACKLE", { enemy = true }).battle + b.double = double + if double then + put(b, 2, actor(Copy(b.player.mon), 2)) + put(b, 3, actor(Copy(b.enemy.mon), 3)) + end + if not native then + b:syncSides() + end + for id = 0, double and 3 or 1 do + local a = at(b, id) + a.mon.nickname, a.mon.hp = "P" .. id, 400 + local ns = { 199, 170, 33, 90 } + if native then + a.mon.moves, a.mon.pp, a.mon.maxPp = ns, { 20, 20, 20, 20 }, { 20, 20, 20, 20 } + a.ability, a.mon.ability, a.mon.speed = 0, 0, 400 - id * 50 + a.mon.item, a.item = 0, 0 + a.type1, a.type2 = 0, nil + else + a.curMoves = { + { id = f.ids.LOCK_ON, pp = 20 }, + { id = f.ids.MIND_READER, pp = 20 }, + { id = f.ids.TACKLE, pp = 20 }, + { id = f.ids.FISSURE, pp = 20 }, + } + a.mon.moves, a.abilityOverride = a.curMoves, false + a.curStats.speed = 400 - id * 50 + a.mon.stats.hp = 400 + a.curTypes = { "NORMAL" } + for _, k in ipairs({ "attack", "defense", "spatk", "spdef", "special" }) do + a.curStats[k] = 50 + end + end + end + if native then + b.playerParty = { at(b, 0).mon, Copy(at(b, 0).mon) } + b.foeParty = { at(b, 1).mon, Copy(at(b, 1).mon) } + b.playerParty[2].nickname, b.foeParty[2].nickname = "BENCH_P", "BENCH_E" + else + b.game.save.party = { at(b, 0).mon, Copy(at(b, 0).mon) } + b.enemyParty = { at(b, 1).mon, Copy(at(b, 1).mon) } + b.game.save.party[2].nickname, b.enemyParty[2].nickname = "BENCH_P", "BENCH_E" + end + if double then + if native then + b.playerParty = { at(b, 0).mon, at(b, 2).mon, Copy(at(b, 0).mon) } + b.foeParty = { at(b, 1).mon, at(b, 3).mon, Copy(at(b, 1).mon) } + at(b, 2).partyIndex, at(b, 3).partyIndex = 2, 2 + b.playerParty[3].nickname, b.foeParty[3].nickname = "BENCH_P", "BENCH_E" + else + b.game.save.party = { at(b, 0).mon, at(b, 2).mon, Copy(at(b, 0).mon) } + b.enemyParty = { at(b, 1).mon, at(b, 3).mon, Copy(at(b, 1).mon) } + b.game.save.party[3].nickname, b.enemyParty[3].nickname = "BENCH_P", "BENCH_E" + end + end + b.rng = function(lo, hi) + local value = hi + draws[#draws + 1] = { lo = lo, hi = hi, value = value } + return value + end + return b + end + local function cast(b, from, to, slot) + local user, target = at(b, from), at(b, to) + if native then + require("src.core.game3.battle.engine").resolveMove( + from, + to, + user.mon.moves[slot], + slot, + require("src.core.game3.battle.adapter").new(b), + b, + {} + ) + else + b:executeAction(user, target, user.curMoves[slot]) + end + end + local function check(b, from, to, kind) + local user, target = at(b, from), at(b, to) + if native then + local context = require("src.core.game3.battle.engine").newContext( + user, + target, + 33, + 3, + require("src.core.game3.battle.adapter").new(b), + b, + {}, + {} + ) + context.move.accuracy = 0 + return context:accuracyCheck(kind or "normal", false) + end + local move = Copy(f.data.moves[f.ids.TACKLE]) + move.accuracy = 0 + return b:accuracyRoll(move, user, target) + end + local function tick(b) + if native then + require("src.core.game3.battle.engine").collectResidualEvents( + b, + require("src.core.game3.battle.adapter").new(b) + ) + else + b:endOfTurn() + end + end + local function timer(b, id) + local a = at(b, id) + return native and a.expLockedOn or a.lockOnTurns + end + local function switch(b, id, pass) + local old = at(b, id) + local slot = b.double and 3 or 2 + if id == 3 then + old.mon.hp = 0 + end + if native then + return require("src.core.game3.battle.engine").performSwitch( + b, + require("src.core.game3.battle.adapter").new(b), + id, + slot, + { batonPass = pass, reason = "switch" } + ) + elseif id % 2 == 0 then + if pass then + old.batonPass = {} + end + b:switchPlayerInto(id, b.game.save.party[slot]) + elseif id == 1 then + b:executeAction(old, at(b, 0), { special = "aiSwitch", index = slot }) + else + b:placeBattler(id, nil) + b.pendingReplacements = { id } + b:fillEmptySlots() + end + return at(b, id) + end + local function snapshot(b, label, extra) + local all = {} + for id = 0, b.double and 3 or 1 do + local a = at(b, id) + if a then + local side = native and a.expLockedOnBy or a.lockOnBy + if not native then + if a.lockOnBy == true then + side = "player" + elseif a.lockOnBy == false then + side = "enemy" + else + side = nil + end + end + all[#all + 1] = { + id = id, + name = a.mon.nickname, + hp = a.mon.hp, + turns = native and a.expLockedOn or a.lockOnTurns, + ownerSide = side, + ownerId = native and a.expLockedOnById or a.lockOnByPos, + pp = native and a.mon.pp + or { a.curMoves[1].pp, a.curMoves[2].pp, a.curMoves[3].pp, a.curMoves[4].pp }, + } + end + end + rows[#rows + 1] = { + label = label, + actors = all, + calls = Copy(calls), + draws = Copy(draws), + moves = Copy(moves), + damageCalls = damageCalls, + extra = extra, + } + end + for _, double in ipairs({ false, true }) do + for owner = 0, double and 3 or 1 do + for _, slot in ipairs({ 1, 2 }) do + for _, mode in ipairs({ "delegate", "deny", "force" }) do + policy = mode + calls, draws, moves = {}, {}, {} + local b = fresh(double) + local victim = owner % 2 == 0 and 1 or 0 + cast(b, owner, victim, slot) + T.eq(timer(b, victim), mode ~= "deny" and 2 or nil, "producer stores two residual phases") + local label = table.concat({ double and "double" or "single", owner, slot, mode }, "/") + snapshot(b, label .. "/cast") + local a = check(b, owner, victim) + local c = check(b, owner, victim) + T.eq(a, c, "native guarantee is not consumed by an accuracy query") + T.eq(a, mode ~= "deny", "only successful native producers guarantee their owner") + snapshot(b, label .. "/repeated", { a, c }) + if double then + snapshot(b, label .. "/partner", { check(b, (owner + 2) % 4, victim) }) + end + tick(b) + T.eq(timer(b, victim), mode ~= "deny" and 1 or nil, "first residual leaves one phase") + snapshot(b, label .. "/first-residual", { check(b, owner, victim) }) + tick(b) + T.eq(timer(b, victim), nil, "second residual expires target-owned binding") + snapshot(b, label .. "/second-residual", { check(b, owner, victim) }) + end + end + end + end + policy = "delegate" + for _, double in ipairs({ false, true }) do + for owner = 0, double and 3 or 1 do + for _, kind in + ipairs( + double and { "owner", "target", "partner", "baton-target" } or { "owner", "target", "baton-target" } + ) + do + if kind ~= "baton-target" or owner % 2 == 1 then + calls, draws, moves = {}, {}, {} + local b = fresh(double) + local victim = owner % 2 == 0 and 1 or 0 + cast(b, owner, victim, 1) + local id = kind == "owner" and owner or kind == "partner" and (owner + 2) % 4 or victim + T.check(switch(b, id, kind == "baton-target") ~= nil, "real switch installs a replacement") + local hit = check(b, owner, victim) + T.eq(hit, kind == "partner" or kind == "baton-target", "ownership follows native switch policy") + snapshot(b, "switch/" .. tostring(double) .. "/" .. owner .. "/" .. kind, { hit }) + end + end + end + end + for owner = 0, 3 do + calls, draws, moves = {}, {}, {} + local b = fresh(true) + local victim = owner % 2 == 0 and 1 or 0 + cast(b, owner, victim, 1) + tick(b) + cast(b, owner, victim, 2) + T.eq(timer(b, victim), 2, "same owner refreshes the duration") + cast(b, (owner + 2) % 4, victim, 1) + T.eq(check(b, owner, victim), false, "reapplication transfers ownership from the old owner") + T.eq(check(b, (owner + 2) % 4, victim), true, "new owner receives the guarantee") + snapshot(b, "refresh/" .. owner) + end + for _, kind in ipairs({ + "locked", + "ordinary", + "expired", + "wrong-owner", + "protect", + "air", + "lower-level", + "sturdy", + }) do + calls, draws, moves = {}, {}, {} + local b = fresh(true) + local user, target = at(b, 1), at(b, 0) + if kind ~= "ordinary" then + cast(b, kind == "wrong-owner" and 3 or 1, 0, 1) + end + if kind == "expired" then + tick(b) + tick(b) + end + if kind == "protect" then + if native then + target.expProtected = true + else + target.protecting = true + end + end + if kind == "air" then + if native then + target.semiInvulnerable = "ON_AIR" + else + target.invulnerable, target.hiddenAs = true, "air" + end + end + if kind == "lower-level" then + target.mon.level = user.mon.level + 1 + end + if kind == "sturdy" then + if native then + target.ability, target.mon.ability = 5, 5 + else + target.abilityOverride = "STURDY" + end + end + cast(b, 1, 0, 4) + snapshot(b, "ohko/" .. kind, { hp = target.mon.hp }) + end + for owner = 0, 3 do + for _, protected in ipairs({ false, true }) do + calls, draws, moves = {}, {}, {} + local b = fresh(true) + local victim = owner % 2 == 0 and 1 or 0 + cast(b, owner, victim, 1) + if native then + at(b, victim).expProtected = protected + else + at(b, victim).protecting = protected + end + cast(b, owner, victim, 3) + cast(b, owner, victim, 3) + T.eq(timer(b, victim), 2, "two real damaging moves do not consume the timed state") + T.eq(damageCalls, protected and 0 or 2, "real damage hooks respect Protect before native guarantee") + snapshot(b, "damaging/" .. owner .. "/" .. tostring(protected)) + end + end + for _, guard in ipairs({ "substitute", "substitute-zero", "protect", "air", "underground", "underwater" }) do + policy = "delegate" + calls, draws, moves = {}, {}, {} + local b = fresh(true) + local target = at(b, 0) + target.substituteHP = guard == "substitute" and 40 or guard == "substitute-zero" and 0 or nil + if native then + target.expProtected = guard == "protect" or nil + target.semiInvulnerable = ({ air = "ON_AIR", underground = "UNDERGROUND", underwater = "UNDERWATER" })[guard] + else + target.protecting = guard == "protect" or nil + target.hiddenAs = ({ air = "air", underground = "underground", underwater = "underwater" })[guard] + target.invulnerable = target.hiddenAs ~= nil + end + cast(b, 1, 0, 1) + snapshot(b, "guard/" .. guard) + end + stopMoves() + stopDamage() + stopCrit() + stop() + f.close() + return rows +end +return M diff --git a/tests/enemy_actions/mirror_move.lua b/tests/enemy_actions/mirror_move.lua new file mode 100644 index 000000000..a87921b3d --- /dev/null +++ b/tests/enemy_actions/mirror_move.lua @@ -0,0 +1,333 @@ +-- Owned command sequences exercise unchanged native received-move history. +local M = {} +function M.run(root, backend, version, options) + options = options or {} + local native = backend == "gen1" + local f = dofile(root .. "/tests/enemy_actions/assist.lua").fixture(root, backend, version, options) + local rows = {} + local caller, custom = native and 119 or "MOVE_119", nil + if options.public then + if native then + local compat, moves = require("src.mods.Gen3Compat"), require("src.core.game3.battle.moves") + caller, custom = assert(compat.moveId("PORTABLE_MIRROR")), assert(compat.moveId("PORTABLE_STRIKE")) + for _, name in ipairs({ "PORTABLE_MIRROR", "PORTABLE_STRIKE" }) do + local n = assert(compat.moveId(name)) + moves._rom[n] = assert(f.data.gen3Moves._rom[n]) + moves.BY_NUM[n], moves._numByName[name] = name, n + end + else + caller, custom = "PORTABLE_MIRROR", "PORTABLE_STRIKE" + end + end + local selected = options.outer and (native and 118 or "MOVE_118") or caller + local function definition(n) + return native and f.data.gen3Moves._rom[n] or f.data.moves[("MOVE_%03d"):format(n)] + end + definition(119).flags = 0 + if not native then + definition(119).mirrorMoveAffected = false + end + local function number(value) + if options.public and value ~= nil then + if native then + return require("src.mods.Gen3Compat").moveName(value) + end + local def = f.data.moves[value] + if def then + return def.name + end + for _, row in pairs(f.data.moves) do + if row.index == value then + return row.name + end + end + return value + end + if native or value == nil then + return value + end + local def = f.data.moves[value] + return def and def.index or value + end + for _, double in ipairs({ false, true }) do + for actor = 0, double and 3 or 1 do + for _, kind in ipairs({ + "none", + "hit", + "unreflectable", + "miss", + "miss-first", + "callback-change", + "other-target", + "protected", + "substitute", + "immune", + "flag-change", + "zero-pp", + "pressure", + "custom-incoming", + "faint-target", + "self-target", + }) do + if (kind ~= "other-target" or double) and (kind ~= "custom-incoming" or options.public) then + for _, item in ipairs({ 0, 186, 187, 219 }) do + local b = f.field(double) + definition(33).flags = 51 + definition(118).flags = 0 + if not native then + definition(33).mirrorMoveAffected = true + end + if options.outer then + if native then + b.moveMax, b.moveExcluded = caller, {} + for n = 1, caller do + if n ~= caller then + b.moveExcluded[n] = true + end + end + else + f.data.constants.moveOrder = { caller } + end + end + local user = f.actor(actor) + local foeId = actor % 2 == 0 and actor + 1 or actor - 1 + local foe = f.actor(foeId) + for id = 0, double and 3 or 1 do + local a = f.actor(id) + f.setMoves(a.mon, { selected, kind == "custom-incoming" and custom or 33, 10 }) + if not native then + a.curMoves = a.mon.moves + end + end + f.put(user, item ~= 0 and item or nil) + if kind == "pressure" then + if native then + foe.ability = "PRESSURE" + else + foe.abilityOverride = "PRESSURE" + end + elseif kind == "substitute" then + user.substituteHP = 100 + end + local scratch = definition(10) + scratch.type = native and 0 or "NORMAL" + scratch.flags, scratch.accuracy = kind == "unreflectable" and 35 or 51, 100 + if not native then + scratch.mirrorMoveAffected = kind ~= "unreflectable" + end + local step = "setup" + local trace, events, damage, draws, animations = {}, {}, {}, {}, {} + local offHeld = f.e.api.hooks:wrap("held_item.trigger", function(nextRead, c) + trace[#trace + 1] = { + step = step, + holder = c.mon.nickname, + item = c.item, + effect = c.effect, + parameter = c.parameter, + definition = c.def and c.def.id, + trigger = c.trigger, + actualBattle = c.battle == b, + } + return nextRead(c) + end) + local offCrit = f.e.api.hooks:wrap("battle.crit", function() + return false + end) + local offMove = f.e.api.events:on("battle.move_used", function(e) + local pp = {} + for i = 1, 3 do + pp[i] = e.user.mon.moves[i].pp + end + events[#events + 1] = { + step = step, + move = e.move.id, + called = e.isCalled or false, + user = e.user.mon.nickname, + target = e.target.mon.nickname, + pp = pp, + } + if step == "copy" and not e.isCalled and kind == "flag-change" then + local changed = definition(33) + changed.flags = 35 + if not native then + changed.mirrorMoveAffected = false + end + end + if step == "copy" and not e.isCalled and kind == "callback-change" then + if native then + foe.lastMoveId, foe.lastMove = 10, 10 + else + foe.lastMove = "MOVE_010" + end + end + end) + local offDamage = f.e.api.events:on("battle.damage_dealt", function(e) + damage[#damage + 1] = { + step = step, + move = e.move.id, + user = e.user.mon.nickname, + target = e.target.mon.nickname, + damage = e.damage, + critical = e.crit or false, + substitute = e.substitute or false, + typeMult = e.typeMult, + } + end) + local targetRoll = 0 + b.rng = function(lo, hi) + local value = lo == 1 and hi == 100 and (step == "miss" and 100 or 1) or hi + if lo == 0 and hi == 3 then + value = targetRoll % 4 + targetRoll = targetRoll + 1 + end + draws[#draws + 1] = { step = step, lo = lo, hi = hi, value = value } + return value + end + local function act(attackerId, targetId, slot, label) + step = label + local a, t = f.actor(attackerId), f.actor(targetId) + local adapter = native and require("src.core.game3.battle.adapter").new(b) + if native then + require("src.core.game3.battle.engine").resolveMove( + attackerId, + targetId, + a.mon.moves[slot], + slot, + adapter, + b, + {} + ) + else + b:executeAction(a, t, a.curMoves[slot]) + end + for _, e in ipairs(native and adapter:eventsSince(0) or b.queue) do + if native and (e.kind == "move" or e.kind == "anim") or not native and e.anim then + local ref = native and (e.kind == "move" and e.moveId or e.name) or e.anim + local isMove = native and e.kind == "move" or not native and f.data.moves[e.anim] ~= nil + animations[#animations + 1] = { + step = step, + kind = isMove and "move" or "anim", + move = isMove and number(ref) or ref, + user = native and e.attackerId or e.attackerPosition, + target = native and e.targetId or e.targetPosition, + turn = native and e.turn or e.animContext and e.animContext.moveTurn or 0, + } + end + end + if not native then + b.queue, b.nextInsert = {}, 0 + end + end + if kind ~= "none" and kind ~= "miss-first" then + act(foeId, actor, 2, "incoming") + end + if kind == "miss" or kind == "miss-first" then + scratch.accuracy = 50 + act(foeId, actor, 3, "miss") + scratch.accuracy = 100 + elseif kind == "unreflectable" then + act(foeId, actor, 3, "unreflectable") + elseif kind == "protected" then + if native then + user.expProtected = true + else + user.protecting = true + end + act(foeId, actor, 3, "protected") + user.expProtected, user.protecting = nil, nil + elseif kind == "immune" then + if native then + user.type1, user.type2 = 7, 7 + else + user.curTypes = { "GHOST" } + f.data.type_chart.matchups[#f.data.type_chart.matchups + 1] = + { attacker = "NORMAL", defender = "GHOST", multiplier = 0 } + f.data.type_chart.types.GHOST = { id = "GHOST", index = 7, category = "physical" } + require("src.battle.TypeChart").load(f.data) + end + act(foeId, actor, 3, "immune") + if native then + user.type1, user.type2 = 0, 0 + else + user.curTypes = { "NORMAL" } + end + elseif kind == "other-target" then + act(foeId, actor < 2 and actor + 2 or actor - 2, 3, "other-target") + elseif kind == "faint-target" then + -- A second received move KOs the recipient. Its earlier valid + -- history must survive; a fainted Pokemon cannot then copy it. + user.mon.hp = 1 + act(foeId, actor, 3, "faint-target") + elseif kind == "self-target" then + act(foeId, foeId, 3, "self-target") + end + if kind == "zero-pp" then + if native then + user.mon.pp[1] = 0 + else + user.curMoves[1].pp = 0 + end + end + if kind ~= "faint-target" then + act(actor, foeId, 1, "copy") + end + offHeld() + offCrit() + offMove() + offDamage() + local state = {} + for id = 0, double and 3 or 1 do + local a = f.actor(id) + local pp = {} + for i = 1, 3 do + pp[i] = native and a.mon.pp[i] or a.curMoves[i].pp + end + state[#state + 1] = { + hp = a.mon.hp, + status = a.mon.status, + last = number(native and a.lastMoveId or a.lastMove), + received = number(native and a.expLastTakenMove or a.gbaLastTakenMove), + substituteHP = a.substituteHP or 0, + pp = pp, + choice = number(a.choicedMove), + flinched = a.flinched or false, + } + end + f.T.eq(#f.result.errors, 0, "received-move callbacks stay error-free") + rows[#rows + 1] = { + double = double, + actor = actor, + case = kind, + item = item, + outer = options.outer or false, + state = state, + trace = trace, + events = events, + damage = damage, + draws = draws, + animations = animations, + } + end + end + end + end + end + f.close() + return rows +end +function M.content(root, backend, version, outer) + return M.run(root, backend, version, { + public = true, + outer = outer, + entry = [=[return function(mod) + assert(mod.content.moves:register("PORTABLE_MIRROR",{id="PORTABLE_MIRROR",name="PORTABLE_MIRROR",type="NORMAL",power=0,accuracy=100,pp=10,effect="MIRROR_MOVE_EFFECT"})) + assert(mod.content.moves:register("PORTABLE_STRIKE",{id="PORTABLE_STRIKE",name="PORTABLE_STRIKE",type="NORMAL",power=35,accuracy=100,pp=10,effect="NO_ADDITIONAL_EFFECT"})) + assert(mod.content.moves:get("PORTABLE_MIRROR").effect=="MIRROR_MOVE_EFFECT") + mod.exports.api=mod + end]=], + }) +end +function M.chained(root, backend, version) + return M.content(root, backend, version, true) +end +return M diff --git a/tests/enemy_actions/move_used_pp.lua b/tests/enemy_actions/move_used_pp.lua new file mode 100644 index 000000000..3966a5c20 --- /dev/null +++ b/tests/enemy_actions/move_used_pp.lua @@ -0,0 +1,384 @@ +-- Execute actual scripts and public event dispatch on unchanged native code. +-- Observe event-visible PP, mutations, command order, final state and all RNG. +local M = {} +function M.run(root, backend, version) + local native = backend == "gen1" + local entry = [=[return function(mod) + assert(mod.content.move_effects:register("PP_CALL_EFFECT", {kind="primary",run=function(ctx) + ctx.callMove("TACKLE"); return {} + end})) + assert(mod.content.moves:register("PP_CALLER", {id="PP_CALLER",name="PP_CALLER",type="NORMAL", + power=0,accuracy=100,pp=10,effect="PP_CALL_EFFECT"})) + mod.exports.api=mod + end]=] + local f = + dofile(root .. "/tests/enemy_actions/choice_band.lua").fixture(root, backend, version, { entry = entry }) + local caller, registries, previousRegistryData + if native then + caller = assert(require("src.mods.Gen3Compat").moveId("PP_CALLER")) + local moves = require("src.core.game3.battle.moves") + moves._rom[caller] = assert(f.data.gen3Moves._rom[caller]) + moves.BY_NUM[caller], moves._numByName.PP_CALLER = "PP_CALLER", caller + registries = require("src.core.game3.battle.mod_registries") + previousRegistryData = registries.data() + registries.bind(f.data) + else + caller = -1 + end + local ids = { + [33] = f.ids.TACKLE, + [10] = f.ids.SCRATCH, + [19] = f.ids.FLY, + [214] = f.ids.SLEEP_TALK, + [165] = f.ids.STRUGGLE, + } + ids[caller] = "PP_CALLER" + for _, row in ipairs({ + { 57, "SURF", 0, "NO_ADDITIONAL_EFFECT", 11, "WATER", 8 }, + { 76, "SOLARBEAM", 151, "SOLARBEAM_EFFECT", 12, "GRASS", 0 }, + }) do + local number, name, effect, binding, kind, typeName, target = unpack(row) + ids[number] = ("MOVE_%03d"):format(number) + if native then + local def = { + effect = effect, + power = 40, + type = kind, + accuracy = 100, + pp = 10, + target = target, + flags = 51, + priority = 0, + secondaryChance = 0, + } + local moves = require("src.core.game3.battle.moves") + moves._rom[number], moves.BY_NUM[number], moves._numByName[name] = def, name, number + f.data.gen3Moves._rom[number], f.data.gen3Pokemon._moveNames[number] = def, name + else + f.data.moves[ids[number]] = { + id = ids[number], + index = number, + name = name, + gen3Effect = effect, + effect = binding, + power = 40, + type = typeName, + accuracy = 100, + pp = 10, + target = target, + flags = 51, + secondaryChance = 0, + } + end + end + local function setMoves(user, numbers, value) + user.mon.moves = {} + if native then + user.mon.pp, user.mon.maxPp = {}, {} + end + for i, number in ipairs(numbers) do + if native then + user.mon.moves[i], user.mon.pp[i], user.mon.maxPp[i] = number, value or 10, 10 + else + user.mon.moves[i] = { id = ids[number], pp = value or 10 } + end + end + if not native then + user.curMoves = user.mon.moves + end + end + local function pp(user) + local out = {} + for i = 1, 4 do + local value + if native then + value = user.mon.pp[i] + else + value = user.curMoves[i] and user.curMoves[i].pp + end + out[i] = value == nil and "absent" or value + end + return out + end + local function setPP(user, slot, value) + if native then + user.mon.pp[slot] = value + else + user.curMoves[slot].pp = value + end + end + local function eventPP(event) + local out = {} + for i = 1, 4 do + local row = event.user.mon.moves[i] + f.T.check(row == nil or type(row) == "table", "public API-3 moves expose PP records") + local value + if row then + value = row.pp + end + out[i] = value == nil and "absent" or value + end + return out + end + local function pressure(target, enabled) + if native then + target.ability, target.mon.ability = enabled and 46 or 0, enabled and 46 or 0 + else + target.abilityOverride = enabled and "PRESSURE" or false + end + end + local rows = {} + for _, double in ipairs({ false, true }) do + for actor = 0, double and 3 or 1 do + for _, kind in ipairs({ + "ordinary", + "pressure", + "one_pp_pressure", + "zero_pp", + "refill_zero", + "callback_zero", + "callback_set", + "callback_replace", + "callback_remove", + "callback_pressure_on", + "callback_pressure_off", + "planned_replace", + "planned_removed", + "sleep_talk", + "awake_sleep_talk", + "sleeping_block", + "charge_start", + "charge_release", + "charge_planned_release", + "charge_planned_release_zero", + "charge_bypass", + "charge_hook_set", + "charge_hook_remove", + "charge_refill_zero", + "sun_skip", + "spread", + "spread_pressure", + "spread_callback_set", + "spread_zero", + "spread_refill_zero", + "spread_callback_remove", + "spread_one_pressure", + "authored_call", + "struggle_zero", + }) do + local b = f.field(double) + local user, targetId = f.at(b, actor), actor % 2 == 0 and actor + 1 or actor - 1 + local target = f.at(b, targetId) + local move = kind == "authored_call" and caller + or kind == "struggle_zero" and 165 + or kind:match("^charge") and 19 + or kind == "sun_skip" and 76 + or kind:match("^spread") and 57 + or kind:find("sleep_talk", 1, true) and 214 + or 33 + setMoves(user, { move, move == 214 and 33 or 10, 165 }, 10) + if + kind == "zero_pp" + or kind == "refill_zero" + or kind == "charge_refill_zero" + or kind == "spread_zero" + or kind == "spread_refill_zero" + or kind == "struggle_zero" + then + setPP(user, 1, 0) + end + if + kind == "one_pp_pressure" + or kind == "spread_one_pressure" + or kind == "charge_planned_release_zero" + then + setPP(user, 1, 1) + end + if kind == "pressure" or kind == "one_pp_pressure" or kind == "callback_pressure_off" then + pressure(target, true) + end + if kind == "spread_pressure" or kind == "spread_one_pressure" then + for id = 0, double and 3 or 1 do + if id % 2 ~= actor % 2 then + pressure(f.at(b, id), true) + end + end + end + if kind == "sleep_talk" or kind == "sleeping_block" then + user.mon.status, user.sleepTurns = "SLP", 4 + end + if kind == "sun_skip" then + if native then + b.weather = "SUN" + else + b.field.weather = "SUN" + end + end + local planned = kind == "planned_replace" or kind == "planned_removed" + if planned then + user.choicedMove = 10 + f.hold(user, 186) + end + local action, number, slot = nil, move, 1 + if native then + local plan = require("src.core.game3.battle.engine").planTurnActions( + b, + require("src.core.game3.battle.adapter").new(b), + { [actor] = { kind = "move", move = move, slot = 1, target = targetId } } + )[1] + number, slot = plan.move, plan.slot + else + action = + require("src.battle.EnemyMoveChoice").withMove(user.curMoves[1], ids[move], user.curMoves[1], 1) + action.kind, action.target = "move", targetId + action.targetType = f.data.moves[ids[move]].target or 0 + action = require("src.battle.Gen3ChoiceBand").plan(b, user, action) + end + local retired = native and user.mon.pp or user.curMoves + if planned then + setMoves(user, kind == "planned_removed" and { 33 } or { 33, 10 }, 7) + end + local function execute() + if native then + require("src.core.game3.battle.engine").resolveMove( + actor, + targetId, + number, + slot, + require("src.core.game3.battle.adapter").new(b), + b, + {} + ) + else + b:executeAction(user, target, action) + end + end + local plannedRelease = kind == "charge_planned_release" or kind == "charge_planned_release_zero" + if kind == "charge_release" or plannedRelease then + execute() + if not native then + action = user.charging + if plannedRelease then + action = require("src.battle.Gen3ChoiceBand").plan(b, user, action) + end + elseif plannedRelease then + local plan = require("src.core.game3.battle.engine").planTurnActions( + b, + require("src.core.game3.battle.adapter").new(b), + { [actor] = { kind = "move", move = number, slot = slot, target = targetId } } + )[1] + number, slot = plan.move, plan.slot + end + b.queue, b.current = {}, nil + end + local events, eventsAfter, chargeCalls, draws = {}, {}, {}, {} + local offCrit = f.e.api.hooks:wrap("battle.crit", function() + return false + end) + local offCharge = f.e.api.hooks:wrap("battle.charge_required", function(nextRead, ctx) + chargeCalls[#chargeCalls + 1] = + { pp = pp(user), called = ctx.isCalled or false, charge = ctx.charge } + if kind == "charge_hook_set" then + setPP(user, 1, 4) + end + if kind == "charge_hook_remove" then + setMoves(user, {}) + end + if kind == "charge_refill_zero" then + setPP(user, 1, 9) + end + if kind == "charge_bypass" then + return false + end + return nextRead(ctx) + end) + local off = f.e.api.events:on("battle.move_used", function(e) + events[#events + 1] = { + pp = pp(user), + publicPP = eventPP(e), + user = e.user.mon.nickname, + target = e.target.mon.nickname, + move = e.move.id, + called = e.isCalled or false, + } + if #events == 1 then + if kind == "refill_zero" or kind == "spread_refill_zero" then + setPP(user, 1, 9) + end + if kind == "callback_zero" then + setPP(user, 1, 0) + end + if kind == "callback_set" or kind == "spread_callback_set" then + setPP(user, 1, 4) + end + if kind == "callback_replace" then + setMoves(user, { 33, 10 }, 7) + end + if kind == "callback_remove" or kind == "spread_callback_remove" then + setMoves(user, {}) + end + if kind == "callback_pressure_on" then + pressure(target, true) + end + if kind == "callback_pressure_off" then + pressure(target, false) + end + end + end) + local offAfter = f.e.api.events:on("battle.move_used", function(e) + eventsAfter[#eventsAfter + 1] = { pp = pp(user), publicPP = eventPP(e), move = e.move.id } + end) + b.rng = function(lo, hi) + draws[#draws + 1] = { lo, hi, hi } + return hi + end + local before = pp(user) + execute() + off() + offAfter() + offCharge() + offCrit() + local state = {} + for id = 0, double and 3 or 1 do + local a = f.at(b, id) + local charging, hidden + if native then + charging, hidden = a.twoTurnMove ~= nil, a.semiInvulnerable ~= nil + else + charging, hidden = a.charging ~= nil, a.invulnerable == true + end + state[#state + 1] = { pp = pp(a), hp = a.mon.hp, charging = charging, hidden = hidden } + end + local oldPP = {} + for i = 1, 4 do + local value + if native then + value = retired[i] + else + value = retired[i] and retired[i].pp + end + oldPP[i] = value == nil and "absent" or value + end + f.T.eq(#f.result.errors, 0, "actual move-used and charge callbacks stay error-free") + rows[#rows + 1] = { + case = kind, + double = double, + actor = actor, + before = before, + events = events, + eventsAfter = eventsAfter, + chargeCalls = chargeCalls, + draws = draws, + state = state, + retiredPP = oldPP, + } + end + end + end + if registries then + registries.bind(previousRegistryData) + end + f.close() + return rows +end +return M diff --git a/tests/enemy_actions/multi_hit.lua b/tests/enemy_actions/multi_hit.lua new file mode 100644 index 000000000..86e72f0af --- /dev/null +++ b/tests/enemy_actions/multi_hit.lua @@ -0,0 +1,406 @@ +local M = {} +function M.fixture(root, backend, version, entry) + local native = backend == "gen1" + return dofile(root .. "/tests/enemy_actions/fixture.lua").new( + root, + backend, + version, + 3, + "unified", + function(data, _, ids, rom, names) + if not native then + data.items.FOCUS_BAND = { + index = 196, + name = "FOCUS BAND", + holdEffect = "FOCUS_BAND", + gen3HoldEffect = 39, + holdEffectParam = 10, + } + data.items.SCOPE_LENS = { index = 198, name = "SCOPE LENS", holdEffect = "SCOPE_LENS" } + data.items.LUCKY_PUNCH = { index = 222, name = "LUCKY PUNCH", holdEffect = "LUCKY_PUNCH" } + data.type_chart.matchups[#data.type_chart.matchups + 1] = + { attacker = "NORMAL", defender = "GHOST", multiplier = 0 } + end + for _, row in ipairs({ + { 3, "DOUBLE_SLAP", 29, "TWO_TO_FIVE_ATTACKS_EFFECT" }, + { 24, "DOUBLE_KICK", 44, "ATTACK_TWICE_EFFECT" }, + { 41, "TWINEEDLE", 77, "TWINEEDLE_EFFECT" }, + }) do + local number, name, effect, hostEffect = unpack(row) + local id = native and name or ("MOVE_%03d"):format(number) + ids[name], names[number] = id, name + if native then + local def = { + effect = effect, + power = 15, + type = 0, + accuracy = 100, + pp = 10, + target = 0, + flags = 51, + secondaryChance = 20, + priority = 0, + } + data.gen3Moves._rom[number], rom[number], data.gen3Pokemon._moveNames[number] = def, def, name + data.gen3Pokemon._types[4], data.gen3Pokemon._abilities[4] = { 0, 0 }, { 0, 0 } + else + data.moves[id] = { + id = id, + index = number, + name = name, + effect = hostEffect, + gen3Effect = effect, + power = 15, + type = "NORMAL", + accuracy = 100, + pp = 10, + target = 0, + flags = 51, + protectAffected = true, + secondaryChance = 20, + } + data.pokemon.CHARMANDER.types, data.pokemon.CHARMANDER.abilities = { "NORMAL" }, {} + end + end + end, + entry + ) +end +function M.run(root, backend, version, options) + options = options or {} + local native = backend == "gen1" + local f = M.fixture(root, backend, version) + local T, Copy = f.T, require("src.mods.Merge").deepCopy + local rows, draws, damage, used = {}, {}, {}, {} + local currentKind, liveUser, liveTarget + -- Public events are detached views. Mutate the owned fixture's real battlers + -- to test live continuation; this does not assert event-view write-through. + local off = f.e.api.events:on("battle.damage_dealt", function(e) + damage[#damage + 1] = { + user = e.user.mon.nickname, + target = e.target.mon.nickname, + move = e.move.id, + damage = e.damage, + crit = e.crit or false, + substitute = e.substitute or false, + typeMult = e.typeMult, + } + if #damage == 1 and currentKind == "sleep_after_hit" then + liveUser.mon.status = "SLP" + end + if #damage == 1 and currentKind == "type_after_hit" then + if native then + liveTarget.type1, liveTarget.type2 = 7, 7 + else + liveTarget.curTypes = { "GHOST" } + end + end + if #damage == 1 and currentKind == "user_ko" then + liveUser.mon.hp = 0 + end + end) + local offMove = f.e.api.events:on("battle.move_used", function(e) + used[#used + 1] = { user = e.user.mon.nickname, target = e.target.mon.nickname, move = e.move.id } + end) + local function at(b, id) + return native and b.battlers[id] or b:battlerAt(id) + end + local function fresh(double, first, second) + local b = f.fresh("TACKLE", { enemy = true }).battle + b.double = double + if not native then + b:syncSides() + end + if double then + for id = 2, 3 do + local mon = Copy(at(b, id % 2).mon) + if native then + b.battlers[id] = require("src.core.game3.battle.state").makeBattler( + mon, + id % 2 == 0 and "player" or "enemy", + { id = id, partyIndex = 2 } + ) + else + b:placeBattler(id, require("src.battle.BattleState").makeBattler(f.data, mon, id % 2 == 0)) + end + end + end + if not native then + b:syncSides() + end + for id = 0, double and 3 or 1 do + local a = at(b, id) + a.mon.nickname = "P" .. id + if native then + a.mon.moves, a.mon.pp, a.mon.maxPp = { 3, 24, 41, 33 }, { 10, 10, 10, 10 }, { 10, 10, 10, 10 } + a.mon.maxHp, a.ability, a.mon.ability, a.type1, a.type2 = 400, 0, 0, 0, 0 + a.mon.speed = 100 - id * 10 + else + a.mon.moves = { + { id = f.ids.DOUBLE_SLAP, pp = 10 }, + { id = f.ids.DOUBLE_KICK, pp = 10 }, + { id = f.ids.TWINEEDLE, pp = 10 }, + { id = f.ids.TACKLE, pp = 10 }, + } + a.curMoves, a.curTypes, a.abilityOverride = a.mon.moves, { "NORMAL" }, false + a.mon.stats.hp = 400 + for _, stat in ipairs({ "attack", "defense", "spatk", "spdef" }) do + a.curStats[stat] = 50 + end + a.curStats.speed = 100 - id * 10 + end + a.mon.hp = 400 + end + local count, crit, variance = 0, 0, 0 + b.rng = function(lo, hi) + local value = hi + if lo == 0 and hi == 3 then + count = count + 1 + value = count == 1 and first or second + end + if lo == 0 and hi == 15 then + crit = crit + 1 + value = currentKind == "crit" and crit % 2 == 1 and 0 or hi + end + if lo == 85 and hi == 100 then + variance = variance + 1 + value = variance % 2 == 1 and lo or hi + end + if lo == 0 and hi == 99 and (currentKind == "poison" or currentKind == "focus_band") then + value = 0 + end + draws[#draws + 1] = { lo = lo, hi = hi, value = value } + return value + end + return b + end + local function view(b) + local all = {} + for id = 0, b.double and 3 or 1 do + local a = at(b, id) + local pp = {} + for i = 1, 4 do + pp[i] = native and a.mon.pp[i] or a.curMoves[i].pp + end + all[#all + 1] = { + hp = a.mon.hp, + pp = pp, + status = a.mon.status, + sub = tonumber(a.substituteHP) or 0, + last = native and a.lastMoveId or a.lastMove and f.data.moves[a.lastMove].index, + attack = a.stages.attack or 0, + } + end + return all + end + for _, double in ipairs({ false, true }) do + for uid = 0, double and 3 or 1 do + local tid = uid % 2 == 0 and uid + 1 or uid - 1 + for slot = 1, 3 do + for first = 0, 3 do + for second = 0, 3 do + for _, kind in ipairs({ + "ordinary", + "crit", + "focus_crit", + "scope_crit", + "focus_scope_crit", + "max_crit", + "sub_survive", + "sub_break", + "endure", + "focus_band", + "target_ko", + "protected", + "hidden", + "immune", + "accuracy_miss", + "poison", + "sleep_after_hit", + "type_after_hit", + "user_ko", + "called", + }) do + currentKind = kind + draws, damage, used = {}, {}, {} + local b = fresh(double, first, second) + local user, target = at(b, uid), at(b, tid) + if options.substitute then + user.substituteHP = 400 + end + liveUser, liveTarget = user, target + if kind == "focus_crit" or kind == "focus_scope_crit" or kind == "max_crit" then + user.focusEnergy = true + end + if kind == "scope_crit" or kind == "focus_scope_crit" then + if native then + user.item, user.mon.item = 198, 198 + else + user.mon.item = "SCOPE_LENS" + end + end + if kind == "max_crit" then + user.species = 113 + if native then + user.item, user.mon.item = 222, 222 + else + user.mon.item = "LUCKY_PUNCH" + end + end + if kind == "sub_survive" then + target.substituteHP = 400 + end + if kind == "sub_break" then + target.substituteHP = 1 + end + if kind == "target_ko" then + target.mon.hp = 1 + end + if kind == "endure" then + target.mon.hp = 1 + target[native and "expEnduring" or "enduring"] = true + end + if kind == "focus_band" then + target.mon.hp = 1 + if native then + target.item, target.mon.item = 196, 196 + else + target.mon.item = "FOCUS_BAND" + end + end + if kind == "protected" then + target[native and "expProtected" or "protecting"] = true + end + if kind == "hidden" then + if native then + target.semiInvulnerable = "ON_AIR" + else + target.invulnerable, target.hiddenAs = true, "air" + end + end + if kind == "immune" then + if native then + target.type1, target.type2 = 7, 7 + else + target.curTypes = { "GHOST" } + end + end + local number = ({ 3, 24, 41 })[slot] + local def = native and f.data.gen3Moves._rom[number] + or f.data.moves[({ f.ids.DOUBLE_SLAP, f.ids.DOUBLE_KICK, f.ids.TWINEEDLE })[slot]] + local old = def.accuracy + if kind == "accuracy_miss" then + def.accuracy = 90 + end + local adapter + if native then + adapter = require("src.core.game3.battle.adapter").new(b) + local nativeSlot = slot + if kind == "called" then + nativeSlot = nil + end + require("src.core.game3.battle.engine").resolveMove( + uid, + tid, + user.mon.moves[slot], + nativeSlot, + adapter, + b, + {}, + { called = kind == "called" } + ) + elseif kind == "called" then + b:performMove(user, target, user.curMoves[slot], true) + else + b:executeAction(user, target, user.curMoves[slot]) + end + def.accuracy = old + local label = (double and "double" or "single") + .. "/" + .. uid + .. "/" + .. slot + .. "/" + .. first + .. "/" + .. second + .. "/" + .. kind + rows[#rows + 1] = { + label = label, + field = view(b), + draws = Copy(draws), + damage = Copy(damage), + used = Copy(used), + } + if options.substitute then + local animations = {} + for _, event in ipairs(native and adapter:eventsSince(0) or b.queue) do + if + native and (event.kind == "move" or event.kind == "anim") or not native and event.anim + then + local move = not native and f.data.moves[event.anim] + local isMove = native and event.kind == "move" or not native and move ~= nil + animations[#animations + 1] = { + kind = isMove and "move" or "anim", + move = native and (isMove and event.moveId or event.name) + or (isMove and move.index or event.anim), + user = native and event.attackerId or event.attackerPosition, + target = native and event.targetId or event.targetPosition, + turn = native and event.turn or event.animContext and event.animContext.moveTurn or 0, + } + end + end + rows[#rows].animations = animations + end + if kind == "ordinary" then + T.eq( + #damage, + slot == 1 and (first > 1 and second + 2 or first + 2) or 2, + "native hit count follows its own four-value opcode" + ) + end + if + kind == "sleep_after_hit" + or kind == "type_after_hit" + or kind == "user_ko" + or kind == "endure" + then + T.eq(#damage, 1, "native continuation stops at its actual live condition") + end + if kind == "sleep_after_hit" then + T.eq(user.mon.status, "SLP", "callback actually changes the live user's status") + end + if kind == "user_ko" then + T.eq(user.mon.hp, 0, "callback actually faints the live user") + end + if kind == "type_after_hit" then + T.eq( + native and target.type1 or target.curTypes[1], + native and 7 or "GHOST", + "callback actually changes the live target's type" + ) + end + if kind == "endure" or kind == "focus_band" then + T.eq(target.mon.hp, 1, "native damage survival is retained") + end + if kind == "sub_break" then + T.check(target.mon.hp < 400, "later hits continue after the Substitute breaks") + end + T.eq( + native and user.mon.pp[slot] or user.curMoves[slot].pp, + kind == "called" and 10 or 9, + "a multi-hit move spends PP once" + ) + end + end + end + end + end + end + off() + offMove() + f.close() + return rows +end +return M diff --git a/tests/enemy_actions/native_bgm_commands.lua b/tests/enemy_actions/native_bgm_commands.lua new file mode 100644 index 000000000..ffe7b9b96 --- /dev/null +++ b/tests/enemy_actions/native_bgm_commands.lua @@ -0,0 +1,136 @@ +local M = {} +function M.run(root, backend, version) + local native = backend == "gen1" + local f = dofile(root .. "/tests/enemy_actions/assist.lua").fixture(root, backend, version) + local cases = { + { + name = "plain-out", + build = function(a, b, c) + return { { "fadeoutbgm", 4 }, { "end" } } + end, + }, + { + name = "speed-zero", + build = function(a, b, c) + return { { "fadeoutbgm", 0 }, { "end" } } + end, + }, + { + name = "keep-and-save", + build = function(a, b, c) + return { { "playbgm", b, 1 }, { "savebgm", c }, { "end" } } + end, + }, + { + name = "keep-two", + build = function(a, b, c) + return { { "savebgm", c }, { "playbgm", b, 2 }, { "end" } } + end, + }, + { + name = "new-music", + build = function(a, b, c) + return { { "fadenewbgm", b }, { "end" } } + end, + }, + { + name = "default-file-only", + build = function(a, b, c) + return { { "playbgm", b, 0 }, { "fadedefaultbgm" }, { "end" } } + end, + }, + { + name = "incoming", + build = function(a, b, c) + return { { "playbgm", b, 0 }, { "fadeinbgm", 8 }, { "end" } } + end, + }, + { + name = "silence", + build = function(a, b, c) + return { { "playbgm", 0, 0 }, { "end" } } + end, + }, + { + name = "clear-saved", + build = function(a, b, c) + return { { "savebgm", b }, { "savebgm", 0xFFFF }, { "end" } } + end, + }, + { + name = "out-options", + options = true, + build = function(a, b, c) + return { { "fadeoutbgm", 1 }, { "end" } } + end, + }, + } + cases[#cases + 1] = { + name = "restart-same-mid-out", + replaceAt = 3, + replacement = "same", + build = function(a, b, c) + return { { "fadeoutbgm", 4 }, { "end" } } + end, + } + cases[#cases + 1] = { + name = "out-and-in", + build = function(a, b, c) + return { { "fadeoutbgm", 4 }, { "fadeinbgm", 4 }, { "end" } } + end, + } + cases[#cases + 1] = { + name = "in-and-out", + build = function(a, b, c) + return { { "fadeinbgm", 4 }, { "fadeoutbgm", 4 }, { "end" } } + end, + } + cases[#cases + 1] = { + name = "in-options", + options = true, + build = function(a, b, c) + return { { "fadeinbgm", 4 }, { "end" } } + end, + } + cases[#cases + 1] = { + name = "incoming-replaced", + replaceAt = 3, + replacement = "other", + build = function(a, b, c) + return { { "fadeinbgm", 4 }, { "end" } } + end, + } + cases[#cases + 1] = { + name = "stopped-fades", + replaceAt = 3, + replacement = "stop", + build = function(a, b, c) + return { { "fadeinbgm", 4 }, { "fadeoutbgm", 4 }, { "end" } } + end, + } + local rows = {} + for _, case in ipairs(cases) do + local h = dofile(root .. "/tests/native_bgm/fixture.lua").install(root, backend, version, f) + local runner = h.start(case.build(unpack(h.ids))) + local frames = {} + for tick = 0, 80 do + h.tick(tick) + if tick == case.replaceAt then + local id = case.replacement == "same" and h.ids[1] or case.replacement == "other" and h.ids[2] or 0 + runner = h.start({ { "playbgm", id, 0 }, { "end" } }) + end + if case.options and (tick == 2 or tick == 4 or tick == 6) then + h.options(tick == 2 and 4 or tick == 4 and 0 or 7) + end + local state = h.state() + state.tick, state.running = tick, runner:isRunning() + frames[#frames + 1] = state + h.update() + end + rows[#rows + 1] = { case = case.name, frames = frames, plays = h.trace() } + h.close() + end + f.close() + return rows +end +return M diff --git a/tests/enemy_actions/native_bgm_devices.lua b/tests/enemy_actions/native_bgm_devices.lua new file mode 100644 index 000000000..e04cf5f08 --- /dev/null +++ b/tests/enemy_actions/native_bgm_devices.lua @@ -0,0 +1,33 @@ +-- No-output hardware retains real native logical command state. +local M = {} +function M.run(root, backend, version) + local f = dofile(root .. "/tests/enemy_actions/assist.lua").fixture(root, backend, version) + local rows = {} + for _, mode in ipairs({ "missing", "nil" }) do + for _, command in ipairs({ "fadeoutbgm", "fadeinbgm", "shared-to-native" }) do + local h = dofile(root .. "/tests/native_bgm/streams.lua").install( + root, + backend, + version, + f, + { queueFailure = mode, initialShared = command == "shared-to-native" } + ) + local frames = { h.state() } + local runner = h.start({ + command == "shared-to-native" and { "playbgm", h.ids[2], 0 } or { command, 4 }, + { "end" }, + }) + for tick = 0, 3 do + local state = h.state() + state.tick, state.running = tick, runner:isRunning() + frames[#frames + 1] = state + h.update() + end + rows[#rows + 1] = { mode = mode, command = command, frames = frames, trace = h.trace() } + h.close() + end + end + f.close() + return rows +end +return M diff --git a/tests/enemy_actions/native_bgm_hooks.lua b/tests/enemy_actions/native_bgm_hooks.lua new file mode 100644 index 000000000..42556482b --- /dev/null +++ b/tests/enemy_actions/native_bgm_hooks.lua @@ -0,0 +1,205 @@ +-- One unchanged public API-3 mod registration observes real music services. +local M = {} +function M.run(root, backend, version) + local native = backend == "gen1" + local f = dofile(root .. "/tests/enemy_actions/assist.lua").fixture(root, backend, version) + local rows = {} + for _, case in ipairs({ + "native", + "native-select", + "native-volume", + "native-map", + "file", + "file-select", + "file-silence", + "file-volume", + "file-options", + "file-options-out", + "file-map", + "file-map-fanfare", + "file-out", + "file-in", + "file-repeat", + "file-replace", + "file-to-native", + "file-fanfare", + "file-failure", + "file-location", + "file-intro", + "file-cry", + "file-cry-stop", + }) do + local crying = case == "file-cry" or case == "file-cry-stop" + local h, cryHardware + if crying then + -- Keep the owned cry image/pack intact. Only shared file transport is + -- needed here; native carrier scenarios use the sequencer fixture. + local base = dofile(root .. "/tests/native_bgm/fixture.lua").install(root, backend, version, f) + cryHardware = base.hardware + h = { + ids = base.ids, + start = base.start, + update = base.update, + close = base.close, + options = base.options, + endFiles = base.hardware.endSources, + state = function() + return { native = base.state() } + end, + override = function() + local songs = native and require("src.core.game3.mod_audio").data.audio.songs or f.data.audio.songs + songs.MUS_CYCLING = { file = "owned-bgm-stream-override.ogg" } + end, + } + else + h = dofile(root .. "/tests/native_bgm/streams.lua").install(root, backend, version, f) + end + local shared = case:sub(1, 4) == "file" + local fanfares = (case == "file-fanfare" or case == "file-map-fanfare") and h.fanfares() or nil + local builder = love.audio.newSource + if shared then + h.override("file") + local songs = native and require("src.core.game3.mod_audio").data.audio.songs or f.data.audio.songs + songs.MUS_SURF = { file = "owned-hook-surf.ogg" } + if case == "file-failure" then + love.audio.newSource = function(input, ...) + if input == "owned-bgm-stream-override.ogg" then + error("owned file unavailable") + end + return builder(input, ...) + end + elseif case == "file-intro" then + songs.MUS_CYCLING.loopFile = "owned-hook-loop.ogg" + end + end + local Game, savedWorld, savedGame + if case == "file-location" then + -- Load the real engine object, rather than the read-only Game facade + -- that a generation-3 mod's lazy require is allowed to receive. + savedGame = package.loaded["src.core.Game"] + Game = dofile("src/core/Game.lua") + package.loaded["src.core.Game"] = Game + savedWorld, Game.overworld = + Game.overworld, { + player = { cellX = 5, cellY = 6 }, + map = { id = "OWNED_HOOK_MAP" }, + tod = "night", + } + end + local calls, frames, tick = {}, {}, 0 + local Json = require("src.link.Json") + local function copy(value) + return Json.decode(Json.encode(value)) + end + local handles = {} + handles[#handles + 1] = f.e.api.hooks:wrap("music.select", function(next, value, context) + local selected = value + if case == "file-silence" then + selected = nil + elseif case == "file-select" then + selected = "MUS_SURF" + elseif case == "native-select" then + selected = "OWNED_MUST_NOT_SELECT_NATIVE" + end + local result = next(selected, context) + calls[#calls + 1] = + { name = "music.select", tick = tick, value = value, context = copy(context), result = result } + return result + end) + handles[#handles + 1] = f.e.api.hooks:wrap("music.volume", function(next, value, context) + local result = + next((case == "file-volume" or case == "native-volume") and value * 0.5 or value, context) + calls[#calls + 1] = + { name = "music.volume", tick = tick, value = value, context = copy(context), result = result } + return result + end) + for _, name in ipairs({ "music.started", "music.stopped" }) do + handles[#handles + 1] = f.e.api.events:on(name, function(event) + calls[#calls + 1] = { name = name, tick = tick, event = copy(event) } + end) + end + local function mapCue() + if native then + require("src.core.game3.audio").playMapSong(h.ids[2], { mapSong = h.ids[2], exact = true }) + else + f.data.audio.mapSongs.OWNED_HOOK_MAP = shared and "MUS_CYCLING" or ("SONG_%03X"):format(h.ids[2]) + require("src.core.Music").playMap(f.data, "OWNED_HOOK_MAP", true, true) + end + end + local script = { { "playbgm", h.ids[case == "file-map-fanfare" and 1 or 2], 0 } } + if case == "file-out" or case == "file-options-out" then + script[#script + 1] = { "fadeoutbgm", 4 } + elseif case == "file-in" then + script[#script + 1] = { "fadeinbgm", 4 } + end + script[#script + 1] = { "end" } + if case == "native-map" or case == "file-map" then + mapCue() + end + local runner = h.start(script) + local ticks = (case == "file-out" or case == "file-options-out") and 70 or crying and 17 or 9 + for frame = 0, ticks do + tick = frame + if tick == 2 then + if case == "file-options" or case == "file-options-out" then + h.options(3) + elseif case == "file-repeat" then + h.start({ { "playbgm", h.ids[2], 0 }, { "end" } }) + elseif case == "file-replace" then + h.start({ { "playbgm", h.ids[3], 0 }, { "end" } }) + elseif case == "file-to-native" then + h.start({ { "playbgm", h.ids[1], 0 }, { "end" } }) + elseif case == "file-fanfare" or case == "file-map-fanfare" then + h.start({ { "playfanfare", fanfares[1] }, { "end" } }) + if case == "file-map-fanfare" then + mapCue() + end + elseif crying then + h.start({ { "playmoncry", 4, 0 }, { "end" } }) + end + end + if tick == 4 and case == "file-cry-stop" then + cryHardware.stop() + end + if tick == 3 and case == "file-intro" then + h.endFiles() + end + local state = h.state().native + -- Physical native labels are internal to the engines. Compare the + -- actual native producer identity and every public music callback. + state.physicalSong = nil + local producer + if native then + local slot = require("src.core.game3.audio")._bgmLocal + producer = slot and slot.songId + elseif require("src.core.ChipAudio").nativeBgmState() then + local label = require("src.core.Music").current() + producer = type(label) == "string" and tonumber(label:match("^SONG_(%x+)$") or "", 16) or nil + end + frames[#frames + 1] = { tick = tick, state = state, producer = producer, running = runner:isRunning() } + h.update() + end + local samples + if crying then + for _, play in ipairs(cryHardware.trace()) do + if play.samples then + samples = #play.samples + end + end + assert(samples and samples > 1000, "the world command must render an actual owned cry") + end + rows[#rows + 1] = { case = case, calls = calls, frames = frames, crySamples = samples } + for _, unsubscribe in ipairs(handles) do + unsubscribe() + end + if Game then + Game.overworld = savedWorld + package.loaded["src.core.Game"] = savedGame + end + love.audio.newSource = builder + h.close() + end + f.close() + return rows +end +return M diff --git a/tests/enemy_actions/native_bgm_options.lua b/tests/enemy_actions/native_bgm_options.lua new file mode 100644 index 000000000..cdd65d37e --- /dev/null +++ b/tests/enemy_actions/native_bgm_options.lua @@ -0,0 +1,149 @@ +-- Engine options and real world actions entered from unchanged public hooks. +local M = {} +function M.run(root, backend, version) + local native = backend == "gen1" + local Music = require("src.core.Music") + local f = dofile(root .. "/tests/enemy_actions/assist.lua").fixture(root, backend, version) + local Json = require("src.link.Json") + local function copy(value) + return Json.decode(Json.encode(value)) + end + local rows = {} + for _, case in ipairs({ + "native-modes", + "file-modes", + "file-modes-out", + "native-missing", + "file-missing", + "file-preserve-mode", + "file-reentrant-native", + "file-reentrant-shared", + "file-reentrant-fanfare", + "native-muted-fanfare", + "native-registered", + "file-preserve-game", + "file-early-options", + }) do + if Music.setAudioMode then + Music.setAudioMode("both") + end + local h = dofile(root .. "/tests/native_bgm/streams.lua").install(root, backend, version, f) + local shared = case:sub(1, 4) == "file" + if shared or case == "native-registered" then + h.override("file") + local songs = native and require("src.core.game3.mod_audio").data.audio.songs or f.data.audio.songs + songs.MUS_SURF = { file = "owned-option-surf.ogg" } + end + local fanfares = case:find("fanfare") and h.fanfares() or nil + local tick, mix, calls, frames = 0, {}, {}, {} + local oldMix = love.audio.setMixWithSystem + love.audio.setMixWithSystem = function(value) + mix[#mix + 1] = { tick = tick, value = value } + end + local mixer = package.loaded["src.audio.AudioMix"] + if mixer and mixer._resetForTest then + mixer._resetForTest() + end + h.options({ musicVol = 7, audioMode = "both" }) + if case == "file-early-options" then + h.options({ musicVol = 3, audioMode = "external_only" }) + end + local entered = false + local off = f.e.api.hooks:wrap("music.volume", function(next, value, context) + if tick == 2 and not entered and case:find("reentrant") then + entered = true + if case == "file-reentrant-native" then + h.start({ { "playbgm", h.ids[1], 0 }, { "fadeoutbgm", 4 }, { "end" } }) + elseif case == "file-reentrant-shared" then + h.start({ { "playbgm", h.ids[3], 0 }, { "end" } }) + else + h.start({ { "playfanfare", fanfares[1] }, { "end" } }) + end + end + local result = next(value, context) + calls[#calls + 1] = { tick = tick, value = value, result = result, context = copy(context) } + return result + end) + local script = { { "playbgm", h.ids[case == "native-registered" and 1 or 2], 0 } } + if case == "file-modes-out" then + script[#script + 1] = { "fadeoutbgm", 4 } + end + script[#script + 1] = { "end" } + local runner = h.start(script) + for frame = 0, 13 do + tick = frame + if case:find("reentrant") then + if tick == 2 then + h.options({ musicVol = 3, audioMode = "both" }) + end + elseif case == "file-early-options" then + if tick == 5 then + h.options({ musicVol = 3, audioMode = "both" }) + end + elseif case:find("missing") then + if tick == 1 then + h.options({ musicVol = 3, audioMode = "both" }) + elseif tick == 3 then + h.options({ audioMode = "both" }) + elseif tick == 4 then + h.start({ { "playbgm", h.ids[1], 0 }, { "fadeoutbgm", 4 }, { "end" } }) + end + elseif case == "native-muted-fanfare" then + if tick == 1 then + h.options({ musicVol = 3, audioMode = "external_only" }) + elseif tick == 2 then + h.start({ { "playfanfare", fanfares[1] }, { "end" } }) + end + else + if tick == 1 then + h.options({ + musicVol = 3, + audioMode = case == "file-preserve-game" and "game_only" or "external_only", + }) + elseif tick == 3 then + h.options( + (case == "file-preserve-mode" or case == "file-preserve-game") and { musicVol = 5 } + or { musicVol = 5, audioMode = "game_only" } + ) + elseif tick == 5 or tick == 6 then + h.options({ musicVol = 3, audioMode = "both" }) + elseif tick == 7 then + h.options({ musicVol = 0, audioMode = "both" }) + elseif tick == 8 then + h.options({ musicVol = 7, audioMode = "external_only" }) + elseif tick == 10 then + h.options({ musicVol = 6, audioMode = "both" }) + end + end + if tick == 4 and case == "native-registered" then + h.start({ { "playbgm", h.ids[2], 0 }, { "end" } }) + end + local hardware = h.state() + local active, muted = 0, true + for _, source in ipairs(hardware.queues) do + if source.playing then + active = active + 1 + muted = muted and source.volume == 0 + end + end + local state = hardware.native + state.physicalSong = nil + frames[#frames + 1] = { + tick = tick, + state = state, + running = runner:isRunning(), + fanfare = fanfares and h.fanfareState(fanfares) or nil, + nativeActive = active, + nativeMuted = muted, + } + h.update() + end + rows[#rows + 1] = { case = case, calls = calls, frames = frames, mix = mix, entered = entered } + off() + love.audio.setMixWithSystem = oldMix + h.close() + end + f.close() + return rows +end +return M diff --git a/tests/enemy_actions/native_bgm_policy.lua b/tests/enemy_actions/native_bgm_policy.lua new file mode 100644 index 000000000..83b4b55cb --- /dev/null +++ b/tests/enemy_actions/native_bgm_policy.lua @@ -0,0 +1,114 @@ +-- Actual world adapters read live map, weather and story state in both engines. +local M = {} +function M.run(root, backend, version) + local f = dofile(root .. "/tests/enemy_actions/assist.lua").fixture(root, backend, version) + local cases = {} + local function add(name, number, options) + options = options or {} + options.name, options.group, options.number = name, options.group or 0, number + options.music = options.music == nil and 413 or options.music + cases[#cases + 1] = options + end + if version == "emerald" then + for _, weather in ipairs({ 0, 8 }) do + for _, flag in ipairs({ false, true }) do + add( + "ROUTE111", + 26, + { weather = weather, activeWeather = weather == 8 and 0 or 8, flags = { [2186] = flag } } + ) + end + end + for _, sky in ipairs({ 0, 1, 2 }) do + for _, flag in ipairs({ false, true }) do + for _, city in ipairs({ 3, 4 }) do + add("SOOTOPOLIS_CITY", 7, { flags = { [2186] = flag }, vars = { [16586] = sky, [16478] = city } }) + end + end + end + local legendary = { + { "LILYCOVE_CITY", 5 }, + { "MOSSDEEP_CITY", 6 }, + { "EVER_GRANDE_CITY", 8 }, + { "ROUTE124", 39 }, + { "ROUTE125", 40 }, + { "ROUTE126", 41 }, + { "ROUTE127", 42 }, + { "ROUTE128", 43 }, + { "ROUTE129", 44 }, + { "ROUTE130", 45 }, + { "ROUTE131", 46 }, + } + for _, map in ipairs(legendary) do + for _, flag in ipairs({ false, true }) do + for _, city in ipairs({ 3, 4 }) do + add(map[1], map[2], { flags = { [2186] = flag }, vars = { [16478] = city } }) + end + end + end + for floor = 1, 2 do + for _, state in ipairs({ 0, 1, 2, 3 }) do + add( + "MOSSDEEP_CITY_SPACE_CENTER_" .. floor .. "F", + floor + 8, + { group = 14, vars = { [16477] = state } } + ) + end + for _, state in ipairs({ 0, 1 }) do + add( + "ROUTE119_WEATHER_INSTITUTE_" .. floor .. "F", + floor - 1, + { group = 32, vars = { [16563] = state } } + ) + end + end + for _, x in ipairs({ 0, 23, 24, 50 }) do + for _, music in ipairs({ 32767, "32767", 413 }) do + add("ROUTE118", 33, { x = x, music = music }) + end + end + for _, music in ipairs({ 413, "413", 0, "unregistered-header" }) do + add("OWNED_UNRELATED", 120, { music = music, weather = 8, flags = { [2186] = true } }) + end + else + for _, music in ipairs({ 0, 413, 65535 }) do + add("ROUTE111", 26, { music = music, weather = 8, flags = { [2186] = true } }) + add("SOOTOPOLIS_CITY", 7, { music = music, vars = { [16586] = 1 } }) + end + end + local rows = {} + for index, input in ipairs(cases) do + local h = + dofile(root .. "/tests/native_bgm/fixture.lua").install(root, backend, version, f, { policy = true }) + h.location(input) + local runner = + h.start({ { "playbgm", h.ids[2], 0 }, { "savebgm", h.ids[3] }, { "fadedefaultbgm" }, { "end" } }) + local frames = {} + for tick = 0, 4 do + h.tick(tick) + if tick == 2 then + -- Re-read live values: a previous result must not become a cached header. + h.location({ + name = input.name, + group = input.group, + number = input.number, + x = 24, + music = input.music, + weather = 0, + flags = {}, + vars = { [16586] = 2, [16477] = 3, [16563] = 1 }, + }) + runner = h.start({ { "fadedefaultbgm" }, { "end" } }) + end + local state = h.state() + state.tick, state.running = tick, runner:isRunning() + frames[#frames + 1] = state + h.update() + end + rows[#rows + 1] = { case = index, input = input, frames = frames, plays = h.trace() } + h.close() + end + f.close() + return rows +end +return M diff --git a/tests/enemy_actions/native_bgm_symbols.lua b/tests/enemy_actions/native_bgm_symbols.lua new file mode 100644 index 000000000..f3b13ac5a --- /dev/null +++ b/tests/enemy_actions/native_bgm_symbols.lua @@ -0,0 +1,51 @@ +local M = {} +function M.run(root, backend, version) + local native = backend == "gen1" + local V = require("src.core.GameVersion") + local oldVersion = V.get() + V.set(version) + local runtime, oldSession, Song, oldSelected + if native then + runtime = require("src.core.game3.runtime") + oldSession, runtime.session = runtime.session, { version = version } + Song = require("src.core.game3.song_ids") + oldSelected = Song.current + Song.select(version) + end + local module = not native and require("src.core.Gen3SongIds") + local names = native and Song.forVersion(version) or module.names(version) + local keys = {} + for key in pairs(names) do + if key:match("^MUS_") then + keys[#keys + 1] = key + end + end + table.sort(keys) + local rows = {} + local function resolve(value) + return native and require("src.core.game3.audio").resolveSong(value) or module.resolve(value, version) + end + local function record(input) + rows[#rows + 1] = { input = input, resolved = resolve(input) } + end + for _, name in ipairs(keys) do + local id = names[name] + record(name) + record(name:lower()) + record(" " .. name .. " ") + record(id) + record(tostring(id)) + rows[#rows + 1] = + { input = id, canonical = native and Song.nameOf(id, version) or module.nameOf(id, version) } + end + for _, input in ipairs({ "MOD_SONG", " MOD_SONG ", "unknown", "0x116", 0, 0x7FFF, 0xFFFF }) do + record(input) + end + if native then + runtime.session = oldSession + Song.select(oldSelected) + end + V.set(oldVersion) + return rows +end +return M diff --git a/tests/enemy_actions/native_cry_commands.lua b/tests/enemy_actions/native_cry_commands.lua new file mode 100644 index 000000000..acc2a39e8 --- /dev/null +++ b/tests/enemy_actions/native_cry_commands.lua @@ -0,0 +1,145 @@ +local M = {} +function M.run(root, backend, version) + local native = backend == "gen1" + local f = dofile(root .. "/tests/enemy_actions/assist.lua").fixture(root, backend, version) + local runtime, savedSession + if native then + runtime = require("src.core.game3.runtime") + savedSession = runtime.session + runtime.session = { version = version } + assert(require("src.core.game3.profile").forSession(nil).id == version) + end + local rows = {} + for _, case in ipairs({ + { name = "silent", silent = true }, + { name = "no-pack", noPack = true }, + { name = "mode-zero" }, + { name = "mode-one", mode = 1 }, + { name = "mode-two", mode = 2 }, + { name = "mode-three", mode = 3 }, + { name = "mode-four", mode = 4 }, + { name = "mode-five", mode = 5 }, + { name = "mode-six", mode = 6 }, + { name = "mode-seven", mode = 7 }, + { name = "mode-eight", mode = 8 }, + { name = "mode-nine", mode = 9 }, + { name = "mode-ten", mode = 10 }, + { name = "mode-eleven", mode = 11 }, + { name = "mode-twelve", mode = 12 }, + { name = "variables", variables = true, mode = 2 }, + { name = "hoenn", species = 277 }, + { name = "legacy-hoenn-cache", species = 277, legacyMapping = true }, + { name = "normal-audio", audioEvery = 1 }, + { name = "half-audio", audioEvery = 2 }, + { name = "early-source-end", finishAt = 2 }, + { name = "mono", mono = true }, + { name = "unknown-mode", mode = 255 }, + { name = "zero-species", species = 0 }, + { name = "old-unown-species", species = 252 }, + { name = "unsupported-species", species = 412 }, + { name = "overlapping", overlap = true }, + { name = "consecutive", consecutive = true }, + { name = "consecutive-no-pack", consecutive = true, noPack = true }, + { name = "file-override", override = "file", finishAt = 3 }, + { name = "string-override", override = "string", finishAt = 3 }, + { name = "disabled-override", override = "disabled" }, + { name = "disabled-after-native", overrideLater = "disabled" }, + }) do + local h = dofile(root .. "/tests/native_cry/fixture.lua").install(native, f, case) + if case.override then + h.override(case.species or 4, case.override) + end + local commands = {} + local function add(name, a, b) + commands[#commands + 1] = native and { op = name, a, b } or { name, a, b } + end + local species, mode = case.species or 4, case.mode or 0 + if case.variables then + add("setvar", 0x8004, species) + add("setvar", 0x8005, mode) + species, mode = 0x8004, 0x8005 + end + if not case.silent then + add("playmoncry", species, mode) + end + if case.overlap then + add("playmoncry", 277, 6) + end + add("waitmoncry") + if case.consecutive then + add("setvar", 0x4002, 1) + add("playmoncry", 277, 6) + add("waitmoncry") + end + add("setvar", 0x4001, 1) + add("end") + local runner, value + h.tick(0) + if native then + runner = require("src.core.game3.scripting.vm").new({ scripts = { OWNED_CRY = commands } }) + assert(runner:start("OWNED_CRY")) + value = function() + return require("src.core.game3.scripting.flags").getVar(runner.store, runner.ctx, 0x4001) + end + else + local Cmd = require("src.script.Gen3Commands") + Cmd.setVar(f.game.save, 0x4001, 0) + local lowered = assert(require("src.script.Gen3ScriptVM").compile({ + map_scripts = { + source = "RomExtractorGen3", + scripts = { OWNED_CRY = commands }, + }, + }, "OWNED_CRY")) + runner = require("src.script.ScriptRunner").new(f.game, {}) + runner:run(lowered, { source = { modId = "portable_held_item", strict = true } }) + value = function() + return Cmd.getVar(f.game.save, 0x4001) + end + end + local frames, completed = {}, nil + for tick = 0, case.consecutive and 150 or 75 do + h.tick(tick) + if tick == case.finishAt then + h.endSources() + end + if case.overrideLater and tick == 1 then + h.override(4, case.overrideLater) + h.play(4, { mode = 2 }) + end + if tick > 0 then + if native then + runner:tick() + else + runner:update() + end + end + local snapshot = h.snapshot() + frames[#frames + 1] = { + tick = tick, + value = value(), + running = runner:isRunning(), + finished = snapshot.finished, + clock = snapshot.clock, + untilFrame = snapshot.untilFrame, + mode = snapshot.mode, + mix = snapshot.mix, + sources = h.active(), + } + if value() == 1 and not completed then + completed = tick + end + if case.audioEvery and tick % case.audioEvery == 0 then + h.update() + end + end + assert(completed ~= nil, "actual native cry command sequence completes") + rows[#rows + 1] = { case = case.name, frames = frames, completed = completed, plays = h.trace() } + h.close() + end + if native then + runtime.session = savedSession + end + f.close() + return rows +end +return M diff --git a/tests/enemy_actions/native_cry_lifecycle.lua b/tests/enemy_actions/native_cry_lifecycle.lua new file mode 100644 index 000000000..8819f312e --- /dev/null +++ b/tests/enemy_actions/native_cry_lifecycle.lua @@ -0,0 +1,71 @@ +-- Actual native/host cry ownership, controls and complete rendered outputs. +local M = {} +function M.run(root, backend, version) + local native = backend == "gen1" + local f = dofile(root .. "/tests/enemy_actions/assist.lua").fixture(root, backend, version) + local runtime, oldSession + if native then + runtime = require("src.core.game3.runtime") + oldSession, runtime.session = runtime.session, { version = version } + end + local cases = { + { name = "default" }, + { name = "no-duck", first = { noDuck = true } }, + { name = "release", first = { release = true } }, + { name = "multi-release", first = { release = true, multi = true } }, + { name = "mode-one-release", first = { mode = 1, release = true } }, + { name = "explicit-pan", first = { pan = -64 } }, + { name = "numeric-zero-pan", first = { pan = 0 } }, + { name = "string-zero-pan", first = { pan = "0" } }, + { name = "right-pan-mono", first = { pan = 63 }, mono = true }, + { name = "volume-zero", first = { volume = 0 } }, + { name = "volume-ninety", first = { volume = 90 } }, + { name = "boolean-controls-only", first = { release = 1, noDuck = "true", multi = 0 } }, + { name = "overlap", second = { mode = 6 } }, + { name = "overlap-same-buffer", same = true, second = { mode = 0 } }, + { name = "overlap-no-duck", second = { noDuck = true } }, + { name = "overlap-mode-one", second = { mode = 1 } }, + { name = "overlap-release", second = { release = true } }, + { name = "stop-latest-only", second = { mode = 6 }, stop = 3 }, + { name = "restart-after-stop", second = { mode = 2 }, stop = 1 }, + { name = "speaker-ends-early", endAt = 2 }, + { name = "file-override-after-native", override = "file", endAt = 3 }, + { name = "disabled-after-native", override = "disabled" }, + } + local rows = {} + for _, case in ipairs(cases) do + local h = dofile(root .. "/tests/native_cry/fixture.lua").install(native, f, case) + local frames = {} + for tick = 0, 80 do + h.tick(tick) + if tick == 0 then + h.play(4, case.first or {}) + end + if tick == case.stop then + h.stop() + end + if tick == 2 and case.second then + h.play(case.same and 4 or 277, case.second) + end + if tick == 2 and case.override then + h.override(4, case.override) + h.play(4, { mode = 2 }) + end + if tick == case.endAt then + h.endSources() + end + local snapshot = h.snapshot() + snapshot.tick, snapshot.sources = tick, h.active() + frames[#frames + 1] = snapshot + h.update() + end + rows[#rows + 1] = { case = case.name, frames = frames, plays = h.trace() } + h.close() + end + if native then + runtime.session = oldSession + end + f.close() + return rows +end +return M diff --git a/tests/enemy_actions/native_cry_pcm.lua b/tests/enemy_actions/native_cry_pcm.lua new file mode 100644 index 000000000..669d99216 --- /dev/null +++ b/tests/enemy_actions/native_cry_pcm.lua @@ -0,0 +1,85 @@ +-- Complete owned PCM/control observations through unchanged native rendering. +local M = {} +function M.run(root, backend, version) + local T = require("tests.modkit") + local native = backend == "gen1" + local module = native and require("src.core.game3.m4a_sample") or require("src.core.NativeCryPcm") + local rows = {} + local function params(mode, volume) + if native then + return module.cryParams(mode, volume, version == "emerald" and { [6] = { volume = 70 } } or nil) + end + return module.params(mode, version, volume) + end + local function record(pcm, rate, mode, options, label) + local controls = params(mode) + local sound, info + if native then + sound, info = module.renderCry(pcm, rate, controls, options) + else + sound, info = module.render(pcm, rate, controls, options) + end + assert(sound and info) + local samples = {} + for i = 0, sound:getSampleCount() - 1 do + local channels = {} + for c = 1, sound:getChannelCount() do + channels[c] = sound:getSample(i, c) + end + samples[#samples + 1] = channels + end + rows[#rows + 1] = { + kind = "pcm", + case = label, + mode = mode, + controls = controls, + options = options, + info = info, + rate = sound:getSampleRate(), + depth = sound:getBitDepth(), + channels = sound:getChannelCount(), + samples = samples, + } + end + local bytes = {} + for i = 0, 128 do + bytes[#bytes + 1] = string.char((i * 73 + i * i * 11) % 256) + end + local short = table.concat(bytes) + local long = short:rep(128):sub(1, 8192) + for _, mode in ipairs({ -1, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 255 }) do + for _, volume in ipairs({ 0, 70, 120, 127 }) do + rows[#rows + 1] = { kind = "params", mode = mode, volume = volume, controls = params(mode, volume) } + end + for _, options in ipairs({ + { outRate = 44100 }, + { outRate = 22050, mono = true, pan = -64 }, + { outRate = 44100, pan = 0 }, + { outRate = 44100, pan = 63 }, + }) do + record(short, 13379, mode, options, "asymmetric-short") + end + record(long, 4000, mode, { outRate = 8000 }, "envelope-and-tail") + end + for _, freq in ipairs({ -1, 0, 1, 499 * 1024, 500 * 1024, 13379 * 1024 + 512, 96000 * 1024 }) do + rows[#rows + 1] = { kind = "rate", input = freq, rate = module.waveRate(freq) } + end + local compressed = {} + for i = 0, 131 do + compressed[#compressed + 1] = string.char((i * 37 + 191) % 256) + end + compressed = table.concat(compressed) + for _, length in ipairs({ 0, 1, 2, 32, 33, 34, 65, 132 }) do + for _, count in ipairs({ 0, 1, 2, 63, 64, 65, 127, 128, 129, 256 }) do + local result = native and module.decodeGfdpcm(compressed:sub(1, length), count) + or module.decodeDpcm(compressed:sub(1, length), count) + local decoded = {} + for i = 1, #result do + decoded[i] = result:byte(i) + end + rows[#rows + 1] = { kind = "dpcm", length = length, count = count, decoded = decoded } + end + end + return rows +end +return M diff --git a/tests/enemy_actions/native_fanfare_commands.lua b/tests/enemy_actions/native_fanfare_commands.lua new file mode 100644 index 000000000..6f2574569 --- /dev/null +++ b/tests/enemy_actions/native_fanfare_commands.lua @@ -0,0 +1,95 @@ +local M = {} +function M.run(root, backend, version) + local native = backend == "gen1" + local f = dofile(root .. "/tests/enemy_actions/assist.lua").fixture(root, backend, version) + local cases = { + { name = "ordinary", speed = 1 }, + { name = "fast", speed = 10 }, + { name = "four-times", speed = 4 }, + { name = "logic-paused", speed = 0 }, + { name = "variable", speed = 1, variable = true }, + { name = "fallback", speed = 1, unknown = true }, + { name = "no-table", speed = 1, unknown = true, noTable = true, ticks = 180 }, + { name = "song-timer", speed = 1, songFrames = 6 }, + { name = "empty-table", speed = 1, emptyTable = true, ticks = 180 }, + { name = "no-fanfare", speed = 1, silent = true }, + { name = "bgm-dispatch", speed = 1, asBgm = true }, + { name = "audio-paused", speed = 1, noAudio = true, ticks = 370 }, + { name = "long", speed = 1, frames = 400, ticks = 420 }, + { name = "long-fast", speed = 10, frames = 400, ticks = 420 }, + { name = "replacement", speed = 1, action = "replace" }, + { name = "new-bgm", speed = 1, action = "new" }, + { name = "same-bgm", speed = 1, action = "same" }, + { name = "stop-bgm", speed = 1, action = "stop" }, + { name = "deferred-default", speed = 1, action = "default", cycle = true }, + { name = "latched-completion", speed = 1, action = "latched", actionAt = 4 }, + } + local rows = {} + for _, case in ipairs(cases) do + local h = dofile(root .. "/tests/native_bgm/fanfares.lua").install(root, backend, version, f, case) + h.clear() + local script = {} + if case.cycle then + script[#script + 1] = { "playbgm", h.bgmIds[2], 0 } + end + local id = case.unknown and 999 or h.ids[1] + if case.variable then + script[#script + 1] = { "setvar", 0x8004, id } + id = 0x8004 + end + if not case.silent then + script[#script + 1] = { case.asBgm and "playbgm" or "playfanfare", id, 1 } + end + script[#script + 1] = { "setvar", 0x4002, 1 } + script[#script + 1] = { "waitfanfare" } + script[#script + 1] = { "setvar", 0x4001, 1 } + script[#script + 1] = { "end" } + local runner = h.start(script) + local frames, completed = {}, nil + for tick = 0, case.ticks or 14 do + h.tick(tick) + if tick == (case.actionAt or 2) then + local action + if case.action == "replace" or case.action == "latched" then + action = { "playfanfare", h.ids[2] } + elseif case.action == "new" then + action = { "playbgm", h.bgmIds[2], 0 } + elseif case.action == "same" then + action = { "playbgm", h.bgmIds[1], 0 } + elseif case.action == "stop" then + action = { "playbgm", 0, 0 } + elseif case.action == "default" then + action = { "fadedefaultbgm" } + end + if action then + h.start({ action, { "end" } }) + end + end + if tick > 0 then + for _ = 1, case.speed do + f.game.save.playTime = (f.game.save.playTime or 0) + 1 / 60 + if native then + runner:tick() + else + runner:update() + end + end + end + local state = h.state() + state.tick, state.running, state.value = tick, runner:isRunning(), h.value(runner) + state.startedValue = h.value(runner, 0x4002) + if state.value == 1 then + completed = completed or tick + end + frames[#frames + 1] = state + if not case.noAudio then + h.update() + end + end + rows[#rows + 1] = { case = case.name, completed = completed, frames = frames, plays = h.trace() } + h.close() + end + f.close() + return rows +end +return M diff --git a/tests/enemy_actions/native_sound_lifecycle.lua b/tests/enemy_actions/native_sound_lifecycle.lua new file mode 100644 index 000000000..fc5ea4ea9 --- /dev/null +++ b/tests/enemy_actions/native_sound_lifecycle.lua @@ -0,0 +1,96 @@ +-- Sequential commands use real native audio and real host M4A sources. +-- Only the speaker hardware is owned; no audio device or normal cache is used. +local M = {} +function M.run(root, backend, version) + local native = backend == "gen1" + local f = dofile(root .. "/tests/enemy_actions/assist.lua").fixture(root, backend, version) + local runtime, savedSession + if native then + runtime = require("src.core.game3.runtime") + savedSession, runtime.session = runtime.session, { version = version } + assert(require("src.core.game3.profile").forSession(nil).id == version, "actual native audio profile") + end + local hardware = dofile(root .. "/tests/native_sound/fixture.lua").install(root, native, f) + local cases = { + { name = "same-id-same-controls", steps = { { 141, -64 }, { 141, -64 } } }, + { name = "same-id-pan-switch", steps = { { 141, -64 }, { 141, 63 } } }, + { name = "different-id-same-player", steps = { { 141, -64 }, { 164, 63 } } }, + { name = "independent-players", otherPlayer = 2, steps = { { 141, -64 }, { 164, 63 } } }, + { name = "replace-one-player", otherPlayer = 2, steps = { { 141, -64 }, { 164, 63 }, { 148, -64 } } }, + { name = "same-id-mono-switch", steps = { { 141, -64 }, { 141, -64, true } } }, + { name = "native-then-role", steps = { { 141, -64 }, { 164, nil, false, true } } }, + { name = "role-then-native", steps = { { 164, nil, false, true }, { 141, -64 } } }, + { name = "same-id-independent-players", steps = { { 141, -64 }, { 141, -64, false, false, 2 } } }, + { + name = "replace-after-clone", + steps = { { 141, -64 }, { 141, -64, false, false, 2 }, { 164, 63, false, false, 2 } }, + }, + { name = "stop-id", otherPlayer = 2, steps = { { 141, -64 }, { 164, 63 }, { op = "stop", id = 141 } } }, + { name = "stop-all", otherPlayer = 2, steps = { { 141, -64 }, { 164, 63 }, { op = "stop" } } }, + { + name = "stop-cloned-id", + steps = { { 141, -64 }, { 141, -64, false, false, 2 }, { op = "stop", id = 141 } }, + }, + { name = "special-player", otherPlayer = 3, steps = { { 164, 63 } } }, + { + name = "retain-special-player", + otherPlayer = 3, + steps = { { 141, -64 }, { 164, 63 }, { + op = "stop", + id = 141, + } }, + }, + { name = "file-override", override = "file", steps = { { 141, -64 }, { 164, 63 } } }, + { name = "string-override", override = "string", steps = { { 141, -64 }, { 164, 63 } } }, + { name = "disabled-override", override = "disabled", steps = { { 141, -64 }, { 164, 63 } } }, + { name = "native-then-playSe", steps = { { 141, -64 }, { 164, 63, false, false, false, true } } }, + { name = "playSe-then-role", steps = { { 141, -64, false, false, false, true }, { 164, nil, false, true } } }, + } + local rows = {} + for _, case in ipairs(cases) do + hardware.clear() + hardware.player(141, 1) + hardware.player(148, 1) + hardware.player(164, case.otherPlayer or 1) + if case.override then + hardware.override(164, case.override) + end + local states, queries = {}, {} + for tick, step in ipairs(case.steps) do + if step[5] then + hardware.player(step[1], step[5]) + end + hardware.tick(tick) + hardware.stereo(not step[3]) + if step[4] then + hardware.role(step[1], "OWNED_ROLE_" .. step[1]) + end + if step.op == "stop" then + if native then + require("src.core.game3.audio").stopSe(step.id) + else + require("src.core.Sound").stopSe(f.data, step.id) + end + elseif native then + require("src.core.game3.audio").playSe(step[1], { pan = step[2] }) + elseif step[4] then + assert(require("src.core.Sound").play(f.data, "OWNED_ROLE_" .. step[1])) + elseif step[6] then + require("src.core.Sound").playSe(f.data, step[1], { pan = step[2], mono = step[3] or false }) + else + require("src.core.Sound").playId(f.data, step[1], { pan = step[2], mono = step[3] or false }) + end + states[#states + 1] = hardware.active() + queries[#queries + 1] = hardware.queries() + end + rows[#rows + 1] = { case = case.name, states = states, sounds = hardware.trace(), queries = queries } + end + hardware.clear() + hardware.close() + if native then + runtime.session = savedSession + end + f.close() + return rows +end +return M diff --git a/tests/enemy_actions/native_sound_pan.lua b/tests/enemy_actions/native_sound_pan.lua new file mode 100644 index 000000000..b4b47a50d --- /dev/null +++ b/tests/enemy_actions/native_sound_pan.lua @@ -0,0 +1,50 @@ +-- Common PCM carrier isolates native stereo/mono control from synthesis. +-- Every resulting sample and all format metadata participate in comparison. +local M = {} +function M.run(root, backend) + local T = require("tests.modkit") + local native = backend == "gen1" + local apply = native and require("src.core.game3.audio")._buildSeSoundData + or require("src.core.NativeSoundPan").apply + local left = { -1, -0.75, -0.25, 0, 0.125, 0.5, 0.75, 1 } + local other = { 1, 0.25, -0.5, 0.75, -0.125, 0, -1, 0.5 } + local rows = {} + for _, channels in ipairs({ 1, 2 }) do + local right = channels == 1 and left or other + for _, mono in ipairs({ false, true }) do + for _, pan in ipairs({ -100, -64, -32, 0, 32, 63, 100 }) do + local source = love.sound.newSoundData(#left, 44100, 16, channels) + for i = 1, #left do + source:setSample(i - 1, 1, left[i]) + if channels == 2 then + source:setSample(i - 1, 2, right[i]) + end + end + local result = native and apply(left, right, 1, pan, mono) or apply(source, pan, mono) + local samples = {} + for i = 0, result:getSampleCount() - 1 do + local frame = {} + for c = 1, result:getChannelCount() do + frame[c] = result:getSample(i, c) + end + samples[#samples + 1] = frame + T.eq(source:getSample(i, 1), left[i + 1], "panning retains original carrier samples") + if channels == 2 then + T.eq(source:getSample(i, 2), right[i + 1], "panning retains original right channel") + end + end + rows[#rows + 1] = { + inputChannels = channels, + mono = mono, + pan = pan, + sampleRate = result:getSampleRate(), + bitDepth = result:getBitDepth(), + channels = result:getChannelCount(), + samples = samples, + } + end + end + end + return rows +end +return M diff --git a/tests/enemy_actions/native_sound_script_waits.lua b/tests/enemy_actions/native_sound_script_waits.lua new file mode 100644 index 000000000..09a984b44 --- /dev/null +++ b/tests/enemy_actions/native_sound_script_waits.lua @@ -0,0 +1,201 @@ +-- Actual native world VM and host lowering/runner; only source hardware is owned. +local M = {} +function M.run(root, backend, version) + local native = backend == "gen1" + local f = dofile(root .. "/tests/enemy_actions/assist.lua").fixture(root, backend, version) + local runtime, savedSession + if native then + runtime = require("src.core.game3.runtime") + savedSession, runtime.session = runtime.session, { version = version } + assert(require("src.core.game3.profile").forSession(nil).id == version) + end + local hardware = dofile(root .. "/tests/native_sound/fixture.lua").install(root, native, f) + local audio = native and require("src.core.game3.audio") or require("src.core.Sound") + local savedWaits = native and audio._waitSe + local function audioTick() + if native then + audio.update(1 / 60) + else + audio.update() + end + end + local function play(id, player) + if player then + hardware.player(id, player) + end + local opts = { pan = id == 141 and -64 or 63 } + if native then + audio.playSe(id, opts) + else + audio.playId(f.data, id, opts) + end + end + local function stop(id) + if native then + audio.stopSe(id) + else + audio.stopSe(f.data, id) + end + end + local rows = {} + for _, case in ipairs({ + { name = "silent" }, + { name = "native-player-one", play = true }, + { name = "native-player-two", play = true, player = 2 }, + { name = "special-player-three", play = true, player = 3 }, + { name = "file-override", override = true }, + { name = "native-and-file-override", play = true, override = true }, + { name = "logic-only-cap", play = true, cap = true }, + { name = "normal-audio-cap", play = true, cap = true, audioEvery = 1, audioCount = 1 }, + { name = "double-audio-cap", play = true, cap = true, audioEvery = 1, audioCount = 2 }, + { name = "half-audio-cap", play = true, cap = true, audioEvery = 2, audioCount = 1 }, + { name = "latched-silence", play = true, latch = true, audioEvery = 1, audioCount = 1 }, + { name = "unlatched-silence", play = true, latch = true }, + { name = "replacement-keeps-waiting", play = true, replace = true }, + { name = "independent-player-keeps-waiting", play = true, other = 2 }, + { name = "special-player-after-regular", play = true, other = 3 }, + { name = "stop-native-keeps-file", play = true, override = true, stopEarly = true }, + { name = "consecutive-logic-waits", play = true, cap = true, twice = true }, + { + name = "consecutive-audio-waits", + play = true, + cap = true, + twice = true, + audioEvery = 1, + audioCount = 1, + }, + }) do + hardware.clear() + audioTick() + hardware.stereo(true) + hardware.player(141, case.player or 1) + hardware.player(164, 1) + hardware.tick(0) + if case.play then + play(141) + end + if case.other then + play(164, case.other) + end + if case.override then + hardware.override(164, "file") + if native then + require("src.core.game3.audio").playSe(164) + else + require("src.core.Sound").playId(f.data, 164) + end + end + local runner, advanced, firstAdvanced + if native then + local script = { { op = "waitse" }, { op = "setvar", 0x4001, 1 }, { op = "end" } } + if case.twice then + script = { + { op = "waitse" }, + { op = "setvar", 0x4001, 1 }, + { op = "waitse" }, + { op = "setvar", 0x4002, 1 }, + { op = "end" }, + } + end + runner = require("src.core.game3.scripting.vm").new({ + scripts = { + OWNED_WAIT = script, + }, + }) + assert(runner:start("OWNED_WAIT")) + advanced = function() + return require("src.core.game3.scripting.flags").getVar( + runner.store, + runner.ctx, + case.twice and 0x4002 or 0x4001 + ) == 1 + end + firstAdvanced = function() + return require("src.core.game3.scripting.flags").getVar(runner.store, runner.ctx, 0x4001) == 1 + end + else + local script = { { "waitse" }, { "setvar", 0x4001, 1 }, { "end" } } + if case.twice then + script = { { "waitse" }, { "setvar", 0x4001, 1 }, { "waitse" }, { "setvar", 0x4002, 1 }, { "end" } } + end + local data = { + map_scripts = { + source = "RomExtractorGen3", + scripts = { + OWNED_WAIT = script, + }, + }, + } + local commands = assert(require("src.script.Gen3ScriptVM").compile(data, "OWNED_WAIT")) + runner = require("src.script.ScriptRunner").new(f.game, {}) + runner:run(commands) + advanced = function() + return require("src.script.Gen3Commands").getVar(f.game.save, case.twice and 0x4002 or 0x4001) == 1 + end + firstAdvanced = function() + return require("src.script.Gen3Commands").getVar(f.game.save, 0x4001) == 1 + end + end + local frames = {} + local limit = case.twice and 365 + or case.cap and 183 + or (case.latch or case.replace or case.other or case.stopEarly) and 5 + or 4 + for tick = 0, limit do + hardware.tick(tick) + if case.replace and tick == 2 then + play(164) + end + if (case.other or case.stopEarly) and tick == 2 or case.replace and tick == 3 then + stop(141) + end + if not case.cap and tick == ((case.replace or case.other or case.latch) and 4 or 3) then + stop() + end + if tick > 0 then + if native then + runner:tick() + else + runner:update() + end + end + frames[#frames + 1] = { + tick = tick, + running = runner:isRunning(), + advanced = advanced(), + active = hardware.active(), + queries = hardware.queries(), + } + if case.twice then + frames[#frames].firstAdvanced = firstAdvanced() + end + if case.latch and tick == 1 then + stop() + end + if case.audioEvery and tick % case.audioEvery == 0 then + for _ = 1, case.audioCount do + audioTick() + end + end + if case.latch and tick == 1 then + play(164) + end + end + assert(frames[#frames].advanced, "actual script must reach its post-wait variable write") + rows[#rows + 1] = { case = case.name, frames = frames, sounds = hardware.trace() } + if not native then + require("src.script.Gen3Commands").setVar(f.game.save, 0x4001, 0) + require("src.script.Gen3Commands").setVar(f.game.save, 0x4002, 0) + end + end + hardware.clear() + audioTick() + hardware.close() + if native then + audio._waitSe = savedWaits + runtime.session = savedSession + end + f.close() + return rows +end +return M diff --git a/tests/enemy_actions/nature_power.lua b/tests/enemy_actions/nature_power.lua new file mode 100644 index 000000000..c54674239 --- /dev/null +++ b/tests/enemy_actions/nature_power.lua @@ -0,0 +1,379 @@ +-- Owned native-shaped definitions exercise the terrain caller independently +-- of cartridge assets. Both engines execute their unchanged native dispatcher. +local M = {} +local DEFS = { + { 267, "NATURE_POWER", 173, "NATURE_POWER_EFFECT", 0, 1 }, + { 214, "SLEEP_TALK", 97, "SLEEP_TALK_EFFECT", 0, 1 }, + { 78, "STUN_SPORE", 67, "PARALYZE_EFFECT", 0, 0 }, + { 75, "RAZOR_LEAF", 0, "NO_ADDITIONAL_EFFECT", 35, 8 }, + { 89, "EARTHQUAKE", 0, "NO_ADDITIONAL_EFFECT", 35, 32 }, + { 56, "HYDRO_PUMP", 0, "NO_ADDITIONAL_EFFECT", 35, 0 }, + { 57, "SURF", 0, "NO_ADDITIONAL_EFFECT", 35, 8 }, + { 61, "BUBBLEBEAM", 0, "NO_ADDITIONAL_EFFECT", 35, 0 }, + { 157, "ROCK_SLIDE", 0, "NO_ADDITIONAL_EFFECT", 35, 8 }, + { 247, "SHADOW_BALL", 0, "NO_ADDITIONAL_EFFECT", 35, 0 }, + { 129, "SWIFT", 0, "NO_ADDITIONAL_EFFECT", 35, 8 }, +} +function M.fixture(root, backend, version, options) + local native = backend == "gen1" + options = options or {} + local f + if options.entry then + f = dofile(root .. "/tests/enemy_actions/choice_band.lua").fixture( + root, + backend, + version, + { entry = options.entry } + ) + local field = f.field + f.field = function(double) + local b = field(double) + f.actor = function(id) + return f.at(b, id) + end + return b + end + f.put = f.hold + else + f = dofile(root .. "/tests/enemy_actions/post_hit_items.lua").fixture(root, backend, version) + end + f.game.save.options.ruleset = "gen3_emerald" + for _, item in ipairs({ + { 187, "KINGS ROCK", 30, "FLINCH", 10 }, + { 219, "SHELL BELL", 62, "SHELL_BELL", 8 }, + }) do + local n, name, effect, kind, parameter = unpack(item) + if native then + f.data.gen3Items._byId[n] = + { name = name, pocket = "ITEMS", price = 100, holdEffect = effect, holdEffectParam = parameter } + else + local id = ("ITEM_%03d"):format(n) + f.data.items[id] = + { id = id, index = n, name = name, price = 100, holdEffect = kind, holdEffectParam = parameter } + end + end + if native then + f.data.gen3Items._byId[186] = + { name = "CHOICE BAND", pocket = "ITEMS", price = 100, holdEffect = 29, holdEffectParam = 0 } + else + f.data.items.ITEM_186 = { + id = "ITEM_186", + index = 186, + name = "CHOICE BAND", + price = 100, + holdEffect = "CHOICE_BAND", + holdEffectParam = 0, + } + end + for _, row in ipairs(DEFS) do + local n, name, effect, binding, power, target = unpack(row) + if native then + local def = { + effect = effect, + power = power, + type = 0, + accuracy = 100, + pp = 10, + target = target, + flags = 51, + secondaryChance = 0, + priority = 0, + } + local moves = require("src.core.game3.battle.moves") + moves._rom[n], moves.BY_NUM[n], moves._numByName[name] = def, name, n + f.data.gen3Moves._rom[n], f.data.gen3Pokemon._moveNames[n] = def, name + else + local id = ("MOVE_%03d"):format(n) + f.data.moves[id] = { + id = id, + index = n, + name = name, + gen3Effect = effect, + effect = binding, + power = power, + type = "NORMAL", + accuracy = 100, + pp = 10, + target = target, + flags = 51, + secondaryChance = 0, + } + end + end + return f +end +function M.run(root, backend, version, options) + options = options or {} + local native = backend == "gen1" + local f = M.fixture(root, backend, version, options) + local caller = native and 267 or "MOVE_267" + if options.public then + if native then + caller = assert(require("src.mods.Gen3Compat").moveId("PORTABLE_NATURE")) + local moves = require("src.core.game3.battle.moves") + moves._rom[caller] = assert(f.data.gen3Moves._rom[caller]) + moves.BY_NUM[caller], moves._numByName.PORTABLE_NATURE = "PORTABLE_NATURE", caller + else + caller = "PORTABLE_NATURE" + end + end + local second = native and 33 or "MOVE_033" + if options.sleepCaller then + second = caller + caller = native and 214 or "MOVE_214" + end + local function history(value) + if not options.public or value == nil then + return value + end + if native then + return require("src.mods.Gen3Compat").moveName(value) + end + local row = f.data.moves[value] + if row then + return row.name + end + for _, def in pairs(f.data.moves) do + if type(def) == "table" and def.index == value then + return def.name + end + end + return value + end + local rows = {} + for _, double in ipairs({ false, true }) do + for actor = 0, double and 3 or 1 do + for _, terrain in ipairs({ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, -1, 10, "4", "bad" }) do + for _, kind in ipairs({ + "ordinary", + "pressure", + "zero_pp", + "parent_zero", + "parent_replace", + "parent_remove", + "parent_reorder", + "child_zero", + "child_refill", + }) do + for _, item in ipairs({ 0, 186, 187, 219 }) do + local b = f.field(double) + b.terrain = terrain + local user, targetId = f.actor(actor), actor % 2 == 0 and actor + 1 or actor - 1 + local target = f.actor(targetId) + user.mon.hp = 200 + if options.sleepCaller then + user.mon.status, user.sleepTurns = "SLP", 4 + end + f.put(user, item ~= 0 and item or nil) + local original + if native then + user.mon.moves, user.mon.pp, user.mon.maxPp = + { caller, second }, { kind == "zero_pp" and 0 or 10, 10 }, { 10, 10 } + original = user.mon.pp + else + user.mon.moves = + { { id = caller, pp = kind == "zero_pp" and 0 or 10 }, { id = second, pp = 10 } } + user.curMoves = user.mon.moves + original = user.curMoves[1] + end + if kind == "pressure" then + for id = 0, double and 3 or 1 do + local a = f.actor(id) + if a ~= user then + if native then + a.ability = "PRESSURE" + else + a.abilityOverride = "PRESSURE" + end + end + end + end + local trace, draws, events, damage = {}, {}, {}, {} + local offHeld = f.e.api.hooks:wrap("held_item.trigger", function(nextRead, c) + trace[#trace + 1] = { + holder = c.mon.nickname, + item = c.item, + definition = c.def and c.def.id, + trigger = c.trigger, + effect = c.effect, + parameter = c.parameter, + actualBattle = c.battle == b, + } + return nextRead(c) + end) + local offCrit = f.e.api.hooks:wrap("battle.crit", function() + return false + end) + local function pp() + local value + if native then + value = user.mon.pp[1] + else + value = user.curMoves[1] and user.curMoves[1].pp + end + return value == nil and "absent" or value + end + local function setPP(value) + if native then + user.mon.pp[1] = value + else + user.curMoves[1].pp = value + end + end + local offMove = f.e.api.events:on("battle.move_used", function(e) + events[#events + 1] = { + user = e.user.mon.nickname, + target = e.target.mon.nickname, + move = e.move.id, + called = e.isCalled or false, + pp = pp(), + publicPP = e.user.mon.moves[1] and e.user.mon.moves[1].pp or "absent", + } + if not e.isCalled then + if kind == "parent_zero" then + setPP(0) + end + if kind == "parent_replace" then + if native then + user.mon.moves, user.mon.pp, user.mon.maxPp = { 33, caller }, { 3, 8 }, { 10, 10 } + else + user.mon.moves = { { id = "MOVE_033", pp = 3 }, { id = caller, pp = 8 } } + user.curMoves = user.mon.moves + end + end + if kind == "parent_remove" then + if native then + user.mon.moves, user.mon.pp, user.mon.maxPp = { [2] = 33 }, { [2] = 10 }, { [2] = 10 } + else + user.mon.moves = { [2] = { id = "MOVE_033", pp = 10 } } + user.curMoves = user.mon.moves + end + elseif kind == "parent_reorder" then + if native then + user.mon.moves, user.mon.pp, user.mon.maxPp = { 33, caller }, { 4, 10 }, { 10, 10 } + else + user.mon.moves = { { id = "MOVE_033", pp = 4 }, { id = caller, pp = 10 } } + user.curMoves = user.mon.moves + end + end + elseif kind == "child_zero" then + setPP(0) + elseif kind == "child_refill" then + setPP(7) + end + end) + local observers = {} + local offObserver = f.e.api.events:on("battle.move_used", function(e) + observers[#observers + 1] = { + move = e.move.id, + called = e.isCalled or false, + pp = pp(), + publicPP = e.user.mon.moves[1] and e.user.mon.moves[1].pp or "absent", + } + end) + local offDamage = f.e.api.events:on("battle.damage_dealt", function(e) + damage[#damage + 1] = { + user = e.user.mon.nickname, + target = e.target.mon.nickname, + move = e.move.id, + damage = e.damage, + critical = e.crit or false, + substitute = e.substitute or false, + typeMult = e.typeMult, + } + end) + local selector = 0 + b.rng = function(lo, hi) + local value = lo == 1 and hi == 100 and 1 or hi + if lo == 0 and hi == 3 then + value = selector % 4 + selector = selector + 1 + end + draws[#draws + 1] = { lo, hi, value } + return value + end + if native then + require("src.core.game3.battle.engine").resolveMove( + actor, + targetId, + caller, + 1, + require("src.core.game3.battle.adapter").new(b), + b, + {} + ) + else + b:executeAction(user, target, user.curMoves[1]) + end + offHeld() + offCrit() + offMove() + offObserver() + offDamage() + local state = {} + for id = 0, double and 3 or 1 do + local a = f.actor(id) + local pps = {} + for i = 1, 4 do + local v + if native then + v = a.mon.pp[i] + else + v = a.curMoves[i] and a.curMoves[i].pp + end + pps[i] = v == nil and "absent" or v + end + local last = native and a.lastMoveId or a.lastMove + if not native and last then + last = f.data.moves[last] and f.data.moves[last].index or last + end + local choice = a.choicedMove + state[#state + 1] = { + hp = a.mon.hp, + pp = pps, + status = a.mon.status, + flinched = a.flinched or false, + lastMove = history(last), + choice = history(choice), + } + end + local oldPP = native and original[1] or original.pp + f.T.eq(#f.result.errors, 0, "Nature Power callbacks remain error-free") + rows[#rows + 1] = { + double = double, + actor = actor, + terrain = terrain, + case = kind, + item = item, + events = events, + observers = observers, + damage = damage, + trace = trace, + draws = draws, + state = state, + retiredPP = oldPP, + } + end + end + end + end + end + f.close() + return rows +end +function M.content(root, backend, version) + return M.run(root, backend, version, { + public = true, + entry = [=[return function(mod) + assert(mod.content.moves:register("PORTABLE_NATURE", {id="PORTABLE_NATURE",name="PORTABLE_NATURE", + type="NORMAL",power=0,accuracy=100,pp=10,effect="NATURE_POWER"})) + local move=assert(mod.content.moves:get("PORTABLE_NATURE")) + assert(move.effect=="NATURE_POWER" and move.pp==10 and move.type=="NORMAL" and move.power==0) + mod.exports.api=mod + end]=], + }) +end +function M.chained(root, backend, version) + return M.run(root, backend, version, { sleepCaller = true }) +end +return M diff --git a/tests/enemy_actions/party_codec.lua b/tests/enemy_actions/party_codec.lua new file mode 100644 index 000000000..18decf502 --- /dev/null +++ b/tests/enemy_actions/party_codec.lua @@ -0,0 +1,206 @@ +-- Compare real codecs, retaining the complete identity/stats/metadata result. +local M = {} +local KEYS = { "hp", "attack", "defense", "speed", "spatk", "spdef" } +local SHORT = { "hp", "atk", "def", "spe", "spa", "spd" } +local SOURCE = { "maxHp", "attack", "defense", "speed", "spAtk", "spDef" } +local SPECIES = { + { 60, { 40, 50, 40, 90, 40, 40 }, 127, { 11, 6 } }, + { 303, { 1, 90, 45, 40, 30, 30 }, 255, { 25, 0 } }, +} +function M.fixture(root, backend, version, format) + local native = backend == "gen1" + local ids = {} + local function configure(data) + data.constants = data.constants or {} + data.constants.natureOrder, data.constants.natures = {}, {} + for n = 0, 24 do + local id = format .. "_NATURE_" .. n + data.constants.natureOrder[n + 1] = id + data.constants.natures[id] = { index = n, modifiers = {} } + local order = { "attack", "defense", "speed", "spatk", "spdef" } + local up, down = math.floor(n / 5) + 1, n % 5 + 1 + if up ~= down then + data.constants.natures[id].modifiers[order[up]] = 110 + data.constants.natures[id].modifiers[order[down]] = 90 + end + end + for _, row in ipairs(SPECIES) do + local n, base, ratio, pair = unpack(row) + local id = format .. "_SPECIES_" .. n + ids[n] = id + if native then + local p = data.gen3Pokemon + p._names[n], p._stats[n], p._types[n] = id, {}, { 0, 0 } + for i, k in ipairs(SHORT) do + p._stats[n][k] = base[i] + end + p._speciesMeta[n] = { genderRatio = ratio, growthRate = 0, friendship = 70 } + p._abilities[n], p._learnsets[n] = pair, { { 1, 33 } } + else + local def = require("src.mods.Merge").deepCopy(data.pokemon.CHARMANDER) + def.id, def.index, def.name, def.genderRatio = id, n, id, ratio + def.baseStats, def.evYield, def.growthRate, def.abilities = {}, {}, "MEDIUM_FAST", {} + for i, k in ipairs(KEYS) do + def.baseStats[k] = base[i] + end + for i, a in ipairs(pair) do + if a ~= 0 then + def.abilities[i] = "ABILITY_" .. a + end + end + data.pokemon[id] = def + end + end + end + local f = + dofile(root .. "/tests/enemy_actions/fixture.lua").new(root, backend, version, 3, "unified", configure) + f.codecIds = ids + return f +end +function M.run(root, backend, version) + local native, rows = backend == "gen1", {} + for _, format in ipairs({ "canonical", "raw", "translated" }) do + local f = M.fixture(root, backend, version, format) + local T, Protocol = f.T, require("src.link.Protocol") + for _, species in ipairs(SPECIES) do + for _, realLevel in ipairs({ 1, 35, 100 }) do + for nature = 0, 24 do + for slot = 1, 2 do + for _, investment in ipairs({ 0, 1, 2 }) do + for _, forced in ipairs({ false, 50 }) do + local pid = 100000 + nature + pid = pid - pid % 25 + nature + local ivs, evs = {}, {} + for i, k in ipairs(KEYS) do + ivs[native and SHORT[i] or k] = investment == 0 and 0 or investment == 1 and 31 or i * 4 + evs[native and SHORT[i] or k] = investment == 0 and 0 + or investment == 1 and (i < 3 and 252 or i == 3 and 6 or 0) + or i * 12 + end + local second = species[4][2] ~= 0 and slot == 2 + local mon = { + species = native and species[1] or f.codecIds[species[1]], + speciesNumbering = native and "internal" or nil, + level = realLevel, + exp = realLevel ^ 3 + (realLevel == 35 and 12 or 0), + hp = 1, + personality = pid, + ivs = ivs, + evs = evs, + nature = native and nature or f.data.constants.natureOrder[nature + 1], + abilitySlot = second and 2 or 1, + abilityNum = second and 1 or 0, + ability = native and species[4][second and 2 or 1] or nil, + moves = native and { 33 } or { { id = f.ids.TACKLE, pp = 6, ppUps = 3 } }, + pp = native and { 6 } or nil, + ppBonusesPacked = native and 3 or nil, + otId = 1234, + otSecretId = 5678, + secretId = 5678, + ot = "TRAINER", + nickname = "PÖKEMON", + otGender = 1, + item = native and 0 or nil, + pokeball = 4, + happiness = 203, + friendship = 203, + contest = { cool = 7, beauty = 19, cute = 31, smart = 43, tough = 55, sheen = 67 }, + language = 5, + gen3Language = 5, + pokerus = 35, + gen3Pokerus = 35, + markings = 9, + gen3Markings = 9, + ribbons = 123456, + metLevel = 12, + metLocation = 17, + metGame = 3, + status = "PAR", + fatefulEncounter = true, + modernFatefulEncounter = investment == 2 and true or nil, + } + if not native then + f.data.items.CODEC_BALL = { index = 4 } + mon.ball = "CODEC_BALL" + end + local before = require("src.link.Json").encode(mon) + local packed = native and Protocol.packMon3(mon) or Protocol.packMon(mon) + packed = require("src.link.Json").decode(require("src.link.Json").encode(packed)) + local restored, why + if native then + restored, why = + Protocol.unpackMon3(f.data, packed, { strict = true, forceLevel = forced or nil }) + else + restored, why = + Protocol.unpackMon(f.data, packed, { strict = true, forceLevel = forced or nil }) + end + T.check(restored ~= nil, "native party packet decodes: " .. tostring(why)) + assert(restored, why) + T.eq(require("src.link.Json").encode(mon), before, "codec does not mutate sender") + local stats, ivOut, evOut = {}, {}, {} + for i, k in ipairs(KEYS) do + stats[i] = native and restored[SOURCE[i]] or restored.stats[k] + ivOut[i], evOut[i] = + restored.ivs[native and SHORT[i] or k], restored.evs[native and SHORT[i] or k] + T.eq(ivOut[i], ivs[native and SHORT[i] or k], "full six-stat IVs survive") + T.eq(evOut[i], evs[native and SHORT[i] or k], "full six-stat EVs survive") + end + T.eq(restored.personality, pid, "PID survives the actual wire path") + T.eq(restored.hp, forced and stats[1] or 1, "forced level heals without changing genotype") + if not forced then + T.eq(restored.status, "PAR", "ordinary status survives") + end + -- Lua's a-and-nil-or-b idiom cannot represent the forced nil. + if forced then + T.eq(restored.status, nil, "forced-level status is cleared") + end + local ability = native and restored.ability or tonumber((restored.ability or ""):match("%d+")) + T.eq(ability, species[4][second and 2 or 1], "stored valid ability slot survives") + local gender = native and ({ M = "male", F = "female", U = "unknown" })[restored.gender] + or restored.gender + local pp = native and restored.pp[1] or restored.moves[1].pp + local maxPp = native and restored.maxPp[1] or restored.moves[1].maxPp + rows[#rows + 1] = { + species = species[1], + format = format, + pid = restored.personality, + nature = native and restored.nature or restored.personality % 25, + gender = gender, + ability = ability, + stats = stats, + ivs = ivOut, + evs = evOut, + level = restored.level, + exp = restored.exp, + hp = restored.hp, + status = restored.status, + moves = { { 33, pp, maxPp } }, + nickname = restored.nickname, + ot = restored.ot, + otId = restored.otId, + secretId = native and restored.otSecretId or restored.secretId, + friendship = restored.friendship, + contest = restored.contest, + language = native and restored.language or restored.gen3Language, + pokerus = native and restored.pokerus or restored.gen3Pokerus, + markings = native and restored.markings or restored.gen3Markings, + ribbons = restored.ribbons, + metLevel = restored.metLevel, + metLocation = restored.metLocation, + metGame = restored.metGame, + ball = native and restored.pokeball or f.data.items[restored.ball].index, + otGender = restored.otGender, + fatefulEncounter = restored.fatefulEncounter, + modernFatefulEncounter = restored.modernFatefulEncounter, + } + end + end + end + end + end + end + f.close() + end + return rows +end +return M diff --git a/tests/enemy_actions/party_codec_hooks.lua b/tests/enemy_actions/party_codec_hooks.lua new file mode 100644 index 000000000..9485ced30 --- /dev/null +++ b/tests/enemy_actions/party_codec_hooks.lua @@ -0,0 +1,84 @@ +local M = {} +function M.run(root, backend, version) + local native, rows = backend == "gen1", {} + local f = + dofile(root .. "/tests/enemy_actions/party_codec.lua").fixture(root, backend, version, "translated") + local P, calls, policy = require("src.link.Protocol"), {}, "none" + local function number(id) + if not native then + return f.data.pokemon[id].index + end + -- API 3 names source species by canonical text ID; the ROM codec uses numbers. + return type(id) == "number" and id or assert(tonumber(id:match("SPECIES_(%d+)$"))) + end + local function hook(nextGender, c) + calls[#calls + 1] = { + species = number(c.species), + definition = number(c.def.id), + ratio = c.ratio, + personality = c.personality, + } + if policy == "female" then + return "female" + end + if policy == "invalid" then + return "invalid" + end + if policy == "threshold" then + c.def.genderRatio = 0 + end + return nextGender(c) + end + for _, mode in ipairs({ "none", "delegate", "female", "invalid", "threshold" }) do + policy = mode + local stop = mode ~= "none" and f.e.api.hooks:wrap("gender.roll", hook) or nil + for _, pid in ipairs({ 0, 1, 127, 128, 255, 65535, 4294967295 }) do + local ivs, evs = {}, {} + for _, k in + ipairs( + native and { "hp", "atk", "def", "spe", "spa", "spd" } + or { "hp", "attack", "defense", "speed", "spatk", "spdef" } + ) + do + ivs[k], evs[k] = 0, 0 + end + local mon = { + species = native and 60 or f.codecIds[60], + speciesNumbering = native and "internal" or nil, + level = 20, + exp = 8000, + personality = pid, + ivs = ivs, + evs = evs, + hp = 1, + moves = native and { 33 } or { { id = f.ids.TACKLE, pp = 1, ppUps = 0 } }, + pp = native and { 1 } or nil, + ability = native and 11 or nil, + abilitySlot = 1, + } + calls = {} + local packed = native and P.packMon3(mon) or P.packMon(mon) + local restored, why + if native then + restored, why = P.unpackMon3(f.data, packed, { strict = true }) + else + restored, why = P.unpackMon(f.data, packed, { strict = true }) + end + f.T.check(restored ~= nil, "public gender policy survives the codec: " .. tostring(why)) + assert(restored, why) + f.T.eq(#calls, mode == "none" and 0 or 2, "pack and unpack retain public hook entry points") + rows[#rows + 1] = { + mode = mode, + pid = pid, + calls = calls, + gender = native and ({ M = "male", F = "female", U = "unknown" })[restored.gender] or restored.gender, + } + end + if stop then + stop() + end + end + f.close() + return rows +end +return M diff --git a/tests/enemy_actions/post_hit_items.lua b/tests/enemy_actions/post_hit_items.lua new file mode 100644 index 000000000..f7105a3d5 --- /dev/null +++ b/tests/enemy_actions/post_hit_items.lua @@ -0,0 +1,482 @@ +-- Identical command inputs drive unchanged native code and the shared host +-- command. Record every held read, RNG call, activation, HP and flinch state. +local M = {} +function M.fixture(root, backend, version) + local native = backend == "gen1" + local f = dofile(root .. "/tests/held_item/fixture.lua").new(root, backend, version, 3, "unified") + f.game.save.options.ruleset = "gen3_emerald" + if native then + f.data.gen3Items._byId[219] = + { name = "SHELL BELL", pocket = "ITEMS", price = 100, holdEffect = 62, holdEffectParam = 8 } + else + f.data.items.ITEM_219 = { + id = "ITEM_219", + index = 219, + name = "SHELL BELL", + price = 100, + holdEffect = "SHELL_BELL", + holdEffectParam = 8, + } + end + function f.put(actor, id) + if native then + actor.item, actor.mon.item = id or 0, id or 0 + else + actor.mon.item = id and ("ITEM_%03d"):format(id) or nil + end + end + function f.field(double) + local b = f.reset(nil, double) + if not native then + b:syncSides() + end + for id = 0, double and 3 or 1 do + local a = f.actor(id) + a.mon.nickname, a.mon.hp = "P" .. id, 400 + a.mon.level = 30 + if native then + a.mon.moves, a.mon.pp, a.mon.maxPp = { 33 }, { 10 }, { 10 } + a.mon.maxHp, a.type1, a.type2, a.ability, a.mon.ability = 400, 0, 0, 0, 0 + a.mon.attack, a.mon.defense, a.mon.spAtk, a.mon.spDef, a.mon.speed = 50, 50, 50, 50, 100 - id * 10 + else + a.mon.moves = { { id = "MOVE_033", pp = 10 } } + a.curMoves = a.mon.moves + a.mon.stats.hp, a.abilityOverride, a.curTypes = 400, false, { "NORMAL" } + a.curStats.attack, a.curStats.defense, a.curStats.spatk, a.curStats.spdef = 50, 50, 50, 50 + a.curStats.speed = 100 - id * 10 + end + f.put(a, nil) + end + return b + end + return f +end +function M.command(root, backend, version) + local native = backend == "gen1" + local f = M.fixture(root, backend, version) + local file = assert(io.open(root .. "/tests/post_hit_items/command-cases.json", "rb")) + local inputs = require("src.link.Json").decode(file:read("*a")) + file:close() + local rows = {} + for _, double in ipairs({ false, true }) do + for actor = 0, double and 3 or 1 do + for _, case in ipairs(inputs.command_cases) do + for _, roll in ipairs(inputs.rng_values) do + local b = f.field(double) + local user = f.actor(actor) + local target = f.actor(case.same_actor and actor or actor % 2 == 0 and actor + 1 or actor - 1) + user.mon.hp, target.mon.hp = case.user_hp, case.target_hp + if native then + user.mon.maxHp = case.user_max_hp + else + user.mon.stats.hp = case.user_max_hp + end + local item = case.physical_item or case.item_effect == "SHELL_BELL" and 562 or nil + f.put(user, item) + if case.player_item then + f.put(f.actor(0), case.player_item) + end + if case.metadata_cleared and item then + if native then + f.data.gen3Items._byId[item].holdEffect = 0 + else + f.data.items[("ITEM_%03d"):format(item)].holdEffect = "NONE" + end + elseif item == 187 then + if native then + f.data.gen3Items._byId[item].holdEffect = 30 + else + f.data.items.ITEM_187.holdEffect = "FLINCH" + end + elseif item == 219 then + if native then + f.data.gen3Items._byId[item].holdEffect = 62 + else + f.data.items.ITEM_219.holdEffect = "SHELL_BELL" + end + end + target.substituteHP = case.substitute_hp + if case.target_moved then + if native then + target.expMovedThisTurn = true + else + target.gbaMovedTurn = b.turnCount or 0 + end + end + if case.ability then + if native then + target.ability = case.ability + else + target.abilityOverride = case.ability + end + end + local trace, draws = {}, {} + local off = f.e.api.hooks:wrap("held_item.trigger", function(nextRead, ctx) + trace[#trace + 1] = { + holder = ctx.mon.nickname, + item = ctx.item, + definition = ctx.def and ctx.def.id, + effect = ctx.effect, + parameter = ctx.parameter, + trigger = ctx.trigger, + actualBattle = ctx.battle == b, + } + local n, hook = #trace, case.hook + if n == 1 and hook == "suppress_attacker" then + return nil, 0 + end + if n == 1 and (hook == "replace_attacker" or hook == "replace_both_differently") then + return case.hook_effect, case.hook_parameter + end + if n == 2 and (hook == "replace_player" or hook == "replace_both_differently") then + return case.second_effect, case.second_parameter + end + if hook == "alternate_each_read" then + if n % 2 == 1 then + return nextRead(ctx) + end + return ctx.effect or "HELD_NONE", (ctx.parameter or 0) + 64 + end + return nextRead(ctx) + end) + b.rng = function(lo, hi) + draws[#draws + 1] = { lo, hi, roll } + return roll + end + local move = not case.no_move and { flags = case.move_flags } or nil + if move and case.no_flags then + move.flags = nil + end + local frame = { + user = not case.no_user and user or nil, + target = not case.no_target and target or nil, + move = move, + firstDmg = case.first_damage, + targetDamaged = case.target_damaged, + noEffect = case.no_effect, + } + local active, animations = false, {} + if native then + local ad = require("src.core.game3.battle.adapter").new(b) + frame.adapter, frame.st = ad, b + active = require("src.core.game3.battle.held_items").kingsRockShellBell(frame) + for _, event in ipairs(ad:eventsSince(0)) do + if event.kind == "anim" then + animations[#animations + 1] = { + name = event.name, + user = event.attackerId, + target = event.targetId, + } + end + end + else + active = require("src.battle.Gen3PostHitItems").run(b, frame) + for _, event in ipairs(b.queue) do + if event.anim then + animations[#animations + 1] = { + name = event.anim, + user = event.attackerPosition, + target = event.targetPosition, + } + end + end + end + off() + local state = {} + for id = 0, double and 3 or 1 do + local a = f.actor(id) + state[#state + 1] = { hp = a.mon.hp, flinched = a.flinched or false } + end + f.T.eq(#f.result.errors, 0, "held command callbacks remain error-free") + rows[#rows + 1] = { + case = case.label, + double = double, + actor = actor, + roll = roll, + active = active or false, + firstDamage = frame.firstDmg, + state = state, + trace = trace, + draws = draws, + animations = animations, + } + end + end + end + end + f.close() + return rows +end +function M.execution(root, backend, version) + local native = backend == "gen1" + local f = M.fixture(root, backend, version) + local ids = {} + for _, row in ipairs({ + { 33, "TACKLE", 0, "NO_ADDITIONAL_EFFECT", 35, 0 }, + { 3, "DOUBLE_SLAP", 29, "TWO_TO_FIVE_ATTACKS_EFFECT", 15, 0 }, + { 167, "TRIPLE_KICK", 104, "TRIPLE_KICK_EFFECT", 10, 0 }, + { 57, "SURF", 0, "NO_ADDITIONAL_EFFECT", 35, 8 }, + { 89, "EARTHQUAKE", 0, "NO_ADDITIONAL_EFFECT", 35, 32 }, + { 214, "SLEEP_TALK", 97, "SLEEP_TALK_EFFECT", 0, 1 }, + }) do + local number, name, effect, binding, power, target = unpack(row) + ids[number] = ("MOVE_%03d"):format(number) + if native then + local def = { + effect = effect, + power = power, + type = 0, + accuracy = 100, + pp = 10, + target = target, + flags = 51, + secondaryChance = 0, + priority = 0, + } + local moves = require("src.core.game3.battle.moves") + moves._rom[number], moves.BY_NUM[number], moves._numByName[name] = def, name, number + f.data.gen3Moves._rom[number], f.data.gen3Pokemon._moveNames[number] = def, name + else + f.data.moves[ids[number]] = { + id = ids[number], + index = number, + name = name, + gen3Effect = effect, + effect = binding, + power = power, + type = "NORMAL", + accuracy = 100, + pp = 10, + target = target, + flags = 51, + secondaryChance = 0, + } + end + end + if not native then + f.data.type_chart.matchups[#f.data.type_chart.matchups + 1] = + { attacker = "NORMAL", defender = "GHOST", multiplier = 0 } + require("src.battle.TypeChart").load(f.data) + end + local rows = {} + for _, double in ipairs({ false, true }) do + for actor = 0, double and 3 or 1 do + for _, number in ipairs({ 33, 3, 167, 57, 89, 214 }) do + for _, itemCase in ipairs({ + "empty", + "king", + "king_player_shell", + "king_player_king", + "shell", + "fake_shell", + "override_shell", + "override_rock", + "suppressed", + "zero_parameter", + }) do + for _, kind in ipairs({ + "ordinary", + "substitute_5", + "substitute_100", + "target_low_hp", + "heal_cap", + "miss", + "immune", + "target_moved", + "live_drop", + "live_equip", + "user_faint", + }) do + for _, chanceRoll in ipairs({ 0, 99 }) do + local b = f.field(double) + local user, targetId = f.actor(actor), actor % 2 == 0 and actor + 1 or actor - 1 + local target = f.actor(targetId) + user.mon.hp = kind == "heal_cap" and 399 or 200 + local item = itemCase:match("^king") and 187 + or itemCase == "shell" and 219 + or itemCase == "fake_shell" and 562 + or itemCase == "suppressed" and 187 + or itemCase == "zero_parameter" and 219 + or nil + f.put(user, item) + if actor ~= 0 and itemCase == "king_player_shell" then + f.put(f.actor(0), 219) + end + if actor ~= 0 and itemCase == "king_player_king" then + f.put(f.actor(0), 187) + end + if kind == "substitute_5" then + target.substituteHP = 5 + end + if kind == "substitute_100" then + target.substituteHP = 100 + end + if kind == "target_low_hp" then + target.mon.hp = 5 + end + if kind == "target_moved" then + if native then + target.expMovedThisTurn = true + else + target.gbaMovedTurn = b.turnCount or 0 + end + end + if kind == "immune" then + if native then + target.type1, target.type2 = 7, 7 + else + target.curTypes = { "GHOST" } + end + end + if number == 214 then + user.mon.status, user.sleepTurns = "SLP", 4 + end + if native then + user.mon.moves, user.mon.pp, user.mon.maxPp = { number }, { 10 }, { 10 } + if number == 214 then + user.mon.moves[2], user.mon.pp[2], user.mon.maxPp[2] = 3, 10, 10 + end + else + user.mon.moves = { { id = ids[number], pp = 10 } } + if number == 214 then + user.mon.moves[2] = { id = ids[3], pp = 10 } + end + user.curMoves = user.mon.moves + f.T.eq(b.ruleset.baseDamageStyle, "gba", "script comparison uses native GBA damage") + end + local trace, draws, damage, events = {}, {}, {}, {} + local off = f.e.api.hooks:wrap("held_item.trigger", function(nextRead, ctx) + trace[#trace + 1] = { + holder = ctx.mon.nickname, + item = ctx.item, + definition = ctx.def and ctx.def.id, + effect = ctx.effect, + parameter = ctx.parameter, + trigger = ctx.trigger, + actualBattle = ctx.battle == b, + } + if ctx.trigger == "flinch" and ctx.mon.nickname == user.mon.nickname then + if itemCase == "override_shell" then + return "HELD_SHELL_BELL", 8 + end + if itemCase == "override_rock" then + return "HELD_FLINCH", 128 + end + if itemCase == "suppressed" then + return nil, 0 + end + if itemCase == "zero_parameter" then + return "HELD_SHELL_BELL", 0 + end + end + return nextRead(ctx) + end) + local offCrit = f.e.api.hooks:wrap("battle.crit", function() + return false + end) + local offAccuracy + if kind == "miss" then + offAccuracy = f.e.api.hooks:wrap("battle.accuracy", function() + return false + end) + end + local offMove = f.e.api.events:on("battle.move_used", function(e) + events[#events + 1] = { + user = e.user.mon.nickname, + target = e.target.mon.nickname, + move = e.move.id, + called = e.isCalled or false, + } + end) + local offDamage = f.e.api.events:on("battle.damage_dealt", function(e) + damage[#damage + 1] = { + user = e.user.mon.nickname, + target = e.target.mon.nickname, + move = e.move.id, + damage = e.damage, + critical = e.crit or false, + substitute = e.substitute or false, + typeMult = e.typeMult, + } + if #damage == 1 then + if kind == "live_drop" then + f.put(user, nil) + end + if kind == "live_equip" then + f.put(user, 219) + end + if kind == "user_faint" then + user.mon.hp = 0 + end + end + end) + b.rng = function(lo, hi) + local value = lo == 0 and hi == 99 and chanceRoll or hi + draws[#draws + 1] = { lo, hi, value } + return value + end + if native then + require("src.core.game3.battle.engine").resolveMove( + actor, + targetId, + number, + 1, + require("src.core.game3.battle.adapter").new(b), + b, + {} + ) + else + b:executeAction(user, target, user.curMoves[1]) + end + off() + offCrit() + offMove() + offDamage() + if offAccuracy then + offAccuracy() + end + local state = {} + for id = 0, double and 3 or 1 do + local a = f.actor(id) + local pp = {} + for i = 1, 4 do + local value + if native then + value = a.mon.pp[i] + else + value = a.curMoves[i] and a.curMoves[i].pp + end + pp[i] = value == nil and "absent" or value + end + state[#state + 1] = { + hp = a.mon.hp, + pp = pp, + status = a.mon.status, + flinched = a.flinched or false, + substitute = a.substituteHP or 0, + } + end + f.T.eq(#f.result.errors, 0, "full held-item script callbacks remain error-free") + rows[#rows + 1] = { + double = double, + actor = actor, + move = number, + itemCase = itemCase, + case = kind, + roll = chanceRoll, + trace = trace, + draws = draws, + damage = damage, + events = events, + state = state, + } + end + end + end + end + end + end + f.close() + return rows +end +return M diff --git a/tests/enemy_actions/rage.lua b/tests/enemy_actions/rage.lua new file mode 100644 index 000000000..b7f692e90 --- /dev/null +++ b/tests/enemy_actions/rage.lua @@ -0,0 +1,525 @@ +local M = {} +function M.fixture(root, backend, version, entry) + local native = backend == "gen1" + return dofile(root .. "/tests/enemy_actions/fixture.lua").new( + root, + backend, + version, + 3, + "unified", + function(data, _, ids, rom, names) + if not native then + data.items.FOCUS_BAND = { + index = 196, + name = "FOCUS BAND", + holdEffect = "FOCUS_BAND", + gen3HoldEffect = 39, + holdEffectParam = 10, + } + data.items.SCOPE_LENS = { index = 198, name = "SCOPE LENS", holdEffect = "SCOPE_LENS" } + data.items.LUCKY_PUNCH = { index = 222, name = "LUCKY PUNCH", holdEffect = "LUCKY_PUNCH" } + data.type_chart.matchups[#data.type_chart.matchups + 1] = + { attacker = "NORMAL", defender = "GHOST", multiplier = 0 } + end + for _, row in ipairs({ + { 3, "DOUBLE_SLAP", 29, "TWO_TO_FIVE_ATTACKS_EFFECT" }, + { 24, "DOUBLE_KICK", 44, "ATTACK_TWICE_EFFECT" }, + { 41, "TWINEEDLE", 77, "TWINEEDLE_EFFECT" }, + { 99, "RAGE", 81, "RAGE_EFFECT" }, + { 700, "MOD_RAGE", 81, "RAGE_EFFECT" }, + }) do + local number, name, effect, hostEffect = unpack(row) + local id = native and name or ("MOVE_%03d"):format(number) + ids[name], names[number] = id, name + if native then + local def = { + effect = effect, + power = 15, + type = 0, + accuracy = 100, + pp = 10, + target = 0, + flags = 51, + secondaryChance = 20, + priority = 0, + } + data.gen3Moves._rom[number], rom[number], data.gen3Pokemon._moveNames[number] = def, def, name + data.gen3Pokemon._types[4], data.gen3Pokemon._abilities[4] = { 0, 0 }, { 0, 0 } + else + data.moves[id] = { + id = id, + index = number, + name = name, + effect = hostEffect, + gen3Effect = effect, + power = 15, + type = "NORMAL", + accuracy = 100, + pp = 10, + target = 0, + flags = 51, + protectAffected = true, + secondaryChance = 20, + } + if number == 700 then + data.moves[id].index = nil + end + data.pokemon.CHARMANDER.types, data.pokemon.CHARMANDER.abilities = { "NORMAL" }, {} + end + end + end, + entry + ) +end +function M.run(root, backend, version) + local native = backend == "gen1" + local f = M.fixture(root, backend, version) + local T, Copy = f.T, require("src.mods.Merge").deepCopy + local rows, draws, damage, used, accuracy = {}, {}, {}, {}, {} + local owner, phase, currentKind, incomingHits + local off = f.e.api.events:on("battle.damage_dealt", function(e) + damage[#damage + 1] = { + user = e.user.mon.nickname, + target = e.target.mon.nickname, + move = e.move.id, + damage = e.damage, + crit = e.crit or false, + substitute = e.substitute or false, + attack = owner and owner.stages.attack or 0, + rage = owner and not not owner.rage or false, + } + if phase == "incoming" then + incomingHits = incomingHits + 1 + if currentKind == "later_immunity" and incomingHits == 1 then + if native then + owner.type1, owner.type2 = 7, 7 + else + owner.curTypes = { "GHOST" } + end + end + end + end) + local offMove = f.e.api.events:on("battle.move_used", function(e) + used[#used + 1] = { user = e.user.mon.nickname, target = e.target.mon.nickname, move = e.move.id } + end) + local offAccuracy = f.e.api.hooks:wrap("battle.accuracy", function(nextAccuracy, ...) + accuracy[#accuracy + 1] = + { rage = owner and not not owner.rage or false, attack = owner and owner.stages.attack or 0 } + return nextAccuracy(...) + end) + local function at(b, id) + return native and b.battlers[id] or b:battlerAt(id) + end + local nums = { + [99] = f.ids.RAGE, + [3] = f.ids.DOUBLE_SLAP, + [24] = f.ids.DOUBLE_KICK, + [33] = f.ids.TACKLE, + [700] = f.ids.MOD_RAGE, + } + local function num(id) + if native then + return tonumber(id) + end + for n, name in pairs(nums) do + if name == id then + return n + end + end + end + local function fresh(double) + local b = f.fresh("TACKLE", { enemy = true }).battle + b.double = double + if not native then + b:syncSides() + end + if double then + for id = 2, 3 do + local mon = Copy(at(b, id % 2).mon) + if native then + b.battlers[id] = require("src.core.game3.battle.state").makeBattler( + mon, + id % 2 == 0 and "player" or "enemy", + { id = id, partyIndex = 2 } + ) + else + b:placeBattler(id, require("src.battle.BattleState").makeBattler(f.data, mon, id % 2 == 0)) + end + end + end + if not native then + b:syncSides() + end + for id = 0, double and 3 or 1 do + local a = at(b, id) + a.mon.nickname = "P" .. id + if native then + a.mon.moves, a.mon.pp, a.mon.maxPp = { 99, 3, 24, 33 }, { 10, 10, 10, 10 }, { 10, 10, 10, 10 } + a.mon.maxHp, a.ability, a.mon.ability, a.type1, a.type2 = 400, 0, 0, 0, 0 + a.mon.speed = 100 - id * 10 + else + a.mon.moves = { + { id = f.ids.RAGE, pp = 10 }, + { id = f.ids.DOUBLE_SLAP, pp = 10 }, + { id = f.ids.DOUBLE_KICK, pp = 10 }, + { id = f.ids.TACKLE, pp = 10 }, + } + a.curMoves, a.curTypes, a.abilityOverride = a.mon.moves, { "NORMAL" }, false + a.mon.stats.hp = 400 + for _, stat in ipairs({ "attack", "defense", "spatk", "spdef" }) do + a.curStats[stat] = 50 + end + a.curStats.speed = 100 - id * 10 + end + a.mon.hp = 400 + end + b.rng = function(lo, hi) + local v = hi + if lo == 0 and hi == 1 and currentKind == "confused" then + v = 0 + end + draws[#draws + 1] = { lo = lo, hi = hi, value = v } + return v + end + return b + end + local function cast(b, uid, tid, slot, called) + local user, target = at(b, uid), at(b, tid) + if native then + local nativeSlot = slot + if called then + nativeSlot = nil + end + require("src.core.game3.battle.engine").resolveMove( + uid, + tid, + user.mon.moves[slot], + nativeSlot, + require("src.core.game3.battle.adapter").new(b), + b, + {}, + { called = called } + ) + elseif called then + b:performMove(user, target, user.curMoves[slot], true) + else + b:executeAction(user, target, user.curMoves[slot]) + end + end + local function record(b, label) + local field = {} + for id = 0, b.double and 3 or 1 do + local a = at(b, id) + local pp = {} + for slot = 1, 4 do + pp[slot] = native and a.mon.pp[slot] or a.curMoves[slot].pp + end + field[#field + 1] = { + hp = a.mon.hp, + pp = pp, + rage = not not a.rage, + legacyLock = not not a.rageMove, + attack = a.stages.attack or 0, + last = num(native and a.lastMoveId or a.lastMove), + sub = tonumber(a.substituteHP) or 0, + status = a.mon.status, + } + end + rows[#rows + 1] = { + label = label, + field = field, + draws = Copy(draws), + damage = Copy(damage), + used = Copy(used), + accuracy = Copy(accuracy), + } + end + for _, double in ipairs({ false, true }) do + for uid = 0, double and 3 or 1 do + local tid = uid % 2 == 0 and uid + 1 or uid - 1 + for _, previous in ipairs({ false, true }) do + for _, kind in ipairs({ + "ordinary", + "repeated", + "protected", + "hidden", + "immune", + "substitute", + "accuracy_miss", + "disabled", + "sleep", + "confused", + "called", + "called_disabled", + "custom", + "absorbed", + }) do + currentKind = kind + phase = "producer" + draws, damage, used, accuracy = {}, {}, {}, {} + local b = fresh(double) + owner = at(b, uid) + local foe = at(b, tid) + owner.rage = previous or nil + local raw = native and f.data.gen3Moves._rom[99] or f.data.moves[f.ids.RAGE] + local accuracy, moveType = raw.accuracy, raw.type + if kind == "protected" then + foe[native and "expProtected" or "protecting"] = true + end + if kind == "hidden" then + if native then + foe.semiInvulnerable = "ON_AIR" + else + foe.invulnerable, foe.hiddenAs = true, "air" + end + end + if kind == "immune" then + if native then + foe.type1, foe.type2 = 7, 7 + else + foe.curTypes = { "GHOST" } + end + end + if kind == "substitute" then + foe.substituteHP = 100 + end + if kind == "accuracy_miss" then + raw.accuracy = 90 + end + if kind == "disabled" or kind == "called_disabled" then + owner[native and "expDisabledMove" or "disabledMove"] = native and 99 or f.ids.RAGE + end + if kind == "sleep" then + owner.mon.status = "SLP" + owner.sleepTurns = 2 + end + if kind == "confused" then + owner[native and "confusionTurns" or "confusedTurns"] = 4 + end + if kind == "custom" then + if native then + owner.mon.moves[1] = 700 + else + owner.curMoves[1].id = f.ids.MOD_RAGE + end + end + if kind == "absorbed" then + raw.type = native and 13 or "ELECTRIC" + if native then + foe.ability, foe.mon.ability = 10, 10 + else + foe.abilityOverride = "VOLT_ABSORB" + end + end + cast(b, uid, tid, 1, kind == "called" or kind == "called_disabled") + if kind == "repeated" then + cast(b, uid, tid, 1) + end + if + kind == "ordinary" + or kind == "repeated" + or kind == "immune" + or kind == "substitute" + or kind == "called" + or kind == "called_disabled" + or kind == "custom" + then + T.eq(owner.rage, true, "native Rage activates independently of a legacy move lock") + T.eq(owner.rageMove, nil, "native Rage never installs the free-PP legacy controller") + end + record( + b, + (double and "double" or "single") .. "/" .. uid .. "/" .. tostring(previous) .. "/" .. kind + ) + raw.accuracy, raw.type = accuracy, moveType + end + end + for _, kind in ipairs({ + "ordinary", + "multi", + "two", + "sub_survive", + "sub_break", + "endure", + "cap", + "last_stage", + "protected", + "hidden", + "immune", + "ally", + "self_called", + "later_immunity", + "residual", + }) do + if double or kind ~= "ally" then + currentKind = kind + phase = "producer" + draws, damage, used, accuracy = {}, {}, {}, {} + local b = fresh(double) + owner = at(b, uid) + cast(b, uid, tid, 1) + record(b, "incoming/" .. kind .. "/activated/" .. uid .. "/" .. tostring(double)) + phase = "incoming" + incomingHits = 0 + local attacker = kind == "ally" and (uid + 2) % 4 or tid + local called = kind == "self_called" + if called then + attacker = uid + end + local slot = (kind == "multi" or kind == "later_immunity") and 2 or kind == "two" and 3 or 4 + if kind == "sub_survive" then + owner.substituteHP = 400 + end + if kind == "sub_break" then + owner.substituteHP = 1 + end + if kind == "endure" then + owner.mon.hp = 1 + owner[native and "expEnduring" or "enduring"] = true + end + if kind == "cap" or kind == "last_stage" then + owner.stages.attack = kind == "cap" and 6 or 5 + end + if kind == "protected" then + owner[native and "expProtected" or "protecting"] = true + end + if kind == "hidden" then + if native then + owner.semiInvulnerable = "ON_AIR" + else + owner.invulnerable, owner.hiddenAs = true, "air" + end + end + if kind == "immune" then + if native then + owner.type1, owner.type2 = 7, 7 + else + owner.curTypes = { "GHOST" } + end + end + if kind == "residual" then + owner.mon.status = "PSN" + if native then + require("src.core.game3.battle.engine").collectResidualEvents( + b, + require("src.core.game3.battle.adapter").new(b) + ) + else + b:endOfTurn() + end + else + cast(b, attacker, uid, slot, called) + end + local expected = ( + kind == "ordinary" + or kind == "multi" + or kind == "two" + or kind == "sub_survive" + or kind == "sub_break" + or kind == "endure" + ) + and 1 + or (kind == "cap" or kind == "last_stage") and 6 + or 0 + T.eq( + owner.stages.attack or 0, + expected, + "native Rage boost applies once at the completed move boundary" + ) + record(b, "incoming/" .. kind .. "/finished/" .. uid .. "/" .. tostring(double)) + end + end + end + end + for _, double in ipairs({ false, true }) do + for _, choice in ipairs({ "rage", "other", "custom", "encore_rage", "encore_other" }) do + currentKind, phase = choice, "planning" + draws, damage, used, accuracy = {}, {}, {}, {} + local b = fresh(double) + -- Compare actual turn-word preparation with fixed choices. Earlier + -- selection/RNG orchestration remains a separate controller boundary. + b.randomTurnNumber, b._nativeOrderReady = nil, false + local chosen = (choice == "rage" or choice == "encore_other") and 99 or choice == "custom" and 700 or 33 + local slot = chosen == 99 and 1 or 4 + local actions = {} + for id = 0, double and 3 or 1 do + local a = at(b, id) + a.rage = true + if chosen == 700 then + if native then + a.mon.moves[4] = 700 + else + a.curMoves[4].id = f.ids.MOD_RAGE + end + end + if choice == "encore_rage" or choice == "encore_other" then + a[native and "expEncoreMove" or "encoreMove"] = native and (choice == "encore_rage" and 99 or 33) + or (choice == "encore_rage" and f.ids.RAGE or f.ids.TACKLE) + a[native and "expEncoreSlot" or "encoreSlot"] = choice == "encore_rage" and 1 or 4 + a[native and "expEncoreTurns" or "encoreTurns"] = 4 + end + actions[id] = { + kind = "move", + move = chosen, + slot = slot, + battler = id, + target = id % 2 == 0 and id + 1 or id - 1, + } + end + if native then + require("src.core.game3.battle.engine").planTurnActions( + b, + require("src.core.game3.battle.adapter").new(b), + actions + ) + else + b.pendingActions = { [0] = at(b, 0).curMoves[slot], [2] = double and at(b, 2).curMoves[slot] or nil } + f.e.choose(function() + return { move = chosen == 99 and "RAGE" or chosen == 700 and "MOD_RAGE" or "TACKLE" } + end) + b:resolveTurn(b.pendingActions[0]) + end + for id = 0, double and 3 or 1 do + T.eq( + not not at(b, id).rage, + choice == "rage" or choice == "encore_rage", + "real planner clears Rage using the actual forced choice before actions" + ) + end + record(b, "planning/" .. choice .. "/" .. tostring(double)) + rows[#rows].preparedTurnWord = b.randomTurnNumber + end + end + for _, kind in ipairs({ "item", "switch", "run" }) do + currentKind, phase = kind, "nonmove" + draws, damage, used, accuracy = {}, {}, {}, {} + local b = fresh(false) + owner = at(b, 0) + at(b, 0).rage, at(b, 1).rage = true, true + b.randomTurnNumber, b._nativeOrderReady = 65535, true + if native then + require("src.core.game3.battle.engine").planTurnFromActions( + b, + require("src.core.game3.battle.adapter").new(b), + { kind = kind == "item" and "bag" or kind, user = "player" }, + { kind = "move", move = 33, slot = 4, user = "enemy" } + ) + else + f.e.choose(function() + return { move = "TACKLE" } + end) + -- The host spends non-move turns through its actual queued reply. + b:nonMoveAction() + end + T.eq(owner.rage, nil, "committed non-move action clears Rage") + T.eq(at(b, 1).rage, nil, "the independently selected enemy move clears its Rage too") + record(b, "nonmove/" .. kind) + rows[#rows].preparedTurnWord = b.randomTurnNumber + end + off() + offMove() + offAccuracy() + f.close() + return rows +end +return M diff --git a/tests/enemy_actions/records.lua b/tests/enemy_actions/records.lua new file mode 100644 index 000000000..f90100e4c --- /dev/null +++ b/tests/enemy_actions/records.lua @@ -0,0 +1,281 @@ +local M = {} +function M.run(root, backend, version) + local f = dofile(root .. "/tests/enemy_actions/fixture.lua").new(root, backend, version, 3, "unified") + local T, observations = f.T, {} + local used = {} + local stopUsed = f.e.api.events:on("battle.move_used", function(event) + used[#used + 1] = event.move.id + end) + local function actorAt(b, id) + return backend == "gen1" and b.battlers[id] or b:battlerAt(id) + end + local function setup(double, move) + local c = f.fresh(move or "TACKLE", { enemy = true }) + local b = c.battle + b.double = double + if double then + if backend == "gen1" then + local S, Copy = require("src.core.game3.battle.state"), require("src.mods.Merge").deepCopy + b.battlers[2] = S.makeBattler(Copy(b.player.mon), "player", { id = 2 }) + b.battlers[3] = S.makeBattler(Copy(b.enemy.mon), "enemy", { id = 3 }) + else + local S, Copy = require("src.battle.BattleState"), require("src.mods.Merge").deepCopy + b:placeBattler(2, S.makeBattler(f.data, Copy(b.player.mon), true)) + b:placeBattler(3, S.makeBattler(f.data, Copy(b.enemy.mon), false)) + end + end + if backend == "gen2" then + b:syncSides() + end + if not move then + for id = 0, double and 3 or 1 do + local actor = actorAt(b, id) + if backend == "gen1" then + actor.mon.moves, actor.mon.pp = { 33, 10 }, { 10, 7 } + else + actor.curMoves = { { id = f.ids.TACKLE, pp = 10 }, { id = f.ids.SCRATCH, pp = 7 } } + actor.mon.moves = actor.curMoves + end + end + end + return c, b + end + local function choose(b, id) + if backend == "gen1" then + return require("src.core.game3.battle.commands").enemyAction(b, id) + end + return b:enemyAction(id and actorAt(b, id) or nil) + end + local function execute(b, id, action) + local user, target = actorAt(b, id), actorAt(b, 0) + if backend == "gen1" then + require("src.core.game3.battle.engine").resolveMove( + user, + target, + action.move, + action.slot, + require("src.core.game3.battle.adapter").new(b), + b, + {} + ) + else + b:executeAction(user, target, action) + end + end + local function pp(actor) + if backend == "gen1" then + return { unpack(actor.mon.pp) } + end + local rows = {} + for i, move in ipairs(actor.curMoves) do + rows[i] = move.pp + end + return rows + end + local function snapshot(action) + return { + kind = action.kind, + move = action.move, + slot = action.slot, + user = action.user, + battler = action.battler, + targetType = action.targetType, + } + end + for _, double in ipairs({ false, true }) do + for _, spec in ipairs({ { name = "default" }, { name = "left", id = 1 }, { name = "right", id = 3 } }) do + if double or spec.id ~= 3 then + for _, mode in ipairs({ "forward", "edit", "copy-edit", "slot" }) do + local c, b = setup(double) + local seen + f.e.choose(function(nextAction, ...) + local action = nextAction(...) + seen = snapshot(action) + if mode == "copy-edit" then + local copied = snapshot(action) + copied.move = 10 + return copied + end + if mode == "edit" and action.kind == "move" and action.move == 33 then + action.move = 10 + end + if mode == "slot" then + action.slot = 2 + end + return action + end) + local action = choose(b, spec.id) + local expected = { + kind = "move", + move = 33, + slot = 1, + user = "enemy", + battler = double and (spec.id or 1) or spec.id, + targetType = double and 0 or nil, + } + local label = backend + .. "/" + .. version + .. "/" + .. tostring(double) + .. "/" + .. spec.name + .. "/" + .. mode + T.same(seen, expected, label .. " exposes common native move fields") + used = {} + execute(b, spec.id or 1, action) + T.eq( + used[#used], + (mode == "edit" or mode == "copy-edit") and "SCRATCH" or "TACKLE", + label .. " executes the selected move through the public event producer" + ) + local want = (mode == "copy-edit" or mode == "slot") and { 10, 6 } or { 9, 7 } + T.same(pp(actorAt(b, spec.id or 1)), want, label .. " preserves native selected-slot PP semantics") + T.check(c.hp() < 400, label .. " actually executes the action") + observations[#observations + 1] = { + double = double, + case = spec.name, + mode = mode, + seen = seen, + pp = pp(actorAt(b, spec.id or 1)), + hit = c.hp() < 400, + used = used[#used], + } + end + end + end + end + for _, row in ipairs({ + { field = "move", value = 10 }, + { field = "move", value = "SCRATCH" }, + { field = "id", value = 10 }, + { field = "id", value = "SCRATCH" }, + }) do + local _, b = setup(false) + f.e.choose(function() + return { [row.field] = row.value } + end) + local seen + local stop = f.e.api.hooks:wrap("battle.enemy_action", function(nextAction, ...) + local action = nextAction(...) + seen = { kind = action.kind, field = action[row.field], fieldType = type(action[row.field]) } + return action + end, 100) + local action = choose(b, 1) + stop() + T.same( + seen, + { field = row.value, fieldType = type(row.value) }, + "nested authored record preserves reference form" + ) + used = {} + execute(b, 1, action) + T.eq(used[#used], "SCRATCH", "nested authored record executes the requested move") + T.same(pp(actorAt(b, 1)), { 10, 6 }, "nested authored record selects the actual learned slot") + observations[#observations + 1] = + { case = "authored-" .. row.field, value = row.value, seen = seen, pp = pp(actorAt(b, 1)) } + end + for _, value in ipairs({ 10, "SCRATCH" }) do + local _, b = setup(false) + f.e.choose(function(nextAction, ...) + local action = nextAction(...) + action.move = value + return action + end) + local seen + local stop = f.e.api.hooks:wrap("battle.enemy_action", function(nextAction, ...) + local action = nextAction(...) + seen = action.move + return action + end, 100) + local action = choose(b, 1) + stop() + T.eq(seen, value, "nested native edit preserves the submitted move reference") + used = {} + execute(b, 1, action) + T.eq(used[#used], "SCRATCH", "nested native edit executes the requested move") + T.same(pp(actorAt(b, 1)), { 9, 7 }, "nested native edit retains the original selected PP slot") + observations[#observations + 1] = + { case = "native-edit", value = value, seen = seen, used = used[#used], pp = pp(actorAt(b, 1)) } + end + local _, paired = setup(false) + f.e.choose(function() + return { kind = "move", move = 10, id = "TACKLE" } + end) + local both + local stopBoth = f.e.api.hooks:wrap("battle.enemy_action", function(nextAction, ...) + local action = nextAction(...) + both = { kind = action.kind, move = action.move, id = action.id } + return action + end, 100) + local pairedChoice = choose(paired, 1) + stopBoth() + T.same( + both, + { kind = "move", move = 10, id = "TACKLE" }, + "both authored references retain independent forms" + ) + used = {} + execute(paired, 1, pairedChoice) + T.eq(used[#used], "SCRATCH", "explicit move takes precedence over the legacy id alias") + T.same(pp(paired.enemy), { 10, 6 }, "two-reference record charges the actual selected move slot") + observations[#observations + 1] = + { case = "both-references", seen = both, pp = pp(paired.enemy), used = used[#used] } + local charging, b = setup(false, "FLY") + local seen = {} + f.e.choose(function(nextAction, ...) + local action = nextAction(...) + seen[#seen + 1] = snapshot(action) + return action + end) + execute(b, 1, choose(b)) + if backend == "gen2" then + b.enemy.chargeReady = true + end + execute(b, 1, choose(b)) + T.same(seen, { + { kind = "move", move = 19, slot = 1, user = "enemy" }, + { kind = "move", move = 19, slot = 1, user = "enemy" }, + }, "charge continuation exposes common move fields") + T.eq(charging.pp(), 9, "public charge record retains once-only PP ownership") + T.check(charging.hp() < 400, "public charge record actually releases") + observations[#observations + 1] = + { case = "charge", seen = seen, pp = charging.pp(), hit = charging.hp() < 400 } + local exhausted, exhaustedBattle = setup(false) + if backend == "gen1" then + exhaustedBattle.enemy.mon.pp = { 0, 0 } + else + for _, move in ipairs(exhaustedBattle.enemy.curMoves) do + move.pp = 0 + end + end + local struggleView + f.e.choose(function(nextAction, ...) + local action = nextAction(...) + struggleView = snapshot(action) + return action + end) + local struggle = choose(exhaustedBattle) + T.same( + struggleView, + { kind = "move", move = "STRUGGLE", user = "enemy" }, + "native exhausted fallback retains the reference's symbolic Struggle move" + ) + used = {} + execute(exhaustedBattle, 1, struggle) + T.eq(used[#used], "STRUGGLE", "exhausted fallback executes actual Struggle") + T.same(pp(exhaustedBattle.enemy), { 0, 0 }, "Struggle cannot spend exhausted party PP") + T.check(exhausted.hp() < 400, "Struggle fallback actually hits") + observations[#observations + 1] = { + case = "struggle", + seen = struggleView, + pp = pp(exhaustedBattle.enemy), + used = used[#used], + hit = exhausted.hp() < 400, + } + stopUsed() + f.close() + return observations +end +return M diff --git a/tests/enemy_actions/secondary.lua b/tests/enemy_actions/secondary.lua new file mode 100644 index 000000000..c986b7ce6 --- /dev/null +++ b/tests/enemy_actions/secondary.lua @@ -0,0 +1,387 @@ +local M = {} +local EFFECTS = { + { "poison", 2, "POISON_SIDE_EFFECT1", "POISON", { status = "PSN" } }, + { "burn", 4, "BURN_SIDE_EFFECT1", "BURN", { status = "BRN" } }, + { "freeze", 5, "FREEZE_SIDE_EFFECT1", "FREEZE", { status = "FRZ" } }, + { "paralysis", 6, "PARALYZE_SIDE_EFFECT1", "PARALYSIS", { status = "PAR" } }, + { "flinch", 31, "FLINCH_SIDE_EFFECT1", "FLINCH", { flinch = true } }, + { "confusion", 76, "CONFUSION_SIDE_EFFECT", "CONFUSION", { confusion = true } }, + { "attack-down", 68, "ATTACK_DOWN_SIDE_EFFECT", "ATK_MINUS_1", { stat = "attack", delta = -1 } }, + { "defense-down", 69, "DEFENSE_DOWN_SIDE_EFFECT", "DEF_MINUS_1", { stat = "defense", delta = -1 } }, + { "spatk-down", 71, "SP_ATK_DOWN_HIT_EFFECT", "SP_ATK_MINUS_1", { stat = "spatk", delta = -1 } }, + { "speed-down", 70, "SPEED_DOWN_SIDE_EFFECT", "SPD_MINUS_1", { stat = "speed", delta = -1 } }, + { "spdef-down", 72, "SP_DEF_DOWN_SIDE_EFFECT", "SP_DEF_MINUS_1", { stat = "spdef", delta = -1 } }, + { "accuracy-down", 73, "ACCURACY_DOWN_SIDE_EFFECT", "ACC_MINUS_1", { stat = "accuracy", delta = -1 } }, + { "attack-up", 139, "ATTACK_UP_HIT_EFFECT", "ATK_PLUS_1", { stat = "attack", delta = 1, user = true } }, + { "defense-up", 138, "DEFENSE_UP_HIT_EFFECT", "DEF_PLUS_1", { stat = "defense", delta = 1, user = true } }, + { "all-stats-up", 140, "ALL_UP_HIT_EFFECT", "ALL_STATS_UP", { allStats = true, user = true } }, + { "tri-attack", 36, "TRI_ATTACK_EFFECT", "TRI_ATTACK", { tri = true } }, + { "toxic", 202, "POISON_FANG_EFFECT", "TOXIC", { status = "TOX" } }, +} +local AB = { + NONE = 0, + SERENE_GRACE = 32, + SHIELD_DUST = 19, + LIMBER = 7, + IMMUNITY = 17, + WATER_VEIL = 41, + MAGMA_ARMOR = 40, + OWN_TEMPO = 20, + INNER_FOCUS = 39, + CLEAR_BODY = 29, + WHITE_SMOKE = 73, + KEEN_EYE = 51, + HYPER_CUTTER = 52, +} +function M.run(root, backend, version, execution) + local native = backend == "gen1" + local function configure(data, engine, ids, rom, names) + if engine == "gen2" then + data.type_chart.matchups[#data.type_chart.matchups + 1] = + { attacker = "NORMAL", defender = "STEEL", multiplier = 5 } + end + for i, e in ipairs(EFFECTS) do + local n, name = 599 + i, "SECONDARY_" .. i + local id = engine == "gen1" and name or ("MOVE_%03d"):format(n) + ids[name], names[n] = id, name + if engine == "gen1" then + local d = { + effect = e[2], + power = 35, + type = 0, + accuracy = 100, + pp = 10, + flags = 2, + target = 0, + priority = 0, + secondaryChance = 30, + } + data.gen3Moves._rom[n], rom[n], data.gen3Pokemon._moveNames[n] = d, d, name + else + data.moves[id] = { + id = id, + name = name, + index = n, + gen3Effect = e[2], + effect = e[3], + power = 35, + type = "NORMAL", + accuracy = 100, + pp = 10, + secondaryChance = 30, + effectChance = 77, + protectAffected = true, + } + end + end + end + local f = + dofile(root .. "/tests/enemy_actions/fixture.lua").new(root, backend, version, 3, "unified", configure) + local T, rows, events, liveUser = f.T, {}, {}, nil + local stops = { + f.e.api.hooks:wrap("battle.accuracy", function() + return true + end), + f.e.api.hooks:wrap("battle.crit", function() + return false + end), + f.e.api.events:on("battle.status_inflicted", function(e) + events[#events + 1] = { + status = e.status, + target = e.target.mon.nickname, + sourceIsUser = e.source == liveUser, + targetStatus = e.target.mon.status, + } + end), + } + local conditions = { + { name = "certain", certain = true, direct = true }, + { name = "certain-mist", certain = true, mist = true, direct = true }, + { name = "certain-clear-body", certain = true, theirs = "CLEAR_BODY", direct = true }, + { name = "certain-shield-dust", certain = true, theirs = "SHIELD_DUST", direct = true }, + { name = "stage-cap-up", stages = 6 }, + { name = "stage-cap-down", stages = -6 }, + { name = "ordinary" }, + { name = "serene-grace", mine = "SERENE_GRACE" }, + { name = "shield-dust", theirs = "SHIELD_DUST" }, + { name = "limber", theirs = "LIMBER" }, + { name = "immunity", theirs = "IMMUNITY" }, + { name = "water-veil", theirs = "WATER_VEIL" }, + { name = "magma-armor", theirs = "MAGMA_ARMOR" }, + { name = "own-tempo", theirs = "OWN_TEMPO" }, + { name = "inner-focus", theirs = "INNER_FOCUS" }, + { name = "clear-body", theirs = "CLEAR_BODY" }, + { name = "white-smoke", theirs = "WHITE_SMOKE" }, + { name = "substitute-zero", sub = 0 }, + { name = "safeguard-zero", safeguard = 0 }, + { name = "mist-zero", mist = 0 }, + { name = "substitute", sub = 1000 }, + { name = "broken-substitute", sub = 1 }, + { name = "fainted", hp = 0, direct = true }, + { name = "fainting-hit", hp = 1 }, + { name = "status-existing", status = "PAR" }, + { name = "frozen", status = "FRZ" }, + { name = "fire-hit-frozen", status = "FRZ", moveType = 10 }, + { name = "fire-substitute-frozen", status = "FRZ", moveType = 10, sub = 1000 }, + { name = "fire-broken-sub-frozen", status = "FRZ", moveType = 10, sub = 1 }, + { name = "safeguard", safeguard = true }, + { name = "mist", mist = true }, + { name = "fire-type", kind = 10 }, + { name = "ice-type", kind = 15 }, + { name = "poison-type", kind = 3 }, + { name = "steel-type", kind = 8 }, + { name = "sun", sky = "SUN" }, + { name = "moved", moved = true }, + { name = "pokedude", demo = true }, + } + local function at(b, id) + return native and b.battlers[id] or b:battlerAt(id) + end + for _, condition in ipairs(conditions) do + if not execution or not condition.direct then + for i, e in ipairs(EFFECTS) do + for _, chance in ipairs({ 0, 30, 100 }) do + for _, word in ipairs({ 0, 30, 31, 60, 99 }) do + events = {} + local b = f.fresh("TACKLE", { enemy = true }).battle + b.double = true + local Copy = require("src.mods.Merge").deepCopy + if native then + local B = require("src.core.game3.battle.state") + b.battlers[2], b.battlers[3] = + B.makeBattler(Copy(b.player.mon), "player", { id = 2 }), + B.makeBattler(Copy(b.enemy.mon), "enemy", { id = 3 }) + else + local B = require("src.battle.BattleState") + b:syncSides() + b:placeBattler(2, B.makeBattler(f.data, Copy(b.player.mon), true)) + b:placeBattler(3, B.makeBattler(f.data, Copy(b.enemy.mon), false)) + b:syncSides() + end + local id = (i + chance + word) % 4 + local user, target = at(b, id), at(b, id % 2 == 0 and 1 or 0) + liveUser = user + for pos = 0, 3 do + local a = at(b, pos) + a.mon.nickname, a.mon.hp = "P" .. pos, 1000 + if native then + a.ability, a.mon.ability, a.type1, a.type2, a.mon.maxHp = 0, 0, 0, 0, 1000 + for _, k in ipairs({ "attack", "defense", "speed", "spAtk", "spDef" }) do + a.mon[k] = 50 + end + else + a.abilityOverride, a.curTypes, a.mon.stats.hp = false, { "NORMAL" }, 1000 + for _, k in ipairs({ "attack", "defense", "speed", "spatk", "spdef" }) do + a.curStats[k] = 50 + end + end + end + local function ability(a, name) + if native then + a.ability, a.mon.ability = AB[name or "NONE"], AB[name or "NONE"] + else + a.abilityOverride = name or false + end + end + if condition.stages then + for pos = 0, 3 do + local a = at(b, pos) + for _, stat in ipairs({ + "attack", + "defense", + "speed", + "spatk", + "spdef", + "accuracy", + "evasion", + }) do + local key = native and ({ spatk = "spAtk", spdef = "spDef" })[stat] or nil + a.stages[key or stat] = condition.stages + end + end + end + ability(user, condition.mine) + ability(target, condition.theirs) + local number, move = 599 + i + if native then + user.mon.moves, user.mon.pp = { number }, { 10 } + move = require("src.core.game3.battle.moves")._rom[number] + move.secondaryChance, move.type = chance, condition.moveType or 0 + target.type1, target.type2 = condition.kind or 0, condition.kind or 0 + target.status, target.mon.status = condition.status, condition.status + else + user.curMoves = { { id = f.ids["SECONDARY_" .. i], pp = 10 } } + user.mon.moves = user.curMoves + move = f.data.moves[user.curMoves[1].id] + move.secondaryChance = chance + move.type = condition.moveType == 10 and "FIRE" or "NORMAL" + move.effectChance = math.floor(chance * 255 / 100) + target.curTypes = { + ({ [0] = "NORMAL", [10] = "FIRE", [15] = "ICE", [3] = "POISON", [8] = "STEEL" })[condition.kind or 0], + } + target.mon.status = condition.status + end + target.mon.hp, target.substituteHP, target.expMovedThisTurn = + condition.hp or 1000, condition.sub, condition.moved + if native then + b.pokedude, b.weather = condition.demo, condition.sky + local side = target.side == "player" and b.playerSide or b.enemySide + side.expSafeguardTurns, side.expMistTurns = + condition.safeguard == true and 5 or condition.safeguard, + condition.mist == true and 5 or condition.mist + else + b.pokedudeDemo, b.field.weather = condition.demo and {}, condition.sky + target.safeguardTurns, target.mist = + condition.safeguard == true and 5 or condition.safeguard, condition.mist + end + local draws = {} + b.rng = function(lo, hi) + local value = lo + word % (hi - lo + 1) + draws[#draws + 1] = { lo = lo, hi = hi, word = word, value = value } + return value + end + local result + local descriptor = Copy(e[5]) + descriptor.certain = condition.certain + if native then + local Engine, Adapter = + require("src.core.game3.battle.engine"), require("src.core.game3.battle.adapter") + if execution then + Engine.resolveMove(user, target, number, 1, Adapter.new(b), b, {}) + result = "execution" + else + local context = Engine.newContext(user, target, number, 1, Adapter.new(b), b, {}, {}) + result = require("src.core.game3.battle.effects.secondary").withChance( + context, + e[4], + condition.certain == true, + e[5].user + ) + end + else + if execution then + b:executeAction(user, target, user.curMoves[1]) + result = "execution" + else + result = require("src.battle.Gen3Secondary").withChance( + require("src.battle.EffectRegistry").makeCtx(b, user, target, move, user.curMoves[1]), + descriptor + ) + end + end + if not execution and not condition.certain then + T.check(#draws >= 1, "native chance draws before effect protection even at zero") + T.same( + draws[1], + { lo = 0, hi = 99, word = word, value = word }, + "native secondary range and input word" + ) + end + if not execution and condition.certain then + local chanceDraws = 0 + for _, draw in ipairs(draws) do + if draw.lo == 0 and draw.hi == 99 then + chanceDraws = chanceDraws + 1 + end + end + T.eq(chanceDraws, 0, "certain effect opcode bypasses the chance draw") + end + if execution then + T.eq( + native and user.mon.pp[1] or user.curMoves[1].pp, + 9, + "native executed move spends owned PP once" + ) + end + local function view(a) + local stats = {} + for _, k in ipairs({ "attack", "defense", "speed", "spatk", "spdef", "accuracy", "evasion" }) do + local nk = native and ({ spatk = "spAtk", spdef = "spDef" })[k] or nil + stats[#stats + 1] = a.stages[nk or k] or 0 + end + return { + status = not native and a.mon.status == "PSN" and a.mon.toxic and "TOX" + or a.mon.status + or false, + hp = a.mon.hp, + stages = stats, + flinched = a.flinched or false, + confusion = (native and a.confusionTurns or a.confusedTurns) or 0, + sub = a.substituteHP or 0, + } + end + rows[#rows + 1] = { + case = condition.name, + effect = e[1], + chance = chance, + word = word, + user = id, + result = result or false, + draws = draws, + target = view(target), + attacker = view(user), + events = events, + } + end + end + end + end + end + if not native then + local c = f.fresh("TACKLE", { enemy = true }) + local b, mon = c.battle, c.target.mon + mon.status, mon.toxic, c.target.toxicCounter = "PSN", true, 1 + f.game.stack.states = { b } + T.eq(f.e.api.world:party()[1].status, "TOX", "API-3 party query preserves badly poisoned identity") + T.eq( + f.e.api.battle:snapshot().player.status, + "TOX", + "API-3 battle snapshot preserves badly poisoned identity" + ) + local rebuilt = require("src.battle.BattleState").makeBattler(f.data, mon, true) + T.eq(rebuilt.toxicCounter, 1, "saved native toxic marker restores the initial residual counter") + for _, desired in ipairs({ "PSN", "TOX" }) do + local stop = f.e.api.hooks:wrap("battle.damage", function(nextDamage, context) + context.target.mon.status = desired + return nextDamage(context) + end) + c.use() + stop() + T.eq( + c.target.toxicCounter, + desired == "TOX" and 1 or nil, + "callback status write resets the owning battler counter" + ) + T.eq(mon.status, "PSN", "native callback writes keep the engine status representation") + T.eq( + mon.toxic == true, + desired == "TOX", + "native callback status edits update the toxic marker atomically" + ) + T.eq(f.e.api.world:party()[1].status, desired, "public read reflects callback status replacement") + end + local record = require("src.battle.MoveEffects").RECORDS.PARALYZE_SIDE_EFFECT1 + local custom = require("src.mods.Merge").deepCopy(record) + custom.run = function() + return {} + end + local ctx = require("src.battle.EffectRegistry").makeCtx( + b, + c.user, + c.target, + f.data.moves[f.ids.SECONDARY_4], + c.user.curMoves[1] + ) + T.eq( + require("src.battle.Gen3Secondary").run(ctx, custom), + false, + "authored secondary handlers retain their own chance contract" + ) + end + for _, stop in ipairs(stops) do + stop() + end + f.close() + return rows +end +return M diff --git a/tests/enemy_actions/sleep_talk.lua b/tests/enemy_actions/sleep_talk.lua new file mode 100644 index 000000000..6d5293032 --- /dev/null +++ b/tests/enemy_actions/sleep_talk.lua @@ -0,0 +1,559 @@ +local M = {} +local DEFS = { + { 701, "MOD_STRIKE", 0, "NO_ADDITIONAL_EFFECT", 35, 0 }, + { 700, "MOD_SLEEP_TALK", 97, "SLEEP_TALK_EFFECT", 0, 1 }, + { 57, "SURF", 0, "NO_ADDITIONAL_EFFECT", 35, 8 }, + { 89, "EARTHQUAKE", 0, "NO_ADDITIONAL_EFFECT", 35, 32 }, + { 99, "RAGE", 81, "RAGE_EFFECT", 15, 0 }, + { 214, "SLEEP_TALK", 97, "SLEEP_TALK_EFFECT", 0, 1 }, + { 173, "SNORE", 92, "SNORE_EFFECT", 40, 0 }, + { 33, "TACKLE", 0, "NO_ADDITIONAL_EFFECT", 35, 0 }, + { 10, "SCRATCH", 0, "NO_ADDITIONAL_EFFECT", 40, 0 }, + { 167, "TRIPLE_KICK", 104, "TRIPLE_KICK_EFFECT", 10, 0 }, + { 3, "DOUBLE_SLAP", 29, "TWO_TO_FIVE_ATTACKS_EFFECT", 15, 0 }, + { 105, "RECOVER", 32, "HEAL_EFFECT", 0, 16 }, + { 118, "METRONOME", 83, "METRONOME_EFFECT", 0, 0 }, + { 119, "MIRROR_MOVE", 9, "MIRROR_MOVE_EFFECT", 0, 0 }, + { 274, "ASSIST", 181, "ASSIST_EFFECT", 0, 0 }, + { 264, "FOCUS_PUNCH", 170, "FOCUS_PUNCH_EFFECT", 150, 0 }, + { 253, "UPROAR", 159, "UPROAR_EFFECT", 50, 0 }, + { 117, "BIDE", 26, "BIDE_EFFECT", 0, 0 }, + { 13, "RAZOR_WIND", 39, "CHARGE_EFFECT", 80, 0 }, + { 143, "SKY_ATTACK", 75, "SKY_ATTACK_EFFECT", 140, 0 }, + { 130, "SKULL_BASH", 145, "CHARGE_EFFECT", 100, 0 }, + { 76, "SOLARBEAM", 151, "SOLARBEAM_EFFECT", 120, 0 }, + { 19, "FLY", 155, "FLY_EFFECT", 70, 0 }, +} +function M.fixture(root, backend, version, entry) + local native = backend == "gen1" + local nativeRows, nativeNames + local fixture = dofile(root .. "/tests/enemy_actions/fixture.lua").new( + root, + backend, + version, + 3, + "unified", + function(data, _, ids, rom, names) + nativeRows, nativeNames = rom, names + for _, r in ipairs(DEFS) do + local number, name, effect, binding, power, target = unpack(r) + local id = native and name or ("MOVE_%03d"):format(number) + ids[name], names[number] = id, name + if native then + local def = { + effect = effect, + power = power, + type = 0, + accuracy = 100, + pp = 10, + target = target, + flags = 51, + secondaryChance = 0, + priority = 0, + } + data.gen3Moves._rom[number], rom[number], data.gen3Pokemon._moveNames[number] = def, def, name + data.gen3Pokemon._types[4], data.gen3Pokemon._abilities[4] = { 0, 0 }, { 0, 0 } + else + data.moves[id] = { + id = id, + index = number, + name = name, + gen3Effect = effect, + effect = binding, + power = power, + type = "NORMAL", + accuracy = 100, + pp = 10, + target = target, + flags = 51, + protectAffected = target ~= 16, + secondaryChance = 0, + } + data.pokemon.CHARMANDER.types, data.pokemon.CHARMANDER.abilities = { "NORMAL" }, {} + end + end + end, + entry + ) + fixture.nativeRows, fixture.nativeNames = nativeRows, nativeNames + return fixture +end +function M.run(root, backend, version, options) + options = options or {} + local native = backend == "gen1" + local f = M.fixture(root, backend, version) + if options.choiceBand and not native then + f.data.items.ITEM_186 = { + id = "ITEM_186", + index = 186, + name = "CHOICE BAND", + holdEffect = "CHOICE_BAND", + holdEffectParam = 0, + } + end + local T, Copy = f.T, require("src.mods.Merge").deepCopy + local rows, draws, used, damage = {}, {}, {}, {} + local offUsed = f.e.api.events:on("battle.move_used", function(e) + used[#used + 1] = { + user = e.user.mon.nickname, + target = e.target.mon.nickname, + move = e.move.id, + called = e.isCalled or false, + } + end) + local offDamage = f.e.api.events:on("battle.damage_dealt", function(e) + damage[#damage + 1] = { + user = e.user.mon.nickname, + target = e.target.mon.nickname, + move = e.move.id, + damage = e.damage, + crit = e.crit or false, + substitute = e.substitute or false, + typeMult = e.typeMult, + } + end) + local function at(b, id) + return native and b.battlers[id] or b:battlerAt(id) + end + local function def(number) + if native then + return f.data.gen3Moves._rom[number] + end + return f.data.moves[("MOVE_%03d"):format(number)] + end + local function field(b) + local all = {} + for id = 0, b.double and 3 or 1 do + local a = at(b, id) + local pp = {} + for i = 1, 4 do + pp[i] = native and a.mon.pp[i] or a.curMoves[i].pp + end + all[#all + 1] = { + hp = a.mon.hp, + status = a.mon.status, + sleep = a.sleepTurns, + pp = pp, + last = native and a.lastMoveId or a.lastMove and f.data.moves[a.lastMove].index, + sub = tonumber(a.substituteHP) or 0, + attack = a.stages.attack or 0, + rage = not not a.rage, + choice = options.choiceBand and a.choicedMove or nil, + } + end + return all + end + for _, double in ipairs({ false, true }) do + for uid = 0, double and 3 or 1 do + local tid = uid % 2 == 0 and uid + 1 or uid - 1 + for pick = 1, 3 do + for _, kind in ipairs({ + "ordinary", + "awake", + "wake", + "early_bird", + "early_wake", + "missing_counter", + "zero_pp", + "disabled_choice", + "encore_self", + "empty", + "multi", + "triple", + "snore", + "recover", + "flinch", + "sleep_block", + "wake_ordinary", + "uproar", + "soundproof", + "forbidden", + "bide", + "razor_wind", + "sky_attack", + "skull_bash", + "solar_beam", + "disabled_caller", + "called", + "called_awake", + "custom_caller", + "zero_id", + "fifth_slot", + "spread_both", + "spread_all", + "rage", + "wake_flinch", + "indexless", + }) do + draws, used, damage = {}, {}, {} + local b = f.fresh("TACKLE", { enemy = true }).battle + b.double = double + if not native then + b:syncSides() + end + if double then + for id = 2, 3 do + local mon = Copy(at(b, id % 2).mon) + if native then + b.battlers[id] = require("src.core.game3.battle.state").makeBattler( + mon, + id % 2 == 0 and "player" or "enemy", + { id = id, partyIndex = 2 } + ) + else + b:placeBattler(id, require("src.battle.BattleState").makeBattler(f.data, mon, id % 2 == 0)) + end + end + end + if not native then + b:syncSides() + end + local slots = { 214, 33, 10, 33 } + if kind == "multi" then + slots = { 214, 3, 3, 3 } + end + if kind == "triple" then + slots = { 214, 167, 167, 167 } + end + if kind == "snore" then + slots = { 214, 173, 173, 173 } + end + if kind == "recover" then + slots = { 214, 105, 105, 105 } + end + if kind == "empty" then + slots = { 214, 118, 119, 274 } + end + if kind == "forbidden" then + slots = { 214, 264, 253, 19 } + end + local nested = ({ spread_both = 57, spread_all = 89, rage = 99, indexless = 701 })[kind] + if nested then + slots = { 214, nested, nested, nested } + end + local banned = ( + { bide = 117, razor_wind = 13, sky_attack = 143, skull_bash = 130, solar_beam = 76 } + )[kind] + if banned then + slots = { 214, banned, banned, banned } + end + if kind == "custom_caller" then + slots[1] = 700 + end + if kind == "zero_id" then + slots = { 214, 0, 10, 0 } + end + if kind == "fifth_slot" then + slots = { 214, 118, 119, 274, 33 } + end + for id = 0, double and 3 or 1 do + local a = at(b, id) + a.mon.nickname = "P" .. id + if native then + a.mon.moves, a.mon.pp, a.mon.maxPp = Copy(slots), { 10, 10, 10, 10 }, { 10, 10, 10, 10 } + a.mon.maxHp, a.ability, a.mon.ability, a.type1, a.type2 = 400, 0, 0, 0, 0 + else + a.mon.moves = {} + for _, num in ipairs(slots) do + a.mon.moves[#a.mon.moves + 1] = { id = ("MOVE_%03d"):format(num), pp = 10 } + end + a.curMoves, a.curTypes, a.abilityOverride = a.mon.moves, { "NORMAL" }, false + a.mon.stats.hp = 400 + for _, stat in ipairs({ "attack", "defense", "spatk", "spdef" }) do + a.curStats[stat] = 50 + end + end + a.mon.hp = 400 + end + if kind == "indexless" and not native then + def(701).index = nil + end + local user, target = at(b, uid), at(b, tid) + if options.choiceBand then + user.mon.item = native and 186 or "ITEM_186" + if native then + user.item = 186 + end + end + user.mon.status, user.sleepTurns = "SLP", 4 + if kind == "awake" or kind == "called_awake" then + user.mon.status, user.sleepTurns = nil, nil + end + if kind == "wake" or kind == "wake_ordinary" or kind == "wake_flinch" then + user.sleepTurns = 1 + end + if kind == "missing_counter" then + user.sleepTurns = nil + end + if kind == "early_bird" or kind == "early_wake" then + user[native and "ability" or "abilityOverride"] = native and 48 or "EARLY_BIRD" + user.sleepTurns = kind == "early_wake" and 2 or 4 + end + if kind == "zero_pp" then + for i = 2, 4 do + if native then + user.mon.pp[i] = 0 + else + user.curMoves[i].pp = 0 + end + end + end + if kind == "disabled_choice" then + user[native and "expDisabledMove" or "disabledMove"] = native and 33 or f.ids.TACKLE + user[native and "expDisableTurns" or "disabledTurns"] = 3 + end + if kind == "disabled_caller" then + user[native and "expDisabledMove" or "disabledMove"] = native and 214 or f.ids.SLEEP_TALK + user[native and "expDisableTurns" or "disabledTurns"] = 3 + end + if kind == "encore_self" then + user[native and "expEncoreMove" or "encoreMove"] = native and 214 or f.ids.SLEEP_TALK + user[native and "expEncoreTurns" or "encoreTurns"] = 3 + end + if kind == "recover" then + user.mon.hp = 100 + end + if kind == "flinch" or kind == "wake_flinch" then + user.flinched = true + end + if kind == "uproar" or kind == "soundproof" then + target[native and "expUproarTurns" or "uproarTurns"] = 3 + if kind == "soundproof" then + user[native and "ability" or "abilityOverride"] = native and 43 or "SOUNDPROOF" + end + end + user.lastMoveId, user.lastMove = native and 10 or nil, native and 10 or f.ids.SCRATCH + local sleepPending = kind == "missing_counter" + b.rng = function(lo, hi) + local value = hi + if lo == 1 and hi <= 3 then + value = math.min(pick, hi) + end + if lo == 1 and hi == 100 then + value = 1 + end + if lo == 0 and hi == 3 then + if sleepPending then + sleepPending = false + value = 0 + else + value = tid + end + end + draws[#draws + 1] = { lo = lo, hi = hi, value = value } + return value + end + local ordinary = kind == "sleep_block" or kind == "wake_ordinary" + local slot = ordinary and 2 or 1 + local called = kind == "called" or kind == "called_awake" + if native then + local nativeSlot = slot + if called then + nativeSlot = nil + end + require("src.core.game3.battle.engine").resolveMove( + uid, + tid, + user.mon.moves[slot], + nativeSlot, + require("src.core.game3.battle.adapter").new(b), + b, + {}, + { called = called } + ) + elseif called then + b:performMove(user, target, user.curMoves[slot], true) + else + b:executeAction(user, target, user.curMoves[slot]) + end + rows[#rows + 1] = { + label = (double and "double" or "single") .. "/" .. uid .. "/" .. pick .. "/" .. kind, + field = field(b), + draws = Copy(draws), + used = Copy(used), + damage = Copy(damage), + } + if kind == "indexless" and not native then + def(701).index = 701 + end + if kind == "indexless" then + T.eq(#used, 2, "a learned custom move needs no imported numeric index") + T.eq(#damage, 1, "the indexless custom move executes through the native caller") + end + if kind == "ordinary" or kind == "zero_pp" then + T.eq(#used, 2, "Sleep Talk and the chosen move each emit one public event") + T.eq(native and user.mon.pp[1] or user.curMoves[1].pp, 9, "only the selected caller spends PP") + T.eq(user.sleepTurns, 3, "nested called execution does not decrement sleep again") + if options.choiceBand then + T.eq(user.choicedMove, 214, "actual called dispatch binds Sleep Talk rather than its child") + end + end + if + banned + or kind == "empty" + or kind == "forbidden" + or kind == "encore_self" + or kind == "fifth_slot" + then + T.eq(#damage, 0, "an empty native eligible pool cannot execute a forbidden move") + end + if kind == "called" then + T.eq(#used, 2, "called Sleep Talk dispatches its nested move") + T.eq(user.sleepTurns, 4, "called Sleep Talk bypasses the outer sleep canceller") + T.eq( + native and user.mon.pp[1] or user.curMoves[1].pp, + 10, + "called Sleep Talk spends neither caller nor child PP" + ) + end + if kind == "sleep_block" then + T.eq(#used, 0, "ordinary moves remain blocked while asleep") + end + if kind == "wake_ordinary" then + T.eq(user.mon.status, nil, "native wake clears persistent sleep") + end + end + end + end + end + T.same(f.result.loader.errors, {}, "public callbacks complete without swallowed runtime errors") + offUsed() + offDamage() + f.close() + return rows +end +function M.content(root, backend, version) + local native = backend == "gen1" + local rows = {} + for _, kind in ipairs({ "ordinary", "zero_pp", "awake", "primary", "patched" }) do + local entry = [=[return function(mod) + assert(mod.content.move_effects:register("PORTABLE_SLEEP_CHOICE",{kind="secondary",run=function(ctx) + mod.exports.runs=mod.exports.runs+1 + return {} + end})) + assert(mod.content.moves:register("PORTABLE_SLEEP_MOVE",{id="PORTABLE_SLEEP_MOVE",name="PORTABLE_SLEEP_MOVE",type="NORMAL",power=35,accuracy=100,pp=10,effect="PORTABLE_SLEEP_CHOICE"})) + mod.exports.runs=0 + mod.exports.api=mod + end]=] + if kind == "primary" then + entry = entry:gsub('kind="secondary"', 'kind="primary"'):gsub("power=35", "power=0") + entry = entry:gsub( + "mod.exports.runs=mod.exports.runs%+1", + 'mod.exports.runs=mod.exports.runs+1;ctx.changeStage(ctx.user,"attack",1)' + ) + elseif kind == "patched" then + entry = entry:gsub( + "mod.exports.runs=0", + 'assert(mod.content.moves:patch("FLY",{effect="PORTABLE_SLEEP_CHOICE"}));mod.exports.runs=0' + ) + end + local f = M.fixture(root, backend, version, entry) + local T = f.T + local c = f.fresh("TACKLE", { enemy = true }) + local b, user, target = c.battle, c.user, c.target + if not native then + for _, actor in ipairs({ user, target }) do + for _, stat in ipairs({ "attack", "defense", "spatk", "spdef" }) do + actor.curStats[stat] = 50 + end + end + end + local number, previousRegistryData + if native then + number = kind == "patched" and 19 + or assert(require("src.mods.Gen3Compat").moveId("PORTABLE_SLEEP_MOVE")) + -- The base fixture mounts its explicit native rows after public load. + -- Mount the actually merged authored row/name too; do not reconstruct + -- or modify its effect/power/type/flags. All mounted tables are owned + -- by this fixture and restored by close(). The source checkout stays clean. + local moves = require("src.core.game3.battle.moves") + assert(moves._rom == f.nativeRows and moves.BY_NUM == f.nativeNames) + f.nativeRows[number] = assert(f.data.gen3Moves._rom[number]) + local name = kind == "patched" and "FLY" or "PORTABLE_SLEEP_MOVE" + f.nativeNames[number] = name + moves._numByName[name] = number + local registries = require("src.core.game3.battle.mod_registries") + previousRegistryData = registries.data() + registries.bind(f.data) + assert(registries.hasEffects()) + user.mon.moves, user.mon.pp, user.mon.maxPp = + { 214, number }, { 10, kind == "zero_pp" and 0 or 10 }, { 10, 10 } + else + b:syncSides() + T.eq( + f.data.moves.PORTABLE_SLEEP_MOVE.index, + nil, + "portable authored move has no required numeric index" + ) + user.mon.moves = { + { id = f.ids.SLEEP_TALK, pp = 10 }, + { id = kind == "patched" and f.ids.FLY or "PORTABLE_SLEEP_MOVE", pp = kind == "zero_pp" and 0 or 10 }, + } + user.curMoves = user.mon.moves + if kind == "patched" then + T.eq(f.data.moves[f.ids.FLY].gen3Effect, nil, "authored effect clears the old native two-turn script") + end + end + user.mon.status, user.sleepTurns = "SLP", 4 + if kind == "awake" then + user.mon.status, user.sleepTurns = nil, nil + end + local draws, used, damage = {}, {}, {} + b.rng = function(lo, hi) + local v = lo == 1 and 1 or hi + draws[#draws + 1] = { lo = lo, hi = hi, value = v } + return v + end + local off = f.e.api.events:on("battle.move_used", function(e) + used[#used + 1] = { move = e.move.id, called = e.isCalled or false } + end) + local offDamage = f.e.api.events:on("battle.damage_dealt", function(e) + damage[#damage + 1] = + { move = e.move.id, damage = e.damage, crit = e.crit or false, typeMult = e.typeMult } + end) + if native then + require("src.core.game3.battle.engine").resolveMove( + 1, + 0, + 214, + 1, + require("src.core.game3.battle.adapter").new(b), + b, + {} + ) + else + b:executeAction(user, target, user.curMoves[1]) + end + T.eq(f.e.runs, kind == "awake" and 0 or 1, "same API-3 authored effect executes through Sleep Talk") + T.eq(native and user.mon.pp[1] or user.curMoves[1].pp, 9, "portable custom call spends caller PP only") + T.eq( + native and user.mon.pp[2] or user.curMoves[2].pp, + kind == "zero_pp" and 0 or 10, + "portable learned custom move keeps its PP" + ) + T.same(f.result.loader.errors, {}, "custom content and public callbacks execute without runtime errors") + rows[#rows + 1] = { + label = kind, + runs = f.e.runs, + attack = user.stages.attack or 0, + pp = { + native and user.mon.pp[1] or user.curMoves[1].pp, + native and user.mon.pp[2] or user.curMoves[2].pp, + }, + hp = { user.mon.hp, target.mon.hp }, + status = user.mon.status, + sleep = user.sleepTurns, + draws = draws, + used = used, + damage = damage, + } + off() + offDamage() + if native then + require("src.core.game3.battle.mod_registries").bind(previousRegistryData) + end + f.close() + end + return rows +end +return M diff --git a/tests/enemy_actions/spit_up.lua b/tests/enemy_actions/spit_up.lua new file mode 100644 index 000000000..e7c8d2dea --- /dev/null +++ b/tests/enemy_actions/spit_up.lua @@ -0,0 +1,516 @@ +local M = {} +function M.run(root, backend, version) + local native = backend == "gen1" + local definitions = { + { "STOCKPILE", 254, 160, "STOCKPILE_EFFECT", 0, 16, 0 }, + { "SPIT_UP", 255, 161, "SPIT_UP_EFFECT", 100, 0, 0 }, + { "SWALLOW", 256, 162, "SWALLOW_EFFECT", 0, 16, 0 }, + { "HELPING_HAND", 270, 176, "HELPING_HAND_EFFECT", 0, 16, 5 }, + } + local function configure(data, engine, ids, rom, names) + for _, row in ipairs(definitions) do + local name, num, effect, handler, power, target, priority = unpack(row) + local key = engine == "gen1" and name or ("MOVE_%03d"):format(num) + ids[name], names[num] = key, name + if engine == "gen1" then + local d = { + effect = effect, + power = power, + type = 0, + target = target, + priority = priority, + accuracy = 100, + pp = 20, + flags = power == 0 and 0 or 51, + secondaryChance = 0, + } + data.gen3Moves._rom[num], rom[num], data.gen3Pokemon._moveNames[num] = d, d, name + else + data.moves[key] = { + id = key, + index = num, + name = name, + effect = handler, + gen3Effect = effect, + power = power, + type = "NORMAL", + target = target, + priority = priority, + accuracy = 100, + accuracyRaw = 255, + pp = 20, + protectAffected = power ~= 0, + } + end + end + if engine == "gen2" then + data.type_chart.types = {} + data.type_chart.matchups = { + { attacker = "NORMAL", defender = "ROCK", multiplier = 5 }, + { attacker = "NORMAL", defender = "STEEL", multiplier = 5 }, + { attacker = "NORMAL", defender = "GHOST", multiplier = 0 }, + } + for name, index in pairs({ + NORMAL = 0, + ROCK = 5, + GHOST = 7, + STEEL = 8, + FIRE = 10, + WATER = 11, + GRASS = 12, + ELECTRIC = 13, + }) do + data.type_chart.types[name] = + { id = name, index = index, category = index < 10 and "physical" or "special" } + end + end + end + local f = + dofile(root .. "/tests/enemy_actions/fixture.lua").new(root, backend, version, 3, "unified", configure) + local T, rows, hits, moves = f.T, {}, {}, {} + local calls, rolls = { crit = 0, damage = 0, variation = 0 }, 100 + local stopCrit = f.e.api.hooks:wrap("battle.crit", function() + calls.crit = calls.crit + 1 + return true + end) + local stopDamage = f.e.api.hooks:wrap("battle.damage", function(nextDamage, ...) + calls.damage = calls.damage + 1 + return nextDamage(...) + end) + local stopHit = f.e.api.events:on("battle.damage_dealt", function(e) + hits[#hits + 1] = { + user = e.user.mon.nickname, + target = e.target.mon.nickname, + move = e.move.id, + amount = e.damage, + crit = e.crit, + typeMult = e.typeMult, + substitute = e.substitute or false, + } + end) + local stopMove = f.e.api.events:on("battle.move_used", function(e) + moves[#moves + 1] = { user = e.user.mon.nickname, target = e.target.mon.nickname, move = e.move.id } + end) + local function at(b, id) + if native then + return b.battlers[id] + end + return b:battlerAt(id) + end + local function make(mon, id) + if native then + return require("src.core.game3.battle.state").makeBattler( + mon, + id % 2 == 0 and "player" or "enemy", + { id = id } + ) + end + return require("src.battle.BattleState").makeBattler(f.data, mon, id % 2 == 0) + end + local function put(b, id, a) + if native then + b.battlers[id] = a + else + b:placeBattler(id, a) + end + end + local function fresh(spec, userId) + moves, hits = {}, {} + local b, copy = f.fresh("TACKLE", { enemy = true }).battle, require("src.mods.Merge").deepCopy + b.double = true + put(b, 2, make(copy(b.player.mon), 2)) + put(b, 3, make(copy(b.enemy.mon), 3)) + if not native then + b:syncSides() + end + for id = 0, 3 do + local a = at(b, id) + a.mon.nickname, a.mon.hp, a.mon.level = "P" .. id, 10000, spec.level or 30 + if native then + a.mon.maxHp, a.ability, a.mon.ability = 10000, 0, 0 + a.mon.moves, a.mon.pp, a.mon.maxPp = { 254, 255, 256, 270 }, { 20, 10, 10, 20 }, { 20, 10, 10, 20 } + for _, k in ipairs({ "attack", "defense", "speed", "spAtk", "spDef" }) do + a.mon[k] = spec.stat or 50 + end + a.type1, a.type2 = 0, 0 + else + a.curStats = { + hp = 10000, + attack = spec.stat or 50, + defense = spec.stat or 50, + speed = 50, + special = spec.stat or 50, + spatk = spec.stat or 50, + spdef = spec.stat or 50, + } + a.mon.stats, a.curTypes, a.abilityOverride = a.curStats, { "NORMAL" }, false + a.curMoves = { + { id = f.ids.STOCKPILE, pp = 20 }, + { id = f.ids.SPIT_UP, pp = 10 }, + { id = f.ids.SWALLOW, pp = 10 }, + { id = f.ids.HELPING_HAND, pp = 20 }, + } + a.mon.moves = a.curMoves + end + end + local user, target = at(b, userId), at(b, userId % 2 == 0 and 1 or 0) + local kind = spec.moveType or "NORMAL" + local typeIds = { NORMAL = 0, FIRE = 10, WATER = 11, GRASS = 12, ELECTRIC = 13 } + local def = native and require("src.core.game3.battle.moves")._rom[255] or f.data.moves[f.ids.SPIT_UP] + def.type = native and typeIds[kind] or kind + if spec.stab ~= false then + if native then + user.type1, user.type2 = typeIds[kind], typeIds[kind] + else + user.curTypes = { kind } + end + end + if spec.ability then + if native then + local ab = require("src.core.game3.battle.abilities").id(spec.ability) + user.ability, user.mon.ability = ab, ab + else + user.abilityOverride = spec.ability + end + end + if spec.defAbility then + if native then + local ab = require("src.core.game3.battle.abilities").id(spec.defAbility) + target.ability, target.mon.ability = ab, ab + else + target.abilityOverride = spec.defAbility + end + end + user.stages.attack, user.stages.spAtk, user.stages.spatk = + spec.attackStage or 0, spec.attackStage or 0, spec.attackStage or 0 + target.stages.defense, target.stages.spDef, target.stages.spdef = + spec.defenseStage or 0, spec.defenseStage or 0, spec.defenseStage or 0 + if spec.item then + local item = ({ NORMAL_POWER = 217, CHOICE_BAND = 186 })[spec.item] + if native then + user.item, user.mon.item = item, item + else + user.items.PROBE = + { holdEffect = spec.item, holdEffectParam = spec.item == "NORMAL_POWER" and 10 or 0 } + user.mon.item = "PROBE" + end + end + if spec.partnerAbility then + local partner = at(b, (userId + 2) % 4) + if native then + local ab = require("src.core.game3.battle.abilities").id(spec.partnerAbility) + partner.ability, partner.mon.ability = ab, ab + else + partner.abilityOverride = spec.partnerAbility + end + end + if spec.pinch then + user.mon.hp = 3000 + end + if spec.sport then + if native then + b.expWaterSport = true + else + b.field.waterSport = "FIRE" + end + end + if spec.defAbility == "VOLT_ABSORB" then + target.mon.hp = 5000 + end + if spec.identified then + if native then + target.expIdentified = true + else + target.identified = true + end + end + if spec.defAbility == "MARVEL_SCALE" then + target.mon.status = "PSN" + end + if spec.endure then + target.mon.hp = 1 + if native then + target.expEnduring = true + else + target.enduring = true + end + end + if spec.weather then + if native then + b.weather = spec.weather + else + b.field.weather = spec.weather + end + end + if spec.stab == false then + if native then + user.type1, user.type2 = 10, 10 + else + user.curTypes = { "FIRE" } + end + end + local types = spec.types or { "NORMAL" } + if native then + local ids = { NORMAL = 0, ROCK = 5, GHOST = 7, STEEL = 8 } + target.type1, target.type2 = ids[types[1]], ids[types[2] or types[1]] + else + target.curTypes = types + end + if spec.defense then + if native then + target.mon.defense = spec.defense + else + target.curStats.defense = spec.defense + end + end + b.rng = function(lo, hi) + if lo == 85 and hi == 100 then + calls.variation = calls.variation + 1 + return rolls + end + if spec.miss and lo == 1 and hi == 100 then + return 100 + end + if spec.miss and lo == 0 and hi == 99 then + return 99 + end + if spec.miss and lo == 0 and hi == 255 then + return 255 + end + return lo or 0 + end + return b, user, target + end + local function count(a) + return native and (a.expStockpile or 0) or (a.stockpile or 0) + end + local function pp(a, slot) + return native and a.mon.pp[slot] or a.curMoves[slot].pp + end + local function action(b, id, slot, target) + if native then + local C = require("src.core.game3.battle.commands") + f.e.choose(function() + return { move = definitions[slot][1], target = target } + end) + local a = id % 2 == 0 and C.playerAction(b, 1, slot, id, target) or C.enemyAction(b, id) + require("src.core.game3.battle.engine").resolveMove( + id, + a.target, + a.move, + a.slot, + require("src.core.game3.battle.adapter").new(b), + b, + {} + ) + else + f.e.choose(function() + return { move = definitions[slot][1], target = target } + end) + local a = id % 2 == 0 and at(b, id).curMoves[slot] or b:enemyAction(at(b, id)) + if id % 2 == 0 then + b.chosenTargets = { [id] = at(b, target) } + end + b:executeAction(at(b, id), at(b, target), a) + end + end + local specs = { + { name = "normal" }, + { name = "no-stab", stab = false }, + { name = "resist", types = { "STEEL" } }, + { name = "dual-resist", types = { "ROCK", "STEEL" } }, + { name = "immune", types = { "GHOST" } }, + { name = "low", level = 1, stat = 1, defense = 500 }, + { name = "wide", stat = 300, defense = 50 }, + { name = "helped", helped = true }, + { name = "protected", protected = true }, + { name = "substitute", substitute = 30 }, + { name = "sleep", sleep = true }, + { name = "hidden", hidden = true }, + { name = "miss", miss = true }, + { name = "burn", burn = true }, + { name = "reflect", reflect = true }, + { name = "huge-stage", stat = 55, ability = "HUGE_POWER", attackStage = 1 }, + { name = "hustle-stage", stat = 55, ability = "HUSTLE", attackStage = -1 }, + { name = "guts-burn", stat = 55, ability = "GUTS", burn = true, attackStage = 1 }, + { name = "marvel-scale", stat = 55, defAbility = "MARVEL_SCALE", defenseStage = 1 }, + { name = "scarf-stage", stat = 55, item = "NORMAL_POWER", attackStage = 1 }, + { name = "huge-band", stat = 55, ability = "HUGE_POWER", item = "CHOICE_BAND", attackStage = -1 }, + { name = "endure-zero", endure = true }, + { name = "fire-rain", moveType = "FIRE", weather = "RAIN" }, + { name = "fire-sun", moveType = "FIRE", weather = "SUN" }, + { name = "thick-fat", moveType = "FIRE", defAbility = "THICK_FAT", stat = 55, attackStage = 1 }, + { + name = "low-dual-no-stab", + level = 1, + stat = 1, + defense = 500, + stab = false, + types = { "ROCK", "STEEL" }, + }, + { name = "identified-ghost", types = { "GHOST" }, identified = true }, + { + name = "plus-minus", + moveType = "ELECTRIC", + ability = "PLUS", + partnerAbility = "MINUS", + stat = 55, + attackStage = 1, + }, + { name = "plus-alone", moveType = "ELECTRIC", ability = "PLUS", stat = 55, attackStage = 1 }, + { + name = "blaze-sport", + moveType = "FIRE", + ability = "BLAZE", + pinch = true, + sport = true, + stat = 55, + attackStage = 1, + }, + { name = "absorbed", moveType = "ELECTRIC", defAbility = "VOLT_ABSORB" }, + { name = "wonder-guard", defAbility = "WONDER_GUARD" }, + } + for id = 0, 3 do + for n = 0, 3 do + for _, spec in ipairs(specs) do + for _, variation in ipairs({ 85, 100 }) do + rolls = variation + local b, user, target = fresh(spec, id) + for i = 1, n do + action(b, id, 1, id) + end + T.eq(count(user), n, "real Stockpile producer records native count") + if spec.helped then + action(b, (id + 2) % 4, 4, (id + 2) % 4) + end + if spec.protected then + if native then + target.expProtected = true + else + target.protecting = true + end + end + if spec.substitute then + target.substituteHP = spec.substitute + end + if spec.sleep then + user.mon.status, user.sleepTurns = "SLP", 5 + end + if spec.burn then + user.mon.status = "BRN" + end + if spec.hidden then + if native then + target.semiInvulnerable = "ON_AIR" + else + target.invulnerable = true + end + end + if spec.reflect then + if native then + (target.side == "player" and b.playerSide or b.enemySide).expReflectTurns = 5 + else + target.reflect = true + end + end + if spec.miss then + local move = native and require("src.core.game3.battle.moves")._rom[255] + or f.data.moves[f.ids.SPIT_UP] + move.accuracy, move.accuracyRaw = 1, 1 + end + moves, hits, calls = {}, {}, { crit = 0, damage = 0, variation = 0 } + local before = target.mon.hp + action(b, id, 2, target == b.player and 0 or target.position or target.id) + if spec.name == "normal" then + T.eq( + before - target.mon.hp, + 45 * n, + "Spit Up multiplies the computed base including its final +2" + ) + end + local expected = spec.sleep or spec.hidden or spec.miss or spec.defAbility == "VOLT_ABSORB" + T.eq(count(user), expected and n or 0, "native count consumption follows execution outcome") + T.eq(pp(user, 2), spec.sleep and 10 or 9, "Spit Up uses owned PP after ordinary interruption") + T.same( + calls, + { crit = 0, damage = 0, variation = 0 }, + "set damage bypasses ordinary damage/critical hooks and variance" + ) + rows[#rows + 1] = { + case = spec.name, + id = id, + count = n, + roll = variation, + amount = before - target.mon.hp, + stockpile = count(user), + pp = pp(user, 2), + hits = hits, + moves = moves, + calls = calls, + substitute = target.substituteHP or 0, + } + if spec.miss then + local move = native and require("src.core.game3.battle.moves")._rom[255] + or f.data.moves[f.ids.SPIT_UP] + move.accuracy, move.accuracyRaw = 100, 255 + end + end + end + end + end + for _, id in ipairs({ 0, 3 }) do + local b, user, target = fresh({}, id) + for i = 1, 4 do + action(b, id, 1, id) + end + T.eq(count(user), 3, "Stockpile caps at three after four actual casts") + T.eq(pp(user, 1), 16, "the capped fourth cast still spends PP") + moves, hits, calls = {}, {}, { crit = 0, damage = 0, variation = 0 } + local before = target.mon.hp + local targetId = id % 2 == 0 and 1 or 0 + action(b, id, 2, targetId) + T.eq(before - target.mon.hp, 135, "capped Stockpile releases its native three-stack base") + action(b, id, 2, targetId) + T.eq(count(user), 0, "spent Stockpile cannot be reused") + T.eq(pp(user, 2), 8, "a second empty Spit Up still consumes its own PP") + T.eq(#hits, 1, "the empty second use emits no damaging hit") + rows[#rows + 1] = { + case = "cap-and-reuse", + id = id, + hits = hits, + moves = moves, + stockpile = count(user), + pp = pp(user, 2), + stockpilePP = pp(user, 1), + } + end + for n = 1, 3 do + local b, user, target = fresh({}, 0) + for i = 1, n do + action(b, 0, 1, 0) + end + user.mon.hp = 1000 + action(b, 0, 3, 0) + T.eq(count(user), 0, "Swallow consumes the same native Stockpile counter") + local healed = user.mon.hp + moves, hits, calls = {}, {}, { crit = 0, damage = 0, variation = 0 } + action(b, 0, 2, 1) + T.eq(#hits, 0, "Swallow leaves no stored damage to release") + rows[#rows + 1] = { + case = "swallow-consumption", + count = n, + healed = healed, + pp = pp(user, 2), + stockpile = count(user), + moves = moves, + hits = hits, + } + end + stopMove() + stopHit() + stopDamage() + stopCrit() + f.close() + return rows +end +return M diff --git a/tests/enemy_actions/substitute_fade.lua b/tests/enemy_actions/substitute_fade.lua new file mode 100644 index 000000000..97861a773 --- /dev/null +++ b/tests/enemy_actions/substitute_fade.lua @@ -0,0 +1,8 @@ +local M = {} +function M.run(root, backend, version) + return dofile(root .. "/tests/enemy_actions/battle_effect_presentation.lua").run(root, backend, version, { + names = { "SUBSTITUTE_FADE" }, + kinds = { "ordinary", "substitute", "lowered-substitute", "hidden", "hidden-mon", "no-substitute" }, + }) +end +return M diff --git a/tests/enemy_actions/substitute_presentation.lua b/tests/enemy_actions/substitute_presentation.lua new file mode 100644 index 000000000..239531220 --- /dev/null +++ b/tests/enemy_actions/substitute_presentation.lua @@ -0,0 +1,184 @@ +-- Owned controller inputs execute the real sequencer/VM and host battle queue. +-- No draw or graphics application is launched; every controller tick is kept. +local M = {} +function M.run(root, backend, version) + local native = backend == "gen1" + local f = dofile(root .. "/tests/enemy_actions/assist.lua").fixture(root, backend, version) + local rows = {} + local Anim, Seq, Runtime, Presentation + if native then + Anim = require("src.core.game3.battle.anim") + Seq = require("src.core.game3.battle.anim_seq") + Runtime = require("src.core.game3.runtime") + local moves = require("src.core.game3.battle.moves") + for _, n in ipairs({ 144, 164 }) do + local def = {} + for k, v in pairs(moves._rom[33]) do + def[k] = v + end + moves._rom[n] = def + f.data.gen3Moves._rom[n] = def + local name = n == 144 and "TRANSFORM" or "SUBSTITUTE" + f.data.gen3Pokemon._moveNames[n] = name + moves.BY_NUM[n], moves._numByName[name] = name, n + end + local function swap(arg) + return { + { op = "createvisualtask", task = "SwapMonSpriteToFromSubstitute", priority = 2, args = { arg } }, + { op = "end" }, + } + end + -- Both qualified version-5 imports use these two decoded special programs. + -- Keep the name table contiguous, as required by the native pack lookup. + local names = {} + for i = 0, 4 do + names[i] = "UNUSED_" .. i + end + names[5], names[6] = "SUBSTITUTE_TO_MON", "MON_TO_SUBSTITUTE" + Anim.loadPack({ + version = 5, + special = { [5] = swap(1), [6] = swap(0) }, + specialNames = names, + tags = {}, + moves = { + [33] = { { op = "delay", frames = 200 }, { op = "end" } }, + [144] = { { op = "delay", frames = 200 }, { op = "end" } }, + [164] = { { op = "delay", frames = 200 }, { op = "end" } }, + }, + }) + else + Presentation = require("src.battle.Gen3SubstitutePresentation") + f.data.constants.gen3BattlerCoords = { + singles = { { x = 72, y = 80 }, { x = 176, y = 40 } }, + doubles = { { x = 32, y = 80 }, { x = 200, y = 40 }, { x = 90, y = 88 }, { x = 152, y = 32 } }, + } + for _, n in ipairs({ 144, 164 }) do + f.data.moves[("MOVE_%03d"):format(n)] = { index = n } + end + end + for _, double in ipairs({ false, true }) do + for id = 0, double and 3 or 1 do + for _, scene in ipairs({ false, true }) do + for _, move in ipairs({ 33, 144, 164 }) do + for _, kind in ipairs({ "lower", "not-substitute", "restore", "broken-restore", "no-lower-restore" }) do + local b = f.field(double) + local user = f.actor(id) + local lower = kind == "lower" or kind == "not-substitute" + local visible = lower and kind ~= "not-substitute" + local wasLowered = kind == "restore" or kind == "broken-restore" + local hp = (kind == "broken-restore" or kind == "not-substitute") and 0 or 100 + -- The preceding Substitute animation creates its visible doll. + -- Both controller inputs begin with that same prepared display. + if not lower and move == 164 and scene and hp > 0 then + visible = true + end + user.substituteHP = hp > 0 and hp or nil + local name = lower and "SUBSTITUTE_TO_MON" or "MON_TO_SUBSTITUTE" + local reached, commandIndex = false, 1 + if native then + require("src.core.game3.battle")._st = b + Runtime.session = { version = version, options = { battleScene = scene and 0 or 1 } } + Anim.reset({ headless = false, double = double }) + Anim.setSubstitute(id, visible) + local events = { + { kind = "anim", anim = "special", name = name, attackerId = id, targetId = id }, + { + kind = "move", + moveId = move, + attackerId = id, + targetId = id % 2 == 0 and id + 1 or id - 1, + }, + } + if not lower then + -- Native buildSteps associates restoration with the preceding + -- move. Execute that preparation normally, then observe every + -- tick of the restoration command from its actual launch. + table.insert(events, 1, { + kind = "move", + moveId = move, + attackerId = id, + targetId = id % 2 == 0 and id + 1 or id - 1, + }) + commandIndex = 2 + end + Seq.beginEvents(events, function() end) + Seq._subLowered[id] = wasLowered or nil + if not lower then + for preparation = 0, 250 do + if preparation > 0 then + Anim.update(1 / 60) + end + if not Anim.busy() then + Seq.update() + end + if Seq._i >= commandIndex and Seq._waitFrames == 0 then + break + end + end + assert( + Seq._i >= commandIndex and Seq._waitFrames == 0, + "restoration preparation did not complete" + ) + end + else + f.game.save.options.animations = scene + b.queue = { + { + anim = name, + gbaSubstituteSpecial = true, + gbaSubstituteMove = ("MOVE_%03d"):format(move), + attackerPosition = id, + attackerIsPlayer = user.isPlayer, + }, + { + fn = function() + reached = true + end, + }, + } + b.current, b.waitFrames, b.nextInsert = nil, nil, 0 + b._gbaSubstituteTransition = nil + b._gbaSubstitutePresentation = + { [user] = { substitute = visible, lowered = wasLowered, x = 0 } } + end + local frames, done = {}, false + for tick = 0, 200 do + local showing, offset + if native then + if tick > 0 then + Anim.update(1 / 60) + end + if not Anim.busy() then + Seq.update() + end + done = Seq._i > commandIndex + local state = Anim.present(id) + showing, offset = state.substitute, state.ox or 0 + else + b:updateQueue() + done = reached + showing, offset = Presentation.showing(b, user), Presentation.offset(b, user) + end + frames[#frames + 1] = { + tick = tick, + substitute = showing, + offset = offset, + done = done, + substituteHP = user.substituteHP or 0, + } + if done then + break + end + end + assert(done, "Substitute controller did not complete") + rows[#rows + 1] = + { double = double, actor = id, scene = scene, move = move, case = kind, frames = frames } + end + end + end + end + end + f.close() + return rows +end +return M diff --git a/tests/enemy_actions/targets.lua b/tests/enemy_actions/targets.lua new file mode 100644 index 000000000..fdc157bb8 --- /dev/null +++ b/tests/enemy_actions/targets.lua @@ -0,0 +1,399 @@ +local M = {} +function M.run(root, backend, version) + local function configure(data, engine, ids, rom, names) + for _, row in ipairs({ + { "TARGET_BOTH", 600, 8 }, + { "TARGET_ALL", 601, 32 }, + { "TARGET_SELF", 602, 16 }, + { "TARGET_FIELD", 603, 64 }, + { "THUNDERSHOCK", 84, 0 }, + }) do + local name, number, target = unpack(row) + local key = engine == "gen1" and name or ("MOVE_%03d"):format(number) + local electric = name == "THUNDERSHOCK" + ids[name], names[number] = key, name + if engine == "gen1" then + local def = { + effect = 0, + power = target == 64 and 0 or 40, + type = electric and 13 or 0, + accuracy = 100, + pp = 20, + secondaryChance = 0, + target = target, + priority = 0, + flags = 51, + } + data.gen3Moves._rom[number], rom[number], data.gen3Pokemon._moveNames[number] = def, def, name + else + data.moves[key] = { + id = key, + name = name, + index = number, + power = target == 64 and 0 or 40, + type = electric and "ELECTRIC" or "NORMAL", + pp = 20, + accuracy = 100, + accuracyRaw = 255, + target = target, + effect = "NO_ADDITIONAL_EFFECT", + } + end + end + end + local f = + dofile(root .. "/tests/enemy_actions/fixture.lua").new(root, backend, version, 3, "unified", configure) + local T, observations, events, damages = f.T, {}, {}, {} + local stopEvent = f.e.api.events:on("battle.move_used", function(e) + events[#events + 1] = { user = e.user.mon.nickname, target = e.target.mon.nickname, move = e.move.id } + end) + local stopDamage = f.e.api.events:on("battle.damage_dealt", function(e) + damages[#damages + 1] = e.target.mon.nickname + end) + local function at(b, id) + return backend == "gen1" and b.battlers[id] or b:battlerAt(id) + end + local function fresh(moveName) + local c = f.fresh("TACKLE", { enemy = true }) + local b, copy = c.battle, require("src.mods.Merge").deepCopy + b.double = true + if backend == "gen1" then + local S = require("src.core.game3.battle.state") + b.battlers[2] = S.makeBattler(copy(b.player.mon), "player", { id = 2 }) + b.battlers[3] = S.makeBattler(copy(b.enemy.mon), "enemy", { id = 3 }) + else + local S = require("src.battle.BattleState") + b:placeBattler(2, S.makeBattler(f.data, copy(b.player.mon), true)) + b:placeBattler(3, S.makeBattler(f.data, copy(b.enemy.mon), false)) + b:syncSides() + end + local number = ({ + TACKLE = 33, + TARGET_BOTH = 600, + TARGET_ALL = 601, + TARGET_SELF = 602, + TARGET_FIELD = 603, + THUNDERSHOCK = 84, + })[moveName] + for id = 0, 3 do + local a = at(b, id) + a.mon.nickname = "P" .. id + a.mon.hp = 400 + if backend == "gen1" then + a.mon.moves, a.mon.pp = { number }, { 20 } + a.mon.speed, a.mon.ability, a.ability = 400 - id * 50, 0, 0 + else + a.curMoves = { { id = f.ids[moveName], pp = 20 } } + a.mon.moves = a.curMoves + a.curStats.speed, a.abilityOverride = 400 - id * 50, false + end + end + return b + end + local function ability(b, id, name) + local a = at(b, id) + if backend == "gen1" then + a.ability, a.mon.ability = name == "PRESSURE" and 46 or 31, name == "PRESSURE" and 46 or 31 + else + a.abilityOverride = name + end + end + local function pp(a) + return backend == "gen1" and a.mon.pp[1] or a.curMoves[1].pp + end + local function choose(b, id) + if backend == "gen1" then + return require("src.core.game3.battle.commands").enemyAction(b, id) + end + return b:enemyAction(at(b, id)) + end + local function execute(b, id, action) + if backend == "gen1" then + local Engine, Adapter = + require("src.core.game3.battle.engine"), require("src.core.game3.battle.adapter") + local ad = Adapter.new(b) + local chosen = { + [0] = { kind = "watch" }, + [1] = { kind = "watch" }, + [2] = { kind = "watch" }, + [3] = { kind = "watch" }, + } + chosen[id] = action + local rows = Engine.planTurnActions(b, ad, chosen) + for _, row in ipairs(rows) do + if row.kind == "move" then + Engine.resolveMove(row.user, row.target, row.move, row.slot, ad, b, {}) + end + end + else + b:executeAction(at(b, id), at(b, 0), action) + end + end + local specs = {} + for _, user in ipairs({ 1, 3 }) do + for target = 0, 3 do + specs[#specs + 1] = { + name = "selected-" .. target, + move = "TACKLE", + user = user, + target = target, + victims = { "P" .. target }, + cost = 1, + } + end + specs[#specs + 1] = { + name = "both-right-first", + move = "TARGET_BOTH", + user = user, + target = 2, + victims = { "P2", "P0" }, + cost = 1, + } + specs[#specs + 1] = { + name = "all", + move = "TARGET_ALL", + user = user, + target = 2, + victims = user == 1 and { "P0", "P2", "P3" } or { "P0", "P1", "P2" }, + cost = 1, + } + specs[#specs + 1] = { + name = "self", + move = "TARGET_SELF", + user = user, + target = user, + victims = { "P" .. user }, + cost = 1, + } + end + for _, spec in ipairs({ + { + name = "cached-target", + move = "TACKLE", + user = 1, + target = 2, + cached = true, + victims = { "P2" }, + cost = 1, + }, + { + name = "pressure-selected", + move = "TACKLE", + user = 1, + target = 2, + pressure = { 2 }, + victims = { "P2" }, + cost = 2, + }, + { + name = "pressure-both", + move = "TARGET_BOTH", + user = 1, + target = 2, + pressure = { 0, 2 }, + victims = { "P2", "P0" }, + cost = 3, + }, + { + name = "pressure-all", + move = "TARGET_ALL", + user = 3, + target = 2, + pressure = { 0, 1, 2, 3 }, + victims = { "P0", "P1", "P2" }, + cost = 4, + }, + { + name = "pressure-self", + move = "TARGET_SELF", + user = 1, + target = 1, + pressure = { 1 }, + victims = { "P1" }, + cost = 1, + }, + { + name = "field", + move = "TARGET_FIELD", + user = 1, + target = 2, + pressure = { 0, 2 }, + victims = { "P2" }, + cost = 3, + }, + { + name = "lightning-rod", + move = "THUNDERSHOCK", + user = 1, + target = 0, + rod = { 2 }, + victims = { "P2" }, + cost = 1, + }, + }) do + specs[#specs + 1] = spec + end + for _, spec in ipairs(specs) do + for _, mode in ipairs({ "replace", "delegate" }) do + local b = fresh(spec.move) + if spec.cached then + if backend == "gen1" then + b.moveTarget = { [1] = 0 } + else + b.chosenTargets = { [1] = at(b, 0) } + end + end + for _, id in ipairs(spec.pressure or {}) do + ability(b, id, "PRESSURE") + end + for _, id in ipairs(spec.rod or {}) do + ability(b, id, "LIGHTNING_ROD") + end + f.e.choose(function(nextAction, ...) + if mode == "delegate" then + local a = nextAction(...) + a.target = spec.target + return a + end + return { kind = "move", move = spec.move, target = spec.target } + end) + events, damages = {}, {} + local action = choose(b, spec.user) + T.eq(action.target, spec.target, "target remains positional until execution") + execute(b, spec.user, action) + local targets = {} + for _, e in ipairs(events) do + targets[#targets + 1] = e.target + end + local label = backend .. "/" .. version .. "/" .. spec.name .. "/" .. spec.user .. "/" .. mode + T.same( + targets, + { spec.victims[1] }, + label .. " emits one native move event for the actual first target" + ) + T.same( + damages, + spec.move == "TARGET_FIELD" and {} or spec.victims, + label .. " actual native damage events use selected targets/order" + ) + T.eq(pp(at(b, spec.user)), 20 - spec.cost, label .. " spends native PP cost exactly once") + observations[#observations + 1] = { + case = spec.name, + user = spec.user, + mode = mode, + target = action.target, + events = events, + damages = damages, + pp = pp(at(b, spec.user)), + } + end + end + -- A new occupant must be resolved after the choice has already been made. + for _, remove in ipairs({ false, true }) do + local b = fresh("TACKLE") + f.e.choose(function() + return { move = "TACKLE", target = 0 } + end) + local action = choose(b, 1) + if remove then + if backend == "gen1" then + b.absent[0] = true + b.battlers[0] = nil + else + b:placeBattler(0, nil) + end + else + local copy = require("src.mods.Merge").deepCopy(b.player.mon) + copy.nickname = "P0_NEXT" + if backend == "gen1" then + b.battlers[0] = require("src.core.game3.battle.state").makeBattler(copy, "player", { id = 0 }) + else + b:placeBattler(0, require("src.battle.BattleState").makeBattler(f.data, copy, true)) + end + end + events, damages = {}, {} + execute(b, 1, action) + local expected = remove and "P2" or "P0_NEXT" + T.eq(events[1] and events[1].target, expected, "selected slot resolves the living current occupant") + T.same(damages, { expected }, "switched/absent slot reaches native actual damage") + T.eq(pp(at(b, 1)), 19, "late resolution spends one owned PP") + observations[#observations + 1] = { + case = remove and "absent" or "replacement", + events = events, + damages = damages, + pp = pp(at(b, 1)), + } + end + + -- Both engines' own planner/executor path, with actual host queue draining. + for _, lightning in ipairs({ false, true }) do + local b = fresh(lightning and "THUNDERSHOCK" or "TACKLE") + if lightning then + ability(b, 0, "LIGHTNING_ROD") + ability(b, 2, "LIGHTNING_ROD") + if backend == "gen1" then + b.player.mon.speed, at(b, 2).mon.speed = 700, 800 + else + b.player.curStats.speed, at(b, 2).curStats.speed = 700, 800 + end + end + f.e.choose(function(_, _, id) + return { + move = lightning and "THUNDERSHOCK" or "TACKLE", + target = lightning and (id == 1 and 3 or 1) or (id == 1 and 2 or 0), + } + end) + events, damages = {}, {} + if backend == "gen1" then + local E, A = require("src.core.game3.battle.engine"), require("src.core.game3.battle.adapter") + local ad = A.new(b) + local rows = E.planTurnActions( + b, + ad, + { [0] = { kind = "watch" }, [2] = { kind = "watch" }, [1] = choose(b, 1), [3] = choose(b, 3) } + ) + for _, row in ipairs(rows) do + if row.kind == "move" then + E.resolveMove(row.user, row.target, row.move, row.slot, ad, b, {}) + end + end + else + b.queue, b.pendingActions = {}, { [2] = { special = "recharge" } } + b:resolveTurn({ special = "recharge" }) + local count = 0 + while b.queue[1] and count < 1000 do + count = count + 1 + local row = table.remove(b.queue, 1) + if row.fn then + b.nextInsert = 0 + row.fn() + end + end + T.check(count < 1000, "actual native turn queue drains") + end + local expected = lightning and { "P2", "P2" } or { "P2", "P0" } + local usedTargets = {} + for _, e in ipairs(events) do + usedTargets[#usedTargets + 1] = e.target + end + T.same( + usedTargets, + expected, + "actual double planner honors both selected targets and Lightning Rod order" + ) + T.same(damages, expected, "actual double planner reaches both native damage targets") + T.eq(pp(at(b, 1)), 19, "left enemy queue action spends its own PP once") + T.eq(pp(at(b, 3)), 19, "right enemy queue action spends its own PP once") + observations[#observations + 1] = { + case = lightning and "queue-lightning-order" or "queue-selected", + events = events, + damages = damages, + pp = { pp(at(b, 1)), pp(at(b, 3)) }, + } + end + stopDamage() + stopEvent() + f.close() + return observations +end +return M diff --git a/tests/enemy_actions/triple_kick.lua b/tests/enemy_actions/triple_kick.lua new file mode 100644 index 000000000..ffa97f484 --- /dev/null +++ b/tests/enemy_actions/triple_kick.lua @@ -0,0 +1,430 @@ +local M = {} +function M.fixture(root, backend, version, entry) + local native = backend == "gen1" + return dofile(root .. "/tests/enemy_actions/fixture.lua").new( + root, + backend, + version, + 3, + "unified", + function(data, _, ids, rom, names) + if not native then + data.items.FOCUS_BAND = { + index = 196, + name = "FOCUS BAND", + holdEffect = "FOCUS_BAND", + gen3HoldEffect = 39, + holdEffectParam = 10, + } + data.items.SCOPE_LENS = { index = 198, name = "SCOPE LENS", holdEffect = "SCOPE_LENS" } + data.items.LUCKY_PUNCH = { index = 222, name = "LUCKY PUNCH", holdEffect = "LUCKY_PUNCH" } + data.type_chart.matchups[#data.type_chart.matchups + 1] = + { attacker = "NORMAL", defender = "GHOST", multiplier = 0 } + end + for _, row in ipairs({ + { 167, "TRIPLE_KICK", 104, "TRIPLE_KICK_EFFECT" }, + }) do + local number, name, effect, hostEffect = unpack(row) + local id = native and name or ("MOVE_%03d"):format(number) + ids[name], names[number] = id, name + if native then + local def = { + effect = effect, + power = 10, + type = 0, + accuracy = 90, + pp = 10, + target = 0, + flags = 51, + secondaryChance = 20, + priority = 0, + } + data.gen3Moves._rom[number], rom[number], data.gen3Pokemon._moveNames[number] = def, def, name + data.gen3Pokemon._types[4], data.gen3Pokemon._abilities[4] = { 0, 0 }, { 0, 0 } + else + data.moves[id] = { + id = id, + index = number, + name = name, + effect = hostEffect, + gen3Effect = effect, + power = 10, + type = "NORMAL", + accuracy = 90, + pp = 10, + target = 0, + flags = 51, + protectAffected = true, + secondaryChance = 20, + } + data.pokemon.CHARMANDER.types, data.pokemon.CHARMANDER.abilities = { "NORMAL" }, {} + end + end + end, + entry + ) +end +function M.run(root, backend, version) + local native = backend == "gen1" + local f = M.fixture(root, backend, version) + local T, Copy = f.T, require("src.mods.Merge").deepCopy + local rows, draws, damage, used = {}, {}, {}, {} + local currentKind, liveUser, liveTarget, missAt + local accuracy, calculations = {}, {} + local offAccuracy = f.e.api.hooks:wrap("battle.accuracy", function(nextAccuracy, ctx) + accuracy[#accuracy + 1] = { + accuracy = ctx.accuracy, + hp = ctx.target.mon.hp, + rage = liveTarget and not not liveTarget.rage or false, + } + return nextAccuracy(ctx) + end) + -- Public events are detached views. Mutate the owned fixture's real battlers + -- to test live continuation; this does not assert event-view write-through. + local offCalculation = f.e.api.hooks:wrap("battle.damage", function(nextDamage, ctx) + calculations[#calculations + 1] = { power = ctx.opts.power, basePower = ctx.move.power } + if currentKind == "power_hook" and #calculations == 2 then + ctx.opts.power = 5 + end + return nextDamage(ctx) + end) + local off = f.e.api.events:on("battle.damage_dealt", function(e) + damage[#damage + 1] = { + user = e.user.mon.nickname, + target = e.target.mon.nickname, + move = e.move.id, + damage = e.damage, + crit = e.crit or false, + substitute = e.substitute or false, + typeMult = e.typeMult, + } + if #damage == 1 and currentKind == "sleep_after_hit" then + liveUser.mon.status = "SLP" + end + if #damage == 1 and (currentKind == "type_after_hit" or currentKind == "rage_type_after_hit") then + if native then + liveTarget.type1, liveTarget.type2 = 7, 7 + else + liveTarget.curTypes = { "GHOST" } + end + end + if #damage == 1 and currentKind == "protect_after_hit" then + liveTarget[native and "expProtected" or "protecting"] = true + end + if #damage == 1 and currentKind == "hidden_after_hit" then + if native then + liveTarget.semiInvulnerable = "ON_AIR" + else + liveTarget.invulnerable, liveTarget.hiddenAs = true, "air" + end + end + if #damage == 1 and currentKind == "user_ko" then + liveUser.mon.hp = 0 + end + end) + local offMove = f.e.api.events:on("battle.move_used", function(e) + used[#used + 1] = { user = e.user.mon.nickname, target = e.target.mon.nickname, move = e.move.id } + end) + local function at(b, id) + return native and b.battlers[id] or b:battlerAt(id) + end + local function fresh(double) + local b = f.fresh("TACKLE", { enemy = true }).battle + b.double = double + if not native then + b:syncSides() + end + if double then + for id = 2, 3 do + local mon = Copy(at(b, id % 2).mon) + if native then + b.battlers[id] = require("src.core.game3.battle.state").makeBattler( + mon, + id % 2 == 0 and "player" or "enemy", + { id = id, partyIndex = 2 } + ) + else + b:placeBattler(id, require("src.battle.BattleState").makeBattler(f.data, mon, id % 2 == 0)) + end + end + end + if not native then + b:syncSides() + end + for id = 0, double and 3 or 1 do + local a = at(b, id) + a.mon.nickname = "P" .. id + if native then + a.mon.moves, a.mon.pp, a.mon.maxPp = { 167, 33, 33, 33 }, { 10, 10, 10, 10 }, { 10, 10, 10, 10 } + a.mon.maxHp, a.ability, a.mon.ability, a.type1, a.type2 = 400, 0, 0, 0, 0 + a.mon.speed = 100 - id * 10 + else + a.mon.moves = { + { id = f.ids.TRIPLE_KICK, pp = 10 }, + { id = f.ids.TACKLE, pp = 10 }, + { id = f.ids.TACKLE, pp = 10 }, + { id = f.ids.TACKLE, pp = 10 }, + } + a.curMoves, a.curTypes, a.abilityOverride = a.mon.moves, { "NORMAL" }, false + a.mon.stats.hp = 400 + for _, stat in ipairs({ "attack", "defense", "spatk", "spdef" }) do + a.curStats[stat] = 50 + end + a.curStats.speed = 100 - id * 10 + end + a.mon.hp = 400 + end + local crit, variance, checks = 0, 0, 0 + b.rng = function(lo, hi) + local value = hi + if lo == 1 and hi == 100 then + checks = checks + 1 + value = checks == missAt and 100 or 1 + end + if lo == 0 and hi == 15 then + crit = crit + 1 + value = currentKind == "crit" and crit % 2 == 1 and 0 or hi + end + if lo == 85 and hi == 100 then + variance = variance + 1 + value = variance % 2 == 1 and lo or hi + end + if lo == 0 and hi == 99 and currentKind == "focus_band" then + value = 0 + end + draws[#draws + 1] = { lo = lo, hi = hi, value = value } + return value + end + return b + end + local function view(b) + local all = {} + for id = 0, b.double and 3 or 1 do + local a = at(b, id) + local pp = {} + for i = 1, 4 do + pp[i] = native and a.mon.pp[i] or a.curMoves[i].pp + end + all[#all + 1] = { + hp = a.mon.hp, + pp = pp, + status = a.mon.status, + sub = tonumber(a.substituteHP) or 0, + last = native and a.lastMoveId or a.lastMove and f.data.moves[a.lastMove].index, + attack = a.stages.attack or 0, + rage = not not a.rage, + } + end + return all + end + for _, double in ipairs({ false, true }) do + for uid = 0, double and 3 or 1 do + local tid = uid % 2 == 0 and uid + 1 or uid - 1 + local slot = 1 + for first = 0, 3 do + for _, kind in ipairs({ + "ordinary", + "crit", + "focus_crit", + "scope_crit", + "focus_scope_crit", + "max_crit", + "sub_survive", + "sub_break", + "endure", + "focus_band", + "target_ko", + "protected", + "hidden", + "immune", + "changed_power", + "rage", + "rage_type_after_hit", + "power_hook", + "sleep_after_hit", + "protect_after_hit", + "hidden_after_hit", + "type_after_hit", + "user_ko", + "called", + }) do + currentKind, missAt = kind, first + draws, damage, used, accuracy, calculations = {}, {}, {}, {}, {} + local b = fresh(double) + local user, target = at(b, uid), at(b, tid) + liveUser, liveTarget = user, target + if kind == "focus_crit" or kind == "focus_scope_crit" or kind == "max_crit" then + user.focusEnergy = true + end + if kind == "scope_crit" or kind == "focus_scope_crit" then + if native then + user.item, user.mon.item = 198, 198 + else + user.mon.item = "SCOPE_LENS" + end + end + if kind == "max_crit" then + user.species = 113 + if native then + user.item, user.mon.item = 222, 222 + else + user.mon.item = "LUCKY_PUNCH" + end + end + if kind == "sub_survive" then + target.substituteHP = 400 + end + if kind == "sub_break" then + target.substituteHP = 1 + end + if kind == "target_ko" then + target.mon.hp = 1 + end + if kind == "endure" then + target.mon.hp = 1 + target[native and "expEnduring" or "enduring"] = true + end + if kind == "focus_band" then + target.mon.hp = 1 + if native then + target.item, target.mon.item = 196, 196 + else + target.mon.item = "FOCUS_BAND" + end + end + if kind == "protected" then + target[native and "expProtected" or "protecting"] = true + end + if kind == "hidden" then + if native then + target.semiInvulnerable = "ON_AIR" + else + target.invulnerable, target.hiddenAs = true, "air" + end + end + if kind == "immune" then + if native then + target.type1, target.type2 = 7, 7 + else + target.curTypes = { "GHOST" } + end + end + local number = 167 + local def = native and f.data.gen3Moves._rom[number] or f.data.moves[f.ids.TRIPLE_KICK] + local old = def.power + if kind == "changed_power" then + def.power = 35 + end + if kind == "rage" or kind == "rage_type_after_hit" then + target.rage = true + end + if native then + local nativeSlot = slot + if kind == "called" then + nativeSlot = nil + end + require("src.core.game3.battle.engine").resolveMove( + uid, + tid, + user.mon.moves[slot], + nativeSlot, + require("src.core.game3.battle.adapter").new(b), + b, + {}, + { called = kind == "called" } + ) + elseif kind == "called" then + b:performMove(user, target, user.curMoves[slot], true) + else + b:executeAction(user, target, user.curMoves[slot]) + end + def.power = old + local label = (double and "double" or "single") .. "/" .. uid .. "/" .. first .. "/" .. kind + rows[#rows + 1] = { + label = label, + field = view(b), + draws = Copy(draws), + damage = Copy(damage), + used = Copy(used), + accuracy = Copy(accuracy), + calculations = Copy(calculations), + } + if kind == "ordinary" or kind == "changed_power" or kind == "crit" then + T.eq( + #damage, + first == 0 and 3 or first - 1, + "Triple Kick stops at the first missed per-hit accuracy check" + ) + T.eq(#accuracy, first == 0 and 3 or first, "only reached kicks dispatch the public accuracy hook") + if first == 0 and kind ~= "crit" then + T.check( + damage[1].damage < damage[2].damage and damage[2].damage < damage[3].damage, + "each reached kick uses increasing native power" + ) + end + end + if + first ~= 1 + and ( + kind == "sleep_after_hit" + or kind == "type_after_hit" + or kind == "rage_type_after_hit" + or kind == "protect_after_hit" + or kind == "hidden_after_hit" + or kind == "user_ko" + or kind == "endure" + or kind == "target_ko" + ) + then + T.eq(#damage, 1, "native continuation stops at its actual live condition") + end + if first ~= 1 and kind == "sleep_after_hit" then + T.eq(user.mon.status, "SLP", "callback changes the owned live user") + end + if first ~= 1 and kind == "user_ko" then + T.eq(user.mon.hp, 0, "callback faints the owned live user") + end + if first ~= 1 and (kind == "type_after_hit" or kind == "rage_type_after_hit") then + T.eq( + native and target.type1 or target.curTypes[1], + native and 7 or "GHOST", + "callback changes the owned live target" + ) + end + if kind == "rage_type_after_hit" then + T.eq( + target.stages.attack or 0, + first == 2 and 1 or 0, + "only a reached immunity check suppresses the completed Rage boost" + ) + end + if kind == "rage" then + T.eq( + target.stages.attack or 0, + first == 1 and 0 or 1, + "landed kicks build Rage once even when a later kick misses" + ) + end + if kind == "endure" or kind == "focus_band" then + T.eq(target.mon.hp, 1, "native survival is retained") + end + if first == 0 and kind == "sub_break" then + T.check(target.mon.hp < 400, "later kicks continue after Substitute breaks") + end + T.eq( + native and user.mon.pp[slot] or user.curMoves[slot].pp, + kind == "called" and 10 or 9, + "a multi-hit move spends PP once" + ) + end + end + end + end + off() + offMove() + offAccuracy() + offCalculation() + f.close() + return rows +end +return M diff --git a/tests/engine/api3_compat.lua b/tests/engine/api3_compat.lua new file mode 100644 index 000000000..a34fe4ae9 --- /dev/null +++ b/tests/engine/api3_compat.lua @@ -0,0 +1,226 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.harness") +local Manifest = require("src.mods.Manifest") +local Loader = require("src.mods.Loader") +local Version = require("src.core.GameVersion") +local Runtime = require("src.mods.Runtime") +local Json = require("src.link.Json") +local saved = { Version.get(), Runtime.events, Runtime.hooks, Runtime.errors, love } +love = nil +local function manifest(api, extra) + local raw = { id = "probe", name = "Probe", version = "1.0.0", entry = "main.lua", api = api } + for k, v in pairs(extra or {}) do + raw[k] = v + end + return raw +end +for _, api in ipairs({ 1, 2, 3 }) do + local m = Manifest.validate(manifest(api)) + local format = api == 3 and "unified" or "native" + for _, field in ipairs({ "content_format", "callback_format", "script_format" }) do + T.eq(m[field], format, "API " .. api .. " default " .. field) + end +end +T.eq(Manifest.validate(manifest()).api, 1, "missing API remains legacy") +for _, field in ipairs({ "content_format", "callback_format", "script_format" }) do + T.raises(function() + Manifest.validate(manifest(3, { [field] = "native" })) + end, "API 3 requires unified", "API 3 refuses contradictory " .. field) + T.eq( + Manifest.validate(manifest(2, { [field] = "unified" }))[field], + "unified", + "API 2 keeps explicit opt-in" + ) + T.raises(function() + Manifest.validate(manifest(1, { [field] = "unified" })) + end, "mod API 2", "API 1 opt-in remains rejected") +end +T.raises(function() + Manifest.validate(manifest(4)) +end, "provides 3", "future API not silently accepted") +T.raises(function() + Manifest.validate(manifest(3.5)) +end, "positive integer", "fractional API rejected") +local function boot(api, extra, second, version) + Version.set(version or "red") + local rows = { probe = manifest(api, extra) } + if second then + rows.other = manifest(api, extra) + rows.other.id = "other" + end + local files = {} + for id, raw in pairs(rows) do + files["mods/" .. id .. "/manifest.json"] = Json.encode(raw) + files["mods/" .. id .. "/main.lua"] = "local mod = ...; mod.exports.api = mod" + end + local fs = { + read = function(p) + return files[p] + end, + load = function(p) + return loadstring(files[p], p) + end, + getInfo = function(p) + if files[p] then + return { type = "file" } + end + if p == "mods" or rows[p:match("^mods/(.*)$")] then + return { type = "directory" } + end + end, + getDirectoryItems = function(p) + return p == "mods" and (second and { "probe", "other" } or { "probe" }) or {} + end, + } + local loader = Loader.new({ fs = fs, dev = true }) + local game, probes = nil, 0 + loader._game = function(_, loadedOnly) + probes = probes + 1 + if api == 3 then + assert(loadedOnly, "API 3 requested Game bootstrap") + end + return game + end + T.check(loader:load({ pokemon = {}, moves = {}, items = {} }), "real loader accepts API " .. api) + local mod = assert(loader.apis.probe) + return mod, loader, function(value) + game = value + end, function() + return probes + end +end +-- Real legacy loaders keep native defaults, API-2 status vocabulary and callback catalog. +for _, api in ipairs({ 1, 2 }) do + local m = boot(api) + T.eq(m.contentFormat, "native", "legacy content native") + T.eq(m.callbackFormat, "native", "legacy callbacks native") + T.eq(m.scriptFormat, "native", "legacy scripts native") +end +do + local m, loader, attach = + boot(2, { content_format = "unified", callback_format = "unified", script_format = "unified" }) + local mon = { + species = "MEW", + nickname = "Scout", + hp = 10, + status = "PSN", + stats = { hp = 20, special = 9 }, + moves = {}, + } + attach({ + save = { party = { mon } }, + data = { pokemon = {}, moves = {}, items = {} }, + stack = { + states = {}, + }, + }) + T.eq(m.world:party()[1].status, "poison", "API-2 unified party status unchanged") + local seen + m.events:on("pokemon.received", function(p) + seen = p.mon.status + end) + Runtime.emit("pokemon.received", { mon = mon }) + T.eq(seen, "poison", "API-2 unified event status unchanged") + T.raises(function() + m.events:on("checkpoint.restored", function() end) + end, "unknown unified callback", "API-2 catalog unchanged") +end +-- Availability must be honest, and a probe cannot construct Game or attach a world. +do + local m, loader, attach, probes = boot(3, nil, true) + T.eq(probes(), 0, "entry does not resolve Game") + local world, battle = m.world, m.battle + T.eq(type(world), "table", "world facade exists before Game") + T.eq(type(world.party), "function", "early world method callable") + T.eq(type(battle.snapshot), "function", "early battle method callable") + for _, name in ipairs({ + "party", + "current", + "activeBlockAt", + "liveMaps", + "queueScript", + "getFlag", + "setFlag", + }) do + T.eq(m:supports("world", name), true, "implemented world method " .. name) + end + for _, name in ipairs({ + "warpTo", + "toggleObject", + "replaceBlock", + "spawnNpc", + "removeNpc", + "npc", + "startWildBattle", + "invalidateMap", + }) do + local available, why = m:supports("world", name) + T.eq(available, false, "no unverified native promise for " .. name) + T.check(type(why) == "string", "unavailable reason for " .. name) + local ok, reason = world[name](world, "bad") + T.eq(ok, nil, "unsupported method does not dispatch " .. name) + T.check(reason:find("unsupported:", 1, true), "explicit refusal for " .. name) + end + T.eq(probes(), 0, "capability and unsupported calls do not resolve Game") + T.eq(world:party(), nil, "no-game party refuses safely") + T.eq(battle:snapshot(), nil, "no-game battle refuses safely") + T.eq(m.datasets:open("gold"), nil, "unavailable dataset API callable") + T.eq(m:supports("hooks", "render.output"), false, "recognized output hook is not falsely available") + T.check( + pcall(function() + m.hooks:wrap("render.output", function(next, ctx) + return next(ctx) + end) + end), + "known absent hook can be registered" + ) + T.check( + pcall(function() + m.events:on("checkpoint.restored", function() end) + end), + "known absent event can be registered" + ) + T.raises(function() + m.events:on("not.catalogued", function() end) + end, "unknown unified callback", "unknown API-3 name rejected") + local save = { party = {}, flags = {}, modData = {} } + attach({ save = save, data = {}, stack = { states = {} } }) + local flags = {} + m.events:on("flag.changed", function(p) + flags[#flags + 1] = { p.name, p.value } + end) + T.eq(world:getFlag("mod:quest.finished"), false, "flag read defaults false") + T.eq(next(save.modData), nil, "flag read allocates no bucket") + T.check(world:setFlag("mod:quest.finished", true), "private flag set") + T.check(world:setFlag("mod:quest.finished", true), "setting same flag accepted") + T.eq(#flags, 1, "unchanged flag emits no duplicate event") + T.eq(loader.apis.other.world:getFlag("mod:quest.finished"), false, "other mod cannot see flag") + T.check(world:setFlag("mod:quest.finished", false), "private flag cleared") + T.eq(save.modData.probe.worldFlags["quest.finished"], nil, "clear removes saved boolean") + T.same( + flags, + { { "mod.probe.quest.finished", true }, { "mod.probe.quest.finished", false } }, + "portable flag notifications" + ) + T.eq(next(save.flags), nil, "private flags never touch native flags") + T.eq(world:setFlag("mod:bad", "yes"), nil, "non-boolean flag rejected") + T.eq(world:setFlag("NATIVE_FLAG", true), nil, "unverified native flag mapping refused") + T.eq(world:setFlag("mod:", true), nil, "empty private flag rejected") + save.modData.probe.worldFlags = false + T.eq(world:setFlag("mod:test", true), nil, "malformed store rejected") + T.eq(save.modData.probe.worldFlags, false, "malformed store never overwritten") + attach({ save = { party = {} }, data = {}, stack = { states = {} } }) + T.eq(world:getFlag("mod:quest.finished"), false, "same handle follows replacement save") + T.eq(m.world, world, "world handle identity stable") + T.eq(m.battle, battle, "battle handle identity stable") +end +for _, version in ipairs({ "platinum", "black" }) do + local m = boot(3, nil, false, version) + T.eq(m:supports("world", "party"), false, "no shared Gen1 contract claimed for " .. version) + local value, why = m.world:party() + T.eq(value, nil, "unsupported generation call refuses") + T.check(why:find("unsupported:", 1, true), "unsupported generation is explicit") +end +Version.set(saved[1]) +Runtime.events, Runtime.hooks, Runtime.errors, love = saved[2], saved[3], saved[4], saved[5] +T.finish("API 3 entry compatibility") diff --git a/tests/engine/api3_entry.lua b/tests/engine/api3_entry.lua new file mode 100644 index 000000000..7eb1a2712 --- /dev/null +++ b/tests/engine/api3_entry.lua @@ -0,0 +1,16 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.harness") +local Contract = require("tests.api3_contract.run") +local Version = require("src.core.GameVersion") +local Runtime = require("src.mods.Runtime") +local saved = { Version.get(), Runtime.events, Runtime.hooks, Runtime.errors, love } +love = nil +for _, version in ipairs({ "red", "blue", "yellow", "gold", "silver", "crystal", "firered", "emerald" }) do + for _, failing in ipairs({ false, true }) do + local ok, why = pcall(Contract.run, "tests/api3_contract", "gen2", version, failing) + T.check(ok, version .. (failing and " rollback: " or ": ") .. tostring(why)) + end +end +Version.set(saved[1]) +Runtime.events, Runtime.hooks, Runtime.errors, love = saved[2], saved[3], saved[4], saved[5] +T.finish("API 3 cross-engine contracts") diff --git a/tests/engine/battle_actions.lua b/tests/engine/battle_actions.lua new file mode 100644 index 000000000..2c6799081 --- /dev/null +++ b/tests/engine/battle_actions.lua @@ -0,0 +1,758 @@ +-- Public intents exercised through real Loader, BattleState and native input. +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local Loader = require("src.mods.Loader") +local BattleState = require("src.battle.BattleState") +local GameVersion = require("src.core.GameVersion") +local Pokemon = require("src.pokemon.Pokemon") +local SaveData = require("src.core.SaveData") +local Font = require("src.render.Font") +local Runtime = require("src.mods.Runtime") +local Json = require("src.link.Json") +local Merge = require("src.mods.Merge") +local Commands = require("src.battle.BattleCommands") +local Intents = require("src.mods.BattleIntents") +local oldVersion = GameVersion.get() + +local function boot(version, trainer, level) + GameVersion.set(version) + math.randomseed(12345) + local data = T.fixtures.fresh() + local files = {} + for _, id in ipairs({ "probe", "other" }) do + files["mods/" .. id .. "/manifest.json"] = + Json.encode({ id = id, name = id, version = "1.0.0", api = level or 2, entry = "main.lua" }) + files["mods/" .. id .. "/main.lua"] = [[return function(mod) + assert(type(mod.battle.submit)=="function") + assert(mod.battle:snapshot()==nil) + end]] + end + local fs = { + read = function(p) + return files[p] + end, + load = function(p) + return loadstring(files[p], p) + end, + getDirectoryItems = function(p) + return p == "mods" and { "probe", "other" } or {} + end, + getInfo = function(p) + if p == "mods" or p == "mods/probe" or p == "mods/other" then + return { type = "directory" } + end + if files[p] then + return { type = "file" } + end + end, + } + local loader = Loader.new({ fs = fs, dev = false }) + local game = { data = data, stack = { states = {} }, save = SaveData.newGame() } + loader.game = game + assert(loader:load(data), table.concat(loader.errors, "; ")) + Font.load(data) + game.save.party = { Pokemon.new(data, "FIXMON_A", 20), Pokemon.new(data, "FIXMON_B", 12) } + game.input = { + wasPressed = function() + return false + end, + isDown = function() + return false + end, + } + game.stack.top = function(stack) + return stack.states[#stack.states] + end + game.stack.push = function(stack, state) + stack.states[#stack.states + 1] = state + end + game.stack.pop = function(stack) + return table.remove(stack.states) + end + local b = trainer and BattleState.newTrainer(game, "OPP_FIX_YOUNGSTER", 1) + or BattleState.newWild(game, "FIXMON_C", 10) + b:syncSides() + b.phase = "menu" + b.gen4CursorActive = true + b.frame = 0 + b.queue = {} + b.menuIndex = 1 + b.moveIndex = 1 + local rolls = 0 + b.rng = function(lo, hi) + rolls = rolls + 1 + return lo + end + game.stack.states = { b } + return loader.apis.probe.battle, + game, + b, + loader.apis.other.battle, + loader, + function() + return rolls + end +end +local function intent(api, id, kind, fields) + local s = assert(api:snapshot()) + local out = { id = id, revision = s.revision, kind = kind } + for k, v in pairs(fields or {}) do + out[k] = v + end + return out +end +local function send(api, id, kind, fields) + return api:submit(intent(api, id, kind, fields)) +end +local function freeze(root) + local graph = {} + local function visit(value) + if type(value) ~= "table" or graph[value] then + return + end + local row = { values = {}, mt = getmetatable(value) } + graph[value] = row + for key, item in pairs(value) do + row.values[key] = item + visit(item) + end + end + visit(root) + return function() + for tbl, row in pairs(graph) do + if getmetatable(tbl) ~= row.mt then + return false + end + for key, value in pairs(row.values) do + if tbl[key] ~= value then + return false + end + end + for key in pairs(tbl) do + if row.values[key] == nil then + return false + end + end + end + return true + end +end +local function forbidden() + error("unexpected native side effect") +end +local function reject(api, game, raw, fragment, label) + local battler = game.stack.states[1] + local oldRng = battler and battler.rng + if battler and battler.isBattleState then + battler.rng = forbidden + end + local unchanged = freeze(game) + local oldLua, oldLove, oldEmit, oldCall = math.random, love.math.random, Runtime.emit, Runtime.call + math.random, love.math.random, Runtime.emit, Runtime.call = forbidden, forbidden, forbidden, forbidden + local ok, reason = api:submit(raw) + math.random, love.math.random, Runtime.emit, Runtime.call = oldLua, oldLove, oldEmit, oldCall + T.eq(ok, nil, label .. ": rejected") + T.check( + type(reason) == "string" and reason:find(fragment, 1, true) ~= nil, + label .. ": reason " .. tostring(reason) + ) + T.check(unchanged(), label .. ": whole game graph unchanged") + if battler and battler.isBattleState then + battler.rng = oldRng + end +end +local function view(api) + local s = assert(api:snapshot()) + s.revision = nil + return s +end +local function press(game, b, key) + game.input.wasPressed = function(_, name) + return name == key + end + b:update(1 / 60) + game.input.wasPressed = function() + return false + end +end + +for _, version in ipairs(GameVersion.ORDER) do + local label = version .. ": " + -- Identical native input and API commands, including actual turn planning. + local api, game, b, other, loader, rolls = boot(version) + local control, cgame, cb, _, _, crolls = boot(version) + T.eq(send(api, 1, "menu", { choice = "fight" }), true, label .. "FIGHT accepted") + press(cgame, cb, "a") + T.same(view(api), view(control), label .. "FIGHT matches native A dispatch") + T.eq(send(api, 2, "move", { slot = 1 }), true, label .. "move accepted") + press(cgame, cb, "a") + T.same(view(api), view(control), label .. "move matches native A dispatch") + T.eq(rolls(), crolls(), label .. "native RNG consumption matches") + T.eq(#b.queue, #cb.queue, label .. "native action queue matches") + T.eq(b.turnCount, 1, label .. "exactly one native turn planned") + local function drain(g, bb) + g.input.wasPressed = function() + return true + end + g.input.isDown = function() + return true + end + for frame = 1, 5000 do + if not bb:updateQueue() then + return true + end + end + return false + end + T.check(drain(game, b), label .. "API native turn queue completes") + T.check(drain(cgame, cb), label .. "input native turn queue completes") + T.eq(b.enemy.mon.hp, cb.enemy.mon.hp, label .. "native damage identical after queue") + T.eq(b.player.mon.hp, cb.player.mon.hp, label .. "enemy retaliation identical after queue") + T.eq(b.player.curMoves[1].pp, cb.player.curMoves[1].pp, label .. "PP consumption identical after queue") + T.eq(rolls(), crolls(), label .. "full native turn RNG identical") + if loader.apis.probe.supports then + T.eq(loader.apis.probe:supports("battle", "submit"), true, label .. "submit capability") + end + -- Menu opening does not bypass the party/bag controller or consume items. + for _, choice in ipairs({ "party", "item", "run" }) do + local a, g, bb = boot(version) + local c, cg, cc = boot(version) + T.eq(send(a, 1, "menu", { choice = choice }), true, label .. choice .. " accepted") + local indices = cc:menuActions() + for i, value in ipairs(indices) do + if value == choice or (choice == "party" and value == "pkmn") then + cc.menuIndex = i + end + end + press(cg, cc, "a") + -- Native cursor is selected by the player; the public API addresses the + -- action by name, so it need not move that display cursor. + local sv, cv = view(a), view(c) + sv.menuIndex = nil + cv.menuIndex = nil + T.same(sv, cv, label .. choice .. " matches native input") + T.eq(#bb.queue, #cc.queue, label .. choice .. " native queue") + end + api, game, b = boot(version) + local first = assert(api:snapshot()) + local bads = { false, {}, setmetatable({ id = 1, revision = first.revision, kind = "back" }, {}) } + for _, raw in ipairs(bads) do + reject(api, game, raw, "", label .. "malformed intent") + end + for _, value in ipairs({ 0, -1, 1.5, math.huge, -math.huge, 0 / 0, "1", true, 9007199254740992 }) do + reject( + api, + game, + { id = value, revision = first.revision, kind = "menu", choice = "fight" }, + "integer", + label .. "unsafe ID" + ) + reject( + api, + game, + { id = 1, revision = value, kind = "menu", choice = "fight" }, + "revision", + label .. "unsafe revision" + ) + end + local malformed = { + { kind = "mystery" }, + { kind = "menu", choice = "catch" }, + { kind = "menu", choice = {} }, + { kind = "move", slot = "1" }, + { kind = "move", slot = 0 }, + { kind = "move", slot = 5 }, + { kind = "target", target = -1 }, + { kind = "target", target = 4 }, + { kind = "mimic", index = 0 }, + { kind = "mimic", index = 5 }, + { kind = "safari", action = "mud" }, + { kind = "safari", action = false }, + { kind = "back", extra = true }, + { kind = "move", slot = 1, idOverride = 3 }, + } + for _, fields in ipairs(malformed) do + local raw = { id = 1, revision = first.revision } + for k, v in pairs(fields) do + raw[k] = v + end + reject(api, game, raw, "", label .. "strict intent fields") + end + local current = intent(api, 1, "menu", { choice = "fight" }) + T.eq(api:submit(current), true, label .. "rejected IDs were not consumed") + reject(api, game, current, "replayed", label .. "accepted ID cannot replay") + current.id = 0 + reject(api, game, current, "integer", label .. "ID zero is invalid") + current = intent(api, 3, "move", { slot = 1 }) + b.player.mon.hp = b.player.mon.hp - 1 + reject(api, game, current, "stale", label .. "changed HP invalidates revision") + current = intent(api, 3, "move", { slot = 1 }) + b.player.curMoves[1] = Merge.deepCopy(b.player.curMoves[1]) + reject(api, game, current, "stale", label .. "identical replacement move invalidates revision") + current = intent(api, 3, "move", { slot = 1 }) + b.player.mon = Merge.deepCopy(b.player.mon) + reject(api, game, current, "stale", label .. "identical replacement Pokemon invalidates revision") + current = intent(api, 3, "move", { slot = 1 }) + game.stack.states[2] = { battle = b } + reject(api, game, current, "stale", label .. "new covering screen invalidates revision") + reject( + api, + game, + intent(api, 3, "move", { slot = 1 }), + "covered", + label .. "fresh covered snapshot still cannot act" + ) + game.stack.states[2] = nil + for _, phase in ipairs({ "intro", "messages", "waitRemote", "finished" }) do + b.phase = phase + reject( + api, + game, + intent(api, 3, "menu", { choice = "fight" }), + "active", + label .. "wrong phase " .. phase + ) + end + b.phase = "menu" + for _, flag in ipairs({ "demo", "dudeDemo", "pokedudeDemo", "spectating", "palace", "arena" }) do + b[flag] = true + reject( + api, + game, + intent(api, 3, "menu", { choice = "fight" }), + "controller", + label .. "automatic controller " .. flag + ) + b[flag] = nil + end + for _, flag in ipairs({ "result", "dead", "waitingUI" }) do + b[flag] = true + reject(api, game, intent(api, 3, "menu", { choice = "fight" }), "ready", label .. "not ready " .. flag) + b[flag] = nil + end + b.kind = "link" + reject(api, game, intent(api, 3, "menu", { choice = "fight" }), "controller", label .. "link refused") + b.kind = "wild" + for _, flag in ipairs({ "mustRecharge", "charging", "rageMove", "thrashTurns" }) do + b.player[flag] = 1 + reject( + api, + game, + intent(api, 3, "menu", { choice = "fight" }), + "locked", + label .. flag .. " automatic lock" + ) + b.player[flag] = nil + end + b.player.ally = true + reject(api, game, intent(api, 3, "menu", { choice = "fight" }), "ready", label .. "NPC ally refused") + b.player.ally = nil + b.player.mon.hp = 0 + reject(api, game, intent(api, 3, "menu", { choice = "fight" }), "ready", label .. "fainted chooser refused") + b.player.mon.hp = 30 + b.phase = "moveSelect" + for _, guard in ipairs({ "pp", "disable", "taunt", "torment", "swap" }) do + local mv = b.player.curMoves[1] + local def = game.data.moves[mv.id] + local power = def.power + if guard == "pp" then + mv.pp = 0 + end + if guard == "disable" then + b.player.disabledSlot = 1 + end + if guard == "taunt" then + b.player.tauntTurns = 2 + def.power = 0 + end + if guard == "torment" then + b.player.tormented = true + b.player.lastMove = mv.id + end + if guard == "swap" then + b.moveSwapIndex = 1 + end + reject(api, game, intent(api, 3, "move", { slot = 1 }), "", label .. guard .. " rejected before action") + mv.pp = 20 + b.player.disabledSlot = nil + b.player.tauntTurns = nil + b.player.tormented = nil + b.player.lastMove = nil + b.moveSwapIndex = nil + def.power = power + end + reject(api, game, intent(api, 3, "move", { slot = 4 }), "slot", label .. "missing slot refused") + b.moveSwapIndex = 1 + T.eq(send(api, 3, "back"), true, label .. "back cancels reorder") + T.eq(b.moveSwapIndex, nil, label .. "reorder marker cleared") + T.eq(b.phase, "menu", label .. "back to menu") + reject(api, game, intent(api, 4, "back"), "nothing", label .. "single menu has no back action") + game.stack.states = {} + reject(api, game, { id = 4, revision = 1, kind = "back" }, "no battle", label .. "outside battle refused") +end +local function doubles(api, game, b) + math.randomseed(54321) + b.double = true + b:placeBattler(2, BattleState.makeBattler(game.data, game.save.party[2], true, game.save)) + local foe = Pokemon.new(game.data, "FIXMON_B", 10) + b.enemyParty = b.enemyParty or { b.enemy.mon } + b.enemyParty[2] = foe + b:placeBattler(3, BattleState.makeBattler(game.data, foe, false)) + b:syncSides() +end +for _, version in ipairs(GameVersion.ORDER) do + local label = version .. " doubles: " + local api, game, b = boot(version) + local control, cgame, cb = boot(version) + doubles(api, game, b) + doubles(control, cgame, cb) + T.eq(send(api, 1, "menu", { choice = "fight" }), true, label .. "first FIGHT") + press(cgame, cb, "a") + T.eq(send(api, 2, "move", { slot = 1 }), true, label .. "first move enters targets") + press(cgame, cb, "a") + T.eq(b.phase, "targetSelect", label .. "native targeting phase") + reject(api, game, intent(api, 3, "target", { target = 0 }), "selector", label .. "cannot target own slot") + local offered = intent(api, 3, "target", { target = 3 }) + b.targetChoices = { unpack(b.targetChoices) } + reject(api, game, offered, "stale", label .. "selector identity changed") + T.eq(send(api, 3, "target", { target = 3 }), true, label .. "position 3 selected") + cb.targetIndex = 2 + press(cgame, cb, "a") + T.same(view(api), view(control), label .. "position maps to correct native cursor") + T.eq(b.chosenTargets[0], b:battlerAt(3), label .. "chosen actual right foe") + T.eq(b.choosingSlot, 2, label .. "second chooser") + T.eq(send(api, 4, "back"), true, label .. "undo first selection from second menu") + press(cgame, cb, "b") + T.same(view(api), view(control), label .. "back matches native B") + T.eq(b.pendingActions[0], nil, label .. "pending first action cleared") + T.eq(b.chosenTargets[0], nil, label .. "pending target cleared") + -- Native B from target returns to moves, then from moves to action menu. + T.eq(send(api, 5, "menu", { choice = "fight" }), true, label .. "fight again") + T.eq(send(api, 6, "move", { slot = 1 }), true, label .. "target again") + T.eq(send(api, 7, "back"), true, label .. "cancel target") + T.eq(b.pendingMove, nil, label .. "pending move cleared") + T.eq(b.targetChoices, nil, label .. "target selector cleared") + T.eq(b.phase, "moveSelect", label .. "back to moves") + T.eq(send(api, 8, "back"), true, label .. "back to menu") + T.eq(send(api, 9, "menu", { choice = "fight" }), true, label .. "choose first action") + T.eq(send(api, 10, "move", { slot = 1 }), true, label .. "select first move") + local stale = intent(api, 11, "target", { target = 1 }) + b.pendingMove = Merge.deepCopy(b.pendingMove) + reject(api, game, stale, "stale", label .. "pending move identity changed") + reject(api, game, intent(api, 11, "target", { target = 1 }), "pending", label .. "orphaned pending move") + b.pendingMove = b.player.curMoves[1] + b.pendingMove.pp = 0 + reject(api, game, intent(api, 11, "target", { target = 1 }), "PP", label .. "pending move rechecks PP") + b.pendingMove.pp = 20 + b:battlerAt(1).mon.hp = 0 + reject(api, game, intent(api, 11, "target", { target = 1 }), "field", label .. "fainted target refused") + b:battlerAt(1).mon.hp = 30 + T.eq(send(api, 11, "target", { target = 1 }), true, label .. "first target") + T.eq(send(api, 12, "menu", { choice = "fight" }), true, label .. "second FIGHT") + T.eq(send(api, 13, "move", { slot = 1 }), true, label .. "second move") + T.eq(send(api, 14, "target", { target = 0 }), true, label .. "target position zero supported") + T.eq(b.chosenTargets[2], b.player, label .. "second actor aimed at left partner") + T.eq(b.turnCount, 1, label .. "second chooser resolves one whole turn") + -- A surviving right flank still owns a usable menu. + api, game, b = boot(version) + doubles(api, game, b) + b:placeBattler(0, nil) + T.eq(api:snapshot().active, 2, label .. "missing-left chooser") + T.eq(send(api, 1, "menu", { choice = "fight" }), true, label .. "right-only FIGHT") + T.eq(b.phase, "moveSelect", label .. "right-only moves") + -- Mimic belongs to its queued user, even when that is the right flank. + b.phase = "mimicSelect" + local user = b:battlerAt(2) + local target = b.enemy + b.mimicCtx = { user = user, target = target, moveInst = user.curMoves[1] } + b.mimicMoves = { { slot = 1, id = target.curMoves[1].id } } + b.mimicIndex = 1 + T.eq(api:snapshot().active, 2, label .. "Mimic uses queued right-flank user") + local picked = intent(api, 2, "mimic", { index = 1 }) + b.mimicCtx.target = b:battlerAt(3) + reject(api, game, picked, "stale", label .. "Mimic source identity changed") + b.mimicCtx.target = target + T.eq(send(api, 2, "mimic", { index = 1 }), true, label .. "native Mimic copy") + T.eq(user.curMoves[1].id, target.curMoves[1].id, label .. "Mimic installs selected source") + T.eq(b.phase, "messages", label .. "Mimic resumes queue") + T.eq(#b.mimicRestores, 1, label .. "native restoration bookkeeping preserved") +end + +-- Safari uses its native model, including Platinum's own action implementation. +for _, version in ipairs({ "red", "firered", "platinum", "heartgold" }) do + for _, action in ipairs({ "ball", "bait", "rock", "run" }) do + local api, game, b = boot(version) + local control, cgame, cb = boot(version) + for _, battle in ipairs({ b, cb }) do + battle.safari = { balls = 5 } + battle.safariCatchRate = 80 + battle.baitFactor = 0 + battle.escapeFactor = 0 + if version == "platinum" then + battle.gen4Safari = require("src.battle.Gen4Safari").init() + end + end + T.eq(send(api, 1, "safari", { action = action }), true, version .. ": Safari " .. action) + cb.menuIndex = ({ ball = 1, bait = 2, rock = 3, run = 4 })[action] + press(cgame, cb, "a") + T.same(view(api), view(control), version .. ": Safari matches native " .. action) + T.eq(b.safari.balls, action == "ball" and 4 or 5, version .. ": ball spent only for throw") + local old = intent(api, 2, "safari", { action = "ball" }) + reject(api, game, old, "", version .. ": cannot repeat Safari action during messages") + b.phase = "menu" + b.result = nil + b.safari.balls = 0 + reject( + api, + game, + intent(api, 2, "safari", { action = "run" }), + "balls", + version .. ": depleted Safari belongs to automatic exit" + ) + b.safari.balls = 1 + reject( + api, + game, + intent(api, 2, "menu", { choice = "fight" }), + "safari", + version .. ": regular menu cannot bypass Safari" + ) + end +end + +-- BW eligibility goes through the native read-only rules, not the snapshot's +-- deliberately partial disabled flag. +for _, version in ipairs({ "black", "white" }) do + for _, guard in ipairs({ "choice", "imprison", "gravity", "heal", "sky" }) do + local api, game, b = boot(version) + b.phase = "moveSelect" + b.bw = { field = {}, sides = { {}, {} } } + local mv = b.player.curMoves[1] + local def = game.data.moves[mv.id] + if guard == "choice" then + game.data.items.CHOICE = { name = "Choice", bwPocket = 1, holdEffect = 55 } + b.player.items = game.data.items + b.player.mon.item = "CHOICE" + b.player.bwChoiceLock = "FIX_CUT" + b.player.curMoves[2] = { id = "FIX_CUT", pp = 3 } + elseif guard == "imprison" then + b.enemy.bwImprison = true + b.enemy.curMoves = { { id = mv.id, pp = 2 } } + elseif guard == "gravity" then + b.bw.field.gravity = 3 + def.gravityBanned = true + elseif guard == "heal" then + b.player.bwHealBlock = 3 + def.healMove = true + else + b.player.bwSkyDropped = b.enemy + end + reject( + api, + game, + intent(api, 1, "move", { slot = 1 }), + "", + version .. ": native BW restriction " .. guard + ) + end +end + +-- Cross-facade atomicity, consumption and recovery are deliberately tested +-- with native boundary overrides; the real-input cases above verify gameplay. +do + local api, game, b, other = boot("crystal") + reject( + api, + game, + { id = 1, revision = 1, kind = "menu", choice = "fight" }, + "snapshot", + "unobserved context" + ) + local native = b.chooseMenu + local executions = 0 + local nested, mid + local otherRequest = intent(other, 1, "menu", { choice = "fight" }) + b.chooseMenu = function(self, choice) + executions = executions + 1 + nested = { other:submit(otherRequest) } + mid = other:snapshot() + return native(self, choice) + end + T.eq(send(api, 1, "menu", { choice = "fight" }), true, "outer command accepted") + T.eq(nested[1], nil, "reentrant other mod rejected") + T.check(nested[2]:find("progress", 1, true) ~= nil, "reentrant rejection reason") + T.eq(mid.prompt, "locked", "mid-dispatch snapshot is locked") + reject( + other, + game, + { id = 1, revision = mid.revision, kind = "back" }, + "stale", + "mid-dispatch revision cannot act after return" + ) + T.eq(executions, 1, "one native dispatch") + T.eq(send(other, 1, "back"), true, "other facade has independent intent ID sequence") + b.chooseMenu = function() + executions = executions + 1 + return true + end + local held = intent(other, 2, "menu", { choice = "fight" }) + T.eq(send(api, 2, "menu", { choice = "fight" }), true, "same-shaped accepted native command") + reject(other, game, held, "stale", "shared epoch invalidates unchanged records across mods") + local raw = intent(api, 3, "menu", { choice = "fight" }) + b.chooseMenu = function() + executions = executions + 1 + b.phase = "messages" + error("native boom") + end + local accepted, err = api:submit(raw) + T.eq(accepted, nil, "native exception reported") + T.check( + err:find("consumed", 1, true) ~= nil and err:find("native boom", 1, true) ~= nil, + "native error explains consumed ID" + ) + reject(api, game, raw, "replayed", "partial failure cannot replay") + T.eq(Intents.state(game).busy, false, "error clears shared busy flag") + b.phase = "menu" + b.chooseMenu = native + T.eq(send(api, 4, "menu", { choice = "fight" }), true, "new request recovers after native failure") +end + +do + local api, game, b, other, loader = boot("red") + local original = b.menuBattler + local request = intent(api, 1, "menu", { choice = "fight" }) + local nested + local otherRequest = intent(other, 1, "menu", { choice = "fight" }) + b.menuBattler = function(self) + nested = { other:submit(otherRequest) } + return original(self) + end + T.eq(api:submit(request), true, "validation rejects recursion while outer intent succeeds") + T.eq(nested[1], nil, "validation reentry rejected") + T.eq(send(api, 2, "back"), true, "validation reentry leaves facade usable") + b.menuBattler = function(self) + game.stack.states[2] = {} + return original(self) + end + local ok, err = api:submit(intent(api, 3, "menu", { choice = "fight" })) + T.eq(ok, nil, "preflight context change refused") + T.check(err:find("stale", 1, true) ~= nil, "preflight context change reason") + -- The intentionally overridden validator above changed the stack itself; + -- test the refusal separately from the normal no-side-effects contract. + game.stack.states[2] = nil + b.menuBattler = original + T.eq(Intents.state(game).busy, false, "preflight failure clears busy flag") + local raw = intent(api, 3, "menu", { choice = "fight" }) + b.menuBattler = function(self) + raw.choice = "run" + raw.id = 999 + return original(self) + end + T.eq(api:submit(raw), true, "intent copied before native validation") + T.eq(b.phase, "moveSelect", "caller mutation cannot change dispatch choice") + b.menuBattler = original + local _, newGame, newBattle = boot("blue") + loader.game = newGame + reject( + api, + newGame, + { id = 3, revision = raw.revision, kind = "back" }, + "replayed", + "consumption survives Game replacement" + ) + T.eq(send(api, 4, "menu", { choice = "fight" }), true, "captured facade follows new Game") + T.eq(newBattle.phase, "moveSelect", "action reaches replacement Game") +end + +-- Accepted calls can enter normal refusal/forced-action paths too. They do +-- not reinterpret a native trainer RUN, no-PP Struggle or trapped FIGHT. +for _, mode in ipairs({ "struggle", "trapping", "ghost", "trainerRun" }) do + local api, game, b = boot("crystal", mode == "trainerRun") + local control, cgame, cb = boot("crystal", mode == "trainerRun") + for _, bb in ipairs({ b, cb }) do + if mode == "struggle" then + for _, mv in ipairs(bb.player.curMoves) do + mv.pp = 0 + end + end + if mode == "trapping" then + bb.player.trappingTurns = 2 + end + if mode == "ghost" then + bb.ghost = true + end + end + local choice = mode == "trainerRun" and "run" or "fight" + T.eq(send(api, 1, "menu", { choice = choice }), true, mode .. ": public selection accepted") + cb.menuIndex = mode == "trainerRun" and 4 or 1 + press(cgame, cb, "a") + local actual, expected = view(api), view(control) + actual.menuIndex = nil + expected.menuIndex = nil + T.same(actual, expected, mode .. ": preserves native branch") + T.eq(#b.queue, #cb.queue, mode .. ": native queue preserved") +end + +do + local api, game, b = boot("red") + local raw = intent(api, 1, "menu", { choice = "fight" }) + local rev = raw.revision + raw.extra = "typo" + reject(api, game, raw, "field", "invalid field is observational") + T.eq(api:snapshot().revision, rev, "pure invalid intent does not advance revision") + raw.extra = nil + local native = b.chooseMenu + b.chooseMenu = function() + return nil, "native declined" + end + local ok, err = api:submit(raw) + T.eq(ok, nil, "native refusal returned") + T.eq(err, "native declined", "native refusal reason retained") + reject(api, game, raw, "replayed", "native invocation consumes even when it declines") + b.chooseMenu = native + T.eq(send(api, 2, "menu", { choice = "fight" }), true, "native decline clears busy lock") + local _, otherGame = boot("blue") + game.stack.states[1] = otherGame.stack.states[1] + reject(api, game, intent(api, 3, "back"), "Game", "foreign Game battle cannot be controlled") +end +do + local api, game, b = boot("red") + local owner = Runtime.currentMod + Runtime.currentMod = "probe" + b.chooseMenu = function() + T.eq(Runtime.currentMod, nil, "native dispatch uses engine attribution") + error("scope failure") + end + local ok = send(api, 1, "menu", { choice = "fight" }) + T.eq(ok, nil, "scoped native error returned") + T.eq(Runtime.currentMod, "probe", "native error restores caller attribution") + Runtime.currentMod = owner +end +do + local api, game, b, other, loader = boot("emerald") + local callbackCount, nested, mid = 0, nil, nil + local held = intent(other, 1, "menu", { choice = "fight" }) + loader.apis.probe.events:on("battle.turn_started", function() + callbackCount = callbackCount + 1 + T.eq(Runtime.currentMod, nil, "real event uses native engine scope") + mid = other:snapshot() + nested = { other:submit(held) } + end) + T.eq(send(api, 1, "menu", { choice = "fight" }), true, "real event test selects FIGHT") + T.eq(send(api, 2, "move", { slot = 1 }), true, "real event test plans native turn") + T.eq(callbackCount, 1, "one native turn-start callback") + T.eq(nested[1], nil, "real event cannot recursively submit") + T.eq(mid.prompt, "locked", "real event sees locked in-flight snapshot") + reject( + other, + game, + { id = 1, revision = mid.revision, kind = "back" }, + "stale", + "real event snapshot expires after dispatch" + ) + T.eq(Runtime.currentMod, nil, "real native event restores engine/caller scope") +end +local api = boot("red", false, 1) +T.eq(send(api, 1, "menu", { choice = "fight" }), true, "API 1 mod can use additive public actions") +GameVersion.set(oldVersion) +T.finish("battle actions") diff --git a/tests/engine/battle_snapshots.lua b/tests/engine/battle_snapshots.lua new file mode 100644 index 000000000..acf55273d --- /dev/null +++ b/tests/engine/battle_snapshots.lua @@ -0,0 +1,613 @@ +-- ROM-free contract tests through the real mod loader and BattleState. +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.harness") +local Loader = require("src.mods.Loader") +local BattleState = require("src.battle.BattleState") +local GameVersion = require("src.core.GameVersion") +local Merge = require("src.mods.Merge") +local Json = require("src.link.Json") +local oldVersion, oldLove = GameVersion.get(), love +love = nil + +-- Exercise the real loader resolver, including Lua 5.1's in-progress marker. +local cachedGame = package.loaded["src.core.Game"] +package.loaded["src.core.Game"] = nil +T.eq(Loader._game({}, true), nil, "loaded-only lookup does not bootstrap Game") +T.eq(package.loaded["src.core.Game"], nil, "lookup leaves module cache untouched") +package.loaded["src.core.Game"] = true +T.eq(Loader._game({}, true), nil, "in-progress Game is unavailable") +local injected = {} +T.eq(Loader._game({ game = injected }, true), injected, "explicit Game takes precedence") +package.loaded["src.core.Game"] = injected +T.eq(Loader._game({}, true), injected, "live cached Game is available") +package.loaded["src.core.Game"] = cachedGame + +local function boot(version, level) + GameVersion.set(version) + local ds = GameVersion.generation() >= 4 + local s, m, item = ds and 25 or "PIKACHU", ds and 33 or "TACKLE", ds and 4 or "POKE_BALL" + local species, move = + { name = "Pikachu", types = { "ELECTRIC" } }, + { name = "Tackle", pp = 35, power = 40, accuracy = 95, type = "NORMAL" } + local data = { + pokemon = { [s] = species }, + moves = { [m] = move }, + items = { + [item] = { name = "Poké Ball", ball = "POKE_BALL" }, + Z_EXTRA = { name = "Extra" }, + A_EXTRA = { name = "Earlier" }, + }, + } + if ds then + data.pokemon.SPECIES_25 = species + data.moves.MOVE_33 = move + end + local function mon(nick) + return { + species = s, + nickname = nick, + level = 7, + hp = 19, + status = "PSN", + stats = { hp = 23, attack = 10, defense = 11, special = 12, speed = 13 }, + moves = { { id = m, pp = 29, ppUps = 1 } }, + isEgg = false, + extension = { flag = false }, + } + end + local a, b, e, f = mon("Left"), mon("Right"), mon("Foe"), mon("Other foe") + if GameVersion.generation() > 1 then + for _, raw in ipairs({ a, b, e, f }) do + raw.stats.spatk, raw.stats.spdef = 14, 15 + end + end + local party = { a, b } + local function battler(raw) + return { + mon = raw, + name = raw.nickname, + species = raw.species, + curStats = raw.stats, + curTypes = { "ELECTRIC" }, + curMoves = raw.moves, + stages = {}, + } + end + local game = { + data = data, + save = { + party = party, + inventory = { + [item] = 3, + Z_EXTRA = 2, + A_EXTRA = 1, + ZERO = 0, + }, + bagOrder = { item, item, "ZERO" }, + }, + stack = { states = {} }, + } + local btl = setmetatable({ + game = game, + data = data, + kind = "wild", + phase = "menu", + queue = {}, + player = battler(a), + enemy = battler(e), + enemyParty = { e, f }, + turnCount = 0, + sides = { { battlers = {} }, { battlers = {} } }, + }, BattleState) + btl:placeBattler(0, btl.player) + btl:placeBattler(1, btl.enemy) + local files = { + ["mods/probe/manifest.json"] = Json.encode({ + id = "probe", + name = "Probe", + version = "1.0.0", + api = level or 2, + entry = "main.lua", + }), + ["mods/probe/main.lua"] = [[return function(mod) + assert(type(mod.battle.snapshot)=="function") + mod.exports.api=mod + mod.content.moves:register("MOVE_9000", {id="MOVE_9000", name="Late move", pp=10, power=10, + accuracy=100, type="NORMAL", effect="NO_ADDITIONAL_EFFECT"}) + end]], + } + local fs = { + read = function(p) + return files[p] + end, + load = function(p) + return loadstring(files[p], p) + end, + getDirectoryItems = function(p) + return p == "mods" and { "probe" } or {} + end, + getInfo = function(p) + return (p == "mods" or p == "mods/probe") and { type = "directory" } or files[p] and { type = "file" } + end, + } + local loader = Loader.new({ fs = fs, dev = false }) + local resolves = 0 + loader._game = function() + resolves = resolves + 1 + return game + end + assert(loader:load(data), table.concat(loader.errors, "; ")) + return loader.apis.probe, loader, game, btl, battler, { s, m, item }, function() + return resolves + end +end + +-- Freeze the whole graph as table identities + raw entries, including cycles. +local function freeze(root) + local graph = {} + local function visit(value) + if type(value) ~= "table" or graph[value] then + return + end + local entries = {} + graph[value] = entries + for key, child in pairs(value) do + entries[key] = child + visit(child) + end + end + visit(root) + return function() + for tbl, entries in pairs(graph) do + for key, value in pairs(entries) do + if tbl[key] ~= value then + return false + end + end + for key in pairs(tbl) do + if entries[key] == nil then + return false + end + end + end + return true + end +end + +local function forbidden() + error("snapshot crossed into gameplay") +end +local oldRandom = math.random +math.random = forbidden +for _, version in ipairs(GameVersion.ORDER) do + local api, loader, game, b, battler, ids, resolves = boot(version) + local label = version .. ": " + T.eq(resolves(), 0, label .. "entry can capture facade before Game exists") + if api.supports then + T.eq(api:supports("battle", "snapshot"), true, label .. "snapshot capability") + T.eq(api:supports("battle", "submit"), true, label .. "additive input submission") + T.eq(resolves(), 0, label .. "probe does not resolve Game") + end + T.eq(api.battle, api.battle, label .. "stable facade") + T.eq(type(api.battle.submit), "function", label .. "additive submit method") + local no, reason = api.battle:snapshot() + T.eq(no, nil, label .. "no battle") + T.eq(reason, "no battle", label .. "no battle reason") + game.stack.states = { { battle = b, phase = "menu", player = b.player, enemy = b.enemy } } + T.eq(api.battle:snapshot(), nil, label .. "a UI holding battle is not battle") + game.stack.states = { b } + b.rng = forbidden + b.menuLockedAction, b.fightLockedAction, b.menuBattler, b.playerHasPP = + forbidden, forbidden, forbidden, forbidden + b.catchAttempt, b.ballDef, b.moveDef, b.choosingSlotNow = forbidden, forbidden, forbidden, forbidden + b.volatile, b.enemyAction, b.update, b.draw = forbidden, forbidden, forbidden, forbidden + local unchanged = freeze(game) + local snap = api.battle:snapshot() + T.check(unchanged(), label .. "complete game graph unchanged") + T.eq(snap.revision, 1, label .. "first revision") + T.eq(snap.prompt, "menu", label .. "menu phase") + T.eq(snap.double, false, label .. "single battle") + T.eq(snap.active, 0, label .. "left actor") + T.eq(snap.kind, "wild", label .. "wild kind") + T.eq(snap.turn, 0, label .. "before first turn") + T.eq(snap.catchable, true, label .. "single wild target") + T.eq(#snap.battlers, 2, label .. "both occupied slots") + T.eq(snap.player.species, "PIKACHU", label .. "portable species") + T.eq(snap.player.name, "Left", label .. "battler display name") + T.eq(snap.player.hp, 19, label .. "logical HP") + T.eq(snap.player.maxHp, 23, label .. "max HP") + T.eq(snap.player.status, "poison", label .. "portable status") + T.eq(snap.player.stats.specialAttack, GameVersion.generation() == 1 and 12 or 14, label .. "special attack") + T.eq( + snap.player.stats.specialDefense, + GameVersion.generation() == 1 and 12 or 15, + label .. "special defense" + ) + T.eq(snap.player.partyIndex, 1, label .. "party slot by identity") + T.eq(snap.party[1].active, true, label .. "lead active") + T.eq(snap.party[2].active, false, label .. "bench inactive") + T.eq(snap.party[2].slot, 2, label .. "party slot one based") + T.eq(snap.moves[1].id, "TACKLE", label .. "portable move") + T.eq(snap.moves[1].maxPp, 42, label .. "PP Up maximum") + T.eq(snap.moves[1].pp, 29, label .. "current PP") + T.eq(snap.moves[1].disabled, false, label .. "unrestricted move") + T.eq(snap.moves[1].accuracy, 95, label .. "base accuracy") + T.eq(#snap.items, 3, label .. "positive inventory deduplicated") + T.eq(snap.items[1].id, "POKE_BALL", label .. "portable ball and bag order") + T.eq(snap.items[1].count, 3, label .. "inventory quantity") + T.eq(snap.items[1].ball, true, label .. "ball classification") + T.eq(snap.items[2].id, "A_EXTRA", label .. "unlisted inventory sorted") + T.eq(snap.items[3].id, "Z_EXTRA", label .. "stable inventory order") + T.eq(snap.items[1].catchChance, nil, label .. "no guessed catch preview") + T.eq(snap.items[1].needsTarget, nil, label .. "no guessed item eligibility") + T.same(api.battle:snapshot(), snap, label .. "unchanged reads stable") + snap.player.hp = 0 + snap.player.native.hp = 0 + snap.player.native.extension.flag = true + snap.moves[1].pp = 0 + snap.items[1].count = 0 + snap.party[1].stats.hp = 0 + snap.player.types[1] = "WATER" + snap.player.stages.attack = 6 + local clean = api.battle:snapshot() + T.eq(clean.revision, 1, label .. "caller writes do not alter revision") + T.eq(clean.player.hp, 19, label .. "caller writes isolated") + T.eq(clean.player.native.extension.flag, false, label .. "false preserved in diagnostics") + T.eq(clean.moves[1].pp, 29, label .. "move tables isolated") + T.eq(clean.items[1].count, 3, label .. "item tables isolated") + T.eq(clean.party[1].stats.hp, 23, label .. "party stats isolated") + T.eq(clean.player.types[1], "ELECTRIC", label .. "types isolated") + T.eq(clean.player.stages.attack, nil, label .. "stages isolated") + + b.frame, b.player.shownHP, b.player.picAnim = 99, 12, { tick = 5 } + T.eq(api.battle:snapshot().revision, 1, label .. "animation ticks excluded") + b.player.mon.hp = 18 + local changed = api.battle:snapshot() + T.eq(changed.revision, 2, label .. "HP changes revision") + T.eq(changed.player.hp, 18, label .. "new logical HP visible") + T.eq(clean.player.hp, 19, label .. "old result remains detached") + b.turnCount = 1 + T.eq(api.battle:snapshot().turn, 1, label .. "turn number updates") + local rev = api.battle:snapshot().revision + game.stack.states[#game.stack.states + 1] = { battle = b } + T.eq(api.battle:snapshot().prompt, "locked", label .. "covered battle locked") + T.check(api.battle:snapshot().revision > rev, label .. "coverage changes revision") + table.remove(game.stack.states) + local cases = { + { "intro", "locked" }, + { "messages", "locked" }, + { "moveSelect", "moves" }, + { "targetSelect", "target" }, + { "mimicSelect", "mimic" }, + { "finished", "locked" }, + } + for _, c in ipairs(cases) do + b.phase = c[1] + T.eq(api.battle:snapshot().prompt, c[2], label .. "phase " .. c[1]) + end + b.phase = "messages" + b.current = { text = "Current text" } + b.msgPrompt = true + b.msgPromptWait = 3 + T.eq(api.battle:snapshot().prompt, "locked", label .. "pre-advance delay") + b.msgPromptWait = 0 + T.eq(api.battle:snapshot().prompt, "advance", label .. "manual message advance") + T.eq(api.battle:snapshot().messageSource, "Current text", label .. "current message source") + T.eq(api.battle:snapshot().message, nil, label .. "no invented visible message") + game.data.font = + { charmap = { { code = 128, seq = "A" }, { code = 129, seq = "B" }, { code = 130, seq = "C" } } } + b.shown = { { 128, 129 }, { 130 } } + local text = api.battle:snapshot() + T.same(text.message, { "AB", "C" }, label .. "only revealed glyphs in visible message") + b.shown[2][2] = 128 + T.check(api.battle:snapshot().revision > text.revision, label .. "revealed glyph changes revision") + T.same(text.message, { "AB", "C" }, label .. "older visible message detached") + b.shown[1][1] = 999999 + T.eq(api.battle:snapshot().message, nil, label .. "unknown glyph is not guessed") + game.data.font.pages = { extra = { charmap = { { code = 999999, seq = "Ω" } } } } + T.same(api.battle:snapshot().message, { "ΩB", "CA" }, label .. "font page charmap read") + local unicode = package.loaded["src.translation.UnicodeFont"] + package.loaded["src.translation.UnicodeFont"] = { chars = { [-1] = "漢" } } + b.shown = { { -1 } } + T.same(api.battle:snapshot().message, { "漢" }, label .. "already loaded Unicode mapping read") + package.loaded["src.translation.UnicodeFont"] = unicode + game.data.font = { source = "Gen2 scaffold" } + b.shown = { { 65, 66 } } + T.same(api.battle:snapshot().message, { "AB" }, label .. "scaffold ASCII glyphs read") + game.stack.states[2] = { battle = b } + T.eq(api.battle:snapshot().message, nil, label .. "covered screen has no guessed visible message") + game.stack.states[2] = nil + b.shown = nil + b.current.choice = function() end + T.eq(api.battle:snapshot().prompt, "locked", label .. "choice is its own controller") + b.current = nil + b.msgPrompt = nil + b.phase = "menu" + for _, flag in ipairs({ "waitingUI", "demo", "pokedudeDemo", "palace", "arena" }) do + b[flag] = true + T.eq(api.battle:snapshot().prompt, "locked", label .. flag .. " locked") + b[flag] = nil + end + for _, flag in ipairs({ "mustRecharge", "charging", "rageMove", "thrashTurns" }) do + b.player[flag] = 1 + T.eq(api.battle:snapshot().prompt, "locked", label .. flag .. " locked") + b.player[flag] = nil + end + b.bw = true + b.player.bwSkyDropped = true + T.eq(api.battle:snapshot().prompt, "locked", label .. "Sky Drop lock") + b.bw = nil + b.player.bwSkyDropped = nil + b.safari = { balls = 3 } + T.eq(api.battle:snapshot().kind, "safari", label .. "Safari battle kind") + T.eq(api.battle:snapshot().safariBalls, 3, label .. "Safari ball count") + T.eq(api.battle:snapshot().prompt, "safari", label .. "Safari prompt") + b.safari.balls = 0 + T.eq(api.battle:snapshot().prompt, "locked", label .. "no Safari balls") + b.safari = nil + b.demo = true + T.eq(api.battle:snapshot().kind, "oldman", label .. "automatic capture demo kind") + b.demo = nil + b.ghost = true + T.eq(api.battle:snapshot().kind, "ghost", label .. "ghost kind") + b.ghost = nil + b.menuIndex = 2 + b.moveIndex = 3 + b.moveSwapIndex = 1 + local cursors = api.battle:snapshot() + T.eq(cursors.menuIndex, 2, label .. "menu cursor") + T.eq(cursors.moveIndex, 3, label .. "move cursor") + T.eq(cursors.moveSwapIndex, 1, label .. "reorder cursor") + b.menuIndex = 1 + T.check(api.battle:snapshot().revision > cursors.revision, label .. "cursor changes revision") + for _, flag in ipairs({ "ghost", "noCatch", "result" }) do + b[flag] = true + T.eq(api.battle:snapshot().catchable, false, label .. flag .. " uncatchable") + b[flag] = nil + end + b.kind = "trainer" + T.eq(api.battle:snapshot().catchable, false, label .. "trainer uncatchable") + b.kind = "wild" + b.phase = "moveSelect" + for _, guard in ipairs({ "disable", "pp", "taunt", "torment" }) do + if guard == "disable" then + b.player.disabledSlot = 1 + end + if guard == "pp" then + b.player.curMoves[1].pp = 0 + end + if guard == "taunt" then + b.player.tauntTurns = 2 + game.data.moves[ids[2]].power = 0 + end + if guard == "torment" then + b.player.tormented = true + b.player.lastMove = ids[2] + end + T.eq(api.battle:snapshot().moves[1].disabled, true, label .. guard .. " menu restriction") + b.player.disabledSlot = nil + b.player.curMoves[1].pp = 29 + b.player.tauntTurns = nil + game.data.moves[ids[2]].power = 40 + b.player.tormented = nil + b.player.lastMove = nil + end + -- Native alias keys may be split during merging; a name patch must not + -- rename a portable ID. Later registrations still appear in fresh reads. + game.data.pokemon[ids[1]].name = "Renamed presentation" + T.eq(api.battle:snapshot().player.species, "PIKACHU", label .. "name patches preserve identity") + local originalMoves = b.player.curMoves + b.player.curMoves = { { id = "MOVE_9000", pp = 8 } } + T.eq(api.battle:snapshot().moves[1].id, "LATE_MOVE", label .. "late mod ID resolved") + b.player.curMoves = originalMoves + b.bw = { field = {}, sides = { {}, {} } } + T.eq(api.battle:snapshot().moves[1].disabled, nil, label .. "BW eligibility remains unknown") + b.player.disabledSlot = 1 + T.eq(api.battle:snapshot().moves[1].disabled, true, label .. "BW known common restriction") + b.player.disabledSlot = nil + b.bw = nil + b.player.curMoves[1].ppUps = 99 + T.eq(api.battle:snapshot().moves[1].maxPp, 56, label .. "PP Ups clamped as native engine") + b.player.curMoves[1].ppUps = -1 + T.eq(api.battle:snapshot().moves[1].maxPp, 35, label .. "negative PP Ups clamped") + b.player.curMoves[1].ppUps = 1 + -- Transform changes the in-battle view while the stored party keeps its moves/stats. + game.data.moves.COPIED = { name = "Copied", pp = 10, power = 50, type = "WATER" } + b.player.curMoves = { { id = "COPIED", pp = 5 } } + b.player.curStats = { hp = 23, attack = 90, special = 80 } + b.player.species = "COPIED_SPECIES" + b.player.curTypes = { "WATER" } + local transformed = api.battle:snapshot() + T.eq(transformed.moves[1].id, "COPIED", label .. "current transformed move") + T.eq(transformed.party[1].moves[1].id, "TACKLE", label .. "stored party move") + T.eq(transformed.player.battleStats.attack, 90, label .. "current transformed stats") + T.eq(transformed.player.stats.attack, 10, label .. "base stats preserved") + T.eq(transformed.player.displaySpecies, "COPIED_SPECIES", label .. "display species separate") + T.eq(transformed.player.species, "PIKACHU", label .. "original species preserved") + -- All four native positional slots, including a missing left flank. + b.double = true + b:placeBattler(2, battler(game.save.party[2])) + b:placeBattler(3, battler(b.enemyParty[2])) + b.choosingSlot = 2 + b.phase = "menu" + local double = api.battle:snapshot() + T.eq(double.double, true, label .. "doubles flag") + T.eq(#double.battlers, 4, label .. "four occupied battlers") + T.eq(double.active, 2, label .. "second chooser") + T.eq(double.player.name, "Right", label .. "player follows chooser") + T.eq(double.player.partyIndex, 2, label .. "second chooser party index") + T.eq(double.catchable, false, label .. "cannot aim at two wild foes") + T.eq(double.party[2].active, true, label .. "both player slots active") + b.phase = "targetSelect" + b.targetChoices = { b.enemy, b.sides[2].battlers[2], b.player } + b.targetIndex = 2 + T.same(api.battle:snapshot().targets, { 1, 3, 0 }, label .. "target IDs, not array positions") + T.eq(api.battle:snapshot().targetIndex, 2, label .. "target cursor") + b:placeBattler(0, nil) + b:placeBattler(1, nil) + b.choosingSlot = nil + b.phase = "menu" + local missing = api.battle:snapshot() + T.eq(missing.active, 2, label .. "right flank after left faint") + T.eq(missing.enemy, nil, label .. "missing enemy left not silently aliased") + T.eq(missing.catchable, true, label .. "one wild foe remaining") + T.eq(#missing.battlers, 2, label .. "missing slots omitted") + T.eq(missing.battlers[1].id, 2, label .. "battler identity survives sparse slots") + b.phase = "mimicSelect" + b.mimicMoves = { { id = ids[2], slot = 3 } } + b.mimicIndex = 1 + local mimic = api.battle:snapshot() + T.eq(mimic.mimicMoves[1].id, "TACKLE", label .. "portable mimic ID") + T.eq(mimic.mimicMoves[1].slot, 3, label .. "original mimic move slot") + b.phase = "menu" + local ally = b.sides[1].battlers[2] + ally.ally = true + b.ally = { party = { ally.mon } } + local withAlly = api.battle:snapshot() + T.eq(withAlly.party[2].active, false, label .. "NPC ally not player active party") + T.eq(withAlly.battlers[1].ally, true, label .. "NPC ally marked") + T.eq(withAlly.prompt, "locked", label .. "NPC controlled actor menu locked") + ally.ally = nil + b.ally = nil + b.kind = "link" + b.playerParty = { game.save.party[2] } + local link = api.battle:snapshot() + T.eq(#link.party, 1, label .. "link uses clamped battle party") + T.eq(link.party[1].nickname, "Right", label .. "link party is actual participant") + T.eq(link.prompt, "locked", label .. "link controller owns input") + b.kind = "wild" + b.playerParty = nil + -- Snapshot diagnostics discard executable values and remain cycle-safe. + local raw = game.save.party[2] + raw.extension.self = raw.extension + raw.extension.fn = forbidden + raw.extension[{}] = "hidden" + setmetatable(raw.extension, { __index = { secret = true } }) + local cyc = api.battle:snapshot() + T.eq(cyc.party[2].native.extension.self, cyc.party[2].native.extension, label .. "cycles copied") + T.eq(cyc.party[2].native.extension.fn, nil, label .. "function dropped") + T.eq(getmetatable(cyc.party[2].native.extension), nil, label .. "metatable dropped") + T.eq(cyc.party[2].native.extension.secret, nil, label .. "no inherited value") + T.eq(api.battle:snapshot().revision, cyc.revision, label .. "cyclic record stable revision") + local last = cyc.revision + game.stack.states = {} + T.eq(api.battle:snapshot(), nil, label .. "battle exit") + game.stack.states = { b } + T.check(api.battle:snapshot().revision > last, label .. "re-entry invalidates revision") + last = api.battle:snapshot().revision + local replacement = setmetatable(Merge.deepCopy(b), BattleState) + game.stack.states = { replacement } + T.check(api.battle:snapshot().revision > last, label .. "identical new battle invalidates revision") + -- A previously captured facade follows the loader's current Game. + local other = { data = game.data, save = game.save, stack = game.stack } + loader._game = function() + return other + end + last = api.battle:snapshot().revision + loader._game = function() + return game + end + T.check(api.battle:snapshot().revision > last, label .. "Game replacement invalidates revision") +end +math.random = oldRandom + +-- Exercise constructors and a native damage action with the committed fixture +-- dataset, rather than asserting only hand-built record shapes. +love = require("tests.love_stub") +local Fixtures = require("tests.modkit.fixtures") +local Pokemon = require("src.pokemon.Pokemon") +local SaveData = require("src.core.SaveData") +local Font = require("src.render.Font") +local function native(version, trainer) + local api, loader = boot(version) + love = require("tests.love_stub") + math.randomseed(12345) + local data = Fixtures.fresh() + Font.load(data) + local save = SaveData.newGame() + save.party = { Pokemon.new(data, "FIXMON_A", 20), Pokemon.new(data, "FIXMON_B", 12) } + local game = { + data = data, + save = save, + input = { + wasPressed = function() + return false + end, + }, + stack = { + states = {}, + push = function() end, + pop = function() end, + top = function(self) + return self.states[#self.states] + end, + }, + } + local b = trainer and BattleState.newTrainer(game, "OPP_FIX_YOUNGSTER", 1) + or BattleState.newWild(game, "FIXMON_C", 10) + game.stack.states = { b } + loader._game = function() + return game + end + local calls = 0 + b.rng = function(_, high) + calls = calls + 1 + return high + end + return api, game, b, function() + return calls + end +end +for _, version in ipairs(GameVersion.ORDER) do + for _, trainer in ipairs({ false, true }) do + local api, game, b, calls = native(version, trainer) + local unchanged = freeze(game) + local rngBefore = calls() + local oldLoveRandom, oldLuaRandom = love.math.random, math.random + love.math.random, math.random = forbidden, forbidden + local Runtime = require("src.mods.Runtime") + local oldEmit, oldCall = Runtime.emit, Runtime.call + Runtime.emit, Runtime.call = forbidden, forbidden + local loaded = {} + for name in pairs(package.loaded) do + loaded[name] = true + end + local s = api.battle:snapshot() + for _ = 1, 10 do + T.eq(api.battle:snapshot().revision, s.revision, version .. ": repeated native snapshot stable") + end + local noNewModules = true + for name in pairs(package.loaded) do + if not loaded[name] then + noNewModules = false + end + end + T.check(noNewModules, version .. ": snapshot loads no modules") + Runtime.emit, Runtime.call = oldEmit, oldCall + T.check(unchanged(), version .. ": native constructor graph unchanged") + T.eq(calls(), rngBefore, version .. ": native RNG unchanged") + T.eq(s.kind, trainer and "trainer" or "wild", version .. ": native constructor kind") + T.eq(s.player.species, "FIXMON_A", version .. ": real Pokemon record") + T.eq(s.player.maxHp, b.player.mon.stats.hp, version .. ": constructor HP") + T.eq(s.moves[1].id, b.player.curMoves[1].id, version .. ": constructor moves") + T.eq(s.catchable, not trainer, version .. ": constructor catchability") + love.math.random, math.random = oldLoveRandom, oldLuaRandom + -- The same native action on an independently constructed control battle + -- must produce the same HP, PP, queue and RNG consumption. + local _, _, control, controlCalls = native(version, trainer) + b:performMove(b.player, b.enemy, b.player.curMoves[1]) + control:performMove(control.player, control.enemy, control.player.curMoves[1]) + T.eq(b.enemy.mon.hp, control.enemy.mon.hp, version .. ": action damage unaffected") + T.eq(b.player.curMoves[1].pp, control.player.curMoves[1].pp, version .. ": action PP unaffected") + T.eq(calls(), controlCalls(), version .. ": action RNG consumption unaffected") + T.eq(#b.queue, #control.queue, version .. ": action queue unaffected") + local later = api.battle:snapshot() + T.eq(later.enemy.hp, b.enemy.mon.hp, version .. ": snapshot follows native damage") + T.check(later.revision > s.revision, version .. ": native damage changes revision") + end +end +local api = boot("crystal", 1) +T.eq(type(api.battle.snapshot), "function", "API 1 mods also get additive reader") +GameVersion.set(oldVersion) +love = oldLove +T.finish("battle snapshots") diff --git a/tests/engine/charge_required_contract.lua b/tests/engine/charge_required_contract.lua new file mode 100644 index 000000000..ce3509cec --- /dev/null +++ b/tests/engine/charge_required_contract.lua @@ -0,0 +1,72 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local Version, Runtime = require("src.core.GameVersion"), require("src.mods.Runtime") +local Fixture, Cases = dofile("tests/charge_required/fixture.lua"), dofile("tests/charge_required/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]) + T.eq( + f.e.api:supports("hooks", "battle.charge_required"), + f.gen <= 3, + "native charge capability is honest" + ) + if f.gen <= 3 then + Cases.run(f) + local off = f.e.api.hooks:wrap("battle.charge_required", function() + return false + end) + local b = f.fresh("FLY") + b.use() + T.eq(b.charging(), false, "fresh public bypass works after consumer unsubscribe") + if f.gen == 3 then + local seen = false + for _, row in ipairs(b.battle.queue) do + if row.anim == f.ids.FLY then + seen = true + T.eq(row.animContext.moveTurn, 1, "GBA bypass uses native release animation branch") + end + end + T.eq(seen, true, "GBA bypass retains ordinary move animation") + end + off() + else + local calls = 0 + local off = f.e.api.hooks:wrap("battle.charge_required", function() + calls = calls + 1 + return false + end) + local b = f.fresh("FLY") + b.use() + T.eq(calls, 0, "DS does not dispatch unavailable seam") + T.eq(b.charging(), true, "DS native charge flow is preserved") + off() + end + f.close() + for _, failing in ipairs({ false, true }) do + local baseline = Fixture.new(".", "gen2", version, mode[1], mode[2], not failing, failing) + if failing then + T.check(#baseline.result.errors > 0, "entry failure is recorded") + else + T.eq(baseline.result.loader.exports.portable_charge_required, nil, "no-mod consumer is absent") + end + for _, link in ipairs(Runtime.hooks.chains["battle.charge_required"] or {}) do + T.check(link.owner ~= "portable_charge_required", "entry rollback removes owner") + end + local call = Runtime.call + Runtime.call = function(name, ...) + assert(name ~= "battle.charge_required", "unsubscribed hot path invoked charge producer") + return call(name, ...) + end + local b = baseline.fresh("FLY") + local pp = b.pp() + b.use() + Runtime.call = call + T.eq(b.charging(), true, "no-mod/rollback restores native continuation") + T.eq(pp - b.pp(), 1, "no-mod/rollback spends native player PP once") + baseline.close() + end + end +end +Version.set(old) +T.finish("native charge-required decisions and public contract") diff --git a/tests/engine/critical_order_contract.lua b/tests/engine/critical_order_contract.lua new file mode 100644 index 000000000..7b9fd6d31 --- /dev/null +++ b/tests/engine/critical_order_contract.lua @@ -0,0 +1,7 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +for _, version in ipairs({ "firered", "emerald" }) do + for _, api in ipairs({ 2, 3 }) do + dofile("tests/critical_order/cases.lua")(".", "gen2", version, api) + end +end +require("tests.modkit").finish("native critical/type loader contracts") 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/gba_damage_base.lua b/tests/engine/gba_damage_base.lua new file mode 100644 index 000000000..e6e0a196d --- /dev/null +++ b/tests/engine/gba_damage_base.lua @@ -0,0 +1,35 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +for _, version in ipairs({ "firered", "emerald" }) do + dofile("tests/gba_damage_base/cases.lua").run("gen2", version) +end +-- Profiles without the GBA opt-in retain their existing arithmetic. +local T = require("tests.modkit") +local D = require("src.battle.Damage") +local rules = require("src.battle.rulesets.gen1_faithful") +for _, generation in ipairs({ 1, 2 }) do + local data = { + constants = { generation = generation }, + items = {}, + type_chart = { types = { NORMAL = { category = "physical" } }, matchups = {} }, + } + require("src.battle.TypeChart").load(data) + local function oldDamage(level, power, attack, defense) + return D.compute( + rules, + { mon = { level = level, hp = 400 }, curStats = { attack = attack }, curTypes = { "NORMAL" } }, + { mon = { hp = 400 }, curStats = { defense = defense }, curTypes = { "NORMAL" } }, + { id = "BASE_RETENTION", power = power, type = "NORMAL" }, + { + forceCrit = false, + data = data, + rng = function() + return 255 + end, + } + ) + end + T.eq(oldDamage(1, 2, 50, 50), 3, "older minimum-base arithmetic retained") + T.eq(oldDamage(30, 35, 300, 50), 94, "older paired stat scaling retained") + T.eq(oldDamage(30, 250, 500, 10), 1498, "older base damage cap retained") +end +T.finish("ordinary GBA base damage and older-profile retention") diff --git a/tests/engine/gen3_ai_item_knowledge.lua b/tests/engine/gen3_ai_item_knowledge.lua new file mode 100644 index 000000000..a88e6e02d --- /dev/null +++ b/tests/engine/gen3_ai_item_knowledge.lua @@ -0,0 +1,39 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +for _, version in ipairs({ "firered", "emerald" }) do + dofile("tests/gen3_ai_item_knowledge/cases.lua").run("gen2", version) +end +local T = require("tests.modkit") +for _, version in ipairs({ "red", "gold", "diamond", "black" }) do + require("src.core.GameVersion").set(version) + local draws = {} + local actor = { name = "TEST", isPlayer = true, mon = {}, confusedTurns = 3 } + local battle = { data = { constants = { generation = require("src.core.GameVersion").generation() } } } + local canMove, _, selfHit = require("src.battle.Status").beforeMove(actor, function(lo, hi) + draws[#draws + 1] = { lo, hi } + return 0 + end, battle) + T.eq(canMove, false, version .. " keeps its existing confusion decision") + T.eq(selfHit, true, version .. " keeps its existing self-hit decision") + T.same(draws, { { 0, 255 } }, version .. " keeps byte-range confusion RNG") + local proto = { data = {} } + setmetatable(proto, { __index = require("src.battle.BattleState") }) + local vanilla = require("src.battle.MoveEffects").RECORDS.OHKO_EFFECT + T.eq(proto:effectRecord("OHKO_EFFECT"), vanilla, version .. " keeps its existing OHKO policy") +end +require("src.core.GameVersion").set("emerald") +do + local vanilla = require("src.battle.MoveEffects").RECORDS.OHKO_EFFECT + local custom = {} + for k, v in pairs(vanilla) do + custom[k] = v + end + custom.gate = function() + return false, "custom" + end + local proto = setmetatable( + { data = { move_effects = { OHKO_EFFECT = custom } } }, + { __index = require("src.battle.BattleState") } + ) + T.eq(proto:effectRecord("OHKO_EFFECT"), custom, "explicit mod OHKO gate is not replaced") +end +require("tests.modkit").finish("native GBA AI item knowledge") diff --git a/tests/engine/gen3_ai_order.lua b/tests/engine/gen3_ai_order.lua new file mode 100644 index 000000000..cacada82f --- /dev/null +++ b/tests/engine/gen3_ai_order.lua @@ -0,0 +1,6 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +for _, version in ipairs({ "firered", "emerald" }) do + dofile("tests/gen3_ai_order/cases.lua").run(".", version) + dofile("tests/gen3_ai_order/planning.lua").run(version) +end +require("tests.modkit").finish("native GBA AI order") diff --git a/tests/engine/gen3_assist.lua b/tests/engine/gen3_assist.lua new file mode 100644 index 000000000..e73455548 --- /dev/null +++ b/tests/engine/gen3_assist.lua @@ -0,0 +1,34 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T, V = require("tests.modkit"), require("src.core.GameVersion") +local before = V.get() +for _, version in ipairs({ "firered", "emerald" }) do + local rows = dofile("tests/enemy_actions/assist.lua").run(".", "gen2", version) + T.eq(#rows, 6720, "all native party, rejection, weighting, callback and PP cases execute") + for _, row in ipairs(rows) do + T.eq(row.events[1].move, "ASSIST", "selected caller announces its public identity") + T.eq(row.events[1].called, false, "selected caller is not a called move") + if row.kind == "zero-caller-pp" then + T.eq(#row.events, 1, "zero PP cannot admit any child") + T.eq(#row.draws, 0, "zero PP cannot choose a party move") + else + for i = 2, #row.events do + T.eq(row.events[i].called, true, "party moves announce native provenance") + T.eq( + row.events[i].pp, + row.kind == "pressure" and 8 or 9, + "Assist spends its own PP before any child event" + ) + end + end + end +end +for _, version in ipairs({ "red", "gold", "diamond", "black" }) do + V.set(version) + T.eq( + require("src.battle.Gen3Builtins").record("ASSIST_EFFECT", nil), + nil, + "other generations gain no GBA Assist binding" + ) +end +V.set(before) +T.finish("native Assist weighted party candidates and execution") diff --git a/tests/engine/gen3_assist_content.lua b/tests/engine/gen3_assist_content.lua new file mode 100644 index 000000000..94e97efc5 --- /dev/null +++ b/tests/engine/gen3_assist_content.lua @@ -0,0 +1,17 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +for _, version in ipairs({ "firered", "emerald" }) do + local rows = dofile("tests/enemy_actions/assist.lua").content(".", "gen2", version) + T.eq(#rows, 6840, "same API-3 registered Assist includes registered bench moves") + for _, row in ipairs(rows) do + T.eq(row.events[1].move, "PORTABLE_ASSIST", "registered caller retains its public identity") + if row.case == "custom-bench" and row.kind ~= "zero-caller-pp" and row.kind ~= "parent-bench-change" then + T.eq( + row.events[2].move, + "PORTABLE_STRIKE", + "custom party move executes through its registered canonical effect" + ) + end + end +end +T.finish("API-3 Assist and registered party moves") diff --git a/tests/engine/gen3_assist_failed_callers.lua b/tests/engine/gen3_assist_failed_callers.lua new file mode 100644 index 000000000..5558bd482 --- /dev/null +++ b/tests/engine/gen3_assist_failed_callers.lua @@ -0,0 +1,19 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +for _, version in ipairs({ "firered", "emerald" }) do + local rows = dofile("tests/enemy_actions/assist.lua").failedCallers(".", "gen2", version) + T.eq(#rows, 36, "all positions and previous histories execute failed native children") + for _, row in ipairs(rows) do + T.eq(row.last, row.previous, "failed child preserves previous native move history") + T.eq(row.pp, 9, "only the selected outer Metronome spends PP") + T.eq(#row.events, 2, "both actual caller announcements remain public") + T.eq(row.events[1].move, "METRONOME", "outer announcement remains native") + T.eq( + row.events[2].move, + row.child == 274 and "ASSIST" or "MIRROR_MOVE", + "selected failed child announces" + ) + T.eq(#row.damage, 0, "failed child does no damage") + end +end +T.finish("native failed Assist and Mirror Move preserve caller history") diff --git a/tests/engine/gen3_assist_link.lua b/tests/engine/gen3_assist_link.lua new file mode 100644 index 000000000..b55e949a0 --- /dev/null +++ b/tests/engine/gen3_assist_link.lua @@ -0,0 +1,145 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T, Copy = require("tests.modkit"), require("src.mods.Merge").deepCopy +for _, version in ipairs({ "firered", "emerald" }) do + local f = dofile("tests/enemy_actions/assist.lua").fixture(".", "gen2", version) + local H = require("src.link.Handshake") + local current = H.hello(f.game, "battle") + local old = Copy(current) + old.nativeBattleRevision = 14 + T.check(current.nativeBattleRevision >= 15, "Assist commands advertise at least revision fifteen") + T.eq(H.checkCompat(current, old), "subset", "old command order cannot admit lockstep") + for _, host in ipairs({ false, true }) do + local state = setmetatable( + { game = f.game, myHello = current, peerHello = old }, + { __index = require("src.link.LinkState") } + ) + state:decideCompat("battle", host) + T.eq(state.stage, "notice", "old ordering is refused before party exchange") + end + T.link.prepare(f.data) + local ga = T.link.fakeGame(f.data, "CHARMANDER", { name = "HOST", level = 30 }) + local gb = T.link.fakeGame(f.data, "CHARMANDER", { name = "GUEST", level = 30 }) + local gs = T.link.fakeGame(f.data, "CHARMANDER", { name = "VIEWER", level = 30 }) + for i, game in ipairs({ ga, gb, gs }) do + game.save.version, game.save.options.ruleset = version, "gen3_emerald" + game.save.party[1].nickname = i == 1 and "HOST" or "GUEST" + game.save.party[1].moves = { { id = "MOVE_274", pp = 10 } } + game.save.party[2] = Copy(game.save.party[1]) + game.save.party[2].nickname = "BENCH" + game.save.party[2].moves = { { id = i == 1 and "MOVE_033" or "MOVE_267", pp = 10 } } + game.save.party[1].item = i == 1 and "ITEM_186" or "ITEM_219" + end + local seen = setmetatable({}, { __mode = "k" }) + f.e.api.events:on("battle.move_used", function(ctx) + local trace = seen[ctx.battle] or {} + seen[ctx.battle] = trace + local slot = ctx.user.mon.moves[1] + trace[#trace + 1] = { + user = ctx.user.mon.nickname, + target = ctx.target.mon.nickname, + move = ctx.move.id, + pp = slot.pp, + called = ctx.isCalled or false, + } + end) + f.e.api.hooks:wrap("held_item.trigger", function(nextRead, ctx) + if ctx.trigger == "damage" and ctx.mon.nickname == "HOST" then + local sum = 0 + for _, event in ipairs(seen[ctx.battle] or {}) do + sum = sum + event.pp + end + return "HELD_NORMAL_BOOST", 10 + sum % 3 + end + return nextRead(ctx) + end) + f.e.api.hooks:wrap("battle.crit", function() + return false + end) + local P, N, L = require("src.link.Protocol"), require("src.link.Net"), require("src.link.LinkBattle") + local na, nb = N.loopbackPair() + local inbox = {} + local sn = { + update = function() end, + poll = function() + local got = inbox + inbox = {} + return got + end, + } + for i, net in ipairs({ na, nb }) do + local send, side = net.send, i == 1 and "host" or "guest" + net.send = function(self, packet) + inbox[#inbox + 1] = { type = "spectate", side = side, msg = Copy(packet) } + return send(self, packet) + end + end + local pa, pb = P.packParty(ga.save.party), P.packParty(gb.save.party) + local a = assert( + L.newHost( + ga, + na, + { myParty = pa, theirParty = pb, theirName = "GUEST", seed = 327, verdict = "full", strict = true } + ) + ) + local b = assert( + L.newGuest( + gb, + nb, + { myParty = pb, theirParty = pa, theirName = "HOST", seed = 327, verdict = "full", strict = true } + ) + ) + local s = assert(L.newSpectator(gs, sn, { + hostParty = pa, + guestParty = pb, + hostName = "HOST", + guestName = "GUEST", + seed = 327, + verdict = "full", + strict = true, + })) + ga.stack:push(a) + gb.stack:push(b) + gs.stack:push(s) + for _, battle in ipairs({ a, b, s }) do + T.eq(battle.ruleset.baseDamageStyle, "gba", "actual linked role uses native GBA rules") + battle.terrain = 2 + for _, actor in ipairs({ battle.player, battle.enemy }) do + actor.mon.hp, actor.mon.stats.hp = 1000, 1000 + end + end + for _ = 1, 40000 do + for i, game in ipairs({ ga, gb, gs }) do + local battle = ({ a, b, s })[i] + require("src.core.Input").pressed = (i == 3 or not battle.localHashes[2]) and { a = true } or {} + game.stack:update(1 / 60) + end + if a.localHashes[2] and b.localHashes[2] and s.turnCount == 2 and s.phase == "waitBoth" then + break + end + end + for turn = 1, 2 do + T.check(a.localHashes[turn] ~= nil and b.localHashes[turn] ~= nil, "both native menu turns complete") + T.eq(a.localHashes[turn], b.localHashes[turn], "event mutations preserve paired completed hashes") + T.same(a.localParts[turn], b.localParts[turn], "all active and bench state components agree") + end + T.same(seen[a], seen[b], "host and guest see all events with the same PP") + T.same(seen[a], seen[s], "spectator sees every event with the same PP") + for _, battle in ipairs({ a, b, s }) do + T.eq(#(seen[battle] or {}), 10, "two turns include ordinary and nested party calls") + for i, event in ipairs(seen[battle] or {}) do + T.eq( + event.pp, + (i <= 5 and 10 or 9) - (event.called and 1 or 0), + "only parent announcement precedes the Assist PP command" + ) + end + T.eq(battle.player.mon.moves[1].pp, 8, "each current PP command runs once") + T.eq(battle.enemy.mon.moves[1].pp, 8, "each remote PP command runs once") + T.eq(battle.result, nil, "no hidden linked simulation failure") + end + T.eq(s.player.mon.hp, a.player.mon.hp, "spectator host HP agrees") + T.eq(s.enemy.mon.hp, a.enemy.mon.hp, "spectator guest HP agrees") + T.eq(#f.result.errors, 0, "public event callback has no swallowed errors") + f.close() +end +T.finish("native Assist party calls, events and PP in actual linked roles") diff --git a/tests/engine/gen3_audio_cold.lua b/tests/engine/gen3_audio_cold.lua new file mode 100644 index 000000000..9891bf4c4 --- /dev/null +++ b/tests/engine/gen3_audio_cold.lua @@ -0,0 +1,145 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local oldAudio, oldSound = love.audio, love.sound.newSoundData +love.audio = { + newSource = function(input) + return { input = input } + end, +} +local hardware = dofile("tests/native_bgm/sounddata.lua").install() +local sd = love.sound.newSoundData(2, 22050, 16, 2) +T.eq(type(sd), "userdata", "hardware shape reaches the production native userdata admission") +sd:setSample(0, 1, -0.5) +sd:setSample(0, 2, 0.75) +T.eq(sd:getSample(0, 1), -0.5, "userdata keeps the rendered left sample") +T.eq(sd:getSample(0, 2), 0.75, "userdata keeps the rendered right sample") +T.eq(sd:getSampleCount(), 2, "userdata preserves frame count") +T.eq(sd:getSampleRate(), 22050, "userdata preserves sample rate") +T.eq(sd:getBitDepth(), 16, "userdata preserves bit depth") +T.eq(sd:getChannelCount(), 2, "userdata preserves channel count") +local src = love.audio.newSource(sd, "static") +T.eq(src.input:getSample(0, 2), 0.75, "CPU Source traces the actual backing samples") +T.eq(hardware.created, 1, "construction is counted without creating a real audio device") +T.eq(hardware.staticInputs, 1, "static sources receive userdata before tracing") +hardware.close() +T.eq(love.sound.newSoundData, oldSound, "hardware fixture restores the SoundData constructor") +love.audio = oldAudio +local V, Music = require("src.core.GameVersion"), require("src.core.Music") +local versionBefore = V.get() +for _, version in ipairs({ "firered", "emerald" }) do + local f = dofile("tests/enemy_actions/assist.lua").fixture(".", "gen2", version) + local h = dofile("tests/native_bgm/streams.lua").install(".", "gen2", version, f) + local ids = h.fanfares() + T.eq(h.soundData.staticInputs, 0, "cold definitions do not render or create static Sources") + local function start() + return h.start({ { "playfanfare", ids[1] }, { "waitfanfare" }, { "end" } }) + end + local runner = start() + T.eq(h.soundData.staticInputs, 1, "first public cue renders cold userdata PCM") + T.eq(runner:isRunning(), true, "public native wait blocks on the real fanfare clock") + local samples, audible = 0, false + for _, source in ipairs(h.trace().files) do + for _, frame in ipairs(source.samples or {}) do + samples = samples + #frame + for _, value in ipairs(frame) do + audible = audible or value ~= 0 + end + end + end + T.check(samples > 0 and audible, "cold renderer emits real audible PCM in the full trace") + for _ = 1, 4 do + h.update() + runner:update() + end + T.eq(runner:isRunning(), false, "cold public wait finishes after four audio ticks") + start() + T.eq(h.soundData.staticInputs, 1, "same-session replay uses the native static source cache") + Music.reload() + runner = start() + T.eq(h.soundData.staticInputs, 2, "reload constructs a new cold Source instead of reusing the old session") + T.eq(h.fanfareState(ids).se.first, true, "fresh cold replay owns the real native SE player") + local static = h.soundData.sources[#h.soundData.sources] + local play, playing = static.play, static.isPlaying + static.play, static.isPlaying = + function() + error("owned invalid static source") + end, function() + error("owned invalid static source") + end + T.eq(pcall(start), true, "an invalid cached static source cannot crash a native script") + T.eq(h.fanfareState(ids).se.first, false, "a refusing static source cannot report audible SE activity") + static.play, static.isPlaying = play, playing + start() + T.eq(h.soundData.staticInputs, 2, "a transient static failure does not poison its same-session cache") + T.eq(h.fanfareState(ids).se.first, true, "the cached static source can recover on the next real cue") + Music.stop() + h.start({ { "playbgm", h.ids[1], 0 }, { "end" } }) + h.update() + local restored = h.invalidateQueues() + T.eq(pcall(h.update), true, "a live source becoming invalid cannot crash the next audio update") + T.eq( + pcall(h.start, { { "fadeoutbgm", 4 }, { "end" } }), + true, + "world commands remain callable while the output handle refuses every audio method" + ) + local platform = require("src.core.Platform").detect() + local mobile = platform.mobile + platform.mobile = true + local game = setmetatable({ data = f.data }, { __index = require("src.core.Game") }) + T.eq( + pcall(game.audioSession, game, false, "owned invalid device"), + true, + "actual Game can suspend invalid handles" + ) + h.queueDevice("nil") + T.eq( + pcall(game.audioSession, game, true, "owned invalid device"), + true, + "foreground handles an unavailable replacement device" + ) + restored() + h.queueDevice(nil) + for _ = 1, 60 do + h.update() + end + T.eq(Music.current(), ("SONG_%03X"):format(h.ids[1]), "foreground retry uses the current Game dataset") + local queues = h.state().queues + T.eq(queues[#queues].playing, true, "foreground retry creates a healthy native stream") + for index = 1, #queues - 1 do + T.eq(queues[index].playing, false, "old-session native handles cannot resume behind the replacement") + end + platform.mobile = mobile + local ending = h.start({ + { "playbgm", h.ids[2], 1 }, + { "savebgm", h.ids[3] }, + { "playfanfare", ids[1] }, + { "waitfanfare" }, + { "end" }, + }) + local endedWait = assert(Music.nativeWaitFanfare()) + T.eq(Music.nativeSnapshot().savedSong, h.ids[3], "ending session owns a saved native song") + T.eq(ending:isRunning(), true, "ending session has a live native audio wait") + -- The actual launcher boot and mod reload use this bus; do not call + -- Music.reload ourselves and hide a missing production registration. + require("src.render.Assets").invalidate() + T.eq(Music.nativeSnapshot().song, nil, "asset-entry invalidation retires the native song") + T.eq(Music.nativeSnapshot().mapSong, nil, "asset-entry invalidation retires native map ownership") + T.eq(Music.nativeSnapshot().savedSong, nil, "asset-entry invalidation retires the old saved song") + T.eq(endedWait(), true, "asset-entry invalidation releases the previous session's native wait") + ending:update() + T.eq(ending:isRunning(), false, "the previous session's actual VM finishes before fresh entry") + local retired = h.state() + local sourcesStopped, queuesStopped = true, true + for _, voice in ipairs(retired.native.voices) do + sourcesStopped = sourcesStopped and not voice.playing + end + for _, queue in ipairs(retired.queues) do + queuesStopped = queuesStopped and not queue.playing + end + T.eq(sourcesStopped, true, "asset-entry invalidation stops every old static source") + T.eq(queuesStopped, true, "asset-entry invalidation stops every old streaming source") + h.close() + f.close() +end +V.set(versionBefore) +T.finish("native cold userdata PCM, cache admission and session reload") diff --git a/tests/engine/gen3_audio_options.lua b/tests/engine/gen3_audio_options.lua new file mode 100644 index 000000000..579a3e043 --- /dev/null +++ b/tests/engine/gen3_audio_options.lua @@ -0,0 +1,134 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local V, Music = require("src.core.GameVersion"), require("src.core.Music") +local before = V.get() +local function fileVolume(frame) + for _, source in ipairs(frame.state.voices) do + if source.playing and source.file then + return source.volume + end + end +end +for _, version in ipairs({ "firered", "emerald" }) do + local rows = dofile("tests/enemy_actions/native_bgm_options.lua").run(".", "gen2", version) + T.eq(#rows, 13, "all audio mode, missing-option and reentrant scenarios execute") + local byName = {} + for _, row in ipairs(rows) do + byName[row.case] = row + for _, event in ipairs(row.mix) do + T.eq(type(event.value), "boolean", "platform mixing policy remains boolean") + end + end + local modes = byName["file-modes"].frames + T.eq(fileVolume(modes[2]), 0, "external audio mode mutes the actual shared file") + T.eq(fileVolume(modes[4]), 0.5, "game-only mode restores the newly requested file level") + T.eq(fileVolume(modes[11]), 0.6, "both mode restores the current requested file level") + T.eq( + fileVolume(byName["file-preserve-mode"].frames[4]), + 0, + "an omitted generic mode retains the existing shared mute policy" + ) + local mix = {} + for _, event in ipairs(byName["file-preserve-game"].mix) do + mix[#mix + 1] = event.value + end + T.same( + mix, + { true, false, true, false, true }, + "native and shared mode defaults retain the reference's distinct platform policy calls" + ) + local missing = byName["file-missing"].frames + T.eq(fileVolume(missing[4]), 0.3, "an omitted shared level retains its previous trim") + T.eq(missing[5].state.fadeOut.start, 1, "an omitted native level defaults independently to full volume") + T.eq(byName["file-reentrant-native"].entered, true, "a real native script is entered from the option hook") + T.eq( + byName["file-reentrant-native"].frames[3].state.fadeOut.start, + 3 / 7, + "a native fade begun inside the old-level callback captures the new native master" + ) + T.eq( + fileVolume(byName["file-reentrant-shared"].frames[3]), + 0.3, + "a shared cue begun inside the hook receives the new bridge level" + ) + local fanfare = byName["file-reentrant-fanfare"].frames[3].state.voices + T.eq(fanfare[#fanfare].volume, 3 / 7, "a reentrant native fanfare receives the new native master") + fanfare = byName["native-muted-fanfare"].frames[3].state.voices + T.eq(fanfare[#fanfare].volume, 0, "external-only mode also mutes the native fanfare source") + T.eq( + fileVolume(byName["file-early-options"].frames[1]), + 0, + "newly registered mod audio receives options before its first cue" + ) + T.eq( + fileVolume(byName["native-registered"].frames[5]), + 0.5, + "registered shared options remain live while the native carrier owns playback" + ) + for _, frame in ipairs(byName["native-modes"].frames) do + if frame.tick == 1 or frame.tick == 8 then + T.eq(frame.nativeActive, 1, "muting retains an actual native carrier") + T.eq(frame.nativeMuted, true, "external-only mode writes zero gain to that carrier") + end + end + local f = dofile("tests/enemy_actions/assist.lua").fixture(".", "gen2", version) + local h = dofile("tests/native_bgm/streams.lua").install(".", "gen2", version, f) + local fresh = {} + for key, value in pairs(f.data) do + fresh[key] = value + end + fresh.audio = { songs = { MUS_CYCLING = { file = "owned-fresh-options.ogg" } } } + local savedData = f.game.data + f.game.data = fresh + local Game = require("src.core.Game") + local game = setmetatable({ data = fresh }, { __index = Game }) + game:applyOptions({ musicVol = 3, audioMode = "external_only" }) + h.start({ { "playbgm", h.ids[2], 0 }, { "end" } }) + local voices = h.state().native.voices + T.eq(voices[#voices].volume, 0, "actual Game options bind its fresh dataset before the next world cue") + game:applyOptions({ musicVol = 3, audioMode = "game_only" }) + T.eq(voices[#voices].playing, true, "changing options preserves shared source ownership") + T.eq(h.state().native.voices[#voices].volume, 0.3, "the same Game can restore its current shared source") + h.close() + f.game.data = savedData + f.close() +end +-- Native options do not rewrite the independent generic player's trim when +-- no shared mod audio is installed. This also protects older game profiles. +local f = dofile("tests/enemy_actions/assist.lua").fixture(".", "gen2", "firered") +local h = dofile("tests/native_bgm/streams.lua").install(".", "gen2", "firered", f) +Music.setAudioMode("both") +Music.setVolumeLevel(7) +Music.applyOptions({ musicVol = 3, audioMode = "external_only" }, { audio = { songs = {} } }) +Music.stop() +V.set("red") +Music.play({ audio = { songs = { OWNED_LEGACY = { file = "owned-legacy-options.ogg" } } } }, "OWNED_LEGACY") +local voices = h.state().native.voices +T.eq(voices[#voices].volume, 0.7, "native-only mute/level options cannot overwrite the generic legacy trim") +V.set("firered") +h.close() +f.close() +local mixer = require("src.audio.AudioMix") +local oldAudio = love.audio +mixer._resetForTest() +love.audio = nil +T.eq(mixer.set(true), false, "a missing platform mixing API is a safe no-op") +T.eq(mixer.current(), nil, "a missing API cannot cache an unapplied policy") +love.audio = { + setMixWithSystem = function() + error("owned platform unavailable") + end, +} +mixer.set(true) +T.eq(mixer.current(), nil, "a throwing platform does not poison a later retry") +local changes = {} +love.audio.setMixWithSystem = function(value) + changes[#changes + 1] = value +end +T.eq(mixer.set(true), true, "a recovered platform can apply the policy") +T.eq(mixer.set(true), false, "repeated policies do not repeat hardware calls") +T.eq(mixer.set(false), true, "a distinct policy reaches the backend") +T.same(changes, { true, false }, "only successful policy transitions are cached") +love.audio = oldAudio +V.set(before) +T.finish("native audio options, shared trims, reentrant hooks and platform policy") diff --git a/tests/engine/gen3_audio_recovery.lua b/tests/engine/gen3_audio_recovery.lua new file mode 100644 index 000000000..cc265558e --- /dev/null +++ b/tests/engine/gen3_audio_recovery.lua @@ -0,0 +1,147 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local Music, V = require("src.core.Music"), require("src.core.GameVersion") +local before = V.get() +for _, version in ipairs({ "firered", "emerald" }) do + local f = dofile("tests/enemy_actions/assist.lua").fixture(".", "gen2", version) + for _, mode in ipairs({ "missing", "nil", "error" }) do + local h = dofile("tests/native_bgm/streams.lua").install(".", "gen2", version, f, { queueFailure = mode }) + T.eq(Music.nativeSnapshot().song, h.ids[1], "missing hardware preserves the initial native song identity") + T.eq(#h.state().queues, 0, "missing hardware cannot invent a live native source") + h.queueDevice(nil) + h.start({ { "playbgm", h.ids[2], 0 }, { "end" } }) + h.update() + T.eq(#h.state().queues, 1, "a new native cue recovers when the device becomes available") + h.start({ { "playbgm", h.ids[1], 0 }, { "end" } }) + h.update() + T.eq( + Music.current(), + ("SONG_%03X"):format(h.ids[1]), + "transient failure cannot poison a later native replay" + ) + T.eq( + h.state().queues[#h.state().queues].playing, + true, + "recovered native replay reaches an actual source" + ) + h.close() + end + for _, mode in ipairs({ "missing", "nil", "error" }) do + local h = dofile("tests/native_bgm/streams.lua").install( + ".", + "gen2", + version, + f, + { initialShared = true, queueFailure = mode } + ) + T.eq(Music.current(), "MUS_TITLE", "the incoming failure scenario starts with actual shared file music") + f.data.audio.songs.OWNED_BROKEN_SHARED = {} + Music.play(f.data, "OWNED_BROKEN_SHARED") + T.eq(Music.current(), "MUS_TITLE", "a bad shared mod definition retains its existing playing song") + h.start({ { "playbgm", h.ids[2], 0 }, { "end" } }) + T.eq(Music.nativeSnapshot().song, h.ids[2], "a failed native replacement retains its new logical song") + T.eq(Music.current(), nil, "a native replacement releases the previous shared physical label") + for _ = 1, 4 do + h.update() + end + for _, source in ipairs(h.state().native.voices) do + T.eq(source.playing, false, "failed incoming native output cannot leave shared file audio sounding") + end + T.eq(#h.state().queues, 0, "failed incoming output never invents a native queue") + h.queueDevice(nil) + h.start({ { "playbgm", h.ids[3], 0 }, { "end" } }) + h.update() + T.eq( + Music.current(), + ("SONG_%03X"):format(h.ids[3]), + "a later native cue recovers after shared replacement failed" + ) + h.close() + end + local h = dofile("tests/native_bgm/streams.lua").install(".", "gen2", version, f) + local ff = h.fanfares() + h.start({ { "playfanfare", ff[1] }, { "end" } }) + local wait = assert(Music.nativeWaitFanfare()) + Music.suspend() + Music.resume(f.data) + T.eq(wait(), true, "a suspended native fanfare cannot re-block its old wait after resume") + h.start({ { "playfanfare", ff[1] }, { "end" } }) + Music.stop() + T.eq(Music.nativeFanfareFinished(), true, "external music stop cancels native fanfare ownership") + T.eq( + require("src.core.Sound").isSePlaying(f.data, ff[1]), + false, + "external stop cannot leave the native fanfare sounding" + ) + h.close() + for _, mode in ipairs({ "nil", "error" }) do + h = dofile("tests/native_bgm/streams.lua").install(".", "gen2", version, f) + ff = h.fanfares() + local builder, sources, unavailable = love.audio.newSource, {}, true + love.audio.newSource = function(...) + if unavailable then + if mode == "error" then + error("owned static audio device unavailable") + end + return nil + end + local source = builder(...) + sources[#sources + 1] = source + return source + end + h.start({ { "playfanfare", ff[1] }, { "end" } }) + T.eq(Music.nativeFanfareFinished(), false, "a missing static source retains the native fanfare clock") + T.eq( + require("src.core.Sound").isSePlaying(f.data, ff[1]), + false, + "a failed static source never owns an SE player" + ) + unavailable = false + h.start({ { "playfanfare", ff[1] }, { "end" } }) + T.eq(#sources, 1, "a failed cold fanfare can build its source on a later cue") + T.eq( + require("src.core.Sound").isSePlaying(f.data, ff[1]), + true, + "the recovered cold fanfare owns its SE player" + ) + local stoppedWait = assert(Music.nativeWaitFanfare()) + Music.stop() + h.start({ { "playfanfare", ff[1] }, { "end" } }) + T.eq(stoppedWait(), true, "an externally cancelled wait stays complete during a later fanfare") + T.eq(#sources, 2, "external stop discards sources owned by the ended session") + Music.reload() + T.eq(sources[#sources]:isPlaying(), false, "reload stops the previous session's fanfare source") + h.start({ { "playfanfare", ff[1] }, { "end" } }) + T.eq(#sources, 3, "reload cannot reuse a stale native fanfare source") + love.audio.newSource = builder + h.close() + end + h = dofile("tests/native_bgm/streams.lua").install(".", "gen2", version, f) + ff = h.fanfares() + local Game, platform = require("src.core.Game"), require("src.core.Platform").detect() + local oldMobile = platform.mobile + platform.mobile = true + local game = setmetatable({ data = f.data }, { __index = Game }) + h.start({ { "playfanfare", ff[1] }, { "end" } }) + local suspendedWait = assert(Music.nativeWaitFanfare()) + game:audioSession(false, "owned test interruption") + h.queueDevice("nil") + game:audioSession(true, "owned test interruption") + T.eq(Music.nativeFanfareFinished(), true, "a failed foreground re-cue cannot revive the old fanfare clock") + T.eq(suspendedWait(), true, "a failed foreground re-cue releases the old native wait") + h.queueDevice(nil) + for _ = 1, 60 do + h.update() + end + T.eq( + Music.current(), + ("SONG_%03X"):format(h.ids[1]), + "actual Game foreground retry uses its own imported dataset" + ) + T.eq(h.state().queues[#h.state().queues].playing, true, "the foreground retry reaches a live native source") + platform.mobile = oldMobile + h.close() + f.close() +end +V.set(before) +T.finish("native audio missing-device recovery, replay and session ownership") diff --git a/tests/engine/gen3_baton_pass.lua b/tests/engine/gen3_baton_pass.lua new file mode 100644 index 000000000..50161a543 --- /dev/null +++ b/tests/engine/gen3_baton_pass.lua @@ -0,0 +1,5 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +for _, v in ipairs({ "firered", "emerald" }) do + require("tests.enemy_actions.baton_pass").run(".", "gen2", v) +end +require("tests.modkit").finish("native Baton Pass command and continuation") diff --git a/tests/engine/gen3_baton_pass_composition.lua b/tests/engine/gen3_baton_pass_composition.lua new file mode 100644 index 000000000..b6a528e77 --- /dev/null +++ b/tests/engine/gen3_baton_pass_composition.lua @@ -0,0 +1,74 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T, Copy = require("tests.modkit"), require("src.mods.Merge").deepCopy +local B = require("src.battle.Gen3BatonPass") +for _, version in ipairs({ "firered", "emerald" }) do + for _, mode in ipairs({ "override", "delegate", "called" }) do + local f = require("tests.enemy_actions.baton_pass").fixture(".", "gen2", version) + local c = f.fresh("TACKLE") + local b, donor = c.battle, c.user + b:syncSides() + b.game.save.party[2] = Copy(donor.mon) + b.game.save.party[2].nickname = "BENCH" + donor.mon.moves = { { id = f.ids.BATON_PASS, pp = 20 }, { id = f.ids.TACKLE, pp = 10 } } + donor.curMoves = donor.mon.moves + donor.stages.defense = 2 + local calls, phases = 0, {} + local original = b.holdItemTrigger + b.holdItemTrigger = function(self, who, phase) + phases[#phases + 1] = phase + return original(self, who, phase) + end + local record = Copy(b.data.move_effects.BATON_PASS_EFFECT) + if mode == "override" then + record.run = function() + calls = calls + 1 + return {} + end + b.data.move_effects.BATON_PASS_EFFECT = record + elseif mode == "delegate" then + local builtin = record.run + record.run = function(ctx) + calls = calls + 1 + return builtin(ctx) + end + b.data.move_effects.BATON_PASS_EFFECT = record + else + b.data.move_effects.CALL_BATON = { + kind = "full", + callsMove = function() + calls = calls + 1 + return f.ids.BATON_PASS + end, + } + b.data.moves[f.ids.TACKLE].effect = "CALL_BATON" + end + local action = donor.curMoves[mode == "called" and 2 or 1] + b:performMove(donor, donor, action) + T.eq(calls, 1, "authored effect is called exactly once") + if mode == "override" then + T.eq(b.player, donor, "authored override is not replaced by native behavior") + T.eq(b._gen3BatonPending, nil, "authored override does not open an unwanted picker") + else + local pending = b._gen3BatonPending + T.check(pending ~= nil, "delegated or called native command pauses for selection") + T.eq(#phases, 0, "native item phases do not run before nested choice completion") + T.check(B.select(b, assert(pending), 2), "nested selection accepts the intended replacement") + B.await(b, pending) + T.eq(b.player.mon, b.game.save.party[2], "native continuation switches after authored composition") + T.eq(b.player.stages.defense, 2, "authored composition retains native transfer") + T.eq( + table.concat(phases, ","), + "entry,moveEnd,moveEnd,action,action", + "nested completion runs each native item phase once" + ) + T.eq( + donor.mon.moves[1].pp, + mode == "called" and 20 or 19, + "composition preserves called-move PP semantics" + ) + T.eq(donor.mon.moves[2].pp, mode == "called" and 9 or 10, "only the calling move spends its own PP") + end + f.close() + end +end +T.finish("native Baton Pass authored effects and called-move scheduling") diff --git a/tests/engine/gen3_baton_pass_link.lua b/tests/engine/gen3_baton_pass_link.lua new file mode 100644 index 000000000..0ca811e3d --- /dev/null +++ b/tests/engine/gen3_baton_pass_link.lua @@ -0,0 +1,170 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local Copy = require("src.mods.Merge").deepCopy +local Input = require("src.core.Input") +for _, version in ipairs({ "firered", "emerald" }) do + local f = require("tests.enemy_actions.baton_pass").fixture(".", "gen2", version) + local def = f.data.pokemon.CHARMANDER + def.evYield = {} + def.baseStats.spatk, def.baseStats.spdef = 50, 50 + T.link.prepare(f.data) + local games = {} + for i, name in ipairs({ "HOST", "GUEST", "VIEWER" }) do + local game = T.link.fakeGame(f.data, "CHARMANDER", { name = name, level = 30 }) + game.stack.states = game.stack.list + games[i] = game + local mon = game.save.party[1] + mon.nickname = name:sub(1, 1) .. "_ALPHA" + mon.moves = + { { id = f.ids.BATON_PASS, pp = 20 }, { id = f.ids.TACKLE, pp = f.data.moves[f.ids.TACKLE].pp } } + for j = 2, 3 do + game.save.party[j] = Copy(mon) + game.save.party[j].nickname = name:sub(1, 1) .. (j == 2 and "_BETA" or "_GAMMA") + game.save.party[j].moves = { { id = f.ids.TACKLE, pp = f.data.moves[f.ids.TACKLE].pp } } + end + end + local Net, P, Link = require("src.link.Net"), require("src.link.Protocol"), require("src.link.LinkBattle") + local na, nb = Net.loopbackPair() + local inbox, packets = {}, {} + local sn = { + update = function() end, + poll = function() + local out = inbox + inbox = {} + return out + end, + } + for i, net in ipairs({ na, nb }) do + local send, side = net.send, i == 1 and "host" or "guest" + net.send = function(self, packet) + if packet.type == "baton_pass" then + packets[#packets + 1] = Copy(packet) + end + inbox[#inbox + 1] = { type = "spectate", side = side, msg = Copy(packet) } + return send(self, packet) + end + end + local pa, pb = P.packParty(games[1].save.party), P.packParty(games[2].save.party) + local a = assert( + Link.newHost( + games[1], + na, + { myParty = pa, theirParty = pb, theirName = "GUEST", seed = 327, verdict = "full", strict = true } + ) + ) + local b = assert( + Link.newGuest( + games[2], + nb, + { myParty = pb, theirParty = pa, theirName = "HOST", seed = 327, verdict = "full", strict = true } + ) + ) + local s = assert(Link.newSpectator(games[3], sn, { + hostParty = pa, + guestParty = pb, + hostName = "HOST", + guestName = "GUEST", + seed = 327, + verdict = "full", + strict = true, + })) + local battles, chosen, intentId = { a, b, s }, {}, 0 + for i, battle in ipairs(battles) do + games[i].stack:push(battle) + battle.player.stages.attack, battle.enemy.stages.attack = 2, 2 + end + local seen = {} + for frame = 1, 30000 do + for i, game in ipairs(games) do + f.result.loader.game = game + local top = game.stack:top() + if top ~= battles[i] then + local view = f.e.api.ui:snapshot() + if view and view.kind == "party" then + T.check(i < 3, "spectator never opens a personal party chooser") + intentId = intentId + 1 + local ok, why = + f.e.api.ui:submit({ id = intentId, revision = view.revision, kind = "select", index = 3 }) + assert(ok, why) + chosen[i] = (chosen[i] or 0) + 1 + end + end + Input.pressed = { a = true } + game.stack:update(1 / 60) + end + if a.localHashes[1] and b.localHashes[1] and not seen[1] then + seen[1] = true + for i, battle in ipairs(battles) do + T.eq( + battle.player.mon.nickname, + (i == 2 and "G" or "H") .. "_GAMMA", + "selected non-first local replacement survives the complete turn" + ) + T.eq( + battle.enemy.mon.nickname, + (i == 2 and "H" or "G") .. "_GAMMA", + "selected remote replacement survives the complete turn" + ) + T.eq(battle.player.stages.attack, 2, "local stages pass through wire continuation") + T.eq(battle.enemy.stages.attack, 2, "remote stages pass through wire continuation") + end + end + if a.localHashes[2] and b.localHashes[2] then + break + end + end + T.check(seen[1], "both native peers complete the Baton Pass turn") + T.eq(chosen[1], 1, "host makes exactly one real UI selection") + T.eq(chosen[2], 1, "guest makes exactly one real UI selection") + T.eq(chosen[3], nil, "spectator consumes transmitted choices") + T.eq(#packets, 2, "both owners send their selected replacement") + for _, packet in ipairs(packets) do + T.eq(packet.index, 3, "wire retains the non-first selection") + end + for turn = 1, 2 do + T.check(a.localHashes[turn] ~= nil and b.localHashes[turn] ~= nil, "full linked turn completes") + T.eq(a.localHashes[turn], b.localHashes[turn], "paired full turn hashes agree") + T.same(a.localParts[turn], b.localParts[turn], "all native state components agree") + end + T.eq(a.result, nil, "host does not silently terminate on a choice mismatch") + T.eq(b.result, nil, "guest does not silently terminate on a choice mismatch") + T.eq(s.result, nil, "spectator continues after both real choices") + T.eq(games[1].save.party[1].moves[1].pp, 20, "link choices do not mutate the persistent save party") + -- A disconnect must still be polled while the native party screen owns input. + local game = T.link.fakeGame(f.data, "CHARMANDER", { name = "DROP", level = 30 }) + game.save.party = Copy(games[1].save.party) + game.stack.states = game.stack.list + local nc, nd = Net.loopbackPair() + local dropped = assert( + Link.newHost( + game, + nc, + { myParty = pa, theirParty = pb, theirName = "GUEST", seed = 327, verdict = "full", strict = true } + ) + ) + game.stack:push(dropped) + dropped.player.curStats.speed, dropped.enemy.curStats.speed = 200, 100 + nd:send({ type = "action", kind = "move", slot = 1 }) + local picker + f.result.loader.game = game + for _ = 1, 15000 do + local view = f.e.api.ui:snapshot() + if view and view.kind == "party" then + picker = game.stack:top() + break + end + Input.pressed = { a = true } + game.stack:update(1 / 60) + end + T.check(picker ~= nil, "real linked picker is active before disconnect") + assert(picker) + nc.closed = true + Input.pressed = {} + game.stack:update(1 / 60) + T.eq(dropped.result, "draw", "link loss is handled while picker owns the stack") + T.eq(game.stack:top(), dropped, "disconnect retires the native picker") + T.eq(dropped._gen3BatonPending, nil, "disconnect clears the pending continuation") + T.eq(dropped.player.mon.nickname, "H_ALPHA", "disconnect never silently selects the first bench") + f.close() +end +T.finish("native Baton Pass link choices and spectator continuation") diff --git a/tests/engine/gen3_baton_pass_packets.lua b/tests/engine/gen3_baton_pass_packets.lua new file mode 100644 index 000000000..f386016ad --- /dev/null +++ b/tests/engine/gen3_baton_pass_packets.lua @@ -0,0 +1,115 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local Copy = require("src.mods.Merge").deepCopy +local C, B = require("src.link.Gen3BatonChoice"), require("src.battle.Gen3BatonPass") +for _, stat in ipairs({ "spatk", "spdef" }) do + local changed = { stages = { [stat] = 2 } } + T.neq(B.signature(changed), B.signature({}), "native active hashes include transferred " .. stat) +end +T.eq( + B.signature({ leechSeedSource = { position = 0 } }, "host"), + B.signature({ leechSeedSource = { position = 1 } }, "guest"), + "seed-source ownership hashes in host coordinates" +) +for _, version in ipairs({ "firered", "emerald" }) do + local f = require("tests.enemy_actions.baton_pass").fixture(".", "gen2", version) + local H = require("src.link.Handshake") + local current, old = H.hello(f.game, "battle"), H.hello(f.game, "battle") + old.nativeBattleRevision = 2 + T.eq(H.checkCompat(current, old), "subset", "pre-choice native rules cannot enter a battle") + T.eq(H.checkCompat(old, current), "subset", "pre-choice native guard is symmetric") + local function fresh(spectator) + local b = f.fresh("TACKLE", { enemy = true }).battle + b:syncSides() + b.turnCount = 1 + local mine, theirs = { b.player.mon }, { b.enemy.mon } + for i = 2, 3 do + mine[i], theirs[i] = Copy(mine[1]), Copy(theirs[1]) + end + local errors, sent = {}, {} + C.attach(b, { + role = "host", + spectator = spectator, + mine = mine, + theirs = theirs, + invalid = function(why) + errors[#errors + 1] = why + end, + send = function(packet) + sent[#sent + 1] = packet + end, + }) + return b, errors, sent, mine, theirs + end + local function pending(b, user) + local candidates, party = B.candidates(b, user) + local p = { user = user, party = party, candidates = candidates, offered = {} } + for _, i in ipairs(candidates) do + p.offered[i] = party[i] + end + b._gen3BatonPending = p + return p + end + local packet = { type = "baton_pass", turn = 1, serial = 1, actor = "guest", index = 3 } + local b, errors = fresh() + T.check(C.receive(b, packet), "reliable early choice can arrive before native command") + local p = pending(b, b.enemy) + T.eq(b:_gen3BatonRequest(p), true, "remote actor waits for a wire choice") + T.eq(p.choice, 3, "early non-first choice is consumed without fallback") + T.check(C.receive(b, Copy(packet)), "identical retransmission is idempotent") + T.eq(#errors, 0, "valid early and duplicate choices remain accepted") + local conflict = Copy(packet) + conflict.index = 2 + T.eq(C.receive(b, conflict), false, "conflicting replay is refused") + T.eq(#errors, 1, "conflicting replay reaches the controller failure path") + T.eq(p.choice, 3, "replay does not change the accepted mon") + for _, edit in ipairs({ + { "turn", 0 }, + { "turn", 2 }, + { "serial", 0 }, + { "serial", 65 }, + { "serial", 1.5 }, + { "index", 0 }, + { "index", 7 }, + { "index", 0 / 0 }, + { "actor", "host" }, + { "actor", "other" }, + { "index", "3" }, + }) do + local bad = Copy(packet) + bad[edit[1]] = edit[2] + local battle, invalid = fresh() + T.eq(C.receive(battle, bad), false, "malformed or unauthorized choice is refused") + T.eq(#invalid, 1, "malformed choice cannot leave the controller waiting") + end + local battle, invalid = fresh(true) + T.eq(C.receive(battle, packet, "host"), false, "spectator authenticates the relayed actor") + T.eq(#invalid, 1, "wrong relay side ends spectator choice processing") + local spec, bad = fresh(true) + T.check(C.receive(spec, packet, "guest"), "spectator buffers authenticated choice") + local remote = pending(spec, spec.enemy) + T.eq(spec:_gen3BatonRequest(remote), true, "spectator never needs a personal picker") + T.eq(remote.choice, 3, "spectator resumes with the real remote selection") + T.eq(#bad, 0, "authenticated spectator choice has no controller error") + local localBattle, localErrors, sent = fresh() + local localPending = pending(localBattle, localBattle.player) + T.eq(localBattle:_gen3BatonRequest(localPending), false, "local owner needs a picker") + T.check(B.select(localBattle, localPending, 3), "local selection is validated before sending") + T.eq(#sent, 1, "exactly one native choice is sent") + T.same( + sent[1], + { type = "baton_pass", turn = 1, serial = 1, actor = "host", index = 3 }, + "wire carries turn, order, owner and actual choice" + ) + T.eq(B.select(localBattle, localPending, 2), false, "local callback cannot send twice") + T.eq(#localErrors, 0, "valid local choice has no controller error") + local stale, failures, _, _, party = fresh() + local waiting = pending(stale, stale.enemy) + stale:_gen3BatonRequest(waiting) + party[3].hp = 0 + C.receive(stale, packet) + T.eq(waiting.choice, nil, "wire cannot choose a newly fainted replacement") + T.eq(#failures, 1, "stale native choice reaches controller failure") + f.close() +end +T.finish("native Baton Pass packet validation and buffered choices") diff --git a/tests/engine/gen3_baton_pass_ui.lua b/tests/engine/gen3_baton_pass_ui.lua new file mode 100644 index 000000000..9c9e49994 --- /dev/null +++ b/tests/engine/gen3_baton_pass_ui.lua @@ -0,0 +1,111 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local Copy = require("src.mods.Merge").deepCopy +for _, version in ipairs({ "firered", "emerald" }) do + local f = require("tests.enemy_actions.baton_pass").fixture(".", "gen2", version) + T.link.prepare(f.data) + local game = T.link.fakeGame(f.data, "CHARMANDER", { name = "PLAYER", level = 30 }) + game.stack.states = game.stack.list + f.result.loader.game = game + local mon = game.save.party[1] + mon.nickname = "ALPHA" + mon.moves = { { id = f.ids.BATON_PASS, pp = 20 }, { id = f.ids.TACKLE, pp = 10 } } + mon.stats.hp, mon.hp = 400, 400 + game.save.party[2], game.save.party[3] = Copy(mon), Copy(mon) + game.save.party[2].nickname, game.save.party[3].nickname = "BETA", "GAMMA" + local b = require("src.battle.BattleState").newWild(game, "CHARMANDER", 30) + b:syncSides() + b.enemy.mon.moves = { { id = f.ids.TACKLE, pp = 10 } } + b.enemy.curMoves = b.enemy.mon.moves + b.enemy.mon.stats.hp, b.enemy.mon.hp = 400, 400 + b.player.curStats.speed, b.enemy.curStats.speed = 200, 100 + b.player.stages.attack = 2 + f.e.choose(function() + return { move = "TACKLE" } + end) + -- A real queued turn, with its slower attack already waiting on the donor slot. + game.stack.states[1] = b + b:resolveTurn(b.player.curMoves[1]) + local ui, screen + local function frame() + require("src.core.Input").pressed = { a = true } + game.stack:update(1 / 60) + end + for _ = 1, 10000 do + local top = game.stack:top() + if top ~= b then + local view = f.e.api.ui:snapshot() + if view and view.kind == "party" then + ui, screen = view, top + break + end + end + frame() + end + T.check(ui ~= nil, "Baton Pass opens the actual native party screen") + assert(ui, "party chooser never opened") + T.eq(b.player.mon, mon, "donor remains active until a choice") + T.eq(mon.moves[1].pp, 19, "PP is spent before native party selection") + T.eq(ui.canBack, false, "public UI cannot cancel a forced Baton Pass choice") + local back, why = f.e.api.ui:submit({ id = 1, revision = ui.revision, kind = "back" }) + T.eq(back, nil, "public back intent is refused") + T.check(type(why) == "string", "back refusal explains the unavailable operation") + screen:cancel() + T.eq(game.stack:top(), screen, "native B/cancel leaves forced picker open") + local pending = b._gen3BatonPending + T.eq( + require("src.battle.Gen3BatonPass").select(b, pending, 1), + false, + "current active donor is not eligible" + ) + game.save.party[3].hp = 0 + local stale = f.e.api.ui:submit({ id = 2, revision = ui.revision, kind = "select", index = 3 }) + T.eq(stale, nil, "party mutation invalidates an old UI offer") + T.eq( + require("src.battle.Gen3BatonPass").select(b, pending, 3), + false, + "fainted replacement is refused even by native continuation" + ) + game.save.party[3].hp = 400 + local current = assert(f.e.api.ui:snapshot()) + local accepted, error = + f.e.api.ui:submit({ id = 3, revision = current.revision, kind = "select", index = 3 }) + T.eq(accepted, true, "public UI chooses the last eligible member: " .. tostring(error)) + T.eq(game.stack:top(), b, "selection returns to the battle queue") + for _ = 1, 15000 do + if b.phase == "menu" and not b._gen3BatonPending then + break + end + frame() + end + T.eq(b.player.mon, game.save.party[3], "queued continuation installs the selected Gamma") + T.eq(b.player.stages.attack, 2, "queued native switch retains donor stages") + T.eq(mon.hp, 400, "slower attack does not damage the retired donor") + T.check(game.save.party[3].hp < 400, "slower queued attack hits the current occupant") + T.eq(mon.moves[1].pp, 19, "resuming the chooser does not spend PP twice") + T.eq(b.turnCount, 1, "Baton Pass does not create an extra turn") + T.eq(b._gen3BatonPending, nil, "choice state is cleared after the turn") + T.eq( + require("src.battle.Gen3BatonPass").select(b, pending, 2), + false, + "completed choice cannot be replayed" + ) + T.eq(b.player.mon.nickname, "GAMMA", "replayed callback cannot replace the selected mon") + local initial = { game.save.party[1] } + local owner = { playerParty = initial } + local Menu = require("src.ui.Gen3PartyMenu") + local linked = Menu.new(game, { battle = owner, party = initial }) + T.eq(linked:party(), initial, "ordinary link picker starts with the live battle roster") + owner.playerParty = { game.save.party[2], game.save.party[3] } + T.eq(linked:party(), owner.playerParty, "ordinary link picker follows a replaced live roster") + local distinct = { game.save.party[3] } + local ally = Menu.new(game, { battle = owner, party = distinct, forceSwitch = true }) + T.eq(ally:party(), distinct, "owner-specific picker retains its distinct party") + T.eq(#ally:party(), 1, "owner-specific roster never expands into the other trainer's party") + local lateOwner = {} + local late = Menu.new(game, { battle = lateOwner, party = initial }) + lateOwner.playerParty = distinct + T.eq(late:party(), distinct, "ordinary picker follows a roster installed after screen creation") + f.close() +end +T.finish("native Baton Pass party UI and queued turn continuation") diff --git a/tests/engine/gen3_battle_protocol.lua b/tests/engine/gen3_battle_protocol.lua new file mode 100644 index 000000000..fbc12f278 --- /dev/null +++ b/tests/engine/gen3_battle_protocol.lua @@ -0,0 +1,130 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local V, F, H = + require("src.core.GameVersion"), require("src.link.Fingerprint"), require("src.link.Handshake") +local Copy = require("src.mods.Merge").deepCopy +local data, before = T.fixtures.fresh(), V.get() +-- Legacy hash retained; native hash independently audits revision 19 -> 20 only. +local LEGACY = "e33d251961f2a41d" +local NATIVE = "43fe7ea27b438e0e" +for _, version in ipairs({ "red", "firered", "gold", "emerald", "diamond", "firered", "black" }) do + V.set(version) + local gba = V.isGen3() + T.eq( + F.compute(data, {}), + gba and NATIVE or LEGACY, + "runtime command revision participates in cached fingerprints" + ) + T.eq(F.nativeBattleRevision(), gba and 20 or nil, "only GBA advertises the new command revision") + local game = { data = data, save = { player = { name = "RULES" } } } + local current = H.hello(game, "battle") + local legacy = Copy(current) + legacy.nativeBattleRevision, legacy.fingerprint = nil, LEGACY + T.eq(H.checkCompat(current, Copy(current)), "full", "identical native rules admit lockstep") + T.eq( + H.checkCompat(current, legacy), + gba and "subset" or "full", + "old wire digest cannot admit changed GBA battles" + ) + T.eq(H.checkCompat(legacy, current), gba and "subset" or "full", "the wire mismatch is symmetric") + T.eq( + H.checkCompat(current, {}), + gba and "refused" or "vanilla_peer", + "legacy unversioned peers remain safe" + ) + if gba then + local previous = Copy(current) + previous.nativeBattleRevision, previous.fingerprint = 19, "22e453523676534e" + T.eq(H.checkCompat(current, previous), "subset", "previous native music rules cannot admit lockstep") + T.eq(H.checkCompat(previous, current), "subset", "native command revision mismatch is symmetric") + T.eq(current.nativePartyRevision, 1, "music commands retain the native party codec") + for _, host in ipairs({ false, true }) do + for _, peer in ipairs({ legacy, previous }) do + local state = setmetatable( + { game = game, myHello = current, peerHello = peer }, + { __index = require("src.link.LinkState") } + ) + state:decideCompat("battle", host) + T.eq(state.stage, "notice", "actual link controller stops before battle setup") + T.eq(state.noticeExits, true, "mismatched commands cannot start a battle") + end + end + local message = table.concat(H.describe(current, legacy, "subset", "battle"), " ") + T.check(message:find("engine versions", 1, true) ~= nil, "rule mismatch names the needed engine update") + for field, value in pairs({ + index = 999, + gen3Effect = 6, + secondaryChance = 100, + target = 8, + flags = 0, + protectAffected = false, + }) do + local changed = Copy(data) + changed.moves.FIX_TACKLE[field] = value + T.neq(F.compute(changed, {}), NATIVE, "native " .. field .. " changes the full link surface") + T.neq( + F.records(changed, "moves").FIX_TACKLE, + F.records(data, "moves").FIX_TACKLE, + "native " .. field .. " changes move compatibility" + ) + V.set("red") + T.eq(F.compute(changed, {}), LEGACY, "native-only move fields retain old game digests") + V.set(version) + end + local Protocol = require("src.link.Protocol") + local mon = require("src.pokemon.Pokemon").new(data, "FIXMON_A", 20) + mon.status, mon.toxic = "PSN", true + local Serializer = require("src.core.SaveSerializer") + local saved = Serializer.decode(Serializer.encode({ party = { mon } })) + T.eq(saved.party[1].toxic, true, "native save representation preserves the toxic marker") + local packed = Protocol.packMon(mon) + local restored = Protocol.unpackMon(data, packed, { strict = true }) + T.eq(packed.toxic, true, "native party wire preserves the toxic marker") + T.eq(restored.toxic, true, "native party decode preserves the toxic marker") + packed.toxic = "true" + T.eq( + Protocol.unpackMon(data, packed, { strict = true }).toxic, + nil, + "wire marker requires an actual boolean" + ) + -- The content/trade record digest remains independent of runtime revision. + local records = F.records(data, "pokemon") + V.set("red") + T.same(F.records(data, "pokemon"), records, "shared Pokemon trade definitions are retained") + V.set(version) + end +end +for _, version in ipairs({ "firered", "emerald" }) do + V.set(version) + local simulated = T.fixtures.fresh() + local loaded = T.sdk.loadNone({ data = simulated }) + T.eq(#loaded.errors, 0, "link mirror fixture loads") + require("src.battle.TypeChart").load(simulated) + T.link.prepare(simulated) + local a = T.link.fakeGame(simulated, "FIXMON_A", { name = "A", level = 20 }) + local b = T.link.fakeGame(simulated, "FIXMON_B", { name = "B", level = 20 }) + a.save.party[1].status, a.save.party[1].toxic = "PSN", true + local result = T.link.lockstep(a, b, { maxFrames = 60000, seed = 327 }) + T.eq(result.completed, true, "native runtime mirror completes a full synthetic link battle") + T.eq(result.agreed, true, "native runtime mirror has no state hash split") + T.eq(result.battleA.player.mon.hp, result.battleB.enemy.mon.hp, "host HP matches peer view") + T.eq(result.battleA.enemy.mon.hp, result.battleB.player.mon.hp, "guest HP matches peer view") + T.eq(result.battleA.player.mon.toxic, true, "host battle retains its toxic marker") + T.eq(result.battleB.enemy.mon.toxic, true, "guest battle retains the remote toxic marker") + local compared = 0 + for turn, hash in pairs(result.battleA.localHashes) do + if result.battleB.localHashes[turn] then + compared = compared + 1 + T.eq(hash, result.battleB.localHashes[turn], "each paired turn hash agrees") + T.same( + result.battleA.localParts[turn], + result.battleB.localParts[turn], + "each paired state component agrees" + ) + end + end + T.check(compared >= 2, "link mirror compares several completed turns") + loaded.release() +end +V.set(before) +T.finish("native GBA battle protocol revision") diff --git a/tests/engine/gen3_beat_up.lua b/tests/engine/gen3_beat_up.lua new file mode 100644 index 000000000..56f89101c --- /dev/null +++ b/tests/engine/gen3_beat_up.lua @@ -0,0 +1,6 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +for _, version in ipairs({ "firered", "emerald" }) do + require("tests.enemy_actions.beat_up").run(".", "gen2", version) + require("tests.enemy_actions.beat_up").run(".", "gen2", version, true) +end +require("tests.modkit").finish("native GBA Beat Up") diff --git a/tests/engine/gen3_beat_up_adjustment.lua b/tests/engine/gen3_beat_up_adjustment.lua new file mode 100644 index 000000000..9414aedba --- /dev/null +++ b/tests/engine/gen3_beat_up_adjustment.lua @@ -0,0 +1,5 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +for _, version in ipairs({ "firered", "emerald" }) do + require("tests.enemy_actions.beat_up").run(".", "gen2", version, false, { adjustment = true }) +end +require("tests.modkit").finish("native GBA Beat Up held damage adjustment") diff --git a/tests/engine/gen3_beat_up_side_party.lua b/tests/engine/gen3_beat_up_side_party.lua new file mode 100644 index 000000000..bdd73b799 --- /dev/null +++ b/tests/engine/gen3_beat_up_side_party.lua @@ -0,0 +1,5 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +for _, version in ipairs({ "firered", "emerald" }) do + require("tests.enemy_actions.beat_up").run(".", "gen2", version, false, { sideParty = true }) +end +require("tests.modkit").finish("native GBA Beat Up joined side parties") diff --git a/tests/engine/gen3_bgm_commands.lua b/tests/engine/gen3_bgm_commands.lua new file mode 100644 index 000000000..c53668943 --- /dev/null +++ b/tests/engine/gen3_bgm_commands.lua @@ -0,0 +1,116 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local Music, V = require("src.core.Music"), require("src.core.GameVersion") +local before, oldAudio = V.get(), love.audio +local Source = {} +Source.__index = Source +function Source:play() + self.playing = true +end +function Source:stop() + self.playing = false +end +function Source:isPlaying() + return self.playing or false +end +function Source:setVolume(value) + self.volume = value +end +function Source:setLooping() end +local sources = {} +love.audio = { + newSource = function(input) + local source = setmetatable({ input = input }, Source) + sources[#sources + 1] = source + return source + end, +} +for _, version in ipairs({ "firered", "emerald" }) do + V.set(version) + Music.reload() + Music.applyOptions({ musicVol = 7 }) + local Ids = require("src.core.Gen3SongIds") + local a, b, c = Ids.resolve("MUS_TITLE"), Ids.resolve("MUS_CYCLING"), Ids.resolve("MUS_SURF") + local names = Ids.names() + names.MUS_TITLE = -1 + T.eq(Ids.resolve("MUS_TITLE"), a, "caller mutation cannot alter the native identifier catalogue") + T.eq(Ids.resolve(" mus_title "), a, "native symbol fallback retains case and whitespace resolution") + T.eq(Ids.resolve(" MOD_SONG "), " MOD_SONG ", "unregistered mod labels retain their exact spelling") + local data = T.fixtures.fresh() + data.maps = { OWNED_MUSIC_MAP = { music = a } } + data.audio = { + songs = { + MUS_TITLE = { file = "owned-title.ogg" }, + MUS_CYCLING = { file = "owned-cycle.ogg" }, + MUS_SURF = { file = "owned-surf.ogg" }, + }, + mapSongs = { OWNED_MUSIC_MAP = "MUS_TITLE" }, + } + local game = { data = data, save = { options = {}, flags = {}, player = {} } } + local world = { map = { id = "OWNED_MUSIC_MAP" }, player = {} } + Music.playMap(data, world.map.id, false, false) + local function run(rows) + local code = assert(require("src.script.Gen3ScriptVM").compile( + { map_scripts = { + source = "RomExtractorGen3", + scripts = { OWNED_BGM = rows }, + } }, + "OWNED_BGM" + )) + local runner = require("src.script.ScriptRunner").new(game, world) + runner:run(code, { source = { modId = "owned_music_contract", strict = true } }) + T.eq(runner:isRunning(), false, "BGM opcodes never turn a fade into a blocking script wait") + return Music.nativeSnapshot() + end + local state = run({ { "playbgm", b, 1 }, { "end" } }) + T.eq(Music.current(), "MUS_CYCLING", "numeric scripts use unchanged named mod music definitions") + T.eq(state.song, b, "native command tracks the numeric song independently of the file label") + T.eq(state.mapSong, a, "script cues preserve the map header's default music") + T.eq(state.savedSong, b, "playbgm keep flag saves the native override") + state = run({ { "savebgm", c }, { "fadenewbgm", a }, { "end" } }) + T.eq(state.savedSong, c, "savebgm records its own persistent override") + T.eq(state.song, a, "fadenewbgm follows actual native immediate play dispatch") + T.eq(Music.current(), "MUS_TITLE", "the native target resolves to its mod file") + local first = sources[#sources] + run({ { "playbgm", a, 2 }, { "end" } }) + T.eq(sources[#sources], first, "same native song dedupes without an outgoing fade") + T.eq(Music.nativeSnapshot().savedSong, c, "only flag one or true changes saved music") + state = run({ { "fadeoutbgm", 8 }, { "end" } }) + T.eq(state.currentMapMusic, 0, "plain outgoing fade reports native silence as its destination") + T.eq(state.fadeOut.dur, 64 / 60, "actual production world adapter uses its fixed native fade speed") + Music.update(data) + T.check(first:isPlaying(), "outgoing fade retains the running source") + T.check(math.abs(first.volume - 0.7 * (1 - 1 / 64)) < 1e-12, "native envelope reaches the actual source") + for _ = 2, 64 do + Music.update(data) + end + T.eq(first:isPlaying(), false, "native audio tick64 stops the faded source") + T.eq(first.volume, 0, "stopping the source preserves the completed fade's zero gain") + T.eq(Music.nativeSnapshot().song, nil, "completed native fade clears the logical song") + T.eq(Music.nativeSnapshot().savedSong, c, "fade completion preserves the saved override") + state = run({ { "fadeinbgm", 1 }, { "end" } }) + local incoming = sources[#sources] + T.eq(state.song, a, "fadeinbgm prefers the map song over the saved override") + T.eq(incoming.volume, 0, "incoming native music begins at zero gain") + for _ = 1, 64 do + Music.update(data) + end + T.eq(Music.nativeSnapshot().fadeIn, nil, "incoming envelope completes on the native audio clock") + T.check(math.abs(incoming.volume - 0.7) < 1e-12, "incoming native fade restores the original user gain") + run({ { "fadeoutbgm", 4 }, { "end" } }) + Music.update(data) + run({ { "playbgm", a, 0 }, { "end" } }) + T.neq(sources[#sources], incoming, "same-ID playback replaces an outgoing fade with a new owned source") + T.eq(incoming:isPlaying(), false, "replaced faded sources cannot continue behind the new cue") + T.eq(Music.nativeSnapshot().fadeOut, nil, "explicit play cancels the previous owner's outgoing fade") + state = run({ { "playbgm", b, 0 }, { "fadedefaultbgm" }, { "end" } }) + T.eq(state.song, a, "file-only native overrides restore default without inventing a native stream") + run({ { "savebgm", 0xFFFF }, { "playbgm", 0 }, { "end" } }) + T.eq(Music.current(), nil, "native song zero stops music") + T.eq(Music.nativeSnapshot().savedSong, nil, "native saved-song sentinel clears the override") + T.eq(Music.nativeSnapshot().song, nil, "native silence retains no logical playback") +end +Music.reload() +love.audio = oldAudio +V.set(before) +T.finish("native BGM symbols, world commands, owned sources and file envelopes") diff --git a/tests/engine/gen3_bgm_streams.lua b/tests/engine/gen3_bgm_streams.lua new file mode 100644 index 000000000..52a8a0b0a --- /dev/null +++ b/tests/engine/gen3_bgm_streams.lua @@ -0,0 +1,145 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local V, Music = require("src.core.GameVersion"), require("src.core.Music") +local versionBefore = V.get() +for _, version in ipairs({ "firered", "emerald" }) do + local f = dofile("tests/enemy_actions/assist.lua").fixture(".", "gen2", version) + local function install(options) + return dofile("tests/native_bgm/streams.lua").install(".", "gen2", version, f, options) + end + local h = install() + local state = h.state() + T.eq(state.queues[1].playing, false, "native cue waits for its audio service") + T.eq(state.queues[1].queued, 0, "native cue construction synthesizes no lookahead") + T.eq(require("src.core.ChipAudio").awaitingFirstBuffer(), true, "native startup is pending until serviced") + h.update() + T.eq(h.state().queues[1].queued, 1, "native service produces one buffer") + T.eq(h.state().queues[1].playing, true, "first native buffer starts playback") + T.eq( + require("src.core.ChipAudio").awaitingFirstBuffer(), + false, + "serviced startup releases the pending state" + ) + h.start({ { "fadeoutbgm", 4 }, { "end" } }) + for _ = 1, 63 do + h.update() + end + local producedBeforeStop = h.state().queues[1].produced + h.drain() + h.update() + state = h.state() + T.eq(state.queues[1].playing, false, "completed outgoing fade stops the actual queue") + T.eq(state.queues[1].queued, 0, "stopping clears pending native PCM") + T.eq(state.queues[1].produced, producedBeforeStop, "final fade tick produces no buffer after its stop") + T.eq(state.queues[1].plays, 1, "native fade never spuriously restarts its source") + h.close() + + h = install({ finite = true }) + for _ = 1, 3 do + h.update() + end + T.eq(h.state().queues[1].produced, 3, "finite native sequences retain their silent carrier until stopped") + h.close() + + h = install() + Music.reload() + local a, b = h.ids[1], h.ids[2] + f.data.audio.songs.MUS_TITLE = f.data.audio.songs[("SONG_%03X"):format(a)] + f.data.audio.songs.MUS_CYCLING = f.data.audio.songs[("SONG_%03X"):format(b)] + f.data.audio.mapSongs.OWNED_NATIVE_BGM_MAP = "MUS_TITLE" + Music.playMap(f.data, "OWNED_NATIVE_BGM_MAP", false, false) + state = h.state() + T.eq(state.queues[#state.queues].playing, true, "named mod M4A extension retains shared immediate playback") + T.eq(state.queues[#state.queues].queued, 4, "named mod M4A extension keeps its shared preroll") + T.eq(state.queues[#state.queues].plays, 1, "Music never starts a chip source a second time") + h.start({ { "playbgm", b, 0 }, { "fadedefaultbgm" }, { "end" } }) + T.eq(h.state().native.song, a, "named M4A mods do not allocate a native background carrier") + T.eq(h.state().native.fadeOut, nil, "shared-only default restoration stays immediate") + h.close() + + for _, failAfterBuffer in ipairs({ false, true }) do + h = install() + Music.stop() + local originalChip = package.loaded["src.core.ChipAudio"] + local oldThread = love.thread + local channels, lastGen, workerError = {}, nil, nil + local function channel(name) + if not channels[name] then + channels[name] = { + values = {}, + push = function(self, value) + if type(value) == "table" and value.cmd == "play" then + lastGen = value.gen + end + self.values[#self.values + 1] = value + end, + pop = function(self) + return table.remove(self.values, 1) + end, + clear = function(self) + self.values = {} + end, + } + end + return channels[name] + end + love.thread = { + newThread = function() + return { + start = function() end, + getError = function() + return workerError + end, + wait = function() end, + } + end, + getChannel = channel, + } + package.loaded["src.core.ChipAudio"] = nil + local chip = require("src.core.ChipAudio") + Music.playMap(f.data, "OWNED_NATIVE_BGM_MAP", false, false) + local count = #h.state().queues + T.eq(h.state().queues[count].playing, false, "native worker startup does not play an empty source") + if failAfterBuffer then + local def = f.data.audio.songs[("SONG_%03X"):format(a)] + local engine = require("src.core.M4ASynth").newEngine(f.data, def) + local sd = require("src.core.M4ASynth").soundData(engine, 8192, 2) + channel("chipaudio_out"):push({ gen = lastGen + 1, sd = sd }) + h.update() + T.eq(h.state().queues[count].queued, 0, "stale worker generations cannot enter the live native queue") + h.start({ { "fadeoutbgm", 4 }, { "end" } }) + channel("chipaudio_out"):push({ gen = lastGen, sd = sd }) + h.update() + local played = h.trace().queues[count].plays + T.eq(#played, 1, "first matching native worker buffer starts exactly once") + T.check( + math.abs(played[1].volume - 0.7 * (1 - 1 / 64)) < 1e-12, + "worker playback starts after this audio tick's fade gain" + ) + h.drain() + end + workerError = "owned worker failure" + h.update() + h.update() + state = h.state() + T.eq(#state.queues, count, "worker fallback retains the native source owned by Music") + T.eq(state.queues[count].playing, true, "worker fallback recovers the actual source") + local expectedGain = 0.7 * (failAfterBuffer and (1 - 3 / 64) or 1) + T.check( + type(state.queues[count].volume) == "number" + and math.abs(state.queues[count].volume - expectedGain) < 1e-12, + "recovered source keeps the native gain and fade owner" + ) + h.start({ { "playbgm", 0, 0 }, { "end" } }) + for _, voice in ipairs(h.state().queues) do + T.eq(voice.playing, false, "native stop releases every worker/fallback source") + end + chip.shutdown() + package.loaded["src.core.ChipAudio"] = originalChip + love.thread = oldThread + h.close() + end + f.close() +end +V.set(versionBefore) +T.finish("native BGM stream clocks, mod ownership and worker recovery") diff --git a/tests/engine/gen3_choice_band.lua b/tests/engine/gen3_choice_band.lua new file mode 100644 index 000000000..5e63d1460 --- /dev/null +++ b/tests/engine/gen3_choice_band.lua @@ -0,0 +1,5 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +for _, version in ipairs({ "firered", "emerald" }) do + dofile("tests/enemy_actions/choice_band.lua").run(".", "gen2", version) +end +require("tests.modkit").finish("native GBA Choice Band move-end command") diff --git a/tests/engine/gen3_choice_baton.lua b/tests/engine/gen3_choice_baton.lua new file mode 100644 index 000000000..00dcdf2e4 --- /dev/null +++ b/tests/engine/gen3_choice_baton.lua @@ -0,0 +1,5 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +for _, version in ipairs({ "firered", "emerald" }) do + dofile("tests/enemy_actions/baton_pass.lua").run(".", "gen2", version, { choiceBand = true }) +end +require("tests.modkit").finish("native GBA Choice Band real Baton Pass continuations") diff --git a/tests/engine/gen3_choice_call_failure.lua b/tests/engine/gen3_choice_call_failure.lua new file mode 100644 index 000000000..311ff1241 --- /dev/null +++ b/tests/engine/gen3_choice_call_failure.lua @@ -0,0 +1,5 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +for _, version in ipairs({ "firered", "emerald" }) do + dofile("tests/enemy_actions/choice_call_failure.lua").run(".", "gen2", version) +end +require("tests.modkit").finish("native GBA Choice Band failed builtin caller boundary") diff --git a/tests/engine/gen3_choice_calls.lua b/tests/engine/gen3_choice_calls.lua new file mode 100644 index 000000000..76979d6d7 --- /dev/null +++ b/tests/engine/gen3_choice_calls.lua @@ -0,0 +1,5 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +for _, version in ipairs({ "firered", "emerald" }) do + dofile("tests/enemy_actions/sleep_talk.lua").run(".", "gen2", version, { choiceBand = true }) +end +require("tests.modkit").finish("native GBA Choice Band actual called-move dispatch") diff --git a/tests/engine/gen3_choice_content.lua b/tests/engine/gen3_choice_content.lua new file mode 100644 index 000000000..13a4968d2 --- /dev/null +++ b/tests/engine/gen3_choice_content.lua @@ -0,0 +1,5 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +for _, version in ipairs({ "firered", "emerald" }) do + dofile("tests/enemy_actions/choice_content.lua").run(".", "gen2", version) +end +require("tests.modkit").finish("native GBA Choice Band authored callMove ownership") diff --git a/tests/engine/gen3_choice_legacy.lua b/tests/engine/gen3_choice_legacy.lua new file mode 100644 index 000000000..fd6bfa48e --- /dev/null +++ b/tests/engine/gen3_choice_legacy.lua @@ -0,0 +1,105 @@ +-- Unchanged legacy callback handles and learned records survive a native +-- wild target selection; private planning fields still drive actual execution. +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +for _, version in ipairs({ "firered", "emerald" }) do + for _, mode in ipairs({ { 1, "native" }, { 2, "native" }, { 2, "unified" }, { 3, "unified" } }) do + local f = dofile("tests/enemy_actions/choice_band.lua").fixture( + ".", + "gen2", + version, + { api = mode[1], format = mode[2] } + ) + local b = f.field(true) + local user, right = b.enemy, b:battlerAt(2) + b.rng = function(lo, hi) + return hi == 65535 and 2 or lo + end + local before = require("src.mods.Merge").deepCopy(user.curMoves[1]) + f.e.choose(function(nextAction, ...) + return nextAction(...) + end) + f.e.api.hooks:wrap("battle.crit", function() + return false + end) + local action = b:enemyAction(user) + if mode[1] < 3 then + T.eq(action, user.curMoves[1], "forwarding retains the actual legacy learned-slot handle") + else + T.neq(action, user.curMoves[1], "API-3 retains its separate native action and positional target") + T.eq(action.target, 2, "API-3 exposes the positional target before execution") + end + T.same(user.curMoves[1], before, "native target/scores never enter party move records") + local planned = require("src.battle.Gen3ChoiceBand").plan(b, user, action) + T.eq(planned.target, 2, "private native target survives forwarding and planning") + T.eq(planned.slot, 1, "private native slot survives forwarding and planning") + T.same(planned.scores, { 0, 0, 0, 0 }, "native wild scores survive forwarding") + b:executeAction(user, b.player, planned) + T.check(right.mon.hp < 400, "actual execution hits the selected opposite right flank") + T.eq(b.player.mon.hp, 400, "actual execution preserves the unselected left flank") + T.eq(user.curMoves[1].pp, 9, "actual execution spends the original learned PP") + T.eq(user.curMoves[1].target, nil, "consuming the private target preserves party shape") + T.eq(#f.result.errors, 0, "every callback level forwards without errors") + f.close() + end + do + local f = dofile("tests/enemy_actions/choice_band.lua").fixture(".", "gen2", version) + local b = f.field(false) + b.ruleset = require("src.mods.Merge").deepCopy(b.ruleset) + b.ruleset.enemyUnlimitedPP = true + f.hold(b.enemy, 186) + b.enemy.choicedMove = 33 + for _, slot in ipairs(b.enemy.curMoves) do + slot.pp = 0 + end + b.rng = function(lo) + return lo + end + T.eq( + b:vanillaEnemyAction(b.enemy).id, + f.ids.TACKLE, + "explicit unlimited enemy policy admits dry learned moves" + ) + local planned = require("src.battle.Gen3ChoiceBand").plan(b, b.enemy, b.enemy.curMoves[2]) + T.eq(planned.id, f.ids.TACKLE, "explicit unlimited enemy policy retains the choice lock") + b:executeAction(b.enemy, b.player, planned) + T.check(b.player.mon.hp < 400, "explicit unlimited enemy policy executes the dry selected move") + T.eq(b.enemy.curMoves[1].pp, 0, "explicit unlimited enemy policy preserves dry PP") + f.close() + end + do + local f = dofile("tests/enemy_actions/choice_band.lua").fixture(".", "gen2", version) + local b, checks, trace = f.field(true), {}, {} + for _, actor in b:activeBattlers() do + f.hold(actor, 186) + actor.choicedMove = 33 + end + b.pendingActions = { [2] = b:battlerAt(2).curMoves[1] } + f.e.api.hooks:wrap("held_item.trigger", function(nextRead, ctx) + trace[#trace + 1] = { + holder = ctx.mon.nickname, + trigger = ctx.trigger, + item = ctx.item, + effect = ctx.effect, + parameter = ctx.parameter, + } + if ctx.trigger == "check" then + checks[#checks + 1] = ctx.mon.nickname + end + return nextRead(ctx) + end) + b:resolveTurn(b.player.curMoves[1]) + -- AI masks for the two enemy actors precede one planner query per slot. + T.same( + checks, + { "P1", "P3", "P0", "P1", "P2", "P3" }, + "actual doubles plans each actor once in native slot order" + ) + print( + "CHOICE_DOUBLE_PLANNING_READS " .. require("src.link.Json").encode({ version = version, trace = trace }) + ) + T.eq(#f.result.errors, 0, "actual double planning callbacks remain error-free") + f.close() + end +end +T.finish("native GBA choice planning retains legacy handles and targets") diff --git a/tests/engine/gen3_choice_link.lua b/tests/engine/gen3_choice_link.lua new file mode 100644 index 000000000..f1ab30724 --- /dev/null +++ b/tests/engine/gen3_choice_link.lua @@ -0,0 +1,161 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T, Copy = require("tests.modkit"), require("src.mods.Merge").deepCopy +for _, version in ipairs({ "firered", "emerald" }) do + local f = dofile("tests/enemy_actions/choice_band.lua").fixture(".", "gen2", version) + local H = require("src.link.Handshake") + local current = H.hello(f.game, "battle") + local older = Copy(current) + older.nativeBattleRevision = 10 + T.check(current.nativeBattleRevision >= 11, "native Choice Band advertises revised commands") + T.eq(H.checkCompat(current, older), "subset", "old held commands cannot enter a full battle") + T.eq(H.checkCompat(older, current), "subset", "revised held commands reject symmetrically") + for _, host in ipairs({ false, true }) do + local state = setmetatable( + { game = f.game, myHello = current, peerHello = older }, + { __index = require("src.link.LinkState") } + ) + state:decideCompat("battle", host) + T.eq(state.stage, "notice", "actual link controller refuses before party exchange") + T.eq(state.noticeExits, true, "revision mismatch cannot start a battle") + end + T.link.prepare(f.data) + local ga = T.link.fakeGame(f.data, "CHARMANDER", { name = "HOST", level = 30 }) + local gb = T.link.fakeGame(f.data, "CHARMANDER", { name = "GUEST", level = 30 }) + local gs = T.link.fakeGame(f.data, "CHARMANDER", { name = "VIEWER", level = 30 }) + gs.save.version, gs.save.options.ruleset = version, "gen3_emerald" + for i, game in ipairs({ ga, gb }) do + game.save.version, game.save.options.ruleset = version, "gen3_emerald" + local mon = game.save.party[1] + mon.nickname = i == 1 and "HOST" or "GUEST" + mon.moves = { { id = f.ids.TACKLE, pp = 10 }, { id = f.ids.SCRATCH, pp = 10 } } + mon.item = "ITEM_186" + end + local seen = setmetatable({}, { __mode = "k" }) + f.e.api.hooks:wrap("held_item.trigger", function(nextRead, ctx) + local trace = seen[ctx.battle] or {} + seen[ctx.battle] = trace + trace[#trace + 1] = { + holder = ctx.mon.nickname, + item = ctx.item, + effect = ctx.effect, + parameter = ctx.parameter, + trigger = ctx.trigger, + } + -- A stateful consumer uses every earlier read, including entry/priority + -- and residual calls. Reordering cannot be hidden by equal final counts. + if ctx.trigger == "damage" and ctx.mon.nickname == "HOST" then + return "HELD_NORMAL_BOOST", 10 + #trace % 3 + end + return nextRead(ctx) + end) + f.e.api.hooks:wrap("battle.crit", function() + return false + end) + local P, N, L = require("src.link.Protocol"), require("src.link.Net"), require("src.link.LinkBattle") + local na, nb = N.loopbackPair() + local inbox = {} + local sn = { + update = function() end, + poll = function() + local messages = inbox + inbox = {} + return messages + end, + } + for i, net in ipairs({ na, nb }) do + local send, side = net.send, i == 1 and "host" or "guest" + net.send = function(self, packet) + inbox[#inbox + 1] = { type = "spectate", side = side, msg = Copy(packet) } + return send(self, packet) + end + end + local pa, pb = P.packParty(ga.save.party), P.packParty(gb.save.party) + local a = assert( + L.newHost( + ga, + na, + { myParty = pa, theirParty = pb, theirName = "GUEST", seed = 327, verdict = "full", strict = true } + ) + ) + local b = assert( + L.newGuest( + gb, + nb, + { myParty = pb, theirParty = pa, theirName = "HOST", seed = 327, verdict = "full", strict = true } + ) + ) + local s = assert(L.newSpectator(gs, sn, { + hostParty = pa, + guestParty = pb, + hostName = "HOST", + guestName = "GUEST", + seed = 327, + verdict = "full", + strict = true, + })) + ga.stack:push(a) + gb.stack:push(b) + gs.stack:push(s) + for _, battle in ipairs({ a, b, s }) do + T.eq(battle.ruleset.baseDamageStyle, "gba", "actual linked battle uses the GBA damage profile") + for _, actor in ipairs({ battle.player, battle.enemy }) do + actor.mon.hp, actor.mon.stats.hp = 1000, 1000 + actor.curStats.attack, actor.curStats.defense, actor.curTypes = 51, 51, { "NORMAL" } + actor.stages.attack, actor.stages.defense = 1, -1 + end + end + local firstTurn = false + for _ = 1, 40000 do + for i, game in ipairs({ ga, gb, gs }) do + local battle = ({ a, b, s })[i] + battle.moveIndex = firstTurn and 2 or 1 + require("src.core.Input").pressed = (i == 3 or firstTurn or not battle.localHashes[1]) and { a = true } + or {} + game.stack:update(1 / 60) + end + if + a.localHashes[1] + and b.localHashes[1] + and s.turnCount == 1 + and s.phase == "waitBoth" + and not firstTurn + then + firstTurn = true + for _, battle in ipairs({ a, b, s }) do + for _, actor in ipairs({ battle.player, battle.enemy }) do + T.eq(actor.choicedMove, 33, "completed native move binds the current learned move") + actor.mon.item = nil + end + end + end + if a.localHashes[2] and b.localHashes[2] and s.turnCount == 2 and s.phase == "waitBoth" then + break + end + end + for turn = 1, 2 do + T.check(a.localHashes[turn] ~= nil and b.localHashes[turn] ~= nil, "both menu-driven turns complete") + T.eq(a.localHashes[turn], b.localHashes[turn], "paired completed turn hashes agree") + T.same(a.localParts[turn], b.localParts[turn], "every active/volatile/bench component agrees") + end + print("CHOICE_LINK_TRACES " .. require("src.link.Json").encode({ + version = version, + host = seen[a], + guest = seen[b], + spectator = seen[s], + })) + T.same(seen[a], seen[b], "both real link roles see the same complete ordered held reads") + T.same(seen[a], seen[s], "real spectator sees the same complete ordered held reads") + T.check(#(seen[a] or {}) > 8, "complete held traces include planning and move-end consumers") + T.eq(s.player.mon.hp, a.player.mon.hp, "spectator retains the stateful host damage result") + T.eq(s.enemy.mon.hp, a.enemy.mon.hp, "spectator retains the stateful guest damage result") + for _, battle in ipairs({ a, b, s }) do + T.eq(battle.player.mon.moves[1].pp, 9, "first native menu spends Tackle PP") + T.eq(battle.player.mon.moves[2].pp, 9, "lost physical item admits Scratch on the next native menu") + T.eq(battle.player.choicedMove, 33, "item loss retains the learned choice identity") + T.check(battle.player.mon.hp < 1000 and battle.enemy.mon.hp < 1000, "real attacks damage both battlers") + T.eq(battle.result, nil, "no hidden link or choice failure ends the mirror") + end + T.eq(#f.result.errors, 0, "all real callback chains remain error-free") + f.close() +end +T.finish("native Choice Band real link menus, item loss and command compatibility") diff --git a/tests/engine/gen3_choice_planning.lua b/tests/engine/gen3_choice_planning.lua new file mode 100644 index 000000000..b82eab294 --- /dev/null +++ b/tests/engine/gen3_choice_planning.lua @@ -0,0 +1,5 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +for _, version in ipairs({ "firered", "emerald" }) do + dofile("tests/enemy_actions/choice_planning.lua").run(".", "gen2", version) +end +require("tests.modkit").finish("native GBA Choice Band planning and actual PP ownership") diff --git a/tests/engine/gen3_choice_public.lua b/tests/engine/gen3_choice_public.lua new file mode 100644 index 000000000..312b944ed --- /dev/null +++ b/tests/engine/gen3_choice_public.lua @@ -0,0 +1,118 @@ +-- Real public Loader facade and native keyboard controller; no admission or +-- dispatch helper is replaced by the test. +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +for _, version in ipairs({ "firered", "emerald" }) do + local f = dofile("tests/enemy_actions/choice_band.lua").fixture(".", "gen2", version) + local api = f.e.api.battle + local function fresh() + local b = f.field(false) + f.game.stack.states = { b } + b.introSlide, b.moveIndex = 0, 2 + f.hold(b.player, 186) + b.player.choicedMove = 33 + return b + end + local function submit(id, slot) + local snapshot = assert(api:snapshot()) + return api:submit({ id = id, revision = snapshot.revision, kind = "move", slot = slot }) + end + for _, kind in ipairs({ "physical", "metadata_cleared", "effect_only", "empty_override", "suppressed" }) do + local b, reads, draws = fresh(), 0, 0 + if kind == "effect_only" then + f.hold(b.player, 529) + end + if kind == "empty_override" then + f.hold(b.player, nil) + end + f.data.items.ITEM_186.holdEffect = kind == "metadata_cleared" and "NONE" or "CHOICE_BAND" + local off = f.e.api.hooks:wrap("held_item.trigger", function(nextRead, ctx) + reads = reads + 1 + if kind == "suppressed" then + return nil, 0 + end + if kind == "empty_override" then + return "HELD_CHOICE_BAND", 0 + end + return nextRead(ctx) + end) + b.rng = function(lo) + draws = draws + 1 + return lo + end + local snapshot = assert(api:snapshot()) + local blocked = kind == "physical" or kind == "metadata_cleared" or kind == "suppressed" + T.eq(snapshot.moves[1].disabled, false, "locked learned slot stays selectable") + T.eq(snapshot.moves[2].disabled, blocked, "snapshot follows physical native Choice Band") + if blocked then + local ok, reason = submit(1, 2) + T.eq(ok, nil, "public action refuses other move") + T.check(reason and reason:find("choice", 1, true) ~= nil, "public refusal identifies native choice") + T.eq(b.phase, "moveSelect", "public refusal preserves current prompt") + T.eq(#b.queue, 0, "public refusal adds no native messages") + end + T.eq(reads, 0, "public snapshot/admission never asks the held-effect hook") + T.eq(draws, 0, "public snapshot/admission consumes no RNG") + T.eq(b.player.curMoves[1].pp, 10, "query/refusal preserves locked slot PP") + T.eq(b.player.curMoves[2].pp, 10, "query/refusal preserves other slot PP") + off() + end + f.data.items.ITEM_186.holdEffect = "CHOICE_BAND" + for _, change in ipairs({ "choice", "item", "imprison", "same_restriction_choice" }) do + local b = fresh() + if change == "same_restriction_choice" then + f.hold(b.player, nil) + end + local snapshot = assert(api:snapshot()) + local old = { id = 1, revision = snapshot.revision, kind = "move", slot = 2 } + if change == "item" then + f.hold(b.player, nil) + elseif change == "imprison" then + b.enemy.imprison = true + else + b.player.choicedMove = 10 + end + local ok, reason = api:submit(old) + T.eq(ok, nil, "changed native admission invalidates offered intent") + T.eq(reason, "stale battle context", "staleness is reported before native dispatch") + T.eq(b.phase, "moveSelect", "stale intent preserves prompt") + T.eq(b.player.curMoves[2].pp, 10, "stale intent spends no PP") + end + do + local b, reads, draws = fresh(), 0, 0 + local off = f.e.api.hooks:wrap("held_item.trigger", function(nextRead, ctx) + reads = reads + 1 + return nextRead(ctx) + end) + b.rng = function(lo) + draws = draws + 1 + return lo + end + f.game.input.wasPressed = function(_, key) + return key == "a" + end + b:update(1 / 60) + T.eq(b.phase, "messages", "actual A input presents native refusal") + T.eq(b.afterQueue, "moveSelect", "native refusal returns to attack selection") + for _ = 1, 240 do + if b.phase ~= "messages" then + break + end + b:update(1 / 60) + end + T.eq(b.phase, "moveSelect", "actual message queue returns to the move selector") + T.eq(b.moveIndex, 2, "native refusal keeps the selected cursor") + T.eq(reads, 0, "manual refusal never calls held-effect consumers") + T.eq(draws, 0, "manual refusal never advances RNG") + T.eq(b.player.curMoves[2].pp, 10, "manual refusal spends no PP") + off() + f.game.input.wasPressed = function() + return false + end + local ok, err = submit(1, 1) + T.eq(ok, true, "fresh public intent admits the locked learned move: " .. tostring(err)) + end + T.eq(#f.result.errors, 0, "public and native callbacks remain error-free") + f.close() +end +T.finish("native GBA Choice Band public admission and native refusal return") diff --git a/tests/engine/gen3_choice_selection.lua b/tests/engine/gen3_choice_selection.lua new file mode 100644 index 000000000..fac4e5b84 --- /dev/null +++ b/tests/engine/gen3_choice_selection.lua @@ -0,0 +1,5 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +for _, version in ipairs({ "firered", "emerald" }) do + dofile("tests/enemy_actions/choice_selection.lua").run(".", "gen2", version) +end +require("tests.modkit").finish("native GBA Choice Band AI and manual selection contracts") diff --git a/tests/engine/gen3_choice_wild.lua b/tests/engine/gen3_choice_wild.lua new file mode 100644 index 000000000..7a062498c --- /dev/null +++ b/tests/engine/gen3_choice_wild.lua @@ -0,0 +1,5 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +for _, version in ipairs({ "firered", "emerald" }) do + dofile("tests/enemy_actions/choice_wild.lua").run(".", "gen2", version) +end +require("tests.modkit").finish("native GBA Choice Band ordinary-wild selection") diff --git a/tests/engine/gen3_constrained_party.lua b/tests/engine/gen3_constrained_party.lua new file mode 100644 index 000000000..59d06a21c --- /dev/null +++ b/tests/engine/gen3_constrained_party.lua @@ -0,0 +1,5 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +for _, version in ipairs({ "firered", "emerald" }) do + require("tests.enemy_actions.constrained_party").run(".", "gen2", version) +end +require("tests.modkit").finish("native Gen-3 constrained party construction") diff --git a/tests/engine/gen3_critical_context.lua b/tests/engine/gen3_critical_context.lua new file mode 100644 index 000000000..a30953fec --- /dev/null +++ b/tests/engine/gen3_critical_context.lua @@ -0,0 +1,19 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +for _, version in ipairs({ "firered", "emerald" }) do + require("tests.enemy_actions.critical_kinds").run(".", "gen2", version) +end +local T = require("tests.modkit") +for _, version in ipairs({ "red", "gold", "diamond", "black" }) do + require("src.core.GameVersion").set(version) + local calls = 0 + local result = require("src.battle.Gen3Critical").roll( + { firstBattle = true, firstBattleKind = "birch", wallyTutorial = true, pokedudeDemo = {} }, + function() + calls = calls + 1 + return true + end + ) + T.eq(result, true, version .. " retains the ordinary critical result") + T.eq(calls, 1, version .. " retains its ordinary random draw") +end +require("tests.modkit").finish("native GBA critical battle context") diff --git a/tests/engine/gen3_critical_order.lua b/tests/engine/gen3_critical_order.lua new file mode 100644 index 000000000..daf578d98 --- /dev/null +++ b/tests/engine/gen3_critical_order.lua @@ -0,0 +1,68 @@ +-- Regression from actual FireRed/Emerald opponent-controller comparisons: +-- base damage 11, critical x2, Steel resistance and roll 85 gives 9, not 8. +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local Damage, TypeChart = require("src.battle.Damage"), require("src.battle.TypeChart") +local copy = require("src.mods.Merge").deepCopy +local rules = require("src.battle.rulesets.gen3_emerald") +TypeChart.load({ + type_chart = { + types = {}, + matchups = { + { attacker = "NORMAL", defender = "STEEL", multiplier = 5 }, + { attacker = "NORMAL", defender = "RESIST", multiplier = 5 }, + { attacker = "NORMAL", defender = "WEAK", multiplier = 20 }, + { attacker = "NORMAL", defender = "GHOST", multiplier = 0 }, + }, + }, +}) +local function battler(types) + return { + mon = { level = 30, hp = 400 }, + def = {}, + stages = {}, + curTypes = types, + curStats = { hp = 400, attack = 50, defense = 50, special = 50, spatk = 50, spdef = 50 }, + } +end +local move = { id = "TACKLE", type = "NORMAL", power = 35 } +-- Literal expectations guard resistance, authored super-effectiveness, +-- STAB, immunity and adjacent noncritical boundaries. The Steel regression +-- is also reproduced through both actual native opponent controllers. +for _, row in ipairs({ + { false, false, { "STEEL" }, 85, 4 }, + { true, false, { "STEEL" }, 85, 9 }, + { true, false, { "STEEL" }, 100, 11 }, + { true, false, { "STEEL", "RESIST" }, 85, 4 }, + { true, false, { "STEEL", "RESIST" }, 100, 5 }, + { true, false, { "NEUTRAL" }, 85, 18 }, + { true, false, { "NEUTRAL" }, 100, 22 }, + { true, false, { "WEAK" }, 85, 37 }, + { true, true, { "STEEL" }, 85, 13 }, + { true, true, { "STEEL", "RESIST" }, 85, 6 }, + { true, true, { "NEUTRAL" }, 85, 28 }, + { true, false, { "GHOST" }, 85, 0 }, +}) do + local value = Damage.compute(rules, battler({ row[2] and "NORMAL" or "OTHER" }), battler(row[3]), move, { + forceCrit = row[1], + rng = function() + return row[4] + end, + }) + T.eq(value, row[5], "native critical/type rounding " .. table.concat(row[3], "/") .. " roll " .. row[4]) +end +local legacy = copy(rules) +legacy.critBeforeType = nil +T.eq( + Damage.compute(legacy, battler({ "OTHER" }), battler({ "STEEL" }), move, { + forceCrit = true, + rng = function() + return 85 + end, + }), + 8, + "profiles without the flag retain their ordering" +) +T.eq(require("src.battle.rulesets.gen4_platinum").critBeforeType, nil, "Gen-4 profile is not changed") +T.eq(require("src.battle.rulesets.gen1_faithful").critBeforeType, nil, "Gen-1 profile is not changed") +T.finish("Gen-3 critical/type ordering") diff --git a/tests/engine/gen3_disable.lua b/tests/engine/gen3_disable.lua new file mode 100644 index 000000000..468afe952 --- /dev/null +++ b/tests/engine/gen3_disable.lua @@ -0,0 +1,5 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +for _, version in ipairs({ "firered", "emerald" }) do + require("tests.enemy_actions.disable").run(".", "gen2", version) +end +require("tests.modkit").finish("native GBA Disable production, interruption and residual expiry") diff --git a/tests/engine/gen3_disable_continuation.lua b/tests/engine/gen3_disable_continuation.lua new file mode 100644 index 000000000..bcb9df863 --- /dev/null +++ b/tests/engine/gen3_disable_continuation.lua @@ -0,0 +1,196 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T, Copy = require("tests.modkit"), require("src.mods.Merge").deepCopy +local V, D = require("src.core.GameVersion"), require("src.battle.Gen3Disable") +for _, version in ipairs({ "firered", "emerald" }) do + local f = require("tests.enemy_actions.disable").fixture(".", "gen2", version) + T.link.prepare(f.data) + local game = T.link.fakeGame(f.data, "CHARMANDER", { name = "PLAYER", level = 30 }) + game.stack.states, f.result.loader.game = game.stack.list, game + game.save.options.ruleset = "gen3_emerald" + local mon = game.save.party[1] + mon.nickname, mon.moves = + "PLAYER", { + { id = f.ids.TACKLE, pp = 10 }, + { id = f.ids.DISABLE, pp = 10 }, + { id = f.ids.TACKLE, pp = 10 }, + { id = f.ids.SCRATCH, pp = 10 }, + } + mon.hp, mon.stats.hp = 400, 400 + local b = require("src.battle.BattleState").newWild(game, "CHARMANDER", 30) + b:syncSides() + b.player.lastMove = f.ids.TACKLE + b.enemy.mon.nickname, b.enemy.mon.hp, b.enemy.mon.stats.hp = "ENEMY", 400, 400 + b.enemy.curMoves = { { id = f.ids.DISABLE, pp = 10 } } + b.enemy.mon.moves = b.enemy.curMoves + b.phase, b.queue, b.frame, b.moveIndex = "moveSelect", {}, 0, 1 + b.player.curStats.speed, b.enemy.curStats.speed = 100, 200 + b.rng = function(_, hi) + return hi + end + game.stack.list[1] = b + local used, declared = {}, nil + local off = f.e.api.events:on("battle.move_used", function(e) + used[#used + 1] = e.move.id + end) + local offStart = f.e.api.events:on("battle.turn_started", function(e) + declared = e.playerAction.id + end) + f.e.choose(function() + return { move = "DISABLE" } + end) + local api = f.e.api.battle + local snapshot = assert(api:snapshot()) + T.eq(snapshot.moves[1].disabled, false, "selected attack is initially available") + local ok, why = api:submit({ id = 1, revision = snapshot.revision, kind = "move", slot = 1 }) + T.eq(ok, true, "public command queues the ordinary selected move: " .. tostring(why)) + for _ = 1, 18000 do + if b.phase == "menu" and b.turnCount == 1 then + break + end + require("src.core.Input").pressed = { a = true } + game.stack:update(1 / 60) + end + T.eq(b.phase, "menu", "same-turn interruption returns to the native menu") + T.eq(b.turnCount, 1, "Disable interruption consumes one full turn") + T.eq(mon.moves[1].pp, 10, "already queued disabled action spends no PP") + T.eq(b.enemy.curMoves[1].pp, 9, "Disable itself spends its real slot PP") + T.eq(b.player.lastMove, nil, "cancellation clears last executed move") + T.eq(b.player.disabledMove, f.ids.TACKLE, "same-turn Disable uses the previous executed move") + T.eq(b.player.disabledTurns, 4, "five-phase timer ages once at end of turn") + T.same(used, { "DISABLE" }, "cancelled action emits no move-used event") + T.eq(declared, "TACKLE", "turn-start still reports the original declaration") + off() + offStart() + b.phase = "moveSelect" + snapshot = assert(api:snapshot()) + T.eq(snapshot.moves[1].disabled, true, "public snapshot reports native Disable") + T.eq(snapshot.moves[3].disabled, true, "duplicate learned move is also disabled") + T.eq(snapshot.moves[4].disabled, false, "a different learned move remains available") + local timer, rng = b.player.disabledTurns, b.rng + b.rng = function() + error("read-only admission drew RNG") + end + for _, slot in ipairs({ 1, 3 }) do + local accepted, reason = api:submit({ id = 2, revision = snapshot.revision, kind = "move", slot = slot }) + T.eq(accepted, nil, "public command rejects every disabled duplicate") + T.eq(reason, "move is disabled", "native refusal explains the current restriction") + end + T.eq(b.player.disabledTurns, timer, "snapshot and admission never age the timer") + b.rng = rng + b:swapMoves(1, 4, b.player) + local accepted = api:submit({ id = 2, revision = snapshot.revision, kind = "move", slot = 1 }) + T.eq(accepted, nil, "a real move reorder invalidates the old offered command") + snapshot = assert(api:snapshot()) + T.eq(snapshot.moves[1].disabled, false, "reordered replacement is available") + T.eq(snapshot.moves[4].disabled, true, "Disable follows the moved identity") + b.player.disabledTurns = timer - 1 + accepted = api:submit({ id = 2, revision = snapshot.revision, kind = "move", slot = 1 }) + T.eq(accepted, nil, "changed timer invalidates the old offered context") + snapshot = assert(api:snapshot()) + b.player.disabledMove = f.ids.SCRATCH + accepted = api:submit({ id = 2, revision = snapshot.revision, kind = "move", slot = 3 }) + T.eq(accepted, nil, "changed disabled identity invalidates the old offered context") + local ai = require("src.battle.Gen3AI").OPS + local vm = { user = b.player, target = b.enemy, moveIndex = 1 } + T.eq( + ai.if_curr_move_disabled_or_encored(vm, { 0, 123 }), + 123, + "native AI current-move predicate reads identity" + ) + T.eq( + ai.if_any_move_disabled_or_encored(vm, { 1, 0, 124 }), + 124, + "native AI any-move predicate reads native marker" + ) + b.player.curMoves[1].id = f.ids.TACKLE + T.eq(D.slot(b.player), nil, "removal does not retain a stale slot") + T.eq( + ai.if_curr_move_disabled_or_encored(vm, { 0, 123 }), + nil, + "native AI current predicate sees removal immediately" + ) + T.eq( + ai.if_any_move_disabled_or_encored(vm, { 1, 0, 124 }), + 124, + "native marker remains until its residual phase" + ) + b:gen3BattlerCountersTick(b.player) + T.eq(b.player.disabledMove, nil, "real native residual removes the now unknown move") + local raw = require("src.battle.MoveEffects").RECORDS.DISABLE_EFFECT + T.neq(f.data.move_effects.DISABLE_EFFECT.run, raw.run, "native adapter is installed per dataset") + f.close() + for _, mode in ipairs({ "override", "delegate" }) do + -- Capture the actual public builtin during mod load, not after installation. + local entry = 'return function(mod) local base = assert(mod.content.move_effects:get("DISABLE_EFFECT")); local run = base.run; base.run=function(ctx) ' + .. (mode == "delegate" and "return run(ctx)" or 'return {"CUSTOM"}') + .. ' end; assert(mod.content.move_effects:override("DISABLE_EFFECT", base)); mod.exports.api=mod end' + local g = require("tests.enemy_actions.disable").fixture(".", "gen2", version, entry) + local c = g.fresh("TACKLE", { enemy = true }) + c.battle:syncSides() + c.target.lastMove = g.ids.TACKLE + c.user.curMoves = { { id = g.ids.DISABLE, pp = 10 } } + c.user.mon.moves = c.user.curMoves + c.battle.rng = function(_, hi) + return hi + end + c.battle:executeAction(c.user, c.target, c.user.curMoves[1]) + T.eq( + c.target.disabledMove, + mode == "delegate" and g.ids.TACKLE or nil, + "public override retains its native delegate or its own policy" + ) + T.eq( + c.target.disabledTurns, + mode == "delegate" and 5 or nil, + "public builtin delegate uses native duration" + ) + T.eq(c.user.curMoves[1].pp, 9, "public override retains real PP ownership") + T.eq( + raw.run, + require("src.battle.MoveEffects").RECORDS.DISABLE_EFFECT.run, + "loads never mutate shared legacy statics" + ) + g.close() + end + for _, mode in ipairs({ "switch", "baton" }) do + local g = require("tests.enemy_actions.disable").fixture(".", "gen2", version) + local c = g.fresh("TACKLE") + local battle, donor = c.battle, c.user + battle:syncSides() + local bench = Copy(donor.mon) + bench.nickname = "BENCH" + battle.game.save.party[2] = bench + donor.disabledMove, donor.disabledTurns = g.ids.TACKLE, 4 + if mode == "switch" then + battle:switchPlayerInto(0, bench) + else + require("src.battle.Gen3BatonPass").switch(battle, donor, 2, battle.game.save.party) + end + T.eq(battle.player.mon, bench, "actual " .. mode .. " replaces the active Pokemon") + T.eq(battle.player.disabledMove, nil, "actual " .. mode .. " never transfers Disable identity") + T.eq(battle.player.disabledTurns, nil, "actual " .. mode .. " never transfers Disable duration") + g.close() + end +end +for _, key in ipairs({ "disabledMove", "disabledTurns" }) do + T.neq(D.signature({ [key] = 1 }), D.signature({}), "native link signature includes " .. key) +end +for _, version in ipairs({ "red", "gold", "diamond", "black" }) do + V.set(version) + local raw = require("src.battle.MoveEffects").RECORDS.DISABLE_EFFECT + T.eq(D.builtin("DISABLE_EFFECT", raw), raw, "other generations keep the original builtin identity") + local target = { name = "LEGACY", curMoves = { { id = "A", pp = 1 }, { id = "B", pp = 1 } } } + raw.run({ + battle = { + data = { moves = { A = { name = "A" }, B = { name = "B" } } }, + rng = function(_, hi) + return hi + end, + }, + target = target, + }) + T.eq(target.disabledSlot, 2, "older games retain random usable-slot selection") + T.eq(target.disabledTurns, 8, "older games retain their legacy duration") + T.eq(target.disabledMove, nil, "native identity does not leak to older games") +end +T.finish("native Disable public admission, queued execution and builtin delegation") diff --git a/tests/engine/gen3_disable_link.lua b/tests/engine/gen3_disable_link.lua new file mode 100644 index 000000000..9a3e59bd8 --- /dev/null +++ b/tests/engine/gen3_disable_link.lua @@ -0,0 +1,92 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T, Copy = require("tests.modkit"), require("src.mods.Merge").deepCopy +for _, version in ipairs({ "firered", "emerald" }) do + local f = require("tests.enemy_actions.disable").fixture(".", "gen2", version) + local H = require("src.link.Handshake") + local current = H.hello(f.game, "battle") + local older = Copy(current) + older.nativeBattleRevision = 4 + T.eq(H.checkCompat(current, older), "subset", "pre-Disable rules cannot enter a full battle") + T.eq(H.checkCompat(older, current), "subset", "native revision guard is symmetric") + for _, host in ipairs({ false, true }) do + local state = setmetatable( + { game = f.game, myHello = current, peerHello = older }, + { __index = require("src.link.LinkState") } + ) + state:decideCompat("battle", host) + T.eq(state.stage, "notice", "actual controller refuses the old native rules") + T.eq(state.noticeExits, true, "old native rules cannot enter party selection") + end + local def = f.data.pokemon.CHARMANDER + def.evYield = {} + def.baseStats.spatk, def.baseStats.spdef, def.baseStats.hp = 50, 50, 200 + T.link.prepare(f.data) + local ga = T.link.fakeGame(f.data, "CHARMANDER", { name = "HOST", level = 30 }) + local gb = T.link.fakeGame(f.data, "CHARMANDER", { name = "GUEST", level = 30 }) + for _, game in ipairs({ ga, gb }) do + game.save.party[1].moves = + { { id = f.ids.TACKLE, pp = 10 }, { id = f.ids.DISABLE, pp = 10 }, { id = f.ids.SCRATCH, pp = 10 } } + end + f.e.api.hooks:wrap("battle.crit", function() + return false + end) + local P, N, L = require("src.link.Protocol"), require("src.link.Net"), require("src.link.LinkBattle") + local na, nb = N.loopbackPair() + local pa, pb = P.packParty(ga.save.party), P.packParty(gb.save.party) + local a = assert( + L.newHost( + ga, + na, + { myParty = pa, theirParty = pb, theirName = "GUEST", seed = 327, verdict = "full", strict = true } + ) + ) + local b = assert( + L.newGuest( + gb, + nb, + { myParty = pb, theirParty = pa, theirName = "HOST", seed = 327, verdict = "full", strict = true } + ) + ) + ga.stack:push(a) + gb.stack:push(b) + a.player.curStats.speed, a.enemy.curStats.speed = 200, 100 + b.player.curStats.speed, b.enemy.curStats.speed = 100, 200 + local seen = {} + for _ = 1, 40000 do + for i, game in ipairs({ ga, gb }) do + local battle = i == 1 and a or b + local turn = battle.turnCount or 0 + battle.moveIndex = turn == 0 and 1 or turn == 1 and (i == 1 and 2 or 1) or 3 + require("src.core.Input").pressed = { a = true } + game.stack:update(1 / 60) + end + if a.localHashes[2] and b.localHashes[2] and not seen[2] then + seen[2] = true + T.eq(a.enemy.disabledMove, f.ids.TACKLE, "host retains the remote disabled move") + T.eq(b.player.disabledMove, f.ids.TACKLE, "guest retains its own disabled move") + T.eq( + a.enemy.disabledTurns, + b.player.disabledTurns, + "native Disable timer agrees after a real full turn" + ) + end + if a.localHashes[3] and b.localHashes[3] then + break + end + end + T.check(seen[2], "the native Disable application turn completes on both roles") + for turn = 1, 3 do + T.check(a.localHashes[turn] ~= nil and b.localHashes[turn] ~= nil, "native full linked turn completes") + T.eq(a.localHashes[turn], b.localHashes[turn], "paired completed turn hashes agree") + T.same(a.localParts[turn], b.localParts[turn], "all active, volatile and bench components agree") + end + for _, battle in ipairs({ a, b }) do + local guest = battle == a and battle.enemy or battle.player + T.eq(guest.mon.moves[1].pp, 9, "disabled already-selected Tackle spends no extra PP") + T.eq(guest.mon.moves[2].pp, 10, "the guest never selects Disable") + T.eq(guest.mon.moves[3].pp, 9, "an independently selected different move executes normally") + T.eq(battle.result, nil, "no hidden choice or hash failure ends the native mirror") + end + f.close() +end +T.finish("native Disable real link menus and completed turn hashes") diff --git a/tests/engine/gen3_effect_presentation.lua b/tests/engine/gen3_effect_presentation.lua new file mode 100644 index 000000000..cd07a93d7 --- /dev/null +++ b/tests/engine/gen3_effect_presentation.lua @@ -0,0 +1,62 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +for _, version in ipairs({ "firered", "emerald" }) do + local rows = dofile("tests/enemy_actions/battle_effect_presentation.lua").run(".", "gen2", version) + T.eq(#rows, 192, "all native effects/actors/options/protection inputs execute") + for _, row in ipairs(rows) do + local last = row.frames[#row.frames] + T.check(last.done, "actual native effect queue completes") + for _, frame in ipairs(row.frames) do + T.eq(frame.rng, 0, "presentation never consumes battle RNG") + end + local positive = row.scene + and (row.case == "ordinary" or row.name == "FOCUS_BAND" and row.case == "lowered-substitute") + if positive and row.name == "POISON" then + T.eq(#row.frames, 72, "native poison waits for all six sounds and visual tasks") + T.eq(row.frames[2].states[row.actor + 1].x, 1, "native initial shake starts on the actual actor") + T.eq( + row.frames[27].states[row.actor + 1].coeff, + 0.75, + "native first color cycle reaches twelve of sixteen" + ) + T.eq(last.states[row.actor + 1].x, 0, "native shake restores its original coordinate") + T.eq(last.states[row.actor + 1].coeff, 0, "native palette cycle releases its tint") + for i, sound in ipairs(row.sounds) do + T.eq(sound.tick, 1 + (i - 1) * 14, "native loop counter owns the sound timing") + T.eq(sound.id, version == "firered" and 141 or 148, "native sound index follows the actual edition") + T.eq(sound.pan, row.actor % 2 == 0 and -64 or 63, "actual output source uses native side panorama") + T.eq(sound.mono, not row.stereo, "actual output source respects the native sound option") + end + elseif positive then + T.eq(#row.frames, 123, "native Focus Band waits for both fades, delay and signed fixed-point return") + T.eq(#row.sounds, 1, "native Focus Band plays exactly one sound") + local index = row.actor + 1 + T.eq(row.frames[2].states[index].x, 0, "motion initialization does not advance its ramp") + T.eq(row.frames[48].states[index].coeff, 9 / 16, "odd fade target is clamped to nine of sixteen") + T.eq( + row.frames[62].states[index].x, + row.actor % 2 == 0 and -13 or 17, + "native side-specific ramp and jitter" + ) + T.eq( + row.frames[122].states[index].x, + row.actor % 2 == 0 and -1 or 0, + "signed return truncates then shifts" + ) + T.eq(last.states[index].x, 0, "completed Focus Band restores actor position") + T.eq(last.states[index].coeff, 0, "completed Focus Band releases palette ownership") + local sound = row.sounds[1] + T.eq(sound.tick, 1, "native Focus Band sound starts on its first update") + T.eq(sound.id, version == "firered" and 164 or 171, "native Focus Band sound follows edition") + T.eq(sound.pan, row.actor % 2 == 0 and -64 or 63, "Focus Band sound uses the actual actor's panorama") + T.eq(sound.mono, not row.stereo, "Focus Band sound respects native mono/stereo") + elseif row.name == "FOCUS_BAND" and not row.scene then + T.eq(#row.frames, 34, "disabled general animations retain their native 32-frame pause") + T.eq(#row.sounds, 0, "disabled general animations are silent") + else + T.eq(#row.frames, 1, "native skipped status animation adds no waiting tick") + T.eq(#row.sounds, 0, "native skipped status animation is silent") + end + end +end +T.finish("native GBA status and Focus Band presentation with actual sound controls") diff --git a/tests/engine/gen3_encore.lua b/tests/engine/gen3_encore.lua new file mode 100644 index 000000000..a563c04d7 --- /dev/null +++ b/tests/engine/gen3_encore.lua @@ -0,0 +1,5 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +for _, version in ipairs({ "firered", "emerald" }) do + require("tests.enemy_actions.encore").run(".", "gen2", version) +end +require("tests.modkit").finish("native GBA Encore production, execution and expiry") diff --git a/tests/engine/gen3_encore_continuation.lua b/tests/engine/gen3_encore_continuation.lua new file mode 100644 index 000000000..73beeadb0 --- /dev/null +++ b/tests/engine/gen3_encore_continuation.lua @@ -0,0 +1,144 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T, Copy = require("tests.modkit"), require("src.mods.Merge").deepCopy +local Input = require("src.core.Input") +for _, version in ipairs({ "firered", "emerald" }) do + for _, selection in ipairs({ "fight", "late_move", "late_priority" }) do + local f = require("tests.enemy_actions.encore").fixture(".", "gen2", version) + T.link.prepare(f.data) + local game = T.link.fakeGame(f.data, "CHARMANDER", { name = "PLAYER", level = 30 }) + game.stack.states = game.stack.list + f.result.loader.game = game + local mon = game.save.party[1] + mon.nickname = "PLAYER" + mon.moves = + { { id = f.ids.TACKLE, pp = 10 }, { id = f.ids.ENCORE, pp = 10 }, { id = f.ids.SCRATCH, pp = 10 } } + mon.hp, mon.stats.hp = 400, 400 + local b = require("src.battle.BattleState").newWild(game, "CHARMANDER", 30) + b:syncSides() + b.enemy.mon.nickname = "ENEMY" + b.enemy.mon.hp, b.enemy.mon.stats.hp = 400, 400 + b.enemy.curMoves = { { id = f.ids.SCRATCH, pp = 10 } } + b.enemy.mon.moves = b.enemy.curMoves + b.player.encoreMove, b.player.encoreSlot, b.player.encoreTurns = f.ids.TACKLE, 1, 4 + b.player.lastMove = f.ids.TACKLE + b.phase, b.queue, b.frame, b.menuIndex, b.moveIndex = "menu", {}, 0, 1, 1 + if selection ~= "fight" then + b.phase = "moveSelect" + end + if selection == "late_priority" then + f.data.moves[f.ids.TACKLE].priority = -1 + b.player.curStats.speed, b.enemy.curStats.speed = 200, 100 + end + game.stack.list[1] = b + f.e.choose(function() + return { move = "SCRATCH" } + end) + local used = {} + local declared + local offStart = f.e.api.events:on("battle.turn_started", function(e) + declared = e.playerAction.id + end) + local off = f.e.api.events:on("battle.move_used", function(e) + used[#used + 1] = { user = e.user.mon.nickname, move = e.move.id } + end) + f.e.api.hooks:wrap("battle.crit", function() + return false + end) + local api = f.e.api.battle + local before = assert(api:snapshot()) + local raw = { id = 1, revision = before.revision, kind = selection == "fight" and "menu" or "move" } + if selection == "fight" then + raw.choice = "fight" + else + raw.slot = 3 + end + if selection == "late_move" then + b.player.encoreSlot = 2 + local accepted, why = api:submit(raw) + T.eq(accepted, nil, "a changed Encore slot invalidates the previously offered intent") + T.check(type(why) == "string", "stale Encore context returns an explanation") + b.player.encoreSlot = 1 + raw.revision = assert(api:snapshot()).revision + end + local accepted, why = api:submit(raw) + T.eq(accepted, true, "public command reaches native Encore continuation: " .. tostring(why)) + for _ = 1, 18000 do + if b.phase == "menu" and b.turnCount == 1 then + break + end + Input.pressed = { a = true } + game.stack:update(1 / 60) + end + T.eq(b.phase, "menu", "Encore returns to the normal native menu") + T.eq(b.turnCount, 1, "forced continuation consumes exactly one turn") + T.eq(mon.moves[1].pp, 9, "only the forced learned slot spends PP") + T.eq(mon.moves[3].pp, 10, "the previously selected alternative keeps its PP") + T.eq(b.player.encoreTurns, 3, "real end-of-turn processing ages Encore once") + T.check(b.enemy.mon.hp < 400, "the forced move actually damages the opponent") + T.eq(#used, 2, "both native actions execute once") + T.eq( + declared, + selection == "fight" and "TACKLE" or "SCRATCH", + "turn-start event retains the original declared choice" + ) + if selection == "late_priority" then + T.eq( + used[1].user, + "ENEMY", + "turn order uses the forced move priority rather than the superseded choice" + ) + end + local playerMoves = 0 + for _, e in ipairs(used) do + if e.user == mon.nickname then + playerMoves = playerMoves + 1 + T.eq(e.move, "TACKLE", "public event names the actual forced move") + end + end + T.eq(playerMoves, 1, "the player emits one actual move event") + off() + offStart() + f.close() + end + for _, mode in ipairs({ "override", "delegate" }) do + local f = require("tests.enemy_actions.encore").fixture(".", "gen2", version) + local c = f.fresh("TACKLE", { enemy = true }) + local b = c.battle + b:syncSides() + c.target.lastMove = f.ids.TACKLE + c.user.mon.moves = { { id = f.ids.ENCORE, pp = 10 } } + c.user.curMoves = c.user.mon.moves + local custom = Copy(b.data.move_effects.ENCORE_EFFECT) + local builtin, calls = custom.run, 0 + custom.run = function(ctx) + calls = calls + 1 + return mode == "delegate" and builtin(ctx) or { "CUSTOM" } + end + b.data.move_effects.ENCORE_EFFECT = custom + b.rng = function(_, hi) + return hi + end + b:executeAction(c.user, c.target, c.user.curMoves[1]) + T.eq(calls, 1, "authored Encore handler runs exactly once") + T.eq(c.user.mon.moves[1].pp, 9, "authored handler keeps native PP ownership") + T.eq( + c.target.encoreTurns, + mode == "delegate" and 6 or nil, + "authored handler retains override or native delegation" + ) + T.eq(c.target.encoreSlot, mode == "delegate" and 1 or nil, "native delegation retains slot binding") + f.close() + end +end +local E = require("src.battle.Gen3Encore") +for _, key in ipairs({ "encoreTurns", "encoreMove", "encoreSlot" }) do + T.neq(E.signature({ [key] = 1 }), E.signature({}), "native hashes include " .. key) +end +for _, version in ipairs({ "red", "gold", "diamond", "black" }) do + require("src.core.GameVersion").set(version) + local target = { name = "TARGET", isPlayer = true, lastMove = "LEGACY" } + require("src.battle.Gen3MoveEffects").primary.ENCORE_EFFECT({}, {}, target) + T.eq(target.encoreTurns, 3, "older games keep their existing Encore duration") + T.eq(target.encoreSlot, nil, "older games do not acquire native slot semantics") +end +T.finish("native Encore public commands, queued turns and legacy policies") diff --git a/tests/engine/gen3_encore_link.lua b/tests/engine/gen3_encore_link.lua new file mode 100644 index 000000000..9e90a55f9 --- /dev/null +++ b/tests/engine/gen3_encore_link.lua @@ -0,0 +1,90 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T, Copy = require("tests.modkit"), require("src.mods.Merge").deepCopy +for _, version in ipairs({ "firered", "emerald" }) do + local f = require("tests.enemy_actions.encore").fixture(".", "gen2", version) + local H = require("src.link.Handshake") + local current = H.hello(f.game, "battle") + local older = Copy(current) + older.nativeBattleRevision = 3 + T.eq(H.checkCompat(current, older), "subset", "pre-Encore rules cannot enter a full battle") + T.eq(H.checkCompat(older, current), "subset", "native revision guard is symmetric") + for _, host in ipairs({ false, true }) do + local state = setmetatable( + { game = f.game, myHello = current, peerHello = older }, + { __index = require("src.link.LinkState") } + ) + state:decideCompat("battle", host) + T.eq(state.stage, "notice", "actual controller refuses the old native rules") + T.eq(state.noticeExits, true, "old native rules cannot enter party selection") + end + local def = f.data.pokemon.CHARMANDER + def.evYield = {} + def.baseStats.spatk, def.baseStats.spdef, def.baseStats.hp = 50, 50, 200 + T.link.prepare(f.data) + local ga = T.link.fakeGame(f.data, "CHARMANDER", { name = "HOST", level = 30 }) + local gb = T.link.fakeGame(f.data, "CHARMANDER", { name = "GUEST", level = 30 }) + for _, game in ipairs({ ga, gb }) do + game.save.party[1].moves = + { { id = f.ids.TACKLE, pp = 10 }, { id = f.ids.ENCORE, pp = 10 }, { id = f.ids.SCRATCH, pp = 10 } } + end + f.e.api.hooks:wrap("battle.crit", function() + return false + end) + local P, N, L = require("src.link.Protocol"), require("src.link.Net"), require("src.link.LinkBattle") + local na, nb = N.loopbackPair() + local pa, pb = P.packParty(ga.save.party), P.packParty(gb.save.party) + local a = assert( + L.newHost( + ga, + na, + { myParty = pa, theirParty = pb, theirName = "GUEST", seed = 327, verdict = "full", strict = true } + ) + ) + local b = assert( + L.newGuest( + gb, + nb, + { myParty = pb, theirParty = pa, theirName = "HOST", seed = 327, verdict = "full", strict = true } + ) + ) + ga.stack:push(a) + gb.stack:push(b) + a.player.curStats.speed, a.enemy.curStats.speed = 200, 100 + b.player.curStats.speed, b.enemy.curStats.speed = 100, 200 + local seen = {} + for _ = 1, 40000 do + for i, game in ipairs({ ga, gb }) do + local battle = i == 1 and a or b + local turn = battle.turnCount or 0 + battle.moveIndex = turn == 0 and 1 or turn == 1 and 2 or 3 + require("src.core.Input").pressed = { a = true } + game.stack:update(1 / 60) + end + if a.localHashes[2] and b.localHashes[2] and not seen[2] then + seen[2] = true + T.eq(a.enemy.encoreMove, f.ids.TACKLE, "host retains the remote forced move") + T.eq(b.player.encoreMove, f.ids.TACKLE, "guest retains its own forced move") + T.eq(a.enemy.encoreSlot, 1, "host retains the native original slot") + T.eq(b.player.encoreSlot, 1, "guest retains the native original slot") + T.eq(a.enemy.encoreTurns, b.player.encoreTurns, "native duration agrees after a real full turn") + end + if a.localHashes[3] and b.localHashes[3] then + break + end + end + T.check(seen[2], "the native Encore application turn completes on both roles") + for turn = 1, 3 do + T.check(a.localHashes[turn] ~= nil and b.localHashes[turn] ~= nil, "native full linked turn completes") + T.eq(a.localHashes[turn], b.localHashes[turn], "paired completed turn hashes agree") + T.same(a.localParts[turn], b.localParts[turn], "all active, volatile and bench components agree") + end + for _, battle in ipairs({ a, b }) do + local guest = battle == a and battle.enemy or battle.player + T.eq(guest.mon.moves[1].pp, 7, "remote and local forced Tackle consume their real slot three times") + T.eq(guest.mon.moves[2].pp, 10, "Encore prevents the slower already-selected Encore action") + T.eq(guest.mon.moves[3].pp, 10, "Encore prevents the next independently selected Scratch action") + T.eq(battle.result, nil, "no hidden choice or hash failure ends the native mirror") + end + f.close() +end +T.finish("native Encore real link menus and completed turn hashes") diff --git a/tests/engine/gen3_follow_me.lua b/tests/engine/gen3_follow_me.lua new file mode 100644 index 000000000..eafe64a2e --- /dev/null +++ b/tests/engine/gen3_follow_me.lua @@ -0,0 +1,18 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +for _, version in ipairs({ "firered", "emerald" }) do + require("tests.enemy_actions.follow_me").run(".", "gen2", version) +end +local V = require("src.core.GameVersion") +local F = require("src.battle.Gen3FollowMe") +local previous = V.get() +for _, version in ipairs({ "red", "gold", "platinum", "black" }) do + V.set(version) + local user = { isPlayer = true } + local battle = { player = user, sides = { {}, { _gen3FollowMePosition = 1 } } } + T.eq(F.center(battle, user), nil, version .. " ignores GBA targeting state") + F.apply(battle, user) + T.eq(battle.sides[1]._gen3FollowMePosition, nil, version .. " does not produce GBA state") +end +V.set(previous) +T.finish("native GBA Follow Me") diff --git a/tests/engine/gen3_held_damage.lua b/tests/engine/gen3_held_damage.lua new file mode 100644 index 000000000..2b4e2185b --- /dev/null +++ b/tests/engine/gen3_held_damage.lua @@ -0,0 +1,5 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +for _, version in ipairs({ "firered", "emerald" }) do + dofile("tests/held_item/damage.lua").run(".", "gen2", version) +end +require("tests.modkit").finish("native GBA held damage command") diff --git a/tests/engine/gen3_held_damage_link.lua b/tests/engine/gen3_held_damage_link.lua new file mode 100644 index 000000000..7743b832e --- /dev/null +++ b/tests/engine/gen3_held_damage_link.lua @@ -0,0 +1,100 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T, Copy = require("tests.modkit"), require("src.mods.Merge").deepCopy +for _, version in ipairs({ "firered", "emerald" }) do + local f = require("tests.held_item.fixture").new(".", "gen2", version, 3, "unified") + local H = require("src.link.Handshake") + local current = H.hello(f.game, "battle") + local older = Copy(current) + older.nativeBattleRevision = 9 + T.check(current.nativeBattleRevision >= 10, "native held damage advertises revised commands") + T.eq(H.checkCompat(current, older), "subset", "old held commands cannot enter a full battle") + T.eq(H.checkCompat(older, current), "subset", "revised held commands reject symmetrically") + for _, host in ipairs({ false, true }) do + local state = setmetatable( + { game = f.game, myHello = current, peerHello = older }, + { __index = require("src.link.LinkState") } + ) + state:decideCompat("battle", host) + T.eq(state.stage, "notice", "actual link controller refuses before party exchange") + T.eq(state.noticeExits, true, "revision mismatch cannot start a battle") + end + T.link.prepare(f.data) + local ga = T.link.fakeGame(f.data, "CHARMANDER", { name = "HOST", level = 30 }) + local gb = T.link.fakeGame(f.data, "CHARMANDER", { name = "GUEST", level = 30 }) + for i, game in ipairs({ ga, gb }) do + game.save.version, game.save.options.ruleset = version, "gen3_emerald" + local mon = game.save.party[1] + mon.nickname = i == 1 and "HOST" or "GUEST" + mon.moves = { { id = f.ids.TACKLE, pp = 10 } } + end + -- The same public override is active in both simulations. It must be read + -- afresh for each base command, never separately by type/stat consumers. + local seen = setmetatable({}, { __mode = "k" }) + f.e.api.hooks:wrap("held_item.trigger", function(nextRead, ctx) + if ctx.trigger == "damage" then + local trace = seen[ctx.battle] or {} + seen[ctx.battle] = trace + trace[#trace + 1] = { holder = ctx.mon.nickname, item = ctx.item, parameter = ctx.parameter } + if ctx.mon.nickname == "HOST" then + return "HELD_NORMAL_BOOST", 13 + end + end + return nextRead(ctx) + end) + f.e.api.hooks:wrap("battle.crit", function() + return false + end) + local P, N, L = require("src.link.Protocol"), require("src.link.Net"), require("src.link.LinkBattle") + local na, nb = N.loopbackPair() + local pa, pb = P.packParty(ga.save.party), P.packParty(gb.save.party) + local a = assert( + L.newHost( + ga, + na, + { myParty = pa, theirParty = pb, theirName = "GUEST", seed = 327, verdict = "full", strict = true } + ) + ) + local b = assert( + L.newGuest( + gb, + nb, + { myParty = pb, theirParty = pa, theirName = "HOST", seed = 327, verdict = "full", strict = true } + ) + ) + ga.stack:push(a) + gb.stack:push(b) + for _, battle in ipairs({ a, b }) do + T.eq(battle.ruleset.baseDamageStyle, "gba", "actual linked battle uses the GBA damage profile") + for _, actor in ipairs({ battle.player, battle.enemy }) do + actor.mon.hp, actor.mon.stats.hp = 1000, 1000 + actor.curStats.attack, actor.curStats.defense, actor.curTypes = 51, 51, { "NORMAL" } + actor.stages.attack, actor.stages.defense = 1, -1 + end + end + for _ = 1, 40000 do + for i, game in ipairs({ ga, gb }) do + local battle = i == 1 and a or b + battle.moveIndex = 1 + require("src.core.Input").pressed = { a = true } + game.stack:update(1 / 60) + end + if a.localHashes[2] and b.localHashes[2] then + break + end + end + for turn = 1, 2 do + T.check(a.localHashes[turn] ~= nil and b.localHashes[turn] ~= nil, "both menu-driven turns complete") + T.eq(a.localHashes[turn], b.localHashes[turn], "paired completed turn hashes agree") + T.same(a.localParts[turn], b.localParts[turn], "every active/volatile/bench component agrees") + end + T.same(seen[a], seen[b], "both real link roles see the same ordered held damage reads") + T.eq(#(seen[a] or {}), 8, "two reads per attack across four actual attacks") + for _, battle in ipairs({ a, b }) do + T.eq(battle.player.mon.moves[1].pp, 8, "two public menu selections spend real learned PP") + T.check(battle.player.mon.hp < 1000 and battle.enemy.mon.hp < 1000, "real attacks damage both battlers") + T.eq(battle.result, nil, "no hidden link or choice failure ends the mirror") + end + T.eq(#f.result.errors, 0, "all real callback chains remain error-free") + f.close() +end +T.finish("native held damage real link menus and command compatibility") diff --git a/tests/engine/gen3_helping_hand.lua b/tests/engine/gen3_helping_hand.lua new file mode 100644 index 000000000..f680de3b4 --- /dev/null +++ b/tests/engine/gen3_helping_hand.lua @@ -0,0 +1,20 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +for _, version in ipairs({ "firered", "emerald" }) do + require("tests.enemy_actions.helping_hand").run(".", "gen2", version) +end +local V, H = require("src.core.GameVersion"), require("src.battle.Gen3HelpingHand") +local previous = V.get() +for _, version in ipairs({ "red", "gold", "platinum", "black" }) do + V.set(version) + local partner, user = { mon = { hp = 100 } }, { _gen3HelpingHand = true } + H.apply({ + partnerOf = function() + return partner + end, + }, user) + T.eq(partner._gen3HelpingHand, nil, version .. " does not produce GBA support") + T.eq(H.damage(user, 11), 11, version .. " ignores GBA support state") +end +V.set(previous) +T.finish("native GBA Helping Hand") diff --git a/tests/engine/gen3_lock_on.lua b/tests/engine/gen3_lock_on.lua new file mode 100644 index 000000000..98af90ae8 --- /dev/null +++ b/tests/engine/gen3_lock_on.lua @@ -0,0 +1,5 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +for _, version in ipairs({ "firered", "emerald" }) do + require("tests.enemy_actions.lock_on").run(".", "gen2", version) +end +require("tests.modkit").finish("native target-owned Lock-On lifecycle") diff --git a/tests/engine/gen3_lock_on_link.lua b/tests/engine/gen3_lock_on_link.lua new file mode 100644 index 000000000..2c0a6e476 --- /dev/null +++ b/tests/engine/gen3_lock_on_link.lua @@ -0,0 +1,128 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local L, Copy = require("src.battle.Gen3LockOn"), require("src.mods.Merge").deepCopy +local target = { lockOnTurns = 2, lockOnBy = true, lockOnByPos = 0 } +T.eq( + L.signature(target, "host"), + L.signature({ lockOnTurns = 2, lockOnBy = false, lockOnByPos = 1 }, "guest"), + "native ownership hashes in canonical host coordinates" +) +for _, key in ipairs({ "lockOnTurns", "lockOnBy", "lockOnByPos" }) do + local changed = Copy(target) + if key == "lockOnBy" then + changed[key] = false + else + changed[key] = target[key] + 1 + end + T.neq(L.signature(changed), L.signature(target), "native active signature detects " .. key) +end +for _, version in ipairs({ "firered", "emerald" }) do + local f = require("tests.enemy_actions.lock_on").fixture(".", "gen2", version) + local H = require("src.link.Handshake") + local current = H.hello(f.game, "battle") + local oldRules = Copy(current) + oldRules.nativeBattleRevision = 1 + T.eq( + H.checkCompat(current, oldRules), + "subset", + "older native rules cannot battle even with the same custom fingerprint" + ) + T.eq(H.checkCompat(oldRules, current), "subset", "native rule gate is symmetric") + for _, host in ipairs({ false, true }) do + local state = setmetatable( + { game = f.game, myHello = current, peerHello = oldRules }, + { __index = require("src.link.LinkState") } + ) + state:decideCompat("battle", host) + T.eq(state.stage, "notice", "actual controller refuses older native rule revision") + T.eq(state.noticeExits, true, "old native rules cannot enter battle setup") + end + local def = f.data.pokemon.CHARMANDER + def.evYield = {} + def.baseStats.spatk, def.baseStats.spdef = 50, 50 + T.link.prepare(f.data) + local ga = T.link.fakeGame(f.data, "CHARMANDER", { name = "A", level = 30 }) + local gb = T.link.fakeGame(f.data, "CHARMANDER", { name = "B", level = 30 }) + for _, game in ipairs({ ga, gb }) do + game.save.party[1].moves = + { { id = f.ids.LOCK_ON, pp = 20 }, { id = f.ids.TACKLE, pp = f.data.moves[f.ids.TACKLE].pp } } + end + local P, Net, Link = require("src.link.Protocol"), require("src.link.Net"), require("src.link.LinkBattle") + local na, nb = Net.loopbackPair() + local pa, pb = P.packParty(ga.save.party), P.packParty(gb.save.party) + local a = assert( + Link.newHost( + ga, + na, + { myParty = pa, theirParty = pb, theirName = "B", seed = 327, verdict = "full", strict = true } + ) + ) + local b = assert( + Link.newGuest( + gb, + nb, + { myParty = pb, theirParty = pa, theirName = "A", seed = 327, verdict = "full", strict = true } + ) + ) + local ra, rb, seenA, seenB = nil, nil, {}, {} + a.onFinish = function(v) + ra = v + end + b.onFinish = function(v) + rb = v + end + ga.stack:push(a) + gb.stack:push(b) + local function capture(battle, seen) + for turn in pairs(battle.localHashes) do + if not seen[turn] then + seen[turn] = { player = battle.player.lockOnTurns, enemy = battle.enemy.lockOnTurns } + end + end + end + local frames = 0 + while (not ra or not rb) and frames < 60000 do + frames = frames + 1 + require("src.core.Input").pressed = { a = true } + -- Drive the real controller: aim once, then select the damaging move. + a.moveIndex = (a.turnCount or 0) > 0 and 2 or 1 + b.moveIndex = (b.turnCount or 0) > 0 and 2 or 1 + ga.stack:update(1 / 60) + capture(a, seenA) + gb.stack:update(1 / 60) + capture(b, seenB) + end + T.check(ra ~= nil and rb ~= nil, "complete native sure-hit link mirror finishes") + T.check(seenA[1] ~= nil and seenB[1] ~= nil, "first native turn is captured on both peers") + if seenA[1] and seenB[1] then + T.eq(seenA[1].player, 1, "host mark survives the first residual") + T.eq(seenA[1].enemy, 1, "remote mark survives the first residual") + T.same(seenA[1], seenB[1], "both actual link roles retain their timed bindings") + end + if seenA[2] and seenB[2] then + T.eq(seenA[2].player, nil, "host mark expires after the next turn") + T.eq(seenA[2].enemy, nil, "remote mark expires after the next turn") + T.same(seenA[2], seenB[2], "both actual roles expire the binding together") + else + T.check(false, "second turn must complete") + end + local compared = 0 + for turn, hash in pairs(a.localHashes) do + if b.localHashes[turn] then + compared = compared + 1 + T.eq(hash, b.localHashes[turn], "complete native turn hash agrees") + T.same(a.localParts[turn], b.localParts[turn], "native owner/duration components agree") + end + end + T.check(compared >= 2, "native mirror compares multiple full turns") + f.close() +end +require("src.core.GameVersion").set("red") +local old = { + player = { name = "PLAYER", isPlayer = true, mon = { hp = 10 } }, + enemy = { name = "ENEMY", isPlayer = false, mon = { hp = 10 } }, +} +require("src.battle.Gen3MoveEffects").primary.LOCK_ON_EFFECT(old, old.player, old.enemy) +T.eq(old.player.lockedOn, old.enemy, "older games keep their existing one-use pointer producer") +T.eq(old.enemy.lockOnTurns, nil, "older games do not acquire a native timer") +T.finish("native Lock-On link mirrors and ownership signatures") diff --git a/tests/engine/gen3_mirror_link.lua b/tests/engine/gen3_mirror_link.lua new file mode 100644 index 000000000..33d7c9c20 --- /dev/null +++ b/tests/engine/gen3_mirror_link.lua @@ -0,0 +1,139 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T, Copy = require("tests.modkit"), require("src.mods.Merge").deepCopy +for _, version in ipairs({ "firered", "emerald" }) do + local f = dofile("tests/enemy_actions/assist.lua").fixture(".", "gen2", version) + local H = require("src.link.Handshake") + local current = H.hello(f.game, "battle") + local old = Copy(current) + old.nativeBattleRevision = 16 + T.check(current.nativeBattleRevision >= 17, "native presentation commands advertise at least revision seventeen") + T.eq(H.checkCompat(current, old), "subset", "old command order cannot admit lockstep") + for _, host in ipairs({ false, true }) do + local state = setmetatable( + { game = f.game, myHello = current, peerHello = old }, + { __index = require("src.link.LinkState") } + ) + state:decideCompat("battle", host) + T.eq(state.stage, "notice", "old ordering is refused before party exchange") + end + T.link.prepare(f.data) + local ga = T.link.fakeGame(f.data, "CHARMANDER", { name = "HOST", level = 30 }) + local gb = T.link.fakeGame(f.data, "CHARMANDER", { name = "GUEST", level = 30 }) + local gs = T.link.fakeGame(f.data, "CHARMANDER", { name = "VIEWER", level = 30 }) + for i, game in ipairs({ ga, gb, gs }) do + game.save.version, game.save.options.ruleset = version, "gen3_emerald" + game.save.party[1].nickname = i == 1 and "HOST" or "GUEST" + game.save.party[1].moves = { { id = i == 1 and "MOVE_119" or "MOVE_033", pp = 10 } } + game.save.party[1].item = nil + end + local seen = setmetatable({}, { __mode = "k" }) + f.e.api.events:on("battle.move_used", function(ctx) + local trace = seen[ctx.battle] or {} + seen[ctx.battle] = trace + local slot = ctx.user.mon.moves[1] + trace[#trace + 1] = { + user = ctx.user.mon.nickname, + target = ctx.target.mon.nickname, + move = ctx.move.id, + pp = slot.pp, + called = ctx.isCalled or false, + } + end) + f.e.api.hooks:wrap("battle.crit", function() + return false + end) + local P, N, L = require("src.link.Protocol"), require("src.link.Net"), require("src.link.LinkBattle") + local na, nb = N.loopbackPair() + local inbox = {} + local sn = { + update = function() end, + poll = function() + local got = inbox + inbox = {} + return got + end, + } + for i, net in ipairs({ na, nb }) do + local send, side = net.send, i == 1 and "host" or "guest" + net.send = function(self, packet) + inbox[#inbox + 1] = { type = "spectate", side = side, msg = Copy(packet) } + return send(self, packet) + end + end + local pa, pb = P.packParty(ga.save.party), P.packParty(gb.save.party) + local a = assert( + L.newHost( + ga, + na, + { myParty = pa, theirParty = pb, theirName = "GUEST", seed = 327, verdict = "full", strict = true } + ) + ) + local b = assert( + L.newGuest( + gb, + nb, + { myParty = pb, theirParty = pa, theirName = "HOST", seed = 327, verdict = "full", strict = true } + ) + ) + local s = assert(L.newSpectator(gs, sn, { + hostParty = pa, + guestParty = pb, + hostName = "HOST", + guestName = "GUEST", + seed = 327, + verdict = "full", + strict = true, + })) + ga.stack:push(a) + gb.stack:push(b) + gs.stack:push(s) + for _, battle in ipairs({ a, b, s }) do + T.eq(battle.ruleset.baseDamageStyle, "gba", "actual linked role uses native GBA rules") + battle.terrain = 2 + for _, actor in ipairs({ battle.player, battle.enemy }) do + actor.mon.hp, actor.mon.stats.hp = 1000, 1000 + actor.mon.stats.speed = actor.mon.nickname == "HOST" and 200 or 20 + end + end + for _ = 1, 40000 do + for i, game in ipairs({ ga, gb, gs }) do + local battle = ({ a, b, s })[i] + require("src.core.Input").pressed = (i == 3 or not battle.localHashes[2]) and { a = true } or {} + game.stack:update(1 / 60) + end + if a.localHashes[2] and b.localHashes[2] and s.turnCount == 2 and s.phase == "waitBoth" then + break + end + end + for turn = 1, 2 do + T.check(a.localHashes[turn] ~= nil and b.localHashes[turn] ~= nil, "both native menu turns complete") + T.eq(a.localHashes[turn], b.localHashes[turn], "event mutations preserve paired completed hashes") + T.same(a.localParts[turn], b.localParts[turn], "all active and bench state components agree") + end + T.same(seen[a], seen[b], "host and guest see all events with the same PP") + T.same(seen[a], seen[s], "spectator sees every event with the same PP") + for _, battle in ipairs({ a, b, s }) do + T.eq(#(seen[battle] or {}), 5, "two turns include failed Mirror, incoming hits and one copied move") + local history = seen[battle] + T.eq(history[1].move, "MIRROR_MOVE", "first turn attempts Mirror before any received move") + T.eq(history[1].called, false, "parent uses public ordinary event") + T.eq(history[3].move, "MIRROR_MOVE", "second turn announces the parent caller") + T.eq(history[4].move, "TACKLE", "second turn copies the received move") + T.eq(history[4].called, true, "copied move is a native child") + T.eq(history[3].pp, 9, "parent event precedes its own PP command") + T.eq(history[4].pp, 8, "copied event follows the parent PP command") + for _, actor in ipairs({ battle.player, battle.enemy }) do + if actor.mon.nickname == "HOST" then + T.eq(actor.gbaLastTakenMove, "MOVE_033", "received history survives actual linked turns") + end + end + T.eq(battle.player.mon.moves[1].pp, 8, "each current PP command runs once") + T.eq(battle.enemy.mon.moves[1].pp, 8, "each remote PP command runs once") + T.eq(battle.result, nil, "no hidden linked simulation failure") + end + T.eq(s.player.mon.hp, a.player.mon.hp, "spectator host HP agrees") + T.eq(s.enemy.mon.hp, a.enemy.mon.hp, "spectator guest HP agrees") + T.eq(#f.result.errors, 0, "public event callback has no swallowed errors") + f.close() +end +T.finish("native Mirror Move received history, events and PP in actual linked roles") diff --git a/tests/engine/gen3_mirror_move.lua b/tests/engine/gen3_mirror_move.lua new file mode 100644 index 000000000..28a4d9e73 --- /dev/null +++ b/tests/engine/gen3_mirror_move.lua @@ -0,0 +1,20 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +for _, version in ipairs({ "firered", "emerald" }) do + local rows = dofile("tests/enemy_actions/mirror_move.lua").run(".", "gen2", version) + T.eq(#rows, 352, "every native received-history command input executes") + for _, row in ipairs(rows) do + local actor = row.state[row.actor + 1] + if row.case == "faint-target" then + T.eq(actor.hp, 0, "the real second incoming command KOs its recipient") + T.eq(actor.received, 33, "a KO does not replace earlier successful received history") + T.eq(actor.pp[1], 10, "a fainted recipient never attempts Mirror Move") + elseif row.case == "self-target" then + T.eq(actor.received, 33, "another actor's self-target cannot overwrite this recipient's history") + local foe = row.state[row.actor % 2 == 0 and row.actor + 2 or row.actor] + T.eq(foe.last, 10, "the self-targeting actor still records its selected move") + T.eq(actor.pp[1], 9, "Mirror still spends its own selected PP command") + end + end +end +T.finish("native Mirror Move received-history conditions and real action sequences") diff --git a/tests/engine/gen3_move_used_link.lua b/tests/engine/gen3_move_used_link.lua new file mode 100644 index 000000000..73f2b3658 --- /dev/null +++ b/tests/engine/gen3_move_used_link.lua @@ -0,0 +1,137 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T, Copy = require("tests.modkit"), require("src.mods.Merge").deepCopy +for _, version in ipairs({ "firered", "emerald" }) do + local f = dofile("tests/enemy_actions/choice_band.lua").fixture(".", "gen2", version) + local H = require("src.link.Handshake") + local current = H.hello(f.game, "battle") + local old = Copy(current) + old.nativeBattleRevision = 11 + T.check(current.nativeBattleRevision >= 12, "PP command ordering advertises at least revision twelve") + T.eq(H.checkCompat(current, old), "subset", "old command order cannot admit lockstep") + for _, host in ipairs({ false, true }) do + local state = setmetatable( + { game = f.game, myHello = current, peerHello = old }, + { __index = require("src.link.LinkState") } + ) + state:decideCompat("battle", host) + T.eq(state.stage, "notice", "old ordering is refused before party exchange") + end + T.link.prepare(f.data) + local ga = T.link.fakeGame(f.data, "CHARMANDER", { name = "HOST", level = 30 }) + local gb = T.link.fakeGame(f.data, "CHARMANDER", { name = "GUEST", level = 30 }) + local gs = T.link.fakeGame(f.data, "CHARMANDER", { name = "VIEWER", level = 30 }) + for i, game in ipairs({ ga, gb, gs }) do + game.save.version, game.save.options.ruleset = version, "gen3_emerald" + game.save.party[1].nickname = i == 1 and "HOST" or "GUEST" + game.save.party[1].moves = { { id = f.ids.TACKLE, pp = 10 } } + game.save.party[1].item = nil + end + local seen = setmetatable({}, { __mode = "k" }) + f.e.api.events:on("battle.move_used", function(ctx) + local trace = seen[ctx.battle] or {} + seen[ctx.battle] = trace + local slot = ctx.user.mon.moves[1] + trace[#trace + 1] = { + user = ctx.user.mon.nickname, + target = ctx.target.mon.nickname, + move = ctx.move.id, + pp = slot.pp, + called = ctx.isCalled or false, + } + end) + f.e.api.hooks:wrap("held_item.trigger", function(nextRead, ctx) + if ctx.trigger == "damage" and ctx.mon.nickname == "HOST" then + local sum = 0 + for _, event in ipairs(seen[ctx.battle] or {}) do + sum = sum + event.pp + end + return "HELD_NORMAL_BOOST", 10 + sum % 3 + end + return nextRead(ctx) + end) + f.e.api.hooks:wrap("battle.crit", function() + return false + end) + local P, N, L = require("src.link.Protocol"), require("src.link.Net"), require("src.link.LinkBattle") + local na, nb = N.loopbackPair() + local inbox = {} + local sn = { + update = function() end, + poll = function() + local got = inbox + inbox = {} + return got + end, + } + for i, net in ipairs({ na, nb }) do + local send, side = net.send, i == 1 and "host" or "guest" + net.send = function(self, packet) + inbox[#inbox + 1] = { type = "spectate", side = side, msg = Copy(packet) } + return send(self, packet) + end + end + local pa, pb = P.packParty(ga.save.party), P.packParty(gb.save.party) + local a = assert( + L.newHost( + ga, + na, + { myParty = pa, theirParty = pb, theirName = "GUEST", seed = 327, verdict = "full", strict = true } + ) + ) + local b = assert( + L.newGuest( + gb, + nb, + { myParty = pb, theirParty = pa, theirName = "HOST", seed = 327, verdict = "full", strict = true } + ) + ) + local s = assert(L.newSpectator(gs, sn, { + hostParty = pa, + guestParty = pb, + hostName = "HOST", + guestName = "GUEST", + seed = 327, + verdict = "full", + strict = true, + })) + ga.stack:push(a) + gb.stack:push(b) + gs.stack:push(s) + for _, battle in ipairs({ a, b, s }) do + T.eq(battle.ruleset.baseDamageStyle, "gba", "actual linked role uses native GBA rules") + for _, actor in ipairs({ battle.player, battle.enemy }) do + actor.mon.hp, actor.mon.stats.hp = 1000, 1000 + end + end + for _ = 1, 40000 do + for i, game in ipairs({ ga, gb, gs }) do + local battle = ({ a, b, s })[i] + require("src.core.Input").pressed = (i == 3 or not battle.localHashes[2]) and { a = true } or {} + game.stack:update(1 / 60) + end + if a.localHashes[2] and b.localHashes[2] and s.turnCount == 2 and s.phase == "waitBoth" then + break + end + end + for turn = 1, 2 do + T.check(a.localHashes[turn] ~= nil and b.localHashes[turn] ~= nil, "both native menu turns complete") + T.eq(a.localHashes[turn], b.localHashes[turn], "event mutations preserve paired completed hashes") + T.same(a.localParts[turn], b.localParts[turn], "all active and bench state components agree") + end + T.same(seen[a], seen[b], "host and guest see all events with the same PP") + T.same(seen[a], seen[s], "spectator sees every event with the same PP") + for _, battle in ipairs({ a, b, s }) do + T.eq(#(seen[battle] or {}), 4, "two actual turns emit four move events") + for i, event in ipairs(seen[battle] or {}) do + T.eq(event.pp, i <= 2 and 10 or 9, "event sees current PP before the command") + end + T.eq(battle.player.mon.moves[1].pp, 8, "each current PP command runs once") + T.eq(battle.enemy.mon.moves[1].pp, 8, "each remote PP command runs once") + T.eq(battle.result, nil, "no hidden linked simulation failure") + end + T.eq(s.player.mon.hp, a.player.mon.hp, "spectator host HP agrees") + T.eq(s.enemy.mon.hp, a.enemy.mon.hp, "spectator guest HP agrees") + T.eq(#f.result.errors, 0, "public event callback has no swallowed errors") + f.close() +end +T.finish("native move-used PP events in actual host guest and spectator") diff --git a/tests/engine/gen3_move_used_pp.lua b/tests/engine/gen3_move_used_pp.lua new file mode 100644 index 000000000..6ca7e55f0 --- /dev/null +++ b/tests/engine/gen3_move_used_pp.lua @@ -0,0 +1,5 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +for _, version in ipairs({ "firered", "emerald" }) do + dofile("tests/enemy_actions/move_used_pp.lua").run(".", "gen2", version) +end +require("tests.modkit").finish("native GBA move-used event and PP command order") diff --git a/tests/engine/gen3_multi_hit.lua b/tests/engine/gen3_multi_hit.lua new file mode 100644 index 000000000..54a3f476e --- /dev/null +++ b/tests/engine/gen3_multi_hit.lua @@ -0,0 +1,5 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +for _, version in ipairs({ "firered", "emerald" }) do + require("tests.enemy_actions.multi_hit").run(".", "gen2", version) +end +require("tests.modkit").finish("native ordinary GBA multi-hit execution") diff --git a/tests/engine/gen3_multi_hit_composition.lua b/tests/engine/gen3_multi_hit_composition.lua new file mode 100644 index 000000000..e8d9162a6 --- /dev/null +++ b/tests/engine/gen3_multi_hit_composition.lua @@ -0,0 +1,142 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local V = require("src.core.GameVersion") +for _, version in ipairs({ "firered", "emerald" }) do + for _, mode in ipairs({ "delegate", "override", "damage", "fallback" }) do + local entry + if mode ~= "fallback" then + entry = [[return function(mod) + local base=assert(mod.content.move_effects:get('TWO_TO_FIVE_ATTACKS_EFFECT')) + local count=base.hitCount + mod.exports.counts,mod.exports.hits=0,0 + base.hitCount=function(ctx) + mod.exports.counts=mod.exports.counts+1 + ]] .. (mode == "delegate" and "return count(ctx)" or "return 4") .. [[ + end + ]] .. (mode == "damage" and [[ + base.chooseDamage=function() return 2,{crit=false,typeMult=10} end + base.perHitDamage=function() + mod.exports.hits=mod.exports.hits+1 + return 2,{crit=false,typeMult=10} + end + ]] or "") .. [[ + assert(mod.content.move_effects:override('TWO_TO_FIVE_ATTACKS_EFFECT',base)) + mod.exports.api=mod + end]] + end + local f = require("tests.enemy_actions.multi_hit").fixture(".", "gen2", version, entry) + local c = f.fresh("TACKLE", { enemy = true }) + local b = c.battle + b:syncSides() + c.user.mon.moves = { { id = f.ids.DOUBLE_SLAP, pp = 10 } } + c.user.curMoves = c.user.mon.moves + local original = b.data.move_effects + if mode == "fallback" then + b.data.move_effects = nil + end + local draws, hits = {}, {} + b.rng = function(lo, hi) + draws[#draws + 1] = { lo, hi } + return hi + end + f.e.api.hooks:wrap("battle.crit", function() + return false + end) + local off = f.e.api.events:on("battle.damage_dealt", function(e) + hits[#hits + 1] = e.damage + end) + b:executeAction(c.user, c.target, c.user.curMoves[1]) + T.eq( + #hits, + (mode == "delegate" or mode == "fallback") and 5 or 4, + "authored count or native delegation controls the actual hits" + ) + T.eq(c.user.curMoves[1].pp, 9, "composed multi-hit spends exactly one PP") + if mode ~= "fallback" then + T.eq(f.e.counts, 1, "the public count handler is called once") + end + if mode == "damage" then + T.eq(f.e.hits, 4, "explicit authored per-hit damage is never replaced") + T.same(hits, { 2, 2, 2, 2 }, "authored per-hit amounts reach real HP and events") + end + if mode == "delegate" or mode == "fallback" then + T.same(draws[2], { 0, 3 }, "native count keeps its raw bounded RNG opcode") + end + local before = #hits + f.data.moves[f.ids.DOUBLE_SLAP].accuracy = 90 + b:executeAction(c.user, c.target, c.user.curMoves[1]) + T.eq(#hits, before, "a failed accuracy check emits no hit") + if mode ~= "fallback" then + T.eq(f.e.counts, 1, "a failed accuracy check never invokes authored hit count") + end + off() + b.data.move_effects = original + f.close() + end + for _, caller in ipairs({ "sleep_talk", "other" }) do + local f = require("tests.enemy_actions.multi_hit").fixture(".", "gen2", version) + local c = f.fresh("TACKLE") + local b = c.battle + b:syncSides() + -- Test the actual nested dispatcher after the calling move's selection. + f.data.moves.CALLER = { + id = "CALLER", + index = caller == "sleep_talk" and 214 or 118, + gen3Effect = caller == "sleep_talk" and 97 or 83, + name = "CALLER", + power = 0, + type = "NORMAL", + accuracy = 100, + pp = 10, + effect = "TEST_CALLER", + } + b.data.move_effects.TEST_CALLER = { + kind = "full", + callsMove = function() + return f.ids.DOUBLE_SLAP + end, + } + c.user.mon.status = "SLP" + c.user.curMoves = { { id = "CALLER", pp = 10 } } + c.user.mon.moves = c.user.curMoves + local hits = 0 + local off = f.e.api.events:on("battle.damage_dealt", function() + hits = hits + 1 + end) + f.e.api.hooks:wrap("battle.crit", function() + return false + end) + b.rng = function(_, hi) + return hi + end + b:performMove(c.user, c.target, c.user.curMoves[1]) + T.eq( + hits, + caller == "sleep_talk" and 5 or 1, + "only a real Sleep Talk parent exempts sleeping multi-hit continuation" + ) + T.eq(c.user.curMoves[1].pp, 9, "the actual calling slot alone spends PP") + T.eq(b._gbaMoveFrame, nil, "nested frames restore the native dispatcher after completion") + off() + f.close() + end +end +local M = require("src.battle.Gen3MultiHit") +local raw = require("src.battle.MoveEffects").RECORDS.TWO_TO_FIVE_ATTACKS_EFFECT +V.set("firered") +local adapted = M.builtin("TWO_TO_FIVE_ATTACKS_EFFECT", raw) +for _, version in ipairs({ "red", "gold", "diamond", "black" }) do + V.set(version) + T.eq(M.builtin("TWO_TO_FIVE_ATTACKS_EFFECT", raw), raw, "older games keep original builtin identity") + local calls = {} + local ctx = { + move = {}, + rng = function(lo, hi) + calls[#calls + 1] = { lo, hi } + return hi + end, + } + T.eq(adapted.hitCount(ctx), 5, "a reused native adapter delegates back to legacy policy") + T.same(calls, { { 0, 7 } }, "older games keep the original eight-value hit-count draw") +end +T.finish("native ordinary multi-hit builtin composition and called-move boundaries") diff --git a/tests/engine/gen3_multi_hit_link.lua b/tests/engine/gen3_multi_hit_link.lua new file mode 100644 index 000000000..eb7584128 --- /dev/null +++ b/tests/engine/gen3_multi_hit_link.lua @@ -0,0 +1,92 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T, Copy = require("tests.modkit"), require("src.mods.Merge").deepCopy +for _, version in ipairs({ "firered", "emerald" }) do + local f = require("tests.enemy_actions.multi_hit").fixture(".", "gen2", version) + local H = require("src.link.Handshake") + local current = H.hello(f.game, "battle") + local older = Copy(current) + older.nativeBattleRevision = 5 + T.eq(H.checkCompat(current, older), "subset", "pre-multi-hit rules cannot enter a full battle") + T.eq(H.checkCompat(older, current), "subset", "native revision guard is symmetric") + for _, host in ipairs({ false, true }) do + local state = setmetatable( + { game = f.game, myHello = current, peerHello = older }, + { __index = require("src.link.LinkState") } + ) + state:decideCompat("battle", host) + T.eq(state.stage, "notice", "actual controller refuses the old native rules") + T.eq(state.noticeExits, true, "old native rules cannot enter party selection") + end + local def = f.data.pokemon.CHARMANDER + def.evYield = {} + def.baseStats.spatk, def.baseStats.spdef, def.baseStats.hp = 50, 50, 200 + T.link.prepare(f.data) + local ga = T.link.fakeGame(f.data, "CHARMANDER", { name = "HOST", level = 30 }) + local gb = T.link.fakeGame(f.data, "CHARMANDER", { name = "GUEST", level = 30 }) + for _, game in ipairs({ ga, gb }) do + game.save.party[1].moves = { + { id = f.ids.DOUBLE_SLAP, pp = 10 }, + { id = f.ids.DOUBLE_KICK, pp = 10 }, + { id = f.ids.TWINEEDLE, pp = 10 }, + } + end + f.e.api.hooks:wrap("battle.crit", function() + return false + end) + local P, N, L = require("src.link.Protocol"), require("src.link.Net"), require("src.link.LinkBattle") + local na, nb = N.loopbackPair() + local pa, pb = P.packParty(ga.save.party), P.packParty(gb.save.party) + local a = assert( + L.newHost( + ga, + na, + { myParty = pa, theirParty = pb, theirName = "GUEST", seed = 327, verdict = "full", strict = true } + ) + ) + local b = assert( + L.newGuest( + gb, + nb, + { myParty = pb, theirParty = pa, theirName = "HOST", seed = 327, verdict = "full", strict = true } + ) + ) + ga.stack:push(a) + gb.stack:push(b) + a.player.curStats.speed, a.enemy.curStats.speed = 200, 100 + b.player.curStats.speed, b.enemy.curStats.speed = 100, 200 + local seen = {} + for _ = 1, 40000 do + for i, game in ipairs({ ga, gb }) do + local battle = i == 1 and a or b + local turn = battle.turnCount or 0 + battle.moveIndex = math.min(turn + 1, 3) + require("src.core.Input").pressed = { a = true } + game.stack:update(1 / 60) + end + if a.localHashes[2] and b.localHashes[2] and not seen[2] then + seen[2] = true + T.check( + a.player.mon.moves[1].pp == 9 and b.enemy.mon.moves[1].pp == 9, + "variable multi-hit spends one PP on both roles" + ) + end + if a.localHashes[3] and b.localHashes[3] then + break + end + end + T.check(seen[2], "the ordinary multi-hit turn completes on both roles") + for turn = 1, 3 do + T.check(a.localHashes[turn] ~= nil and b.localHashes[turn] ~= nil, "native full linked turn completes") + T.eq(a.localHashes[turn], b.localHashes[turn], "paired completed turn hashes agree") + T.same(a.localParts[turn], b.localParts[turn], "all active, volatile and bench components agree") + end + for _, battle in ipairs({ a, b }) do + local guest = battle == a and battle.enemy or battle.player + for slot = 1, 3 do + T.eq(guest.mon.moves[slot].pp, 9, "each actual multi-hit turn spends its own learned slot once") + end + T.eq(battle.result, nil, "no hidden choice or hash failure ends the native mirror") + end + f.close() +end +T.finish("native ordinary multi-hit real link menus and completed turn hashes") diff --git a/tests/engine/gen3_music_hooks.lua b/tests/engine/gen3_music_hooks.lua new file mode 100644 index 000000000..eaab1c222 --- /dev/null +++ b/tests/engine/gen3_music_hooks.lua @@ -0,0 +1,119 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local V = require("src.core.GameVersion") +local before = V.get() +for _, version in ipairs({ "firered", "emerald" }) do + local rows = dofile("tests/enemy_actions/native_bgm_hooks.lua").run(".", "gen2", version) + T.eq(#rows, 23, "every public music hook, event and ownership scenario executes") + local byName = {} + for _, row in ipairs(rows) do + byName[row.case] = row + if row.case:sub(1, 6) == "native" then + T.eq(#row.calls, 0, "native carriers do not enter shared mod music hooks or events") + for _, frame in ipairs(row.frames) do + T.eq( + frame.producer, + version == "firered" and 282 or 403, + "a shared selection/volume wrapper cannot replace or silence the actual native producer" + ) + end + end + for _, call in ipairs(row.calls) do + if call.context then + T.eq(call.context.mapSong, nil, "native map identities cannot leak into the shared music context") + T.eq(call.context.onBike, false, "shared music retains the generic player's bike context") + T.eq(call.context.surfing, false, "shared music retains the generic player's surf context") + if call.name == "music.volume" then + T.eq(call.context.fading, false, "a native fade does not become a generic music fade") + T.check( + call.context.song == nil or call.context.song == "MUS_CYCLING" or call.context.song == "MUS_SURF", + "the volume hook observes the shared physical song" + ) + if row.case == "file-location" then + T.eq(call.context.x, 5, "volume context reads the actual engine world's x") + T.eq(call.context.y, 6, "volume context reads the actual engine world's y") + T.eq(call.context.mapId, "OWNED_HOOK_MAP", "volume context preserves the actual engine map") + T.eq(call.context.tod, "night", "volume context preserves time of day") + end + end + end + end + end + local swapped + for _, call in ipairs(byName["file-select"].calls) do + if call.name == "music.started" then + swapped = call.event.song + end + end + T.eq(swapped, "MUS_SURF", "the public selector changes the actual shared music cue") + T.eq( + #byName["file-silence"].calls, + 1, + "a nil selector returns before source construction and started events" + ) + for _, frame in ipairs(byName["file-silence"].frames) do + for _, source in ipairs(frame.state.voices) do + T.eq(source.playing, false, "a silenced shared cue cannot leave a source playing") + end + end + local voices = byName["file-volume"].frames[1].state.voices + T.eq(voices[#voices].volume, 0.35, "the volume wrapper scales the actual shared Source") + local changes = {} + for _, call in ipairs(byName["file-options"].calls) do + if call.tick == 2 then + changes[#changes + 1] = call.result + end + end + T.same( + changes, + { 0.7, 0.3, 0.3 }, + "options reapply the stored level, new level and regular audio service in order" + ) + changes = {} + for _, call in ipairs(byName["file-options-out"].calls) do + if call.tick == 2 then + changes[#changes + 1] = call + end + end + T.eq(#changes, 4, "options during a native fade retain all four public volume observations") + T.eq( + changes[1].context.optionScale, + 62 / 64, + "the option preflight observes the actual previous fade level" + ) + local order = {} + for _, call in ipairs(byName["file-fanfare"].calls) do + if call.tick == 5 then + order[#order + 1] = call.name + end + end + T.same( + order, + { "music.volume", "music.stopped", "music.select", "music.volume", "music.started" }, + "fanfare completion follows the old shared update before replacing its source" + ) + local deferred = byName["file-map-fanfare"].frames + local cycling = version == "firered" and 282 or 403 + local title = version == "firered" and 278 or 413 + T.eq( + deferred[3].state.deferred, + cycling, + "a map cue defers its shared song while the fanfare owns the clock" + ) + T.eq(deferred[6].state.song, title, "map ownership cannot switch before the fanfare timer completes") + T.eq(deferred[7].state.song, cycling, "the completed fanfare starts the pending map cue") + for _, name in ipairs({ "file-cry", "file-cry-stop" }) do + local row = byName[name] + T.eq(row.crySamples, 5645, "native world cry commands render the real owned PCM") + T.eq(#row.calls, 22, "native cry start/end/stop cannot add shared music volume callbacks") + local entered = 0 + for _, call in ipairs(row.calls) do + if call.tick == 2 then + entered = entered + 1 + end + end + T.eq(entered, 1, "the cry start tick contains only the normal shared audio-service callback") + end +end +V.set(before) +T.finish("native/shared public music hooks, option order and map fanfare ownership") diff --git a/tests/engine/gen3_native_accuracy.lua b/tests/engine/gen3_native_accuracy.lua new file mode 100644 index 000000000..7678c8888 --- /dev/null +++ b/tests/engine/gen3_native_accuracy.lua @@ -0,0 +1,7 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +for _, version in ipairs({ "firered", "emerald" }) do + for _, execution in ipairs({ false, true }) do + require("tests.enemy_actions.accuracy").run(".", "gen2", version, execution) + end +end +require("tests.modkit").finish("native GBA accuracy command and execution boundaries") diff --git a/tests/engine/gen3_native_cry.lua b/tests/engine/gen3_native_cry.lua new file mode 100644 index 000000000..7cd263323 --- /dev/null +++ b/tests/engine/gen3_native_cry.lua @@ -0,0 +1,265 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local Sound, V = require("src.core.Sound"), require("src.core.GameVersion") +local beforeVersion = V.get() +local expected = { + silent = 0, + ["no-pack"] = 64, + ["mode-five"] = 10, + ["mode-seven"] = 9, + ["mode-eleven"] = 9, + ["mode-twelve"] = 9, + ["early-source-end"] = 2, + ["zero-species"] = 64, + ["old-unown-species"] = 64, + ["unsupported-species"] = 64, + consecutive = 16, + ["consecutive-no-pack"] = 128, + ["file-override"] = 3, + ["string-override"] = 3, + ["disabled-override"] = 64, + ["disabled-after-native"] = 64, +} +for _, version in ipairs({ "firered", "emerald" }) do + local rows = dofile("tests/enemy_actions/native_cry_commands.lua").run(".", "gen2", version) + T.eq(#rows, 33, "all native command, variable, mode, override and wait cases execute") + for _, row in ipairs(rows) do + T.eq(row.completed, expected[row.case] or 8, row.case .. " follows native completion timing") + T.eq(row.frames[#row.frames].running, false, "completed cry releases the actual script runner") + for _, frame in ipairs(row.frames) do + T.eq(type(frame.finished), "boolean", "native completion stays boolean") + T.eq(type(frame.running), "boolean", "native script status stays boolean") + end + end + for _, speed in ipairs({ 1, 2, 10 }) do + local f = dofile("tests/enemy_actions/assist.lua").fixture(".", "gen2", version) + local hardware = dofile("tests/native_cry/fixture.lua").install(false, f) + local Game, Fixed = require("src.core.Game"), require("src.core.FixedStep") + local stack = require("src.core.StateStack") + stack:init() + local game = setmetatable({ + data = f.data, + save = f.game.save, + stack = stack, + input = require("src.core.Input"), + speedOverride = speed, + }, { __index = Game }) + game.input:init() + local ow = setmetatable( + { npcs = { {}, {} }, player = { facing = "up" } }, + { __index = require("src.world.OverworldController") } + ) + local runner = require("src.script.ScriptRunner").new(game, ow) + local hookEvents, ended = {}, {} + f.e.api.hooks:wrap("script.command", function(next, ctx, name, args) + hookEvents[#hookEvents + 1] = { edge = "before", name = name } + local result = next(ctx, name, args) + hookEvents[#hookEvents + 1] = { edge = "after", name = name } + return result + end) + f.e.api.events:on("script.ended", function(event) + ended[#ended + 1] = { + completed = event.completed, + value = require("src.script.Gen3Commands").getVar(game.save, 0x4001), + } + end) + local script = assert(require("src.script.Gen3ScriptVM").compile({ + map_scripts = { + source = "RomExtractorGen3", + scripts = { + OWNED_GAME_CRY = { + { "lockall" }, + { "setvar", 0x8004, 4 }, + { "setvar", 0x8005, 0 }, + { "playmoncry", 0x8004, 0x8005 }, + { "waitmoncry" }, + { "setvar", 0x4001, 1 }, + { "releaseall" }, + { "end" }, + }, + }, + }, + }, "OWNED_GAME_CRY")) + local steps, completed, freezeBroken = 0, nil, false + stack:push({ + update = function() + hardware.tick(steps) + if steps == 0 then + runner:run(script, { + source = { + modId = "portable_held_item", + mapId = "OWNED_CRY_MAP", + hook = "native_cry", + strict = true, + }, + }) + else + runner:update() + end + if + runner:isRunning() + and (not ow.gen3Locked or not ow.npcs[1].gen3ScriptFrozen or not ow.npcs[2].gen3ScriptFrozen) + then + freezeBroken = true + end + if require("src.script.Gen3Commands").getVar(game.save, 0x4001) == 1 then + completed = completed or steps + end + steps = steps + 1 + end, + }) + Fixed:init(function(dt) + game:step(dt) + end) + for _ = 1, 70 do + game:update(1 / 60) + end + T.eq(completed, 8, "actual game clock completes the native eight-frame owned cry") + local snapshot = require("src.core.NativeCryPlayer").snapshot() + T.check( + snapshot.clock >= 70, + "Game:update advances the independent real audio clock after script completion" + ) + T.check(snapshot.clock < 80, "fast-forward cannot multiply the real audio clock after completion") + T.eq(runner:isRunning(), false, "native cry returns through the real Game/state clocks") + T.eq(freezeBroken, false, "field objects stay frozen throughout the yielding cry") + T.eq(ow.gen3Locked, nil, "releaseall restores the field") + for _, npc in ipairs(ow.npcs) do + T.eq(npc.gen3ScriptFrozen, nil, "releaseall restores each NPC") + end + T.eq(runner.ctx.g3Locked, nil, "completed cry retains no input lock") + T.eq(runner.ctx.source.mapId, "OWNED_CRY_MAP", "native cry retains source metadata") + local entered, exited + for i, event in ipairs(hookEvents) do + if event.name == "g3_wait_cry" then + if event.edge == "before" then + T.eq(entered, nil, "wait hook enters once") + entered = i + else + T.eq(exited, nil, "wait hook returns once") + exited = i + end + end + end + T.check(entered and exited and entered < exited, "public hooks surround the yielding native cry wait") + T.same(ended, { { completed = true, value = 1 } }, "one script-ended event follows the write") + local latest = assert(Sound.playCry(f.data, "CHARMANDER")) + local ownedState = require("src.core.NativeCryPlayer").snapshot() + local isolated = assert(Sound.playCry(f.data, "CHARMANDER", { isolated = true, mode = 4 })) + T.neq(latest, isolated, "isolated dex playback owns a fresh source") + T.same( + require("src.core.NativeCryPlayer").snapshot(), + ownedState, + "isolated playback leaves native wait state and controls intact" + ) + local same = assert(Sound.playCry(f.data, "CHARMANDER")) + T.neq(latest, same, "repeated native cries never rewind a playing source") + T.eq(latest.input, same.input, "repeat playback reuses only immutable rendered PCM") + Sound.stopCry() + T.eq(same:isPlaying(), false, "stopCry stops the most recent native source") + T.eq(latest:isPlaying(), true, "stopCry retains earlier overlapping sources") + T.eq(isolated:isPlaying(), true, "stopCry does not stop independently owned dex playback") + Sound.invalidate("cry:CHARMANDER") + T.eq(latest:isPlaying(), false, "species invalidation releases old native voices") + T.eq(isolated:isPlaying(), false, "species invalidation also releases device-bound isolated sources") + local fresh = assert(Sound.playCry(f.data, "CHARMANDER")) + T.neq(fresh.input, latest.input, "invalidation rebuilds cached native PCM") + Sound.suspend() + T.eq(fresh:isPlaying(), false, "session suspension stops native cries") + T.eq(Sound.playCry(f.data, "CHARMANDER"), nil, "suspended playback cannot create a new source") + Sound.resume() + T.check(Sound.playCry(f.data, "CHARMANDER") ~= nil, "resumed session rebuilds the native source") + Sound.invalidate() + local originalSound = love.sound + love.sound = nil + T.eq( + Sound.playCry(f.data, "CHARMANDER"), + nil, + "headless native playback does not require a SoundData device" + ) + love.sound = originalSound + T.eq(Sound.isCryFinished(), false, "headless playback retains the real recording's native duration") + local wait, polls = Sound.waitCry(), 0 + repeat + polls = polls + 1 + until wait() + T.eq(polls, 8, "headless logic fallback follows recording length instead of the no-pack delay") + Sound.invalidate() + local originalAudio = love.audio + love.audio = nil + T.eq(Sound.playCry(f.data, "CHARMANDER"), nil, "native controls tolerate an unavailable output device") + love.audio = originalAudio + T.eq(Sound.isCryFinished(), false, "an absent output device does not skip the native wait") + Sound.invalidate() + local originalBuilder = love.audio.newSource + love.audio.newSource = function() + error("owned native-cry device-loss case") + end + T.eq(Sound.playCry(f.data, "CHARMANDER"), nil, "device loss is contained at the hardware boundary") + love.audio.newSource = originalBuilder + T.check( + Sound.playCry(f.data, "CHARMANDER") ~= nil, + "device recovery retries the cached PCM with a fresh source" + ) + hardware.close() + f.close() + end +end +-- Exercise the unchanged structural importer and writer against owned bytes. +local count, wave = 388, 388 * 24 +local bytes = {} +for i = 1, wave + 16 + 400 do + bytes[i] = 0 +end +local function put(offset, value, size) + for i = 0, size - 1 do + bytes[offset + i + 1] = math.floor(value / 256 ^ i) % 256 + end +end +for direction = 0, 1 do + for index = 0, count - 1 do + local at = (direction * count + index) * 12 + put(at, direction, 1) + put(at + 1, 60, 1) + put(at + 4, 0x08000000 + wave, 4) + end +end +put(wave + 4, 8000 * 1024, 4) +put(wave + 12, 400, 4) +local chars = {} +for i, value in ipairs(bytes) do + chars[i] = string.char(value) +end +local importer = require("src.import.RomExtractorGen3").new(table.concat(chars), "firered") +local order = {} +for i = 1, 412 do + order[i] = "OWNED_" .. i +end +importer._constants = { speciesOrder = order } +importer:extractCries() +T.eq(importer._audio.gen3Cries.mapping, "native-species-v1", "import declares native mapping provenance") +T.eq(importer._audio.gen3Cries.species, 411, "all supported raw species receive their native cry") +for species, index in pairs({ + [1] = 0, + [4] = 3, + [251] = 250, + [252] = 200, + [276] = 200, + [277] = 273, + [280] = 270, + [317] = 251, + [411] = 387, +}) do + T.eq( + importer._audio.cries[order[species]].m4a, + index, + "structural importer retains native reorder " .. species + ) +end +T.eq(importer._audio.cries[order[412]], nil, "unsupported egg slot is not assigned an arbitrary cry") +T.check( + love.filesystem.getInfo("data/generated/audio.lua") ~= nil, + "actual writer stores the mapped registry in isolated test FS" +) +V.set(beforeVersion) +T.finish("native cry PCM, script/audio clocks, field ownership, isolated playback and mapping") diff --git a/tests/engine/gen3_native_fanfare.lua b/tests/engine/gen3_native_fanfare.lua new file mode 100644 index 000000000..074c555df --- /dev/null +++ b/tests/engine/gen3_native_fanfare.lua @@ -0,0 +1,144 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local V, Music = require("src.core.GameVersion"), require("src.core.Music") +local before = V.get() +local expected = { + ordinary = 4, + fast = 4, + ["four-times"] = 4, + variable = 4, + fallback = 4, + ["no-table"] = 160, + ["song-timer"] = 6, + ["empty-table"] = 160, + ["no-fanfare"] = 0, + ["bgm-dispatch"] = 4, + ["audio-paused"] = 361, + long = 361, + ["long-fast"] = 37, + replacement = 8, + ["new-bgm"] = 2, + ["same-bgm"] = 4, + ["stop-bgm"] = 4, + ["deferred-default"] = 4, + ["latched-completion"] = 4, +} +for _, version in ipairs({ "firered", "emerald" }) do + local rows = dofile("tests/enemy_actions/native_fanfare_commands.lua").run(".", "gen2", version) + T.eq(#rows, 20, "all native timer, variable, metadata and latched-wait cases execute") + for _, row in ipairs(rows) do + T.eq(row.completed, expected[row.case], row.case .. " retains native completion timing") + T.eq(row.frames[1].startedValue, 1, "playing a fanfare does not block the following script command") + for _, frame in ipairs(row.frames) do + T.eq(type(frame.finished), "boolean", "native fanfare completion stays boolean") + T.eq(type(frame.running), "boolean", "native script running state stays boolean") + end + end + for _, speed in ipairs({ 1, 4, 10 }) do + local f = dofile("tests/enemy_actions/assist.lua").fixture(".", "gen2", version) + local h = dofile("tests/native_bgm/fanfares.lua").install(".", "gen2", version, f) + h.clear() + local Game, Fixed = require("src.core.Game"), require("src.core.FixedStep") + local stack = require("src.core.StateStack") + stack:init() + local game = setmetatable({ + data = f.data, + save = f.game.save, + stack = stack, + input = require("src.core.Input"), + speedOverride = speed, + }, { __index = Game }) + game.input:init() + local ow = setmetatable( + { npcs = { {}, {} }, player = { facing = "up" } }, + { __index = require("src.world.OverworldController") } + ) + local runner = require("src.script.ScriptRunner").new(game, ow) + local script = assert(require("src.script.Gen3ScriptVM").compile({ + map_scripts = { + source = "RomExtractorGen3", + scripts = { + OWNED_GAME_FANFARE = { + { "lockall" }, + { "setvar", 0x8004, h.ids[1] }, + { "playfanfare", 0x8004 }, + { "waitfanfare" }, + { "setvar", 0x4001, 1 }, + { "releaseall" }, + { "end" }, + }, + }, + }, + }, "OWNED_GAME_FANFARE")) + local steps, frame, completed, frozen = 0, 0, nil, true + local hooks, ended = {}, {} + f.e.api.hooks:wrap("script.command", function(next, ctx, name, args) + hooks[#hooks + 1] = { edge = "before", name = name } + local value = next(ctx, name, args) + hooks[#hooks + 1] = { edge = "after", name = name } + return value + end) + f.e.api.events:on("script.ended", function(event) + ended[#ended + 1] = event.completed + end) + stack:push({ + update = function() + h.tick(frame) + if steps == 0 then + runner:run(script, { source = { modId = "portable_held_item", strict = true } }) + else + runner:update() + end + if runner:isRunning() and (not ow.gen3Locked or not ow.npcs[1].gen3ScriptFrozen) then + frozen = false + end + if require("src.script.Gen3Commands").getVar(game.save, 0x4001) == 1 then + completed = completed or frame + end + steps = steps + 1 + end, + }) + Fixed:init(function(dt) + game:step(dt) + end) + for tick = 0, 12 do + frame = tick + game:update(1 / 60) + end + T.eq(completed, 4, "actual Game audio ticks release the four-frame fanfare independently of logic speed") + T.eq(runner:isRunning(), false, "actual game runner completes the fanfare script") + T.eq(frozen, true, "field ownership persists throughout the yielding fanfare wait") + T.eq(ow.gen3Locked, nil, "completed fanfare releases field input ownership") + T.same(ended, { true }, "one completed event follows the native wait") + T.eq(Music.nativeSnapshot().song, h.bgmIds[1], "actual Game audio restores its own dataset's map music") + T.eq( + Music.current(), + "MUS_TITLE", + "actual Game restoration replays the physical music from its own dataset" + ) + local entered, exited = 0, 0 + for _, event in ipairs(hooks) do + if event.name == "g3_wait_fanfare" then + if event.edge == "before" then + entered = entered + 1 + else + exited = exited + 1 + end + end + end + T.eq(entered, 1, "public wait hook enters once") + T.eq(exited, 1, "public wait hook returns once after yielding") + h.close() + f.close() + end + local f = dofile("tests/enemy_actions/assist.lua").fixture(".", "gen2", version) + local h = dofile("tests/native_bgm/streams.lua").install(".", "gen2", version, f) + local ff = h.fanfares() + local worker = dofile("tests/native_bgm/worker.lua") + worker.actual(f.data, f.data.audio.songs[("SONG_%03X"):format(h.ids[1])], T) + worker.transport(h, f, ff, T) + h.close() + f.close() +end +V.set(before) +T.finish("native fanfare commands, game clocks, field ownership and latched waits") diff --git a/tests/engine/gen3_native_secondary.lua b/tests/engine/gen3_native_secondary.lua new file mode 100644 index 000000000..8015baebf --- /dev/null +++ b/tests/engine/gen3_native_secondary.lua @@ -0,0 +1,7 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +for _, version in ipairs({ "firered", "emerald" }) do + for _, execution in ipairs({ false, true }) do + require("tests.enemy_actions.secondary").run(".", "gen2", version, execution) + end +end +require("tests.modkit").finish("native GBA secondary chance and effect boundaries") diff --git a/tests/engine/gen3_native_sound.lua b/tests/engine/gen3_native_sound.lua new file mode 100644 index 000000000..f51fd3a37 --- /dev/null +++ b/tests/engine/gen3_native_sound.lua @@ -0,0 +1,115 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local V, Sound = require("src.core.GameVersion"), require("src.core.Sound") +local previousAudio, sources = love.audio, {} +local Source = {} +Source.__index = Source +function Source:play() + self.plays = self.plays + 1 +end +function Source:stop() end +function Source:setVolume(value) + self.volume = value +end +function Source:setLooping() end +love.audio = { + newSource = function(input) + local source = setmetatable({ input = input, plays = 0 }, Source) + sources[#sources + 1] = source + return source + end, +} +V.set("firered") +-- An owned native M4A square-wave instrument and one finite note. Exercise +-- the real sequencer, sound construction, native-ID dispatch and pan cache. +local bytes = {} +for i = 1, 64 do + bytes[i] = 0 +end +bytes[1], bytes[2], bytes[11], bytes[12] = 1, 60, 15, 1 +local program = { 0xBD, 0, 0xBE, 127, 0xBF, 64, 0xD0, 60, 127, 0x81, 0xB1 } +for i, byte in ipairs(program) do + bytes[32 + i] = byte +end +local chars = {} +for i, byte in ipairs(bytes) do + chars[i] = string.char(byte) +end +love.filesystem.write("owned-native-effect.bin", table.concat(chars)) +local data = { + audio = { + sfx = {}, + songs = { + SONG_08D = { index = 141, tracks = { 32 }, voicegroup = 0, sfxName = "OWNED_NATIVE_EFFECT" }, + }, + gen3 = { base = 0, file = "owned-native-effect.bin" }, + }, +} +Sound.invalidate() +local plain = assert(Sound.playId(data, 141), "native effect without a role must play") +T.eq(plain.input:getChannelCount(), 2, "actual M4A carrier is stereo") +T.check(plain.input:getSampleCount() >= 64, "actual native effect is synthesized") +local audible = false +for i = 0, plain.input:getSampleCount() - 1 do + if math.abs(plain.input:getSample(i, 1)) > 0.01 then + audible = true + break + end +end +T.check(audible, "the carrier contains a real synthesized note") +for _, mono in ipairs({ false, true }) do + for _, pan in ipairs({ -64, -32, 0, 32, 63 }) do + local played = assert(Sound.playId(data, 141, { pan = pan, mono = mono })) + T.eq( + played, + Sound.playId(data, 141, { pan = pan, mono = mono }), + "identical controls reuse only their own source" + ) + T.neq(played, plain, "panning leaves the unmodified carrier source independent") + local pcm = played.input + T.eq(pcm:getSampleCount(), plain.input:getSampleCount(), "pan preserves the actual effect length") + T.eq(pcm:getSampleRate(), plain.input:getSampleRate(), "pan preserves the native carrier rate") + T.eq(pcm:getChannelCount(), mono and 1 or 2, "mono folds after native panorama gains") + for i = 0, pcm:getSampleCount() - 1 do + local l = plain.input:getSample(i, 1) * (1 - math.max(0, pan / 64)) + local r = plain.input:getSample(i, 2) * (1 - math.max(0, -pan / 64)) + T.eq(pcm:getSample(i, 1), mono and (l + r) / 2 or l, "actual source retains native left/mono sample") + if not mono then + T.eq(pcm:getSample(i, 2), r, "actual source retains native right sample") + end + end + end +end +T.eq(Sound.playId(data, 141), plain, "subsequent default playback retains its original cache") +for _, id in ipairs({ -1, 0.5, 65536, math.huge }) do + T.eq(Sound.playId(data, id), nil, "invalid native sound numbers are refused") +end +local Pan = require("src.core.NativeSoundPan") +T.eq(Pan.value(0 / 0), 0, "invalid panorama cannot enter PCM output") +T.eq(Pan.value("63"), 63, "numeric control strings resolve consistently") +data.audio.sfx.ROLE = { m4a = 141 } +Sound.invalidate() +T.eq(Sound.playId(data, 141), Sound.play(data, "ROLE"), "existing named-role playback retains its path") +data.audio.sfx = { OWNED_NATIVE_EFFECT = { file = "owned-override.wav" } } +Sound.invalidate() +T.eq( + Sound.playId(data, 141, { pan = 63 }).input, + "owned-override.wav", + "native-name file override retains priority" +) +data.audio.sfx.OWNED_NATIVE_EFFECT = "owned-string.wav" +Sound.invalidate() +T.eq( + Sound.playId(data, 141, { pan = -64 }).input, + "owned-string.wav", + "native-name string file retains priority" +) +data.audio.sfx.OWNED_NATIVE_EFFECT = false +Sound.invalidate() +T.eq(Sound.playId(data, 141, { pan = -64 }), nil, "disabled native-name sound remains silent") +V.set("red") +data.audio.sfx = {} +T.eq(Sound.playId(data, 141), nil, "native song fallback does not alter legacy sound dispatch") +Sound.invalidate() +love.audio = previousAudio +T.finish("native GBA sound number dispatch and stereo control") diff --git a/tests/engine/gen3_nature_power.lua b/tests/engine/gen3_nature_power.lua new file mode 100644 index 000000000..b50d02fcc --- /dev/null +++ b/tests/engine/gen3_nature_power.lua @@ -0,0 +1,60 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local V = require("src.core.GameVersion") +local before = V.get() +for _, version in ipairs({ "firered", "emerald" }) do + local rows = dofile("tests/enemy_actions/nature_power.lua").run(".", "gen2", version) + T.eq(#rows, 3024, "all terrain, slot, PP, item and pressure cases execute") + for _, row in ipairs(rows) do + if row.case == "zero_pp" then + T.eq(#row.events, 1, "zero PP announces only the caller") + T.eq(#row.damage, 0, "zero PP never admits the child") + else + T.eq(#row.events, 2, "caller and called child announce exactly once") + T.eq(row.events[1].called, false, "the selected Nature Power is the caller") + T.eq(row.events[2].called, true, "the terrain attack is a native child") + local expected = row.case == "parent_zero" and 0 + or row.case == "parent_replace" and 3 + or row.case == "parent_remove" and "absent" + or row.case == "parent_reorder" and 4 + or 10 + T.eq(row.events[2].pp, expected, "the child event precedes the selected-slot PP command") + end + end + V.set(version) + local N = require("src.battle.Gen3NaturePower") + for name, number in pairs({ + GRASS = 0, + LONG_GRASS = 1, + SAND = 2, + UNDERWATER = 3, + WATER = 4, + POND = 5, + MOUNTAIN = 6, + CAVE = 7, + BUILDING = 8, + PLAIN = 9, + }) do + T.eq( + N.terrain({ gen3Terrain = name, game = {} }), + number, + "native cached terrain names retain the GBA identifier" + ) + end + T.eq( + N.terrain({ terrain = "bad", gen3Terrain = "GRASS" }), + 8, + "explicit invalid terrain retains reference fallback" + ) + T.eq(N.terrain({ terrain = "4" }), 4, "numeric terrain string is retained") +end +for _, version in ipairs({ "red", "gold", "diamond", "black" }) do + V.set(version) + T.eq( + require("src.battle.Gen3Builtins").record("NATURE_POWER_EFFECT", nil), + nil, + "other generations gain no native Nature Power binding" + ) +end +V.set(before) +T.finish("native Nature Power selection and child PP command") diff --git a/tests/engine/gen3_nature_power_chained.lua b/tests/engine/gen3_nature_power_chained.lua new file mode 100644 index 000000000..200932d42 --- /dev/null +++ b/tests/engine/gen3_nature_power_chained.lua @@ -0,0 +1,18 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +for _, version in ipairs({ "firered", "emerald" }) do + local rows = dofile("tests/enemy_actions/nature_power.lua").chained(".", "gen2", version) + T.eq(#rows, 3024, "nested native caller keeps every slot, PP and terrain case") + for _, row in ipairs(rows) do + local last = row.state[row.actor + 1].lastMove + if #row.events == 3 then + T.eq(row.events[2].move, "NATURE_POWER", "Sleep Talk invokes the native terrain caller") + T.eq(last, 267, "the inner native caller owns resulting move history") + elseif #row.events == 2 then + T.eq(last, 214, "changed learned slots preserve ordinary Sleep Talk ownership") + else + T.eq(row.case, "zero_pp", "only zero-PP admission omits every child") + end + end +end +T.finish("native Sleep Talk to Nature Power caller chains") diff --git a/tests/engine/gen3_nature_power_content.lua b/tests/engine/gen3_nature_power_content.lua new file mode 100644 index 000000000..614a92998 --- /dev/null +++ b/tests/engine/gen3_nature_power_content.lua @@ -0,0 +1,13 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +for _, version in ipairs({ "firered", "emerald" }) do + local rows = dofile("tests/enemy_actions/nature_power.lua").content(".", "gen2", version) + T.eq(#rows, 3024, "same API-3 registered Nature Power caller exercises every contract case") + for _, row in ipairs(rows) do + T.eq(row.events[1].move, "PORTABLE_NATURE", "public registered caller retains its canonical identity") + if row.case ~= "zero_pp" then + T.eq(#row.events, 2, "registered caller dispatches one child announcement") + end + end +end +T.finish("public API-3 Nature Power content registration and native execution") diff --git a/tests/engine/gen3_nature_power_link.lua b/tests/engine/gen3_nature_power_link.lua new file mode 100644 index 000000000..bea2b4541 --- /dev/null +++ b/tests/engine/gen3_nature_power_link.lua @@ -0,0 +1,138 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T, Copy = require("tests.modkit"), require("src.mods.Merge").deepCopy +for _, version in ipairs({ "firered", "emerald" }) do + local f = dofile("tests/enemy_actions/nature_power.lua").fixture(".", "gen2", version) + local H = require("src.link.Handshake") + local current = H.hello(f.game, "battle") + local old = Copy(current) + old.nativeBattleRevision = 13 + T.check(current.nativeBattleRevision >= 14, "Nature Power commands advertise at least revision fourteen") + T.eq(H.checkCompat(current, old), "subset", "old command order cannot admit lockstep") + for _, host in ipairs({ false, true }) do + local state = setmetatable( + { game = f.game, myHello = current, peerHello = old }, + { __index = require("src.link.LinkState") } + ) + state:decideCompat("battle", host) + T.eq(state.stage, "notice", "old ordering is refused before party exchange") + end + T.link.prepare(f.data) + local ga = T.link.fakeGame(f.data, "CHARMANDER", { name = "HOST", level = 30 }) + local gb = T.link.fakeGame(f.data, "CHARMANDER", { name = "GUEST", level = 30 }) + local gs = T.link.fakeGame(f.data, "CHARMANDER", { name = "VIEWER", level = 30 }) + for i, game in ipairs({ ga, gb, gs }) do + game.save.version, game.save.options.ruleset = version, "gen3_emerald" + game.save.party[1].nickname = i == 1 and "HOST" or "GUEST" + game.save.party[1].moves = { { id = "MOVE_267", pp = 10 } } + game.save.party[1].item = i == 1 and "ITEM_186" or "ITEM_219" + end + local seen = setmetatable({}, { __mode = "k" }) + f.e.api.events:on("battle.move_used", function(ctx) + local trace = seen[ctx.battle] or {} + seen[ctx.battle] = trace + local slot = ctx.user.mon.moves[1] + trace[#trace + 1] = { + user = ctx.user.mon.nickname, + target = ctx.target.mon.nickname, + move = ctx.move.id, + pp = slot.pp, + called = ctx.isCalled or false, + } + end) + f.e.api.hooks:wrap("held_item.trigger", function(nextRead, ctx) + if ctx.trigger == "damage" and ctx.mon.nickname == "HOST" then + local sum = 0 + for _, event in ipairs(seen[ctx.battle] or {}) do + sum = sum + event.pp + end + return "HELD_NORMAL_BOOST", 10 + sum % 3 + end + return nextRead(ctx) + end) + f.e.api.hooks:wrap("battle.crit", function() + return false + end) + local P, N, L = require("src.link.Protocol"), require("src.link.Net"), require("src.link.LinkBattle") + local na, nb = N.loopbackPair() + local inbox = {} + local sn = { + update = function() end, + poll = function() + local got = inbox + inbox = {} + return got + end, + } + for i, net in ipairs({ na, nb }) do + local send, side = net.send, i == 1 and "host" or "guest" + net.send = function(self, packet) + inbox[#inbox + 1] = { type = "spectate", side = side, msg = Copy(packet) } + return send(self, packet) + end + end + local pa, pb = P.packParty(ga.save.party), P.packParty(gb.save.party) + local a = assert( + L.newHost( + ga, + na, + { myParty = pa, theirParty = pb, theirName = "GUEST", seed = 327, verdict = "full", strict = true } + ) + ) + local b = assert( + L.newGuest( + gb, + nb, + { myParty = pb, theirParty = pa, theirName = "HOST", seed = 327, verdict = "full", strict = true } + ) + ) + local s = assert(L.newSpectator(gs, sn, { + hostParty = pa, + guestParty = pb, + hostName = "HOST", + guestName = "GUEST", + seed = 327, + verdict = "full", + strict = true, + })) + ga.stack:push(a) + gb.stack:push(b) + gs.stack:push(s) + for _, battle in ipairs({ a, b, s }) do + T.eq(battle.ruleset.baseDamageStyle, "gba", "actual linked role uses native GBA rules") + battle.terrain = 2 + for _, actor in ipairs({ battle.player, battle.enemy }) do + actor.mon.hp, actor.mon.stats.hp = 1000, 1000 + end + end + for _ = 1, 40000 do + for i, game in ipairs({ ga, gb, gs }) do + local battle = ({ a, b, s })[i] + require("src.core.Input").pressed = (i == 3 or not battle.localHashes[2]) and { a = true } or {} + game.stack:update(1 / 60) + end + if a.localHashes[2] and b.localHashes[2] and s.turnCount == 2 and s.phase == "waitBoth" then + break + end + end + for turn = 1, 2 do + T.check(a.localHashes[turn] ~= nil and b.localHashes[turn] ~= nil, "both native menu turns complete") + T.eq(a.localHashes[turn], b.localHashes[turn], "event mutations preserve paired completed hashes") + T.same(a.localParts[turn], b.localParts[turn], "all active and bench state components agree") + end + T.same(seen[a], seen[b], "host and guest see all events with the same PP") + T.same(seen[a], seen[s], "spectator sees every event with the same PP") + for _, battle in ipairs({ a, b, s }) do + T.eq(#(seen[battle] or {}), 8, "two actual turns announce caller and child for both actors") + for i, event in ipairs(seen[battle] or {}) do + T.eq(event.pp, i <= 4 and 10 or 9, "event sees current PP before the command") + end + T.eq(battle.player.mon.moves[1].pp, 8, "each current PP command runs once") + T.eq(battle.enemy.mon.moves[1].pp, 8, "each remote PP command runs once") + T.eq(battle.result, nil, "no hidden linked simulation failure") + end + T.eq(s.player.mon.hp, a.player.mon.hp, "spectator host HP agrees") + T.eq(s.enemy.mon.hp, a.enemy.mon.hp, "spectator guest HP agrees") + T.eq(#f.result.errors, 0, "public event callback has no swallowed errors") + f.close() +end +T.finish("native Nature Power events and selected PP in actual linked roles") diff --git a/tests/engine/gen3_party_codec.lua b/tests/engine/gen3_party_codec.lua new file mode 100644 index 000000000..6aaaae0e0 --- /dev/null +++ b/tests/engine/gen3_party_codec.lua @@ -0,0 +1,6 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +for _, version in ipairs({ "firered", "emerald" }) do + require("tests.enemy_actions.party_codec").run(".", "gen2", version) + require("tests.enemy_actions.party_codec_hooks").run(".", "gen2", version) +end +require("tests.modkit").finish("native GBA party codec") diff --git a/tests/engine/gen3_party_transport.lua b/tests/engine/gen3_party_transport.lua new file mode 100644 index 000000000..411705e6e --- /dev/null +++ b/tests/engine/gen3_party_transport.lua @@ -0,0 +1,275 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local P, H = require("src.link.Protocol"), require("src.link.Handshake") +local Copy, Json = require("src.mods.Merge").deepCopy, require("src.link.Json") +local C = require("src.link.Gen3Party") +for _, version in ipairs({ "firered", "emerald" }) do + local f = require("tests.enemy_actions.party_codec").fixture(".", "gen2", version, "translated") + local Stats, Pokemon = require("src.pokemon.Stats"), require("src.pokemon.Pokemon") + Stats.setNatures(f.data.constants.natures) + local function mon(pid, slot) + local m = Pokemon.new(f.data, f.codecIds[60], 30) + m.evs = { hp = 252, attack = 0, defense = 6, speed = 252, spatk = 0, spdef = 0 } + Pokemon.applySeed( + f.data, + m, + { personality = pid, ivs = { hp = 31, attack = 29, defense = 27, speed = 25, spatk = 23, spdef = 21 } } + ) + m.abilitySlot, m.ot, m.otId, m.secretId, m.otSecretId = slot, "TRAINER", 1234, 5678, 5678 + m.moves = { { id = f.ids.TACKLE, pp = 10, ppUps = 3 } } + m.nickname, m.extra = "PÖKEMON", { consumer = { marker = version } } + return m + end + local original = mon(100001, 2) + local packed = P.packMon(original) + local decoded = assert(P.unpackMon(f.data, Json.decode(Json.encode(packed)), { strict = true })) + T.same(decoded.ivs, original.ivs, "actual packet preserves IVs") + T.same(decoded.evs, original.evs, "actual packet preserves EVs") + T.same(decoded.stats, original.stats, "party reconstructed with its original native stats") + T.eq(decoded.abilitySlot, 2, "explicit alternate ability survives independently of PID") + T.eq(decoded.otSecretId, 5678, "public shiny reader receives the secret ID alias") + T.eq( + require("src.mods.Api3Records").shiny(decoded, 3), + require("src.pokemon.IdentityRolls").nativeGen3Shiny(original.personality, 1234, 5678), + "public identity agrees after transport" + ) + local saved = require("src.core.SaveSerializer").decode( + require("src.core.SaveSerializer").encode({ party = { decoded } }) + ) + T.same(saved.party[1].ivs, original.ivs, "received genetics survive save reload") + decoded.ivs.hp = 0 + decoded.extra.consumer.marker = "changed" + T.eq(original.ivs.hp, 31, "received genotype is detached") + T.eq(original.extra.consumer.marker, version, "received extension namespace is detached") + local cases = { + { + "codec", + function(p) + p.gen3Codec = nil + end, + }, + { + "PID", + function(p) + p.personality = 4294967296 + end, + }, + { + "NaN", + function(p) + p.personality = 0 / 0 + end, + }, + { + "infinity", + function(p) + p.personality = math.huge + end, + }, + { + "nature", + function(p) + p.nature = 24 + end, + }, + { + "gender", + function(p) + p.gender = "female" + end, + }, + { + "IVs", + function(p) + p.ivs.hp = 32 + end, + }, + { + "EVs", + function(p) + p.evs.hp = 256 + end, + }, + { + "EV sum", + function(p) + p.evs.defense = 7 + end, + }, + { + "ability", + function(p) + p.abilitySlot = 3 + end, + }, + { + "species", + function(p) + p.species = "MISSING" + end, + }, + { + "moves", + function(p) + p.moves = {} + end, + }, + { + "duplicate", + function(p) + p.moves[2] = Copy(p.moves[1]) + end, + }, + { + "PP", + function(p) + p.moves[1].pp = 999 + end, + }, + { + "PP Ups", + function(p) + p.moves[1].ppUps = 4 + end, + }, + { + "OT", + function(p) + p.secretId = 65536 + end, + }, + { + "level", + function(p) + p.level = 0 + end, + }, + { + "exp", + function(p) + p.exp = 0 + end, + }, + { + "name", + function(p) + p.ot = "A\nB" + end, + }, + { + "item", + function(p) + p.item = "MISSING" + end, + }, + } + for _, case in ipairs(cases) do + local p = Copy(packed) + case[2](p) + local ok, got, why = pcall(P.unpackMon, f.data, p, { strict = true }) + T.eq(ok, true, "malformed " .. case[1] .. " returns without crashing") + T.eq(got, nil, "malformed " .. case[1] .. " is refused") + T.check(type(why) == "string", "refusal explains malformed " .. case[1]) + end + for _, status in ipairs({ "SLP", "PSN", "PAR", "BRN", "FRZ" }) do + local p = Copy(packed) + p.status, p.toxic, p.sleepTurns = status, true, 7 + local m = assert(P.unpackMon(f.data, p, { strict = true })) + T.eq(m.status, status, "party retains status") + T.eq(m.toxic, status == "PSN" and true or nil, "only poison retains strong-poison identity") + local forced = assert(P.unpackMon(f.data, p, { strict = true, forceLevel = 50 })) + T.eq(forced.status, nil, "auto-level clears status") + T.eq(forced.sleepTurns, nil, "auto-level clears sleep count") + T.eq(forced.toxic, nil, "auto-level clears toxic identity") + T.eq(forced.hp, forced.stats.hp, "auto-level starts full") + T.eq(forced.personality, original.personality, "auto-level retains personality") + end + local a, b = + P.TradeSession.new(f.data, { original }, { strict = true }), + P.TradeSession.new(f.data, { mon(100024, 1) }, { strict = true }) + local ao, bo = a:opening(), b:opening() + a:handle(bo) + b:handle(ao) + local ap, bp = a:pick(1), b:pick(1) + a:handle(bp) + b:handle(ap) + local ac, bc = a:confirm(true), b:confirm(true) + a:handle(bc) + b:handle(ac) + T.eq(a.stage, "done", "real trade controller completes") + T.eq(b.stage, "done", "both trade roles complete") + T.same(b.theirParty[1].ivs, original.ivs, "actual trade receives six native IVs") + T.eq(b.theirParty[1].abilitySlot, 2, "actual trade keeps selected ability") + local received = b:apply() + T.eq(received, b.party[1], "actual trade apply files the received party member") + T.eq(received.traded, true, "received native mon retains traded identity") + T.same(received.ivs, original.ivs, "trade application keeps all native IVs") + T.same(received.evs, original.evs, "trade application keeps all native EVs") + local persisted = + require("src.core.SaveSerializer").decode(require("src.core.SaveSerializer").encode({ party = b.party })) + T.eq(persisted.party[1].otSecretId, 5678, "applied trade retains secret ID through save reload") + local bad = P.TradeSession.new(f.data, { original }, { strict = true }) + local packet = P.packParty({ original, original }) + packet[1].ivs.hp = -1 + bad:handle({ type = "party", mons = packet }) + T.eq(bad.stage, "cancelled", "invalid row cancels instead of sliding later slots") + local hello = H.hello({ data = f.data, save = { player = { name = "RULES" } } }, "trade") + local old = Copy(hello) + old.nativePartyRevision = nil + for _, pair in ipairs({ { hello, old }, { old, hello } }) do + T.eq(H.checkCompat(pair[1], pair[2]), "refused", "old native party codec is refused in both roles") + for _, mode in ipairs({ "trade", "battle" }) do + local state = setmetatable( + { game = f.game, myHello = pair[1], peerHello = pair[2] }, + { __index = require("src.link.LinkState") } + ) + state:decideCompat(mode, true) + T.eq(state.stage, "notice", "actual controller stops unsafe " .. mode) + T.eq(state.noticeExits, true, "unsafe codec cannot enter party exchange") + end + end + T.link.prepare(f.data) + local ga = T.link.fakeGame(f.data, f.codecIds[60], { name = "A", level = 30 }) + local gb = T.link.fakeGame(f.data, f.codecIds[60], { name = "B", level = 30 }) + ga.save.party, gb.save.party = { original }, { mon(100024, 1) } + local result = T.link.lockstep(ga, gb, { seed = 327, maxFrames = 60000 }) + T.eq(result.completed, true, "full native-genetics link mirror completes") + T.eq(result.agreed, true, "native-genetics turn hashes agree") + for _, m in ipairs({ result.battleA.player.mon, result.battleB.enemy.mon }) do + T.same(m.ivs, original.ivs, "local and remote battle receive original IVs") + T.same(m.evs, original.evs, "local and remote battle receive original EVs") + T.eq(m.personality, original.personality, "local and remote battle receive original PID") + T.eq(m.abilitySlot, 2, "local and remote battle retain alternate ability") + end + for turn, hash in pairs(result.battleA.localHashes) do + if result.battleB.localHashes[turn] then + T.eq(hash, result.battleB.localHashes[turn], "every completed turn agrees") + T.same( + result.battleA.localParts[turn], + result.battleB.localParts[turn], + "every completed state component agrees" + ) + end + end + local signature = C.signature(original) + for _, key in ipairs({ "personality", "abilitySlot", "level", "exp", "otId", "otSecretId" }) do + local changed = Copy(original) + changed[key] = (changed[key] or 0) + 1 + T.neq(C.signature(changed), signature, "state signature detects altered " .. key) + end + for _, kind in ipairs({ "ivs", "evs" }) do + local changed = Copy(original) + changed[kind].hp = changed[kind].hp + 1 + T.neq(C.signature(changed), signature, "state signature detects altered " .. kind) + end + Stats.setNatures(nil) + f.close() +end +require("src.core.GameVersion").set("red") +local classic = T.fixtures.fresh() +local original = require("src.pokemon.Pokemon").new(classic, "FIXMON_A", 20) +local packed = P.packMon(original) +T.eq(packed.gen3Codec, nil, "older-game packet keeps its existing shape") +T.eq(packed.ivs, nil, "older games do not acquire GBA genetics") +T.same(assert(P.unpackMon(classic, packed)).dvs, original.dvs, "classic DV codec remains intact") +T.finish("native GBA party transport, trade and link") diff --git a/tests/engine/gen3_post_hit_execution.lua b/tests/engine/gen3_post_hit_execution.lua new file mode 100644 index 000000000..6957a1e29 --- /dev/null +++ b/tests/engine/gen3_post_hit_execution.lua @@ -0,0 +1,5 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +for _, version in ipairs({ "firered", "emerald" }) do + dofile("tests/enemy_actions/post_hit_items.lua").execution(".", "gen2", version) +end +require("tests.modkit").finish("native GBA post-hit items in actual scripts") diff --git a/tests/engine/gen3_post_hit_items.lua b/tests/engine/gen3_post_hit_items.lua new file mode 100644 index 000000000..953d3e795 --- /dev/null +++ b/tests/engine/gen3_post_hit_items.lua @@ -0,0 +1,5 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +for _, version in ipairs({ "firered", "emerald" }) do + dofile("tests/enemy_actions/post_hit_items.lua").command(".", "gen2", version) +end +require("tests.modkit").finish("native GBA shared post-hit item command") diff --git a/tests/engine/gen3_post_hit_link.lua b/tests/engine/gen3_post_hit_link.lua new file mode 100644 index 000000000..f2c691bd7 --- /dev/null +++ b/tests/engine/gen3_post_hit_link.lua @@ -0,0 +1,176 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T, Copy = require("tests.modkit"), require("src.mods.Merge").deepCopy +for _, version in ipairs({ "firered", "emerald" }) do + for _, hostKing in ipairs({ false, true }) do + local f = dofile("tests/enemy_actions/post_hit_items.lua").fixture(".", "gen2", version) + local H = require("src.link.Handshake") + local current = H.hello(f.game, "battle") + local old = Copy(current) + old.nativeBattleRevision = 12 + T.check(current.nativeBattleRevision >= 13, "shared post-hit commands advertise at least revision thirteen") + T.eq(H.checkCompat(current, old), "subset", "old command order cannot admit lockstep") + for _, host in ipairs({ false, true }) do + local state = setmetatable( + { game = f.game, myHello = current, peerHello = old }, + { __index = require("src.link.LinkState") } + ) + state:decideCompat("battle", host) + T.eq(state.stage, "notice", "old ordering is refused before party exchange") + end + f.data.moves.MOVE_033.flags = 51 + f.data.moves.MOVE_033.gen3Effect = 0 + T.link.prepare(f.data) + local ga = T.link.fakeGame(f.data, "CHARMANDER", { name = "HOST", level = 30 }) + local gb = T.link.fakeGame(f.data, "CHARMANDER", { name = "GUEST", level = 30 }) + local gs = T.link.fakeGame(f.data, "CHARMANDER", { name = "VIEWER", level = 30 }) + for i, game in ipairs({ ga, gb, gs }) do + game.save.version, game.save.options.ruleset = version, "gen3_emerald" + game.save.party[1].nickname = i == 1 and "HOST" or "GUEST" + game.save.party[1].moves = { { id = "MOVE_033", pp = 10 } } + game.save.party[1].item = (i == 1 and hostKing or i ~= 1 and not hostKing) and "ITEM_187" or "ITEM_219" + end + local seen, reads, draws, pending = {}, {}, {}, {} + f.e.api.events:on("battle.move_used", function(ctx) + local trace = seen[ctx.battle] or {} + seen[ctx.battle] = trace + trace[#trace + 1] = { + user = ctx.user.mon.nickname, + target = ctx.target.mon.nickname, + move = ctx.move.id, + pp = ctx.user.mon.moves[1].pp, + called = ctx.isCalled or false, + } + end) + f.e.api.hooks:wrap("held_item.trigger", function(nextRead, ctx) + local trace = reads[ctx.battle] or {} + reads[ctx.battle] = trace + local row = { + user = ctx.mon.nickname, + item = ctx.item, + effect = ctx.effect, + parameter = ctx.parameter, + trigger = ctx.trigger, + } + trace[#trace + 1] = row + if ctx.trigger == "flinch" then + if pending[ctx.battle] then + pending[ctx.battle] = false + row.effectOut, row.parameterOut = "HELD_FLINCH", 256 + return row.effectOut, row.parameterOut + end + if ctx.effect == "HELD_FLINCH" then + pending[ctx.battle] = true + end + end + local effect, parameter = nextRead(ctx) + row.effectOut, row.parameterOut = effect, parameter + return effect, parameter + end) + f.e.api.hooks:wrap("battle.crit", function() + return false + end) + local P, N, L = require("src.link.Protocol"), require("src.link.Net"), require("src.link.LinkBattle") + local na, nb = N.loopbackPair() + local inbox = {} + local sn = { + update = function() end, + poll = function() + local got = inbox + inbox = {} + return got + end, + } + for i, net in ipairs({ na, nb }) do + local send, side = net.send, i == 1 and "host" or "guest" + net.send = function(self, packet) + inbox[#inbox + 1] = { type = "spectate", side = side, msg = Copy(packet) } + return send(self, packet) + end + end + local pa, pb = P.packParty(ga.save.party), P.packParty(gb.save.party) + local a = assert( + L.newHost( + ga, + na, + { myParty = pa, theirParty = pb, theirName = "GUEST", seed = 327, verdict = "full", strict = true } + ) + ) + local b = assert( + L.newGuest( + gb, + nb, + { myParty = pb, theirParty = pa, theirName = "HOST", seed = 327, verdict = "full", strict = true } + ) + ) + local s = assert(L.newSpectator(gs, sn, { + hostParty = pa, + guestParty = pb, + hostName = "HOST", + guestName = "GUEST", + seed = 327, + verdict = "full", + strict = true, + })) + ga.stack:push(a) + gb.stack:push(b) + gs.stack:push(s) + for _, battle in ipairs({ a, b, s }) do + T.eq(battle.ruleset.baseDamageStyle, "gba", "actual linked role uses native GBA rules") + for _, actor in ipairs({ battle.player, battle.enemy }) do + actor.mon.hp, actor.mon.stats.hp = 500, 1000 + actor.curStats.speed = actor.mon.nickname == "HOST" and 200 or 100 + end + end + for _, battle in ipairs({ a, b, s }) do + local random = battle.rng + draws[battle] = {} + battle.rng = function(lo, hi) + local value = random(lo, hi) + draws[battle][#draws[battle] + 1] = { lo, hi, value } + return value + end + end + for _ = 1, 40000 do + for i, game in ipairs({ ga, gb, gs }) do + local battle = ({ a, b, s })[i] + require("src.core.Input").pressed = (i == 3 or not battle.localHashes[2]) and { a = true } or {} + game.stack:update(1 / 60) + end + if a.localHashes[2] and b.localHashes[2] and s.turnCount == 2 and s.phase == "waitBoth" then + break + end + end + for turn = 1, 2 do + T.check(a.localHashes[turn] ~= nil and b.localHashes[turn] ~= nil, "both native menu turns complete") + T.eq(a.localHashes[turn], b.localHashes[turn], "event mutations preserve paired completed hashes") + T.same(a.localParts[turn], b.localParts[turn], "all active and bench state components agree") + end + T.same(seen[a], seen[b], "host and guest see all events with the same PP") + T.same(seen[a], seen[s], "spectator sees every event with the same PP") + T.same(reads[a], reads[b], "host and guest retain all stateful held reads") + T.same(reads[a], reads[s], "spectator retains all stateful held reads") + T.same(draws[a], draws[b], "host and guest retain every RNG draw") + T.same(draws[a], draws[s], "spectator retains every RNG draw") + for _, battle in ipairs({ a, b, s }) do + T.eq(#(seen[battle] or {}), hostKing and 2 or 4, "moved targets resist flinch; waiting targets skip") + local host = battle.linkRole == "guest" and battle.enemy or battle.player + local guest = battle.linkRole == "guest" and battle.player or battle.enemy + T.eq(host.mon.moves[1].pp, 8, "host acts on both turns") + T.eq(guest.mon.moves[1].pp, hostKing and 10 or 8, "native flinch prevents the waiting action") + local commandReads = 0 + for _, row in ipairs(reads[battle] or {}) do + if row.trigger == "flinch" and row.parameterOut == 256 then + commandReads = commandReads + 1 + T.eq(row.user, "HOST", "the second flinch read uses canonical host player on every role") + end + end + T.eq(commandReads, 2, "one complete King's Rock command runs per turn") + T.eq(battle.result, nil, "no hidden linked simulation failure") + end + T.eq(s.player.mon.hp, a.player.mon.hp, "spectator host HP agrees") + T.eq(s.enemy.mon.hp, a.enemy.mon.hp, "spectator guest HP agrees") + T.eq(#f.result.errors, 0, "public event callback has no swallowed errors") + f.close() + end +end +T.finish("native post-hit items in actual host guest and spectator") diff --git a/tests/engine/gen3_rage.lua b/tests/engine/gen3_rage.lua new file mode 100644 index 000000000..94beba910 --- /dev/null +++ b/tests/engine/gen3_rage.lua @@ -0,0 +1,5 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +for _, v in ipairs({ "firered", "emerald" }) do + require("tests.enemy_actions.rage").run(".", "gen2", v) +end +require("tests.modkit").finish("native GBA Rage activation, PP and move-end boosts") diff --git a/tests/engine/gen3_rage_continuation.lua b/tests/engine/gen3_rage_continuation.lua new file mode 100644 index 000000000..653c8313e --- /dev/null +++ b/tests/engine/gen3_rage_continuation.lua @@ -0,0 +1,194 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T, Copy = require("tests.modkit"), require("src.mods.Merge").deepCopy +local R, V = require("src.battle.Gen3Rage"), require("src.core.GameVersion") +for _, version in ipairs({ "firered", "emerald" }) do + local f = require("tests.enemy_actions.rage").fixture(".", "gen2", version) + T.link.prepare(f.data) + local game = T.link.fakeGame(f.data, "CHARMANDER", { name = "PLAYER", level = 30 }) + game.save.options.ruleset = "gen3_emerald" + game.stack.states = game.stack.list + f.result.loader.game = game + local mon = game.save.party[1] + mon.nickname = "PLAYER" + mon.hp, mon.stats.hp = 400, 400 + mon.moves = { { id = f.ids.RAGE, pp = 10 }, { id = f.ids.TACKLE, pp = 10 } } + local b = require("src.battle.BattleState").newWild(game, "CHARMANDER", 30) + b:syncSides() + b.enemy.mon.nickname, b.enemy.mon.hp, b.enemy.mon.stats.hp = "ENEMY", 400, 400 + b.enemy.curMoves = { { id = f.ids.TACKLE, pp = 10 } } + b.enemy.mon.moves = b.enemy.curMoves + b.player.curStats.speed, b.enemy.curStats.speed = 100, 200 + b.phase, b.queue, b.frame, b.menuIndex, b.moveIndex = "moveSelect", {}, 0, 1, 1 + game.stack.list[1] = b + b.rng = function(_, hi) + return hi + end + f.e.choose(function() + return { move = "TACKLE" } + end) + f.e.api.hooks:wrap("battle.crit", function() + return false + end) + local started, used, stages = {}, {}, {} + local off = f.e.api.events:on("battle.turn_started", function(e) + started[#started + 1] = { id = e.playerAction.id, rage = not not b.player.rage } + end) + local offMove = f.e.api.events:on("battle.move_used", function(e) + used[#used + 1] = { user = e.user.mon.nickname, move = e.move.id } + end) + local nativeHeld = b.holdItemTrigger + b.holdItemTrigger = function(self, who, when) + if when == "moveEnd" and who == self.player then + stages[#stages + 1] = who.stages.attack or 0 + end + return nativeHeld(self, who, when) + end + local api, command = f.e.api.battle, 0 + local function submit(raw) + local snapshot = assert(api:snapshot()) + command = command + 1 + raw.id, raw.revision = command, snapshot.revision + local ok, why = api:submit(raw) + T.eq(ok, true, "public Rage command is accepted: " .. tostring(why)) + end + local function complete(turn) + for _ = 1, 18000 do + if b.phase == "menu" and b.turnCount == turn then + break + end + require("src.core.Input").pressed = { a = true } + game.stack:update(1 / 60) + end + T.eq(b.phase, "menu", "native Rage turn returns to the actual menu") + T.eq(b.turnCount, turn, "exactly one native turn completes") + end + submit({ kind = "move", slot = 1 }) + complete(1) + T.eq(b.player.rage, true, "real Rage producer activates its native flag") + T.eq(b.player.rageMove, nil, "real Rage does not install a legacy lock") + T.eq(mon.moves[1].pp, 9, "first selected Rage spends PP") + T.eq(b.player.stages.attack or 0, 0, "earlier attack before first Rage does not build rage") + T.eq(assert(api:snapshot()).prompt, "menu", "native Rage leaves public menu actionable") + submit({ kind = "menu", choice = "fight" }) + T.eq(b.phase, "moveSelect", "FIGHT opens the real move list after Rage") + submit({ kind = "move", slot = 1 }) + complete(2) + T.eq(mon.moves[1].pp, 8, "a separately selected repeat spends another PP") + T.eq(b.player.stages.attack, 1, "faster foe builds Rage once before the second attack") + T.eq(stages[3], 1, "Rage boost is present before the real move-end item read") + submit({ kind = "menu", choice = "fight" }) + submit({ kind = "move", slot = 2 }) + complete(3) + T.eq(mon.moves[1].pp, 8, "another selected move does not consume Rage PP") + T.eq(mon.moves[2].pp, 9, "the independently selected alternative actually executes") + T.eq(b.player.rage, nil, "alternative choice clears Rage before the faster opponent") + T.eq(b.player.stages.attack, 1, "no stale Rage boost occurs on the alternative turn") + T.same( + started, + { { id = "RAGE", rage = false }, { id = "RAGE", rage = true }, { id = "TACKLE", rage = false } }, + "turn-start observes Rage cleanup and original selected moves" + ) + T.eq(#used, 6, "both real actions execute on all three turns") + b.phase = "moveSelect" + local offered = assert(api:snapshot()) + b.player.rage = true + local stale = api:submit({ id = command + 1, revision = offered.revision, kind = "move", slot = 2 }) + T.eq(stale, nil, "changed Rage flag invalidates an old offered command") + T.eq( + require("src.battle.Gen3AI").OPS.if_status2({ user = b.player }, { 1, 0x800000, 71 }), + 71, + "native AI sees produced Rage status bit" + ) + off() + offMove() + f.close() + for _, mode in ipairs({ "override", "delegate" }) do + local entry = [[return function(mod) + local base=assert(mod.content.move_effects:get('RAGE_EFFECT'));local run=base.afterDamage + mod.exports.calls=0 + assert(mod.content.move_effects:override('RAGE_EFFECT',{kind='full',afterDamage=function(ctx) + mod.exports.calls=mod.exports.calls+1 + ]] .. (mode == "delegate" and "return run(ctx)" or "return") .. [[ + end}));mod.exports.api=mod + end]] + local g = require("tests.enemy_actions.rage").fixture(".", "gen2", version, entry) + local c = g.fresh("TACKLE", { enemy = true }) + c.battle:syncSides() + c.user.curMoves = { { id = g.ids.RAGE, pp = 10 } } + c.user.mon.moves = c.user.curMoves + c.battle.rng = function(_, hi) + return hi + end + c.battle:executeAction(c.user, c.target, c.user.curMoves[1]) + T.eq(g.e.calls, 1, "authored Rage handler runs once") + T.eq( + not not c.user.rage, + mode == "delegate", + "complete override retains its own policy or native delegate" + ) + T.eq(c.user.rageMove, nil, "native delegate never installs the legacy lock") + T.eq(c.user.curMoves[1].pp, 9, "authored Rage retains native PP ownership") + g.close() + end + for _, mode in ipairs({ "item", "ball", "reply", "switch", "baton", "run" }) do + local g = require("tests.enemy_actions.rage").fixture(".", "gen2", version) + local c = g.fresh("TACKLE") + local battle, donor = c.battle, c.user + battle:syncSides() + donor.rage, battle.enemy.rage = true, true + local calls = 0 + g.e.choose(function() + calls = calls + 1 + T.eq(donor.rage, true, "enemy selection sees the original pre-cleanup Rage state") + return { move = "TACKLE" } + end) + battle.rng = function(_, hi) + return hi + end + if mode == "item" then + battle:itemUsed({}) + elseif mode == "run" then + battle.player.curStats.speed, battle.enemy.curStats.speed = 200, 100 + battle:tryRun() + T.eq(battle.result, "run", "actual successful escape finishes with the cleaned native state") + elseif mode == "ball" then + battle:throwBall("POKE_BALL") + elseif mode == "reply" then + local run = battle:nonMoveAction() + g.e.choose(function() + error("native reply selected twice") + end) + run() + T.eq(donor.stages.attack or 0, 0, "non-move reply cannot build stale Rage") + else + local bench = Copy(donor.mon) + bench.nickname = "BENCH" + battle.game.save.party[2] = bench + if mode == "switch" then + battle:resolveSwitch(bench) + else + require("src.battle.Gen3BatonPass").switch(battle, donor, 2, battle.game.save.party) + end + end + if mode ~= "baton" then + T.eq(donor.rage, nil, "committed non-move action clears native Rage") + T.eq(calls, 1, "native non-move action prepares exactly one enemy reply") + else + T.eq(battle.player.rage, nil, "Baton Pass never transfers Rage") + end + g.close() + end +end +T.neq(R.signature({ rage = true }), R.signature({}), "native active hash includes Rage") +T.eq(R.signature({ rage = false }), R.signature({}), "false and absent flags share the same semantic hash") +local raw = require("src.battle.MoveEffects").RECORDS.RAGE_EFFECT +for _, v in ipairs({ "red", "gold", "diamond", "black" }) do + V.set(v) + T.eq(R.builtin("RAGE_EFFECT", raw), raw, "older generation retains original builtin identity") + local user = {} + local move = { id = "LEGACY" } + raw.afterDamage({ user = user, moveInst = move }) + T.eq(user.rageMove, move, "older generation keeps the existing move lock") + T.eq(user.rage, nil, "native marker does not leak to other generations") +end +T.finish("native Rage public commands, replies, composition and legacy behavior") diff --git a/tests/engine/gen3_rage_link.lua b/tests/engine/gen3_rage_link.lua new file mode 100644 index 000000000..d47a977a0 --- /dev/null +++ b/tests/engine/gen3_rage_link.lua @@ -0,0 +1,104 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T, Copy = require("tests.modkit"), require("src.mods.Merge").deepCopy +for _, version in ipairs({ "firered", "emerald" }) do + local f = require("tests.enemy_actions.rage").fixture(".", "gen2", version) + local H = require("src.link.Handshake") + local current = H.hello(f.game, "battle") + local older = Copy(current) + older.nativeBattleRevision = 6 + T.eq(H.checkCompat(current, older), "subset", "pre-Rage rules cannot enter a full battle") + T.eq(H.checkCompat(older, current), "subset", "native revision guard is symmetric") + for _, host in ipairs({ false, true }) do + local state = setmetatable( + { game = f.game, myHello = current, peerHello = older }, + { __index = require("src.link.LinkState") } + ) + state:decideCompat("battle", host) + T.eq(state.stage, "notice", "actual controller refuses the old native rules") + T.eq(state.noticeExits, true, "old native rules cannot enter party selection") + end + local def = f.data.pokemon.CHARMANDER + def.evYield = {} + def.baseStats.spatk, def.baseStats.spdef, def.baseStats.hp = 50, 50, 200 + T.link.prepare(f.data) + local ga = T.link.fakeGame(f.data, "CHARMANDER", { name = "HOST", level = 30 }) + local gb = T.link.fakeGame(f.data, "CHARMANDER", { name = "GUEST", level = 30 }) + for _, game in ipairs({ ga, gb }) do + game.save.options.ruleset = "gen3_emerald" + game.save.party[1].moves = { + { id = f.ids.RAGE, pp = 10 }, + { id = f.ids.DOUBLE_SLAP, pp = 10 }, + { id = f.ids.TACKLE, pp = 10 }, + } + end + f.e.api.hooks:wrap("battle.crit", function() + return false + end) + local P, N, L = require("src.link.Protocol"), require("src.link.Net"), require("src.link.LinkBattle") + local na, nb = N.loopbackPair() + local pa, pb = P.packParty(ga.save.party), P.packParty(gb.save.party) + local a = assert( + L.newHost( + ga, + na, + { myParty = pa, theirParty = pb, theirName = "GUEST", seed = 327, verdict = "full", strict = true } + ) + ) + local b = assert( + L.newGuest( + gb, + nb, + { myParty = pb, theirParty = pa, theirName = "HOST", seed = 327, verdict = "full", strict = true } + ) + ) + ga.stack:push(a) + gb.stack:push(b) + a.player.curStats.speed, a.enemy.curStats.speed = 200, 100 + b.player.curStats.speed, b.enemy.curStats.speed = 100, 200 + local seen = {} + for _ = 1, 40000 do + for i, game in ipairs({ ga, gb }) do + local battle = i == 1 and a or b + local turn = battle.turnCount or 0 + battle.moveIndex = turn < 2 and 1 or turn == 2 and (i == 1 and 2 or 1) or 3 + require("src.core.Input").pressed = { a = true } + game.stack:update(1 / 60) + end + if a.localHashes[3] and b.localHashes[3] and not seen[3] then + seen[3] = true + T.eq( + a.player.stages.attack, + 2, + "choosing an alternative clears the faster owner's Rage before the reply" + ) + T.eq(a.enemy.stages.attack, 2, "the whole incoming multi-hit builds Rage only once") + T.eq(b.enemy.stages.attack, 2, "remote owner keeps the same completed stage") + T.eq(b.player.stages.attack, 2, "guest observes one completed multi-hit boost") + end + if a.localHashes[4] and b.localHashes[4] then + break + end + end + T.check(seen[3], "the mixed Rage and multi-hit turn completes on both roles") + for turn = 1, 4 do + T.check(a.localHashes[turn] ~= nil and b.localHashes[turn] ~= nil, "native full linked turn completes") + T.eq(a.localHashes[turn], b.localHashes[turn], "paired completed turn hashes agree") + T.same(a.localParts[turn], b.localParts[turn], "all active, volatile and bench components agree") + end + for _, battle in ipairs({ a, b }) do + local host = battle == a and battle.player or battle.enemy + local guest = battle == a and battle.enemy or battle.player + T.eq(host.mon.moves[1].pp, 8, "host's two separately selected Rage actions both spend PP") + T.eq(guest.mon.moves[1].pp, 7, "guest's three Rage actions all spend their learned PP") + T.eq(host.mon.moves[2].pp, 9, "host actually executes the independently selected multi-hit") + T.eq(guest.mon.moves[2].pp, 10, "guest never spends the unselected multi-hit slot") + for _, battler in ipairs({ host, guest }) do + T.eq(battler.mon.moves[3].pp, 9, "last independently selected Tackle spends its own PP") + T.eq(battler.rage, nil, "both native Rage flags clear before the last turn") + T.eq(battler.stages.attack, 2, "no stale Rage boost occurs on the last turn") + end + T.eq(battle.result, nil, "no hidden input or hash failure ends the native mirror") + end + f.close() +end +T.finish("native Rage real link menus and completed turn hashes") diff --git a/tests/engine/gen3_sleep_talk.lua b/tests/engine/gen3_sleep_talk.lua new file mode 100644 index 000000000..42ffbf90f --- /dev/null +++ b/tests/engine/gen3_sleep_talk.lua @@ -0,0 +1,6 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +for _, version in ipairs({ "firered", "emerald" }) do + require("tests.enemy_actions.sleep_talk").run(".", "gen2", version) + require("tests.enemy_actions.sleep_talk").content(".", "gen2", version) +end +require("tests.modkit").finish("native GBA Sleep Talk selection and execution") diff --git a/tests/engine/gen3_sleep_talk_composition.lua b/tests/engine/gen3_sleep_talk_composition.lua new file mode 100644 index 000000000..b41feaa6c --- /dev/null +++ b/tests/engine/gen3_sleep_talk_composition.lua @@ -0,0 +1,150 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +for _, version in ipairs({ "firered", "emerald" }) do + for _, mode in ipairs({ + "delegate", + "primary_delegate", + "override", + "status_override", + "fallback", + "awake", + "empty", + }) do + local entry + if mode ~= "fallback" then + entry = [[return function(mod) + local base=assert(mod.content.move_effects:get("SLEEP_TALK_EFFECT")) + local pick,run=base.callsMove,base.run + mod.exports.calls=0 + ]] .. (mode == "primary_delegate" and [[ + base={kind="primary",run=function(ctx)mod.exports.calls=mod.exports.calls+1;return run(ctx)end} + ]] or [[ + base.callsMove=function(ctx) + mod.exports.calls=mod.exports.calls+1 + ]] .. (mode == "override" and "return ctx.user.curMoves[3].id" or "return pick(ctx)") .. [[ + end + ]]) .. [[ + assert(mod.content.move_effects:override("SLEEP_TALK_EFFECT",base)) + ]] .. (mode == "status_override" and [[ + local status=mod.content.statuses:get("SLP") + status.beforeMove=function()return false,{}end + assert(mod.content.statuses:override("SLP",status)) + ]] or "") .. [[ + mod.exports.api=mod + end]] + end + local f = require("tests.enemy_actions.sleep_talk").fixture(".", "gen2", version, entry) + local c = f.fresh("TACKLE", { enemy = true }) + local b = c.battle + b:syncSides() + c.user.mon.status, c.user.sleepTurns = "SLP", 4 + c.user.mon.moves = + { { id = f.ids.SLEEP_TALK, pp = 10 }, { id = f.ids.TACKLE, pp = 0 }, { id = f.ids.SCRATCH, pp = 10 } } + c.user.curMoves = c.user.mon.moves + if mode == "awake" then + c.user.mon.status, c.user.sleepTurns = nil, nil + end + if mode == "empty" then + c.user.curMoves[2].id, c.user.curMoves[3].id = f.ids.METRONOME, f.ids.MIRROR_MOVE + end + c.user.lastMove = f.ids.SCRATCH + local originalEffects, originalStatuses = b.data.move_effects, b.data.statuses + if mode == "fallback" then + b.data.move_effects, b.data.statuses = nil, nil + end + local reads = {} + local offReads = f.e.api.hooks:wrap("held_item.trigger", function(nextRead, ctx) + reads[#reads + 1] = { trigger = ctx.trigger, parameter = ctx.parameter, item = ctx.item } + return nextRead(ctx) + end) + local used = {} + local off = f.e.api.events:on("battle.move_used", function(e) + used[#used + 1] = { move = e.move.id, target = e.target.mon.nickname, called = e.isCalled or false } + end) + b.rng = function(lo, hi) + if lo == 1 then + return 1 + end + return hi + end + b:executeAction(c.user, c.user, c.user.curMoves[1]) + if mode == "awake" or mode == "empty" then + T.eq(#used, 1, "failed Sleep Talk emits only its caller event") + T.eq(c.user.curMoves[1].pp, 9, "failed executed caller still spends its PP") + T.eq(c.user.lastMove, f.ids.SCRATCH, "failed caller preserves previous move bookkeeping") + T.eq(#reads, 2, "failed caller performs only the separate after-action item reads") + for _, read in ipairs(reads) do + T.eq(read.trigger, "residual", "no failed-call move-end sweep is inserted") + end + elseif mode == "status_override" then + T.eq(#used, 0, "authored sleep interruption owns cancellation") + T.eq(f.e.calls, 0, "cancelled caller never selects a nested move") + T.eq(c.user.curMoves[1].pp, 10, "authored cancellation spends no PP") + else + T.eq(#used, 2, "default or delegated caller reaches one nested move") + T.eq( + used[2].move, + mode == "override" and "SCRATCH" or "TACKLE", + "authored choice or native zero-PP eligibility is respected" + ) + T.eq(used[2].called, true, "nested public event retains called provenance") + T.eq(c.user.curMoves[1].pp, 9, "caller alone spends PP") + T.eq(c.user.curMoves[2].pp, 0, "called learned slot retains zero PP") + T.eq(c.user.sleepTurns, 3, "native sleep ticks once") + T.eq(c.user.mon.sleepTurns, 3, "the party record retains the actual remaining sleep count") + T.check( + c.target.mon.hp < 400 and c.user.mon.hp == 400, + "nested attack targets the foe rather than the caller" + ) + if mode ~= "primary_delegate" then + local asleep, announced + for i, row in ipairs(b.queue) do + if row.text and row.text:find("is fast asleep", 1, true) then + asleep = asleep or i + end + if row.text and row.text:find("used", 1, true) then + announced = announced or i + end + end + T.check( + asleep and announced and asleep < announced, + "native sleep text precedes the move announcement" + ) + end + T.eq(c.user.lastMove, f.ids.SLEEP_TALK, "native bookkeeping retains selected caller identity") + if mode ~= "fallback" then + T.eq(f.e.calls, 1, "captured builtin delegation is invoked once") + end + end + T.eq(b._gbaMoveFrame, nil, "nested dispatcher restores its frame") + T.eq( + require("src.battle.MoveEffects").RECORDS.SLEEP_TALK_EFFECT.callsMove, + nil, + "shared legacy effect remains unchanged" + ) + T.same(f.result.loader.errors, {}, "record callbacks complete without swallowed runtime errors") + off() + offReads() + b.data.move_effects, b.data.statuses = originalEffects, originalStatuses + f.close() + end +end +local V = require("src.core.GameVersion") +local before = V.get() +for _, version in ipairs({ "red", "gold" }) do + V.set(version) + local base = require("src.battle.Status").RECORDS.SLP + T.eq( + require("src.battle.Gen3SleepTalk").status("SLP", base), + base, + "older-generation status records are not adapted" + ) + local user = { name = "OLD", mon = { status = "SLP" }, sleepTurns = 1 } + local canMove = require("src.battle.Status").beforeMove(user, function(lo) + return lo + end, nil, { id = "SLEEP_TALK" }) + T.eq(canMove, false, "older-generation waking still forfeits the turn") + T.eq(user.mon.status, nil, "older-generation waking clears sleep") +end +V.set(before) +T.finish("native Sleep Talk effect and sleep status composition") diff --git a/tests/engine/gen3_sleep_talk_link.lua b/tests/engine/gen3_sleep_talk_link.lua new file mode 100644 index 000000000..c86932ebc --- /dev/null +++ b/tests/engine/gen3_sleep_talk_link.lua @@ -0,0 +1,99 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T, Copy = require("tests.modkit"), require("src.mods.Merge").deepCopy +for _, version in ipairs({ "firered", "emerald" }) do + local f = require("tests.enemy_actions.sleep_talk").fixture(".", "gen2", version) + local H = require("src.link.Handshake") + local current = H.hello(f.game, "battle") + local older = Copy(current) + older.nativeBattleRevision = 8 + T.eq(H.checkCompat(current, older), "subset", "pre-Sleep-Talk rules cannot enter a full battle") + T.eq(H.checkCompat(older, current), "subset", "native revision guard is symmetric") + for _, host in ipairs({ false, true }) do + local state = setmetatable( + { game = f.game, myHello = current, peerHello = older }, + { __index = require("src.link.LinkState") } + ) + state:decideCompat("battle", host) + T.eq(state.stage, "notice", "actual controller refuses the old native rules") + T.eq(state.noticeExits, true, "old native rules cannot enter party selection") + end + local def = f.data.pokemon.CHARMANDER + def.evYield = {} + def.baseStats.spatk, def.baseStats.spdef, def.baseStats.hp = 50, 50, 200 + T.link.prepare(f.data) + local ga = T.link.fakeGame(f.data, "CHARMANDER", { name = "HOST", level = 30 }) + local gb = T.link.fakeGame(f.data, "CHARMANDER", { name = "GUEST", level = 30 }) + for _, game in ipairs({ ga, gb }) do + game.save.party[1].status, game.save.party[1].sleepTurns = "SLP", 3 + game.save.party[1].moves = { + { id = f.ids.SLEEP_TALK, pp = 10 }, + { id = f.ids.TACKLE, pp = 10 }, + { id = f.ids.SCRATCH, pp = 10 }, + } + end + f.e.api.hooks:wrap("battle.crit", function() + return false + end) + f.e.api.hooks:wrap("battle.accuracy", function(nextAccuracy, ctx) + ctx.accuracy = 100 + return nextAccuracy(ctx) + end) + local P, N, L = require("src.link.Protocol"), require("src.link.Net"), require("src.link.LinkBattle") + local na, nb = N.loopbackPair() + local pa, pb = P.packParty(ga.save.party), P.packParty(gb.save.party) + local a = assert( + L.newHost( + ga, + na, + { myParty = pa, theirParty = pb, theirName = "GUEST", seed = 327, verdict = "full", strict = true } + ) + ) + local b = assert( + L.newGuest( + gb, + nb, + { myParty = pb, theirParty = pa, theirName = "HOST", seed = 327, verdict = "full", strict = true } + ) + ) + ga.stack:push(a) + gb.stack:push(b) + a.player.curStats.speed, a.enemy.curStats.speed = 200, 100 + b.player.curStats.speed, b.enemy.curStats.speed = 100, 200 + local seen = {} + for _ = 1, 40000 do + for i, game in ipairs({ ga, gb }) do + local battle = i == 1 and a or b + local turn = battle.turnCount or 0 + battle.moveIndex = (turn == 1 or turn >= 3) and 2 or 1 + require("src.core.Input").pressed = { a = true } + game.stack:update(1 / 60) + end + if a.localHashes[2] and b.localHashes[2] and not seen[2] then + seen[2] = true + T.check( + a.player.mon.moves[1].pp == 9 and b.enemy.mon.moves[1].pp == 9, + "variable multi-hit spends one PP on both roles" + ) + end + if a.localHashes[4] and b.localHashes[4] then + break + end + end + T.eq(a.player.mon.status, nil, "real linked turns wake the host") + T.eq(a.enemy.mon.status, nil, "real linked turns wake the guest") + T.check(seen[2], "the ordinary multi-hit turn completes on both roles") + for turn = 1, 4 do + T.check(a.localHashes[turn] ~= nil and b.localHashes[turn] ~= nil, "native full linked turn completes") + T.eq(a.localHashes[turn], b.localHashes[turn], "paired completed turn hashes agree") + T.same(a.localParts[turn], b.localParts[turn], "all active, volatile and bench components agree") + end + for _, battle in ipairs({ a, b }) do + local guest = battle == a and battle.enemy or battle.player + T.eq(guest.mon.moves[1].pp, 8, "sleeping and awake Sleep Talk each spend caller PP") + T.eq(guest.mon.moves[2].pp, 9, "independently selected Tackle spends its own PP") + T.eq(guest.mon.moves[3].pp, 10, "unused move retains its PP") + T.eq(battle.result, nil, "no hidden choice or hash failure ends the native mirror") + end + f.close() +end +T.finish("native Sleep Talk real link menus and completed turn hashes") diff --git a/tests/engine/gen3_sound_player_lifecycle.lua b/tests/engine/gen3_sound_player_lifecycle.lua new file mode 100644 index 000000000..79b7123ee --- /dev/null +++ b/tests/engine/gen3_sound_player_lifecycle.lua @@ -0,0 +1,111 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local Sound, V = require("src.core.Sound"), require("src.core.GameVersion") +for _, version in ipairs({ "firered", "emerald" }) do + local rows = dofile("tests/enemy_actions/native_sound_lifecycle.lua").run(".", "gen2", version) + T.eq(#rows, 20, "every native player/control/role/override case executes") + for _, row in ipairs(rows) do + local last = row.states[#row.states] + local expected = ({ + ["independent-players"] = 2, + ["replace-one-player"] = 2, + ["same-id-independent-players"] = 2, + ["replace-after-clone"] = 2, + ["file-override"] = 2, + ["string-override"] = 2, + ["stop-all"] = 0, + ["stop-cloned-id"] = 0, + })[row.case] or 1 + T.eq(#last, expected, row.case .. " keeps exactly its native output ownership") + if row.case == "same-id-independent-players" then + T.same(last[1], last[2], "one PCM variant can sound on two independently owned players") + elseif row.case == "special-player" or row.case == "retain-special-player" then + T.eq( + row.queries[#row.queries].any, + version == "firered", + "Emerald regular SE polling excludes player three" + ) + T.eq(row.queries[#row.queries]["164"], true, "explicit native polling includes player three") + elseif + row.case == "file-override" + or row.case == "string-override" + or row.case == "disabled-override" + then + T.eq(row.queries[#row.queries]["164"], false, "override outputs do not impersonate a native player") + T.eq(row.queries[#row.queries]["141"], true, "override does not cut the existing native player") + end + end + local f = dofile("tests/enemy_actions/assist.lua").fixture(".", "gen2", version) + local Handshake, Copy = require("src.link.Handshake"), require("src.mods.Merge").deepCopy + local current = Handshake.hello(f.game, "battle") + T.check(current.nativeBattleRevision >= 18, "native player lifecycle advertises revision eighteen or newer") + T.eq(current.nativePartyRevision, 1, "player lifecycle does not change the party codec") + local older = Copy(current) + older.nativeBattleRevision = 17 + T.eq(Handshake.checkCompat(current, older), "subset", "old ownership cannot admit lockstep") + T.eq(Handshake.checkCompat(older, current), "subset", "old ownership mismatch is symmetric") + for _, host in ipairs({ false, true }) do + local state = setmetatable( + { game = f.game, myHello = current, peerHello = older }, + { __index = require("src.link.LinkState") } + ) + state:decideCompat("battle", host) + T.eq(state.stage, "notice", "ownership mismatch stops actual link controller before battle setup") + end + local h = dofile("tests/native_sound/fixture.lua").install(".", false, f) + h.clear() + h.player(141, 1) + local first = assert(Sound.playId(f.data, 141, { pan = -64 })) + h.player(141, 2) + local second = assert(Sound.playId(f.data, 141, { pan = -64 })) + T.neq(first, second, "simultaneous players cannot rewind the same Source") + T.eq(#h.active(), 2, "both actual native sources remain sounding") + Sound.setVolumeLevel(2) + T.eq(#h.volumes(), 2, "both independently owned voices receive the volume update") + for _, volume in ipairs(h.volumes()) do + T.check(math.abs(volume - 0.8 * 2 / 7) < 1e-12, "volume update reaches each owned voice") + end + Sound.invalidate("OWNED_NATIVE_141") + T.eq(#h.active(), 0, "name invalidation stops original and extra player voices") + T.eq(Sound.isSePlaying(f.data, 141), false, "invalidation releases player ownership") + local rebuilt = assert(Sound.playId(f.data, 141, { pan = -64 })) + T.neq(rebuilt, first, "invalidated PCM sources are rebuilt") + T.check(Sound.isPlaying("OWNED_NATIVE_141"), "generic named polling observes native control variants") + Sound.stop("OWNED_NATIVE_141") + T.eq(#h.active(), 0, "generic named stop reaches native control variants") + local beforeSuspend = assert(Sound.playId(f.data, 141, { pan = -64 })) + Sound.suspend() + T.eq(#h.active(), 0, "suspension releases sustained native effects") + T.eq(Sound.isSePlaying(f.data), false, "suspended native queries are silent") + T.eq(Sound.playId(f.data, 141, { pan = -64 }), nil, "suspended session cannot build or restart effects") + Sound.resume() + T.neq( + assert(Sound.playId(f.data, 141, { pan = -64 })), + beforeSuspend, + "resume replaces device-bound native sources" + ) + h.clear() + h.player(141, 1) + h.player(164, 1) + Sound.playId(f.data, 141, { pan = -64 }) + local originalNewSource = love.audio.newSource + love.audio.newSource = function() + error("owned device-loss test") + end + local failed = Sound.playId(f.data, 164, { pan = 63 }) + love.audio.newSource = originalNewSource + T.eq(failed, nil, "native device failure is contained") + T.eq(#h.active(), 0, "native replacement releases its prior player before a new source build") + Sound.invalidate() + V.set("red") + f.data.audio.sfx.LEGACY_ONE, f.data.audio.sfx.LEGACY_TWO = + { file = "owned-effect-141.wav" }, { file = "owned-effect-164.wav" } + Sound.play(f.data, "LEGACY_ONE") + Sound.play(f.data, "LEGACY_TWO") + T.eq(#h.active(), 2, "legacy independent effects retain their existing overlap") + h.clear() + h.close() + f.close() + Sound.setVolumeLevel(7) +end +T.finish("native M4A source ownership, queries, overrides and session cleanup") diff --git a/tests/engine/gen3_sound_script_waits.lua b/tests/engine/gen3_sound_script_waits.lua new file mode 100644 index 000000000..d53c91f23 --- /dev/null +++ b/tests/engine/gen3_sound_script_waits.lua @@ -0,0 +1,178 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local Sound, V = require("src.core.Sound"), require("src.core.GameVersion") +for _, version in ipairs({ "firered", "emerald" }) do + local rows = dofile("tests/enemy_actions/native_sound_script_waits.lua").run(".", "gen2", version) + T.eq(#rows, 18, "every native script wait/control/clock case executes") + local expected = { + silent = 0, + ["file-override"] = 0, + ["special-player-three"] = version == "emerald" and 0 or 3, + ["logic-only-cap"] = 181, + ["normal-audio-cap"] = 180, + ["double-audio-cap"] = 90, + ["half-audio-cap"] = 181, + ["latched-silence"] = 2, + ["unlatched-silence"] = 4, + ["replacement-keeps-waiting"] = 4, + ["independent-player-keeps-waiting"] = 4, + ["special-player-after-regular"] = version == "emerald" and 2 or 4, + ["stop-native-keeps-file"] = 2, + ["consecutive-logic-waits"] = 362, + ["consecutive-audio-waits"] = 360, + } + for _, row in ipairs(rows) do + local completed + for _, frame in ipairs(row.frames) do + if frame.advanced and completed == nil then + completed = frame.tick + end + T.eq(type(frame.running), "boolean", "script-running state remains boolean") + T.eq(type(frame.advanced), "boolean", "post-wait variable observation remains boolean") + end + T.eq(completed, expected[row.case] or 3, row.case .. " follows the measured native completion tick") + T.eq(row.frames[#row.frames].running, false, row.case .. " returns from the actual runner") + if row.case:match("consecutive") then + local first + for _, frame in ipairs(row.frames) do + if frame.firstAdvanced then + first = frame.tick + break + end + end + T.eq( + first, + row.case == "consecutive-logic-waits" and 181 or 180, + "each consecutive wait starts its own native clocks" + ) + elseif row.case == "latched-silence" then + T.eq(row.frames[3].queries["164"], true, "latched completion survives an active replacement") + end + end + for _, speed in ipairs({ 1, 2, 10 }) do + local f = dofile("tests/enemy_actions/assist.lua").fixture(".", "gen2", version) + local hardware = dofile("tests/native_sound/fixture.lua").install(".", false, f) + hardware.clear() + Sound.update() + hardware.stereo(true) + hardware.tick(0) + assert(Sound.playId(f.data, 141, { pan = -64 })) + local Game, Fixed = require("src.core.Game"), require("src.core.FixedStep") + local stack = require("src.core.StateStack") + stack:init() + local game = setmetatable({ + data = f.data, + save = f.game.save, + stack = stack, + input = require("src.core.Input"), + speedOverride = speed, + }, { __index = Game }) + game.input:init() + local ow = setmetatable( + { npcs = { {}, {} }, player = { facing = "up" } }, + { __index = require("src.world.OverworldController") } + ) + local runner = require("src.script.ScriptRunner").new(game, ow) + local hookEvents, ended = {}, {} + f.e.api.hooks:wrap("script.command", function(next, ctx, name, args) + hookEvents[#hookEvents + 1] = { edge = "before", name = name } + local result = next(ctx, name, args) + hookEvents[#hookEvents + 1] = { edge = "after", name = name } + return result + end) + f.e.api.events:on("script.ended", function(event) + ended[#ended + 1] = + { completed = event.completed, value = require("src.script.Gen3Commands").getVar(game.save, 0x4001) } + end) + local script = assert(require("src.script.Gen3ScriptVM").compile({ + map_scripts = { + source = "RomExtractorGen3", + scripts = { + OWNED_GAME_WAIT = { + { "lockall" }, + { "waitse" }, + { "setvar", 0x4001, 1 }, + { "releaseall" }, + { "end" }, + }, + }, + }, + }, "OWNED_GAME_WAIT")) + local steps, firstCompleted, resumed, freezeBroken = 0, nil, 0, false + stack:push({ + update = function() + hardware.tick(steps) + if steps == 0 then + runner:run(script, { + source = { + modId = "portable_held_item", + mapId = "OWNED_WAIT_MAP", + hook = "native_wait", + strict = true, + }, + }) + else + runner:update() + end + if + runner:isRunning() + and (not ow.gen3Locked or not ow.npcs[1].gen3ScriptFrozen or not ow.npcs[2].gen3ScriptFrozen) + then + freezeBroken = true + end + if require("src.script.Gen3Commands").getVar(game.save, 0x4001) == 1 then + firstCompleted = firstCompleted or steps + resumed = resumed + 1 + end + steps = steps + 1 + end, + }) + Fixed:init(function(dt) + game:step(dt) + end) + for _ = 1, 190 do + game:update(1 / 60) + end + T.eq( + firstCompleted, + speed == 1 and 180 or 181, + "actual Game:update services audio after logic independently of fast-forward" + ) + T.eq(runner:isRunning(), false, "native script returns through the actual game/state clock") + T.check(resumed > 1, "the owning state keeps updating after the native wait returns") + T.check(game.save.playTime > 0, "the actual game logic advances its ordinary play clock") + T.eq(freezeBroken, false, "native field objects stay frozen while the sound wait yields") + T.eq(ow.gen3Locked, nil, "releaseall restores the field after the wait") + T.eq(ow.npcs[1].gen3ScriptFrozen, nil, "releaseall unfreezes the first native field object") + T.eq(ow.npcs[2].gen3ScriptFrozen, nil, "releaseall unfreezes every native field object") + T.eq(runner.ctx.g3Locked, nil, "the completed script does not retain its input lock") + T.eq(runner.ctx.source.strict, true, "native wait retains strict script ownership") + T.eq(runner.ctx.source.mapId, "OWNED_WAIT_MAP", "native wait retains source map metadata") + local waitBefore, waitAfter + for index, event in ipairs(hookEvents) do + if event.name == "g3_wait_se" then + if event.edge == "before" then + T.eq(waitBefore, nil, "waiting command enters its public hook exactly once") + waitBefore = index + else + T.eq(waitAfter, nil, "waiting command exits its public hook exactly once") + waitAfter = index + end + end + end + T.check( + waitBefore and waitAfter and waitBefore < waitAfter, + "the actual command hook resumes around the yielding native wait" + ) + T.same( + ended, + { { completed = true, value = 1 } }, + "script-ended event follows the native wait and variable write once" + ) + hardware.clear() + Sound.update() + hardware.close() + f.close() + end +end +T.finish("native world-script sound waits, audio clocks and actual Game:update") diff --git a/tests/engine/gen3_spit_up.lua b/tests/engine/gen3_spit_up.lua new file mode 100644 index 000000000..f1e340235 --- /dev/null +++ b/tests/engine/gen3_spit_up.lua @@ -0,0 +1,5 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +for _, version in ipairs({ "firered", "emerald" }) do + require("tests.enemy_actions.spit_up").run(".", "gen2", version) +end +require("tests.modkit").finish("native GBA Spit Up") diff --git a/tests/engine/gen3_substitute_art.lua b/tests/engine/gen3_substitute_art.lua new file mode 100644 index 000000000..12aaa46b8 --- /dev/null +++ b/tests/engine/gen3_substitute_art.lua @@ -0,0 +1,175 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local E = require("src.import.RomExtractorGen3") +T.eq(E.STAGE_COUNT, #E.DATA_STAGES + #E.ASSET_STAGES, "import progress includes every registered stage") +local Import = require("src.import.Gen3SubstituteArt") +local Fixture = dofile("tests/substitute_art/fixture.lua") +local previousImage = love.image +love.image = { newImageData = Fixture.image } +local function literal(raw) + local size, out = #raw, { string.char(16, #raw % 256, math.floor(#raw / 256), 0) } + for i = 1, size, 8 do + out[#out + 1] = string.char(0) + for j = i, math.min(size, i + 7) do + out[#out + 1] = string.char(raw[j]) + end + end + return table.concat(out) +end +local palette, front, back = {}, {}, {} +for i = 1, 32 do + palette[i] = 0 +end +palette[1], palette[2] = 0xB2, 0x63 -- transparent RGB is retained too +palette[3], palette[4] = 31, 0 +palette[5], palette[6] = 0xE0, 3 +for i = 1, 2048 do + front[i], back[i] = 0x21, 0x12 +end +front[2] = 0x10 +local function extractor(change) + local symbols = { gSubstituteDollPal = 0, gSubstituteDollFrontGfx = 64, gSubstituteDollBackGfx = 4096 } + local bytes = literal(palette) + bytes = bytes .. string.rep("\0", 64 - #bytes) .. literal(front) + bytes = bytes .. string.rep("\0", 4096 - #bytes) .. literal(back) + if change then + bytes, symbols = change(bytes, symbols) + end + local e, writes, images = E.new(bytes, "firered", { symbols = symbols }), {}, {} + e.saveImage = function(_, image, path) + images[path] = image + end + e.write = function(_, name, value) + writes[name] = value + end + return e, writes, images +end +local e, writes, images = extractor() +local record = assert(Import.extract(e)) +T.eq(record.width, 64, "native doll keeps its full OAM width") +T.eq(writes.constants.gen3SubstituteArt, record, "only decoded art is advertised to runtime") +local r, g, b, a = images["battle/substitute_front.png"]:getPixel(0, 0) +T.same({ r, g, b, a }, { 248 / 255, 0, 0, 1 }, "low nibble is the left pixel") +r, g, b, a = images["battle/substitute_front.png"]:getPixel(1, 0) +T.same({ r, g, b, a }, { 0, 248 / 255, 0, 1 }, "high nibble is the right pixel") +r, g, b, a = images["battle/substitute_front.png"]:getPixel(2, 0) +T.same({ r, g, b, a }, { 144 / 255, 232 / 255, 192 / 255, 0 }, "transparent native RGB is retained") +r, g, b, a = images["battle/substitute_back.png"]:getPixel(0, 0) +T.same({ r, g, b, a }, { 0, 248 / 255, 0, 1 }, "front and back are separate cartridge sheets") +for _, change in ipairs({ + function(bytes, symbols) + symbols.gSubstituteDollPal = nil + return bytes, symbols + end, + function(bytes, symbols) + symbols.gSubstituteDollBackGfx = #bytes + return bytes, symbols + end, + function(bytes, symbols) + return bytes:sub(1, 64) .. string.char(0) .. bytes:sub(66), symbols + end, + function(bytes, symbols) + return bytes:sub(1, 6) .. string.char(128) .. bytes:sub(8), symbols + end, + function(bytes, symbols) + return bytes:sub(1, 4096) .. string.char(16, 1, 0, 0), symbols + end, +}) do + local invalid, invalidWrites, invalidImages = extractor(change) + T.eq(Import.extract(invalid), nil, "malformed/missing art is refused") + T.eq(next(invalidWrites), nil, "invalid art cannot advertise a partial record") + T.eq(next(invalidImages), nil, "all three blobs validate before any image write") +end +local V, Draw = require("src.core.GameVersion"), require("src.battle.Gen3SubstituteArt") +V.set("firered") +local raw = { + singles = { { x = 72, y = 80 }, { x = 176, y = 40 } }, + doubles = { { x = 32, y = 80 }, { x = 200, y = 40 }, { x = 90, y = 88 }, { x = 152, y = 32 } }, +} +local battle = { + data = { constants = { gen3SubstituteArt = record, gen3BattlerCoords = raw } }, + isDouble = function(self) + return self.double + end, + gen3AnimShake = function() + return -5, 2 + end, + gen3SurfaceWidth = function() + return 320 + end, +} +local graphics, Assets = love.graphics, require("src.render.Assets") +local originalDraw, originalNewImage = graphics.draw, graphics.newImage +local drawn = {} +graphics.newImage = function(path) + local relative = path:gsub("^assets/generated/", "") + local image = images[relative] + if not image then + return originalNewImage(path) + end + image.path = path + return image +end +graphics.draw = function(...) + drawn[#drawn + 1] = { ... } +end +Assets.flush() +graphics.setColor(0.2, 0.4, 0.6, 0.7) +for _, double in ipairs({ false, true }) do + battle.double = double + for id = 0, double and 3 or 1 do + local player = id % 2 == 0 + local actor = { position = id, isPlayer = player } + local pose = assert(Draw.pose(battle, actor)) + local point = (double and raw.doubles or raw.singles)[id + 1] + T.eq(pose.image, player and record.back or record.front, "each slot selects its native facing") + T.eq(pose.x, point.x + 40 - 32 - 5, "native OAM X includes centered wide field and live offset") + T.eq(pose.y, point.y + (player and 17 or 16) - 32 + 2, "native OAM Y uses the Substitute coordinate") + require("src.battle.BattleState").drawSubstituteDoll(battle, actor) + local call = drawn[#drawn] + T.eq(call[1].path, pose.image, "actual battle renderer loads the imported facing through Assets") + T.same({ call[2], call[3] }, { pose.x, pose.y }, "actual draw uses native coordinates and live offsets") + T.same({ graphics.getColor() }, { 0.2, 0.4, 0.6, 0.7 }, "doll drawing preserves graphics color state") + end +end +local paints = {} +battle.gen3AnimTint = function() + return 1, 1, 1, 0.5 +end +graphics.draw = function(...) + paints[#paints + 1] = { args = { ... }, color = { graphics.getColor() }, blend = graphics.getBlendMode() } +end +graphics.setBlendMode("alpha") +T.check(Draw.draw(battle, { position = 0, isPlayer = true }), "native doll accepts live palette fade") +T.eq(#paints, 2, "actual renderer composites both native palette contributions") +T.same(paints[1].color, { 0.1, 0.2, 0.3, 0.7 }, "base draw retains incoming alpha and untinted contribution") +T.same(paints[2].color, { 0.5, 0.5, 0.5, 0.7 }, "white contribution retains incoming alpha") +T.eq(paints[2].blend, "add", "palette contribution adds through sprite coverage") +T.eq(graphics.getBlendMode(), "alpha", "native palette draw restores blend state") +T.same({ graphics.getColor() }, { 0.2, 0.4, 0.6, 0.7 }, "native palette draw restores incoming color") +local B, actor = require("src.battle.BattleState"), { position = 0, isPlayer = true } +battle.gen3AnimMonBgHidden = function() + return false +end +battle.gen3AnimRotate = function() end +battle.gen3AnimSquash = function() end +battle.gen3AnimSpecialMonState = B.gen3AnimSpecialMonState +battle.battlerPic = function() + return images["battle/substitute_back.png"] +end +battle.drawBattlerPicAt = function(self, user) + B.drawSubstituteDoll(self, user) +end +battle._gbaEffectPresentation = { user = actor, alpha = 0.5 } +paints = {} +B.drawBattlerPic(battle, actor, 0, 0, 1) +T.eq(#paints, 2, "complete battler wrapper retains native doll palette draws") +T.eq(paints[1].color[4], 0.35, "real battler wrapper applies native fade alpha to the doll") +T.eq(paints[2].color[4], 0.35, "palette contribution shares native fade alpha") +T.same({ graphics.getColor() }, { 0.2, 0.4, 0.6, 0.7 }, "complete battler wrapper restores incoming alpha") +graphics.draw, graphics.newImage = originalDraw, originalNewImage +Assets.flush() +V.set("red") +T.eq(Draw.pose(battle, { isPlayer = true }), nil, "legacy rendering is forwarded") +love.image = previousImage +T.finish("native GBA Substitute art import and placement") diff --git a/tests/engine/gen3_substitute_fade.lua b/tests/engine/gen3_substitute_fade.lua new file mode 100644 index 000000000..767c9e347 --- /dev/null +++ b/tests/engine/gen3_substitute_fade.lua @@ -0,0 +1,52 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +for _, version in ipairs({ "firered", "emerald" }) do + local rows = dofile("tests/enemy_actions/substitute_fade.lua").run(".", "gen2", version) + T.eq(#rows, 144, "all native fade actors/options/presentation states execute") + for _, row in ipairs(rows) do + local last, index = row.frames[#row.frames], row.actor + 1 + T.check(last.done, "native fade returns to the actual queue") + T.eq(#row.sounds, 0, "native Substitute fade remains silent") + for _, frame in ipairs(row.frames) do + T.eq(frame.rng, 0, "fade never draws battle RNG") + for _, state in ipairs(frame.states) do + T.eq(state.hp, 400, "fade never changes actual Pokemon HP") + end + end + if row.case == "hidden" then + T.eq(#row.frames, 1, "hidden Substitute exchanges without animation or pause") + else + local x = row.double and ({ 32, 200, 90, 152 })[index] or (row.actor == 0 and 72 or 176) + local outward = row.actor % 2 == 0 and math.floor((x + 32) / 5) + 1 + or math.floor((304 - x - 32) / 5) + 1 + T.eq(#row.frames, 58 + outward * 2, "fade tasks, script delays and swap retain native ticks") + T.eq(row.frames[5].states[index].alpha, 15 / 16, "first alpha decrement follows three callbacks") + T.eq(row.frames[50].states[index].alpha, 0, "sixteen alpha steps finish before sprite exchange") + T.eq(row.frames[18].states[index].coeff, 1, "white palette ramp reaches sixteen of sixteen") + T.eq(row.frames[58].states[index].coeff, 0, "native palette reset precedes outward swap") + T.eq(last.states[index].alpha, 1, "exchange restores Pokemon alpha") + end + T.eq(last.states[index].x, 0, "completed fade releases sprite offset") + T.eq( + last.states[index].substitute, + row.case == "substitute" or row.case == "lowered-substitute", + "queue completion reconciles logical protection after the temporary sprite exchange" + ) + end + local f = dofile("tests/enemy_actions/assist.lua").fixture(".", "gen2", version) + local b, target = f.field(false), f.actor(1) + target.substituteHP = 5 + T.eq( + b:applyDamage(target, 8, true, { bandRolled = true }), + 5, + "real damage absorbs only remaining protection" + ) + T.eq(target.substituteHP, nil, "logical protection breaks immediately") + T.eq(target.mon.hp, 400, "native break does not spill damage into Pokemon HP") + T.eq(b:substituteVisible(target), true, "queued break retains the doll until presentation executes") + T.eq(b.queue[1].anim, "SUBSTITUTE_FADE", "actual damage path queues native general animation") + T.eq(b.queue[1].attackerPosition, 1, "broken recipient owns the animation") + T.check(type(b.queue[2].text) == "string", "native animation precedes the break message") + f.close() +end +T.finish("native GBA Substitute break producer and full fade controller") diff --git a/tests/engine/gen3_substitute_presentation.lua b/tests/engine/gen3_substitute_presentation.lua new file mode 100644 index 000000000..1749041b6 --- /dev/null +++ b/tests/engine/gen3_substitute_presentation.lua @@ -0,0 +1,67 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +for _, version in ipairs({ "firered", "emerald" }) do + local rows = dofile("tests/enemy_actions/substitute_presentation.lua").run(".", "gen2", version) + T.eq(#rows, 180, "every actor/options/command input executes") + for _, row in ipairs(rows) do + local last = row.frames[#row.frames] + T.check(last.done, "native controller returns to the actual battle queue") + T.eq(last.offset, 0, "controller finishes at the original battler coordinate") + local initialHP = row.frames[1].substituteHP + for _, frame in ipairs(row.frames) do + T.eq(frame.substituteHP, initialHP, "sprite exchange never removes gameplay protection") + end + local allowed = row.scene or row.move == 144 or row.move == 164 + local plays = allowed and (row.case == "lower" or row.case == "restore") + if plays then + local x = row.double and ({ 32, 200, 90, 152 })[row.actor + 1] or (row.actor == 0 and 72 or 176) + local outward = row.actor % 2 == 0 and math.floor((x + 32) / 5) + 1 + or math.floor((304 - x - 32) / 5) + 1 + T.eq(last.tick, outward * 2 + 1, "offscreen swap uses its native outward/exchange/return ticks") + else + T.eq(last.tick, 0, "skipped controller command adds no waiting tick") + end + end +end +local V = require("src.core.GameVersion") +local B = require("src.battle.BattleState") +local P = require("src.battle.Gen3SubstitutePresentation") +local animation, trailing = { anim = "MOVE_033", attackerPosition = 0 }, { fn = function() end } +local sparse = { queue = { [1] = animation, [3] = trailing }, nextInsert = 1, _gbaMoveFrame = {} } +P.mark(sparse, animation, { substituteHP = 100 }) +P.finish(sparse, sparse._gbaMoveFrame) +T.eq(sparse.queue[1].anim, "SUBSTITUTE_TO_MON", "sparse queue retains native lowering") +T.eq(sparse.queue[2], animation, "sparse queue retains original animation identity") +T.eq(sparse.queue[3].anim, "MON_TO_SUBSTITUTE", "sparse queue retains native restoration") +T.eq(sparse.queue[4], nil, "sparse queue preserves its original hole") +T.eq(sparse.queue[5], trailing, "sparse queue preserves trailing callback identity") +local user = { isPlayer = true, position = 0, substituteHP = 100, mon = { species = "FIX", hp = 400 } } +local battle = setmetatable({ + player = user, + data = { + moves = { MOVE_033 = { index = 33 } }, + constants = { gen3BattlerCoords = { singles = { { x = 72, y = 80 }, { x = 176, y = 40 } } } }, + pokemon = { FIX = { backPicCoords = { yOffset = 8 } } }, + }, + game = { save = { options = { animations = true } } }, + queue = {}, +}, B) +local row = { anim = "SUBSTITUTE_TO_MON", gbaSubstituteMove = "MOVE_033", attackerIsPlayer = true } +T.check(P.start(battle, row), "native transition starts on the real battle controller") +for _ = 1, 43 do + P.update(battle) +end +T.eq(battle:substituteVisible(user), false, "lowered sprite shows the Pokemon with protection retained") +T.eq(user.substituteHP, 100, "lowering does not mutate Substitute HP") +local animator = setmetatable({ data = battle.data }, require("src.battle.Gen3MoveAnim")) +T.eq(animator:battlerOamY(battle, user), 56, "lowered Pokemon uses its native species OAM Y") +T.eq(battle:updateQueue(), false, "empty actual battle queue completes") +T.eq(battle._gbaSubstitutePresentation, nil, "queue completion releases transient presentation state") +T.eq(battle:substituteVisible(user), true, "completion reconciles the doll with real protection") +T.eq(animator:battlerOamY(battle, user), 65, "restored doll uses native Substitute OAM Y") +V.set("red") +T.eq(P.start(battle, row), false, "GBA controller commands cannot alter legacy battles") +T.eq(battle._gbaSubstituteTransition, nil, "legacy forwarding leaves no transition") +user.substituteHP = false +T.eq(battle:substituteVisible(user), false, "legacy false protection remains hidden") +T.finish("native GBA Substitute controller presentation") diff --git a/tests/engine/gen3_triple_kick.lua b/tests/engine/gen3_triple_kick.lua new file mode 100644 index 000000000..d9e4f2222 --- /dev/null +++ b/tests/engine/gen3_triple_kick.lua @@ -0,0 +1,5 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +for _, version in ipairs({ "firered", "emerald" }) do + require("tests.enemy_actions.triple_kick").run(".", "gen2", version) +end +require("tests.modkit").finish("native GBA Triple Kick execution") diff --git a/tests/engine/gen3_triple_kick_composition.lua b/tests/engine/gen3_triple_kick_composition.lua new file mode 100644 index 000000000..eb7d0b62b --- /dev/null +++ b/tests/engine/gen3_triple_kick_composition.lua @@ -0,0 +1,84 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +for _, version in ipairs({ "firered", "emerald" }) do + for _, mode in ipairs({ "delegate", "count", "full", "damage", "neverMiss", "fallback" }) do + local entry + if mode ~= "fallback" then + entry = [[return function(mod) + local base = assert(mod.content.move_effects:get("TRIPLE_KICK_EFFECT")) + local count = base.hitCount + mod.exports.counts, mod.exports.hits = 0, 0 + ]] .. (mode == "full" and 'base = {kind="full", hitCount=count}' or "") .. [[ + ]] .. (mode == "neverMiss" and "base.neverMiss = true" or "") .. [[ + base.hitCount = function(ctx) + mod.exports.counts = mod.exports.counts + 1 + ]] .. (mode == "count" and "return 2" or "return count(ctx)") .. [[ + end + ]] .. (mode == "damage" and [[ + base.chooseDamage = function() return 2, {crit=false,typeMult=10} end + base.perHitDamage = function() + mod.exports.hits = mod.exports.hits + 1 + return 2, {crit=false,typeMult=10} + end + ]] or "") .. [[ + assert(mod.content.move_effects:override("TRIPLE_KICK_EFFECT", base)) + mod.exports.api = mod + end]] + end + local f = require("tests.enemy_actions.triple_kick").fixture(".", "gen2", version, entry) + local c = f.fresh("TACKLE", { enemy = true }) + local b = c.battle + b:syncSides() + c.user.mon.moves = { { id = f.ids.TRIPLE_KICK, pp = 10 } } + c.user.curMoves = c.user.mon.moves + local original = b.data.move_effects + if mode == "fallback" then + b.data.move_effects = nil + end + local draws, hits = {}, {} + b.rng = function(lo, hi) + draws[#draws + 1] = { lo, hi } + return lo == 1 and hi == 100 and 1 or hi + end + f.e.api.hooks:wrap("battle.crit", function() + return false + end) + local off = f.e.api.events:on("battle.damage_dealt", function(e) + hits[#hits + 1] = e.damage + end) + b:executeAction(c.user, c.target, c.user.curMoves[1]) + T.eq(#hits, mode == "count" and 2 or 3, "authored count remains authoritative") + T.eq(c.user.curMoves[1].pp, 9, "composed Triple Kick spends one PP") + local accuracy = 0 + for _, d in ipairs(draws) do + if d[1] == 1 and d[2] == 100 then + accuracy = accuracy + 1 + end + end + T.eq( + accuracy, + mode == "neverMiss" and 0 or mode == "full" and 1 or #hits, + "complete authored scripts own their accuracy policy" + ) + if mode == "delegate" or mode == "count" or mode == "fallback" then + T.check(hits[1] < hits[2], "native delegation retains increasing kick power") + elseif mode == "full" then + T.eq(hits[1], hits[2], "complete authored override has no private native ramp") + elseif mode == "damage" then + T.same(hits, { 2, 2, 2 }, "explicit authored damage is not replaced") + T.eq(f.e.hits, 3, "every authored per-hit handler runs") + end + if mode ~= "fallback" then + T.eq(f.e.counts, 1, "count handler runs once per attack") + end + T.eq( + require("src.battle.Gen3MoveEffects").full.TRIPLE_KICK_EFFECT.gbaTripleKick, + nil, + "per-load adapter leaves shared builtin statics untouched" + ) + off() + b.data.move_effects = original + f.close() + end +end +T.finish("native Triple Kick content composition") diff --git a/tests/engine/gen3_triple_kick_link.lua b/tests/engine/gen3_triple_kick_link.lua new file mode 100644 index 000000000..b5d4b8b0e --- /dev/null +++ b/tests/engine/gen3_triple_kick_link.lua @@ -0,0 +1,96 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T, Copy = require("tests.modkit"), require("src.mods.Merge").deepCopy +for _, version in ipairs({ "firered", "emerald" }) do + local f = require("tests.enemy_actions.triple_kick").fixture(".", "gen2", version) + local H = require("src.link.Handshake") + local current = H.hello(f.game, "battle") + local older = Copy(current) + older.nativeBattleRevision = 7 + T.eq(H.checkCompat(current, older), "subset", "pre-Triple-Kick rules cannot enter a full battle") + T.eq(H.checkCompat(older, current), "subset", "native revision guard is symmetric") + for _, host in ipairs({ false, true }) do + local state = setmetatable( + { game = f.game, myHello = current, peerHello = older }, + { __index = require("src.link.LinkState") } + ) + state:decideCompat("battle", host) + T.eq(state.stage, "notice", "actual controller refuses the old native rules") + T.eq(state.noticeExits, true, "old native rules cannot enter party selection") + end + local def = f.data.pokemon.CHARMANDER + def.evYield = {} + def.baseStats.spatk, def.baseStats.spdef, def.baseStats.hp = 50, 50, 200 + T.link.prepare(f.data) + local ga = T.link.fakeGame(f.data, "CHARMANDER", { name = "HOST", level = 30 }) + local gb = T.link.fakeGame(f.data, "CHARMANDER", { name = "GUEST", level = 30 }) + for _, game in ipairs({ ga, gb }) do + game.save.party[1].moves = { + { id = f.ids.TRIPLE_KICK, pp = 10 }, + { id = f.ids.TACKLE, pp = 10 }, + { id = f.ids.SCRATCH, pp = 10 }, + } + end + f.e.api.hooks:wrap("battle.crit", function() + return false + end) + f.e.api.hooks:wrap("battle.accuracy", function(nextAccuracy, ctx) + ctx.accuracy = 100 + return nextAccuracy(ctx) + end) + local P, N, L = require("src.link.Protocol"), require("src.link.Net"), require("src.link.LinkBattle") + local na, nb = N.loopbackPair() + local pa, pb = P.packParty(ga.save.party), P.packParty(gb.save.party) + local a = assert( + L.newHost( + ga, + na, + { myParty = pa, theirParty = pb, theirName = "GUEST", seed = 327, verdict = "full", strict = true } + ) + ) + local b = assert( + L.newGuest( + gb, + nb, + { myParty = pb, theirParty = pa, theirName = "HOST", seed = 327, verdict = "full", strict = true } + ) + ) + ga.stack:push(a) + gb.stack:push(b) + a.player.curStats.speed, a.enemy.curStats.speed = 200, 100 + b.player.curStats.speed, b.enemy.curStats.speed = 100, 200 + local seen = {} + for _ = 1, 40000 do + for i, game in ipairs({ ga, gb }) do + local battle = i == 1 and a or b + local turn = battle.turnCount or 0 + battle.moveIndex = turn == 1 and 2 or 1 + require("src.core.Input").pressed = { a = true } + game.stack:update(1 / 60) + end + if a.localHashes[2] and b.localHashes[2] and not seen[2] then + seen[2] = true + T.check( + a.player.mon.moves[1].pp == 9 and b.enemy.mon.moves[1].pp == 9, + "variable multi-hit spends one PP on both roles" + ) + end + if a.localHashes[3] and b.localHashes[3] then + break + end + end + T.check(seen[2], "the ordinary multi-hit turn completes on both roles") + for turn = 1, 3 do + T.check(a.localHashes[turn] ~= nil and b.localHashes[turn] ~= nil, "native full linked turn completes") + T.eq(a.localHashes[turn], b.localHashes[turn], "paired completed turn hashes agree") + T.same(a.localParts[turn], b.localParts[turn], "all active, volatile and bench components agree") + end + for _, battle in ipairs({ a, b }) do + local guest = battle == a and battle.enemy or battle.player + T.eq(guest.mon.moves[1].pp, 8, "two separately selected Triple Kicks spend one PP each") + T.eq(guest.mon.moves[2].pp, 9, "independently selected Tackle spends its own PP") + T.eq(guest.mon.moves[3].pp, 10, "unused move retains its PP") + T.eq(battle.result, nil, "no hidden choice or hash failure ends the native mirror") + end + f.close() +end +T.finish("native Triple Kick real link menus and completed turn hashes") diff --git a/tests/engine/held_item_authority.lua b/tests/engine/held_item_authority.lua new file mode 100644 index 000000000..793fbe636 --- /dev/null +++ b/tests/engine/held_item_authority.lua @@ -0,0 +1,73 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local V = require("src.core.GameVersion") +local Fixture = dofile("tests/held_item/fixture.lua") +local old = V.get() +for _, version in ipairs({ "gold", "silver", "crystal", "firered", "emerald" }) 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 = f.e + local b = f.battle + local holder = b.enemy + -- External/link constructors and replacements bind to the real battle. + local replacement = require("src.battle.BattleState").makeBattler(f.data, holder.mon, false) + b:placeBattler(1, replacement) + f.read("damage", "enemy") + T.eq(e.state.last.battle, b, "placed battler is bound to actual battle") + T.eq(e.state.last.mon.nickname, "ENEMY", "placed holder has actual Pokemon") + b.enemy = require("src.battle.BattleState").makeBattler(f.data, replacement.mon, false) + b:syncSides() + collectgarbage("collect") + f.read("accuracy", "enemy") + T.eq(e.state.last.battle, b, "sync binds external/linked battlers across GC") + -- Field/party reads do not turn into battle comparisons. + f.reset("LEFTOVERS") + local before = e.state.calls + require("src.battle.HeldItems").effect(f.data, f.mon("player"), "damage") + require("src.battle.HeldItems").cleanseTagRate(f.data, { f.mon("player") }, 100) + T.eq(e.state.calls, before, "raw field/party item reads do not dispatch battle hook") + if mode[2] == "unified" then + f.reset("LEFTOVERS") + local off = e.api.hooks:wrap("held_item.trigger", function(next, ctx) + ctx.mon.hp = 1 + ctx.def.heldEffect = {} + return next(ctx) + end, 100) + T.eq(f.read("residual"), f.effect("HELD_LEFTOVERS"), "invalid definition edit recovers native verdict") + off() + T.eq(f.mon("player").hp, 400, "invalid definition edit cannot partially commit Pokemon changes") + T.eq(e.state.last.effect, "HELD_LEFTOVERS", "invalid definition edit does not mutate stored effect") + end + if f.gen == 2 then + b = f.reset("LEFTOVERS") + e.state.mode, e.state.effect, e.state.trigger = "override", "HELD_PORTABLE_CUSTOM", "damage" + T.eq( + f.read("damage"), + "HELD_PORTABLE_CUSTOM", + "Gold retains unknown named effects for native comparisons" + ) + local nativeItem = f.data.items.LEFTOVERS + T.eq(nativeItem.heldEffect, 3, "effect substitution does not rewrite stored attributes") + T.eq(nativeItem.heldParam, 10, "effect substitution does not rewrite stored parameter") + else + b = f.reset("LEFTOVERS") + local nativeItem = f.data.items.ITEM_200 + for _, name in ipairs({ "HELD_QUICK_CLAW", "HELD_BRIGHTPOWDER", "HELD_FOCUS_BAND", "HELD_FLINCH" }) do + for _, parameter in ipairs({ -10, 0, 256, 300 }) do + e.state.mode, e.state.effect, e.state.parameter = "override", name, parameter + local _, actual = f.read("check") + T.eq( + actual, + math.max(0, math.min(256, parameter)) * 100 / 256, + "GBA public byte odds clamp at native boundary" + ) + end + end + T.eq(nativeItem.holdEffect, "LEFTOVERS", "GBA substitution leaves imported held enum unchanged") + T.eq(nativeItem.holdEffectParam, 10, "GBA substitution leaves imported parameter unchanged") + end + f.close() + end +end +V.set(old) +T.finish("held item native ownership and metadata transaction authority") diff --git a/tests/engine/held_item_contract.lua b/tests/engine/held_item_contract.lua new file mode 100644 index 000000000..562e3853f --- /dev/null +++ b/tests/engine/held_item_contract.lua @@ -0,0 +1,43 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local V, Runtime = require("src.core.GameVersion"), require("src.mods.Runtime") +local Fixture, Cases = dofile("tests/held_item/fixture.lua"), dofile("tests/held_item/cases.lua") +local old = V.get() +for _, version in ipairs(V.ORDER) do + for _, mode in ipairs({ { 1, "native" }, { 2, "native" }, { 2, "unified" }, { 3, "unified" } }) do + local f = Fixture.new(".", "gen2", version, mode[1], mode[2]) + local available = f.gen == 2 or f.gen == 3 + T.eq(f.e.api:supports("hooks", "held_item.trigger"), available, "honest held comparison capability") + if available then + Cases.run(f) + else + f.e.state.mode, f.e.state.effect, f.e.state.parameter = "override", "HELD_BERRY", 7 + f.read("residual") + T.eq(f.e.state.calls, 0, "GB/DS never dispatch unavailable held seam") + end + f.close() + for _, failing in ipairs({ false, true }) do + local bare = Fixture.new(".", "gen2", version, mode[1], mode[2], not failing, failing) + if failing then + T.check(#bare.result.errors > 0, "entry failure recorded") + else + T.eq(bare.result.loader.exports.portable_held_item, nil, "genuine no-mod fixture") + end + for _, link in ipairs(Runtime.hooks.chains["held_item.trigger"] or {}) do + T.check(link.owner ~= "portable_held_item", "rollback removes held owner") + end + local call = Runtime.call + Runtime.call = function(name, ...) + assert(name ~= "held_item.trigger", "empty held chain dispatched") + return call(name, ...) + end + bare.mon("player").hp = 100 + bare.residual() + Runtime.call = call + T.eq(bare.mon("player").hp, bare.gen == 1 and 100 or 125, "native no-mod/rollback held behavior") + bare.close() + end + end +end +V.set(old) +T.finish("native held-item public contract and generation boundaries") diff --git a/tests/engine/held_item_scheduling.lua b/tests/engine/held_item_scheduling.lua new file mode 100644 index 000000000..1ced9d567 --- /dev/null +++ b/tests/engine/held_item_scheduling.lua @@ -0,0 +1,11 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local Fixture = dofile("tests/held_item/fixture.lua") +local Cases = dofile("tests/held_item/scheduling.lua") +for _, version in ipairs({ "firered", "emerald" }) do + 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.close() + end +end +require("tests.modkit").finish("native GBA held-item scheduling") diff --git a/tests/engine/mod_supports.lua b/tests/engine/mod_supports.lua new file mode 100644 index 000000000..5d4562cd8 --- /dev/null +++ b/tests/engine/mod_supports.lua @@ -0,0 +1,251 @@ +-- API availability is observable through the real loader, before Game/world +-- construction. No imported content, graphics, or network is required. +package.path = './?.lua;./?/init.lua;' .. package.path +local T = require('tests.harness') +local GameVersion = require('src.core.GameVersion') +local Loader = require('src.mods.Loader') +local Runtime = require('src.mods.Runtime') +local WorldAPI = require('src.world.WorldAPI') +local Schemas = require('src.mods.Schemas') +local Catalog = require('tests.modkit.catalog') +local originalEvents, originalHooks, originalErrors = Runtime.events, Runtime.hooks, Runtime.errors +local originalVersion = GameVersion.get() +local savedLove = love +love = nil + +local files = { + ['mods/probe/manifest.json'] = [[{"id":"probe","name":"Probe","version":"1.0.0","api":2,"entry":"main.lua"}]], + ['mods/probe/main.lua'] = [[ +return function(mod) + -- Runs inside the entry window, not after the loader has finished. + mod.exports.early = mod:supports('world', 'warpTo') + mod.exports.api = mod +end +]], +} +local fs = { + read = function(path) return files[path] end, + getDirectoryItems = function(path) + if path == 'mods' then return { 'probe' } end + return {} + end, + getInfo = function(path) + if path == 'mods' or path == 'mods/probe' then return { type = 'directory' } end + if files[path] then return { type = 'file' } end + end, + load = function(path) return loadstring(files[path], path) end, +} +local function probe(version) + GameVersion.set(version) + local data = { pokemon = {} } + local loader = Loader.new({ fs = fs, dev = false }) + loader._game = function() error('supports must not construct Game') end + assert(loader:load(data)) + assert(loader.apis.probe, tostring(loader.errors.probe)) + return loader.apis.probe, loader, data +end +local function yes(api, surface, name) + local ok, reason = api:supports(surface, name) + return ok == true and reason == nil +end +local function no(api, surface, name) + local ok, reason = api:supports(surface, name) + return ok == false and type(reason) == 'string' and #reason > 0 +end + +-- Builtin registrants can allocate fresh closures on each load. Compare +-- record structure and values while treating function identity as opaque, +-- as the registry parity gate does. +local function record(value) + if type(value) == 'function' then return '' end + if type(value) ~= 'table' then return value end + local out = {}; for k, v in pairs(value) do out[k] = record(v) end; return out +end + +local allHooks, allEvents = {}, {} +for _, version in ipairs(GameVersion.ORDER) do + local api, loader, data = probe(version) + T.check(api.exports.early, version .. ': query succeeds inside entry chunk') + T.check(yes(api, 'hooks', 'battle.damage') and yes(api, 'events', 'world.stepped'), + version .. ': shared simulation/world call sites available') + local registries = true + for name in pairs(Schemas.REGISTRIES) do + registries = registries and yes(api, 'content', name) + end + T.check(registries, version .. ': all API-2 registration surfaces available') + T.eq(yes(api, 'world', 'flyTo'), type(WorldAPI.flyTo) == 'function', + version .. ': additive native flight interface is reported when installed') + T.check(no(api, 'hooks', 'core.logic_speed') + and no(api, 'hooks', 'item.use') and no(api, 'events', 'egg.hatched'), + version .. ': absent Gen1Recomp API-3 features are not advertised') + T.check(yes(api, 'events', 'mod.probe.ready') and yes(api, 'events', 'mod.other.ready'), + version .. ': namespaced user events do not need a producer installed') + for _, name in ipairs(Catalog.hooks()) do + if yes(api, 'hooks', name) then allHooks[name] = true end + end + for _, name in ipairs(Catalog.events()) do + if yes(api, 'events', name) then allEvents[name] = true end + end + -- Mutation of the handed-out metadata cannot change a capability answer. + local statusHud = api:supports('hooks', 'battle.status_hud_visible') + api.gameVersion, api.generation, api.host.version = 'red', 1, 'red' + GameVersion.set('red') + T.eq(api:supports('hooks', 'battle.status_hud_visible'), statusHud, + version .. ': query retains captured host even after metadata/global changes') + T.check(next(loader.events.listeners) == nil and next(loader.hooks.chains) == nil, + version .. ': probing installs no listeners or wrappers') + local pristine = { pokemon = {} } + GameVersion.set(version) + local vanilla = Loader.new({ fs = fs, noMods = true, dev = false }) + assert(vanilla:load(pristine)) + T.same(record(data), record(pristine), version .. ': querying mod leaves merged data equal to no-mod boot') +end + +-- The union covers every existing native callback; stale catalog entries +-- also fail. This gate reads source rather than a second hand-kept name list. +T.same(allHooks, (function() + local out = {}; for _, name in ipairs(Catalog.hooks()) do out[name] = true end; return out +end)(), 'all native hook names have a queryable implementation') +T.same(allEvents, (function() + local out = {}; for _, name in ipairs(Catalog.events()) do out[name] = true end; return out +end)(), 'all native event names have a queryable implementation') + +-- Also reject advertised names whose native call site was removed. Read +-- the simple declaration lists without exporting mutable production state. +local h = assert(io.open('src/mods/Capabilities.lua', 'r')) +local body = h:read('*a'); h:close() +local declared = { hooks = {}, events = {} } +for surface, list in body:gmatch('add%("(%w+)", "%d+", ([^%)]+)%)') do + local names = list:match('^%[%[(.-)%]%]$') or list:match('^"(.-)"$') + assert(names, 'unrecognized capability declaration') + for name in names:gmatch('%S+') do declared[surface][name] = true end +end +T.same(declared.hooks, allHooks, 'advertised hook catalog has no stale call sites') +T.same(declared.events, allEvents, 'advertised event catalog has no stale call sites') + +local api, loader = probe('crystal') +local noWorldBefore = package.loaded['src.world.WorldAPI'] +T.check(yes(api, 'world', 'warpTo') and yes(api, 'world', 'heightAt'), + 'world methods available independently of save/map state') +T.eq(package.loaded['src.world.WorldAPI'], noWorldBefore, 'queries never require WorldAPI') +for _, surface in ipairs({ 'content', 'world', 'hooks', 'events' }) do + for _, invalid in ipairs({ '', 1, false, {}, function() end }) do + T.check(no(api, surface, invalid), surface .. ': malformed name declines with reason') + end + T.check(no(api, surface, nil), surface .. ': missing name declines with reason') + T.check(no(api, surface, 'does.not.exist'), surface .. ': unknown name declines with reason') +end +for _, surface in ipairs({ '', 'event', 'hook', 'storage', 1, false, {} }) do + T.check(no(api, surface, 'anything'), 'unknown/malformed surface declines with reason') +end +T.check(no(api, nil, 'anything'), 'missing surface declines with reason') +T.check(no(api, 'events', 'mod.probe.') and no(api, 'events', 'mod..ready'), + 'empty user-event namespace/name is unavailable') +T.check(no(api, 'world', 'new') and no(api, 'world', '_mapNamed') + and no(api, 'world', 'face') and no(api, 'world', '__index'), + 'constructors, internals and NPC-handle methods are not world capabilities') +T.check(no(api, 'content', 'Pokemon') and no(api, 'hooks', 'BATTLE.DAMAGE'), + 'names are case-sensitive') + +-- Verify world catalog against the real public method table, not just names +-- duplicated in a test. A supported method may still decline without a map. +love = savedLove or require('tests.love_stub') +local WorldAPI = require('src.world.WorldAPI') +for name, method in pairs(WorldAPI) do + if type(method) == 'function' and name ~= 'new' and name:sub(1, 1) ~= '_' then + T.check(yes(api, 'world', name), 'public WorldAPI method query: ' .. name) + end +end +local worldNames = assert(body:match('for name in %(%[%[(.-)%]%]%):gmatch')) +for name in worldNames:gmatch('%S+') do + T.eq(type(WorldAPI[name]), 'function', 'advertised world method exists: ' .. name) +end +local world = WorldAPI.new({}, 'probe') +local result, reason = world:warpTo('missing', 0, 0) +T.check(result == nil and reason == 'no overworld' and yes(api, 'world', 'warpTo'), + 'availability is distinct from current-state eligibility') + +-- Version-specific routing is deliberately different from Gen1Recomp. +for _, version in ipairs(GameVersion.ORDER) do + local mod = probe(version) + local gen = GameVersion.generation(version) + T.eq(mod:supports('hooks', 'battle.status_hud_visible'), gen >= 4, + version .. ': DS-only HUD seam') + T.eq(mod:supports('hooks', 'battle.overlay'), gen <= 3, + version .. ': GB/GBA-only overlay seam') + T.eq(mod:supports('hooks', 'ui.party.submenu'), gen <= 3, + version .. ': native party-menu seam') + T.eq(mod:supports('hooks', 'ui.pc.items'), gen <= 2, + version .. ': native PC seam') + T.eq(mod:supports('events', 'intro.birch_speech.started'), version == 'emerald', + version .. ': Emerald-only Birch intro') + T.eq(mod:supports('hooks', 'intro.oak_speech.build'), gen <= 2 and version ~= 'prism', + version .. ': Oak route excludes Prism and newer intros') +end + +-- A content query gates an actual registration through the entry API. +local entry = files['mods/probe/main.lua'] +files['mods/probe/main.lua'] = [[ +return function(mod) + if mod:supports('content', 'strings') then + mod.content.strings:register('QUERY_TEXT', 'Query succeeded') + end + if mod:supports('content', 'not_a_registry') then + error('unavailable registration must be skipped') + end +end +]] +local _, contentLoader, contentData = probe('crystal') +T.eq(contentData.strings.QUERY_TEXT, 'Query succeeded', 'supported registry accepts real registration') +T.eq(#contentLoader.errors, 0, 'unavailable content fallback loads without errors') +files['mods/probe/main.lua'] = entry + +-- A generation-specific query gates the real DS HUD call site. Returning +-- false avoids drawing and lets this run without a graphics context. +local Gen4Battle = require('src.battle.Gen4Battle') +for _, version in ipairs({ 'platinum', 'black' }) do + local mod = probe(version) + local calls = 0 + if mod:supports('hooks', 'battle.status_hud_visible') then + mod.hooks:wrap('battle.status_hud_visible', function() + calls = calls + 1 + return false + end) + end + T.eq(Gen4Battle.drawHealthboxes({}), true, version .. ': supported HUD hook suppresses native boxes') + T.eq(calls, 1, version .. ': native HUD call reaches mod once') +end + +-- A successful probe gates an actual native call: world.timeOfDay dispatches +-- the hook and resulting event. Queries themselves have no bus side effects. +api, loader = probe('crystal') +local calls, changes = 0, 0 +if api:supports('hooks', 'world.tod') then + api.hooks:wrap('world.tod', function(next, tod, ctx) + calls = calls + 1 + return 'TEST_NIGHT' + end) +end +if api:supports('events', 'world.tod_changed') then + api.events:on('world.tod_changed', function(payload) + changes = changes + 1 + T.eq(payload.tod, 'TEST_NIGHT', 'native event carries hook result') + end) +end +local Overworld = require('src.world.OverworldController') +local ow = setmetatable({ tod = 'DAY', map = { id = 'PROBE' }, player = {} }, { __index = Overworld }) +ow.syncGen2Tod = function() end +T.eq(ow:timeOfDay(), 'TEST_NIGHT', 'queried native hook executes through actual world method') +T.check(calls == 1 and changes == 1, 'native hook/event each dispatched once') +-- Unsubscribe and use the same native path: original time-of-day behavior. +loader.events:removeOwner('probe') +loader.hooks:removeOwner('probe') +GameVersion.set('red') +ow.tod = 'DAY' +T.eq(ow:timeOfDay(), 'DAY', 'unmodified native path retains vanilla result') +T.check(calls == 1 and changes == 1, 'no-mod path invokes no mod callbacks') + +GameVersion.set(originalVersion) +love = savedLove +Runtime.install(originalEvents, originalHooks, originalErrors) +T.finish('mod supports') diff --git a/tests/engine/pokemon_identity_authority.lua b/tests/engine/pokemon_identity_authority.lua new file mode 100644 index 000000000..6c6528b66 --- /dev/null +++ b/tests/engine/pokemon_identity_authority.lua @@ -0,0 +1,101 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T, V = require("tests.modkit"), require("src.core.GameVersion") +local F = dofile("tests/pokemon_rolls/fixture.lua") +local P, R, Copy = + require("src.pokemon.Pokemon"), require("src.mods.Runtime"), require("src.mods.Merge").deepCopy +local old = V.get() +for _, version in ipairs({ "gold", "firered", "emerald" }) do + for _, mode in ipairs({ { 1, "native" }, { 2, "native" }, { 2, "unified" }, { 3, "unified" } }) do + local f = F.new(".", "gen2", version, mode[1], mode[2], "delegate") + local gen = f.gen + T.eq(f.e.api:supports("hooks", "shiny.roll"), true, "native shiny capability") + T.eq(f.e.api:supports("hooks", "gender.roll"), true, "native gender capability") + local mon = f.make(false) + local genes = { dvs = Copy(mon.dvs), ivs = Copy(mon.ivs), pid = mon.personality, stats = Copy(mon.stats) } + f.e.mode = "force" + if gen == 2 then + P.syncIdentity(f.data, mon) + end + T.eq(f.shiny(mon), true, "actual native identity read follows public hook") + T.eq(f.gender(mon, true), "female", "actual native gender predicate follows public hook") + T.same( + { dvs = mon.dvs, ivs = mon.ivs, pid = mon.personality, stats = mon.stats }, + genes, + "pure steering preserves stats and genetic values" + ) + local cached = mon.gender + f.e.mode = "suppress" + if gen == 3 then + T.eq(P.genderOf(f.data, mon), cached, "GBA native display retains constructor gender cache") + T.eq(f.gender(mon, true), "male", "GBA predicate remains a separate live query") + else + P.syncIdentity(f.data, mon) + T.eq(mon.gender, "male", "Gen-2 summary refresh updates gender") + T.eq(P.isShiny(mon), true, "Gen-2 true shiny remains authoritative at refresh") + end + local calls = f.e.calls["shiny.roll"] or 0 + local forced = f.make(false, true) + T.eq(f.shiny(forced), true, "explicit forced shiny wins over suppressing hook") + T.eq(f.e.calls["shiny.roll"] or 0, calls, "forced path bypasses shiny callback") + f.e.stopShiny() + f.e.stopGender() + local counts = Copy(f.e.calls) + if gen == 2 then + P.syncIdentity(f.data, mon) + end + f.shiny(mon) + f.gender(mon, true) + T.same(f.e.calls, counts, "retired callbacks stop real producer dispatch") + f.close() + end +end +for _, format in ipairs({ { 2, "unified" }, { 3, "unified" } }) do + local f = F.new(".", "gen2", "gold", format[1], format[2], "delegate") + local def = f.data.pokemon.SPECIES_004 + local ratio = def.genderRatio + local captured, nextSaved + local stop = f.e.api.hooks:wrap("gender.roll", function(next, ctx) + captured, nextSaved = ctx, next + ctx.dvs.attack = 0 + ctx.def.genderRatio = 254 + error("intentional pre-next identity transaction failure") + end, 100) + local mon = f.make(false) + T.eq(mon.dvs.attack, 15, "failed unified callback cannot change saved DVs") + T.eq(def.genderRatio, ratio, "failed unified callback cannot change definition threshold") + T.eq(mon.gender, "male", "failed unified callback delegates ordinary gender") + stop() + captured.def.genderRatio = 0 + captured.dvs.attack = 0 + T.eq(def.genderRatio, ratio, "retained detached context cannot edit engine data later") + T.eq(mon.dvs.attack, 15, "retained detached DVs cannot edit created mon later") + T.raises(function() + nextSaved() + end, "next is only valid", "late downstream call rejected") + local stopBad = f.e.api.hooks:wrap("gender.roll", function(next, ctx) + ctx.def.genderRatio = 256 + return next(ctx) + end, 100) + f.gender(mon, true) + T.eq(def.genderRatio, ratio, "invalid definition threshold rolls back before delegation") + stopBad() + f.close() +end +-- A true cartridge-forced wild encounter must skip the birth hook, too. +do + local f = F.new(".", "gen2", "gold", 3, "unified", "suppress") + f.game.save.party = { f.make(false) } + local before = f.e.calls["shiny.roll"] or 0 + local battle = require("src.battle.BattleState").newWild(f.game, "SPECIES_004", 5, { shiny = true }) + T.eq(P.isShiny(battle.enemy.mon), true, "native forced-wild path retains shininess") + T.eq(f.e.calls["shiny.roll"] or 0, before, "native forced-wild birth bypasses shiny hook") + f.close() +end +for _, version in ipairs({ "red", "platinum", "black" }) do + local f = F.new(".", "gen2", version, 3, "unified", "delegate") + T.eq(f.e.api:supports("hooks", "shiny.roll"), false, "unsupported shiny capability is honest") + T.eq(f.e.api:supports("hooks", "gender.roll"), false, "unsupported gender capability is honest") + f.close() +end +V.set(old) +T.finish("identity ownership, cached/native queries, forced wild and rollback") diff --git a/tests/engine/pokemon_identity_rolls.lua b/tests/engine/pokemon_identity_rolls.lua new file mode 100644 index 000000000..8e81c4bb7 --- /dev/null +++ b/tests/engine/pokemon_identity_rolls.lua @@ -0,0 +1,10 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T, V = require("tests.modkit"), require("src.core.GameVersion") +local old = V.get() +for _, mode in ipairs({ { 1, "native" }, { 2, "native" }, { 2, "unified" }, { 3, "unified" } }) do + for _, version in ipairs(V.ORDER) do + dofile("tests/pokemon_rolls/cases.lua").run(".", "gen2", version, mode[1], mode[2]) + end +end +V.set(old) +T.finish("native shiny.roll and gender.roll contracts") diff --git a/tests/engine/portable_enemy_action_arguments.lua b/tests/engine/portable_enemy_action_arguments.lua new file mode 100644 index 000000000..d2507af0d --- /dev/null +++ b/tests/engine/portable_enemy_action_arguments.lua @@ -0,0 +1,112 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +for _, version in ipairs({ "firered", "emerald" }) do + require("tests.enemy_actions.arguments").run(".", "gen2", version) +end +local function setup(f, double) + local c = f.fresh("TACKLE", { enemy = true }) + local b = c.battle + b.double = double + if double then + local Battle, Merge = require("src.battle.BattleState"), require("src.mods.Merge") + b:placeBattler(2, Battle.makeBattler(f.data, Merge.deepCopy(b.player.mon), true)) + b:placeBattler(3, Battle.makeBattler(f.data, Merge.deepCopy(b.enemy.mon), false)) + end + b:syncSides() + return b +end +for _, version in ipairs({ "firered", "emerald" }) do + for _, mode in ipairs({ { 1, "native" }, { 2, "native" }, { 2, "unified" } }) do + local f = require("tests.enemy_actions.fixture").new(".", "gen2", version, mode[1], mode[2]) + local b = setup(f, true) + for _, actor in ipairs({ b.enemy, b:battlerAt(3) }) do + local seen + f.e.choose(function(nextAction, battle, who) + T.eq(battle, b, "legacy opaque battle remains identical") + seen = who + if mode[2] == "native" then + T.eq(who, actor, "API-1/2 native actor identity is preserved") + else + T.neq(who, actor, "API-2 unified actor remains detached") + T.eq(who.side, "enemy", "API-2 unified actor keeps side") + T.eq(who.mon.species, "CHARMANDER", "API-2 unified actor keeps canonical Pokemon") + end + return nextAction(battle, who) + end) + local action = b:enemyAction(actor) + T.eq(type(seen), "table", "existing API-1/2 argument contract stays a record") + T.eq(action, actor.curMoves[1], "legacy native choice handle is preserved") + end + f.close() + end + for _, double in ipairs({ false, true }) do + local f = require("tests.enemy_actions.fixture").new(".", "gen2", version, 3, "unified") + local b, seen, native = setup(f, double), {}, {} + f.e.choose(function(nextAction, battle, id) + seen[#seen + 1] = id == nil and "nil" or id + return nextAction(battle, id) + end) + local stop = f.result.loader.hooks:wrap("battle.enemy_action", function(nextAction, battle, actor, ...) + T.eq(select("#", ...), 0, "mixed native callback retains arity") + T.eq(battle, b, "mixed native callback retains battle") + T.check(actor == b.enemy or actor == b:battlerAt(3), "mixed native callback receives actual actor") + native[#native + 1] = actor + return nextAction(battle, actor) + end, -100, "native-argument-fixture") + if double then + b.pendingActions = { [2] = b:battlerAt(2).curMoves[1] } + end + b.queue = {} + b:resolveTurn(b.player.curMoves[1]) + local count = 0 + while b.queue[1] and count < 1000 do + count = count + 1 + local row = table.remove(b.queue, 1) + if row.fn then + b.nextInsert = 0 + row.fn() + end + end + T.check(count < 1000, "actual native turn queue completes") + T.same( + seen, + double and { 1, 3 } or { "nil" }, + "actual controller publishes shared single/double selectors" + ) + T.eq(#native, double and 2 or 1, "each native enemy choice runs once") + T.eq(native[1], b.enemy, "mixed native callback retains left actor identity") + if double then + T.eq(native[2], b:battlerAt(3), "mixed native callback retains right actor identity") + end + for _, actor in b:activeBattlers() do + T.eq(actor.curMoves[1].pp, 9, "actual controller spends each actor's PP exactly once") + end + stop() + if not double then + local entered = 0 + b.kind, b.trainer = + "trainer", { + brain = function() + entered = entered + 1 + error("intentional native brain failure") + end, + } + local ok = pcall(b.enemyAction, b) + T.check(not ok, "native AI failure reaches the caller") + T.eq(entered, 1, "native AI failure is not retried") + b.kind, b.trainer = "wild", nil + local later + f.e.choose(function(_, battle, id) + later = id + return "SCRATCH" + end) + local result = f.result.loader.hooks:call("battle.enemy_action", function() + error("must not delegate") + end, b, b.enemy) + T.eq(later, 1, "failed request leaves no stale default-selector scope") + T.eq(result, "SCRATCH", "independent native hook invocation still completes") + end + f.close() + end +end +T.finish("portable enemy action arguments") diff --git a/tests/engine/portable_enemy_action_choices.lua b/tests/engine/portable_enemy_action_choices.lua new file mode 100644 index 000000000..967e97d77 --- /dev/null +++ b/tests/engine/portable_enemy_action_choices.lua @@ -0,0 +1,9 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local Cases = require("tests.enemy_actions.cases") +for _, version in ipairs({ "firered", "emerald" }) do + for _, mode in ipairs({ { 1, "native" }, { 2, "native" }, { 2, "unified" }, { 3, "unified" } }) do + Cases.run(".", "gen2", version, mode[1], mode[2]) + end +end +T.finish("portable enemy move choices") diff --git a/tests/engine/portable_enemy_move_records.lua b/tests/engine/portable_enemy_move_records.lua new file mode 100644 index 000000000..7285d5cdb --- /dev/null +++ b/tests/engine/portable_enemy_move_records.lua @@ -0,0 +1,64 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +for _, version in ipairs({ "firered", "emerald" }) do + require("tests.enemy_actions.records").run(".", "gen2", version) +end +for _, version in ipairs({ "firered", "emerald" }) do + local f = require("tests.enemy_actions.fixture").new(".", "gen2", version, 1, "native") + local c = f.fresh("TACKLE", { enemy = true }) + local b = c.battle + local detached = require("src.mods.Merge").deepCopy(b.enemy) + detached.curMoves = { { id = f.ids.SCRATCH, pp = 7 } } + detached.mon.moves = detached.curMoves + f.e.choose(function(nextAction, battle) + return nextAction(battle, detached) + end) + local action = b:enemyAction() + T.eq(action.id, f.ids.SCRATCH, "native cloned-actor query remains supported") + b:executeAction(b.enemy, b.player, action) + T.eq(b.enemy.curMoves[1].pp, 9, "native cloned-actor choice spends caller PP") + T.eq(detached.curMoves[1].pp, 7, "native cloned-actor query does not mutate queried PP") + f.close() +end +for _, version in ipairs({ "firered", "emerald" }) do + for _, field in ipairs({ "move", "id" }) do + for _, changeNative in ipairs({ false, true }) do + local f = require("tests.enemy_actions.fixture").new(".", "gen2", version, 3, "unified") + local c = f.fresh("TACKLE", { enemy = true }) + local b = c.battle + b.enemy.curMoves[2] = { id = f.ids.SCRATCH, pp = 7 } + f.e.choose(function() + return { [field] = "SCRATCH" } + end) + local nativeValue, publicValue + local stopNative = f.result.loader.hooks:wrap("battle.enemy_action", function(nextAction, ...) + local action = nextAction(...) + nativeValue = action[field] + if changeNative then + action[field] = f.ids.TACKLE + end + return action + end, 100, "native-result-fixture") + local stopPublic = f.e.api.hooks:wrap("battle.enemy_action", function(nextAction, ...) + local action = nextAction(...) + publicValue = action[field] + return action + end, 200) + local action = b:enemyAction() + T.eq(nativeValue, f.ids.SCRATCH, "mixed native hook retains physical move representation") + local expected = changeNative and (field == "move" and 33 or "TACKLE") or "SCRATCH" + T.eq(publicValue, expected, "public view retains authored form or observes native rewrite") + b:executeAction(b.enemy, b.player, action) + T.eq( + b.enemy.curMoves[1].pp, + changeNative and 9 or 10, + "mixed result preserves native rewrite PP ownership" + ) + T.eq(b.enemy.curMoves[2].pp, changeNative and 7 or 6, "mixed result spends actual selected PP") + stopPublic() + stopNative() + f.close() + end + end +end +T.finish("portable enemy move records") diff --git a/tests/engine/portable_enemy_targets.lua b/tests/engine/portable_enemy_targets.lua new file mode 100644 index 000000000..127e62328 --- /dev/null +++ b/tests/engine/portable_enemy_targets.lua @@ -0,0 +1,74 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +for _, version in ipairs({ "firered", "emerald" }) do + require("tests.enemy_actions.targets").run(".", "gen2", version) +end +local function fresh(f) + local b = f.fresh("TACKLE", { enemy = true }).battle + b.double = true + local S, copy = require("src.battle.BattleState"), require("src.mods.Merge").deepCopy + b:placeBattler(2, S.makeBattler(f.data, copy(b.player.mon), true)) + b:placeBattler(3, S.makeBattler(f.data, copy(b.enemy.mon), false)) + b:syncSides() + for _, actor in b:activeBattlers() do + actor.mon.hp, actor.abilityOverride, actor.mon.item = 400, false, nil + actor.curStats = { hp = 400, attack = 50, defense = 50, speed = 50, special = 50, spatk = 50, spdef = 50 } + actor.mon.stats = actor.curStats + end + return b +end +for _, version in ipairs({ "firered", "emerald" }) do + local f = require("tests.enemy_actions.fixture").new(".", "gen2", version, 1, "native") + for _, row in ipairs({ + { true }, + { false }, + { "P2" }, + { {} }, + { -1 }, + { 4 }, + { 1.5 }, + { math.huge }, + { 0 / 0 }, + { function() end }, + }) do + local b = fresh(f) + f.e.choose(function() + return { move = "TACKLE", target = row[1] } + end) + local ok, err = pcall(function() + b:executeAction(b.enemy, b.player, b:enemyAction()) + end) + T.check(ok, "malformed action target retains safe native fallback: " .. tostring(err)) + T.check(b.player.mon.hp < 400, "malformed action target still executes against ordinary foe") + T.eq(b:battlerAt(2).mon.hp, 400, "malformed action target cannot select arbitrary actor") + end + local b = fresh(f) + f.e.choose(function() + return { move = "TACKLE", target = b:battlerAt(2) } + end) + b:executeAction(b.enemy, b.player, b:enemyAction()) + T.check(b:battlerAt(2).mon.hp < 400, "legacy native target handle still selects its actual actor") + T.eq(b.player.mon.hp, 400, "legacy target handle preserves the other foe") + local runs = {} + for _, explicit in ipairs({ false, true }) do + local randomBattle = fresh(f) + randomBattle.data.moves[f.ids.TACKLE].target = 4 + local draws = {} + randomBattle.rng = function(lo, hi) + draws[#draws + 1] = { lo, hi } + return lo + end + f.e.choose(function() + return { move = "TACKLE", target = explicit and 2 or nil } + end) + randomBattle:executeAction(randomBattle.enemy, randomBattle.player, randomBattle:enemyAction()) + runs[#runs + 1] = { + draws = draws, + hp = { randomBattle.player.mon.hp, randomBattle:battlerAt(2).mon.hp }, + pp = randomBattle.enemy.curMoves[1].pp, + } + end + T.same(runs[1], runs[2], "random-target policy ignores a chosen hint without adding target RNG") + f.close() +end +T.finish("portable enemy targets") 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/ui_actions.lua b/tests/engine/ui_actions.lua new file mode 100644 index 000000000..8103d644f --- /dev/null +++ b/tests/engine/ui_actions.lua @@ -0,0 +1,792 @@ +-- Real native screens, Loader facades and ROM-free generation fixtures. +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local Loader = require("src.mods.Loader") +local GameVersion = require("src.core.GameVersion") +local Pokemon = require("src.pokemon.Pokemon") +local SaveData = require("src.core.SaveData") +local Font = require("src.render.Font") +local Runtime = require("src.mods.Runtime") +local Json = require("src.link.Json") +local Intents = require("src.mods.BattleIntents") +local original = GameVersion.get() +local versions = GameVersion.ORDER +local function boot(version, apiLevel) + GameVersion.set(version) + local data = T.fixtures.fresh() + local files = {} + for _, id in ipairs({ "probe", "other" }) do + files["mods/" .. id .. "/manifest.json"] = + Json.encode({ id = id, name = id, version = "1.0.0", api = apiLevel or 2, entry = "main.lua" }) + files["mods/" .. id .. "/main.lua"] = [[return function(mod) + assert(mod.ui:snapshot()==nil) + assert(type(mod.ui.insertBefore)=="function") + if mod.supports then + assert(mod:supports("ui", "snapshot")) + assert(mod:supports("ui", "submit")) + assert(not mod:supports("ui", "unknown")) + end + end]] + end + local fs = { + read = function(p) + return files[p] + end, + load = function(p) + return loadstring(files[p], p) + end, + getDirectoryItems = function(p) + return p == "mods" and { "probe", "other" } or {} + end, + getInfo = function(p) + if p == "mods" or p == "mods/probe" or p == "mods/other" then + return { type = "directory" } + end + if files[p] then + return { type = "file" } + end + end, + } + local loader = Loader.new({ fs = fs, dev = false }) + T.check(loader:load(data), "fixture mods load") + local game = { data = data, stack = { states = {} }, save = SaveData.newGame() } + loader.game = game + Font.load(data) + game.save.party = { Pokemon.new(data, "FIXMON_A", 20), Pokemon.new(data, "FIXMON_B", 12) } + game.input = { + wasPressed = function() + return false + end, + isDown = function() + return false + end, + } + function game.stack:top() + return self.states[#self.states] + end + function game.stack:push(state) + self.states[#self.states + 1] = state + end + function game.stack:pop() + return table.remove(self.states) + end + return loader.apis.probe.ui, game, loader.apis.other.ui, loader +end +local function button(game, screen, name) + local old = game.input + game.input = { + wasPressed = function(_, key) + return key == name + end, + isDown = function() + return false + end, + } + screen:update(1 / 60) + game.input = old +end +local function submit(ui, id, kind, fields) + local snap, err = ui:snapshot() + assert(snap, err) + local intent = { id = id, revision = snap.revision, kind = kind } + for k, v in pairs(fields or {}) do + intent[k] = v + end + return ui:submit(intent) +end +local partyClass = { + red = "PartyMenu", + crystal = "PartyMenu", + emerald = "Gen3PartyMenu", + platinum = "Gen4PartyMenu", + heartgold = "Gen4PartyMenu", + black = "Gen4PartyMenu", +} +local bagClass = { + red = "BagMenu", + crystal = "BagMenu", + emerald = "Gen3BagMenu", + platinum = "Gen4BagMenu", + heartgold = "HgssBagMenu", + black = "Gen5BagMenu", +} +for _, version in ipairs(versions) do + local gen = GameVersion.VERSIONS[version].generation + partyClass[version] = partyClass[version] + or (gen <= 2 and "PartyMenu" or gen == 3 and "Gen3PartyMenu" or "Gen4PartyMenu") + bagClass[version] = bagClass[version] + or ( + gen <= 2 and "BagMenu" + or gen == 3 and "Gen3BagMenu" + or version == "soulsilver" and "HgssBagMenu" + or gen == 5 and "Gen5BagMenu" + or "Gen4BagMenu" + ) + local ui, game, other = boot(version) + local chosen, cancelled = 0, 0 + local screen = require("src.ui." .. partyClass[version]).new(game, { + pickOnly = true, + onSwitch = function(mon) + T.eq(mon, game.save.party[2], "native picker target") + chosen = chosen + 1 + end, + onCancel = function() + cancelled = cancelled + 1 + end, + }) + game.stack:push(screen) + local snap = assert(ui:snapshot()) + T.eq(snap.kind, "party", version .. " party recognized") + T.eq(snap.rows[2].species, "FIXMON_B", "portable party ID") + T.eq(ui:snapshot().revision, snap.revision, "unchanged read retains revision") + snap.rows[1].hp = -999 + T.neq(game.save.party[1].hp, -999, "snapshot detached") + local stale = other:snapshot() + T.check(submit(ui, 1, "select", { index = 2 }), "native party selection accepted") + T.eq(chosen, 1, "picker callback once") + T.eq(#game.stack.states, 0, "picker popped") + T.eq( + other:submit({ id = 1, revision = stale.revision, kind = "back" }), + nil, + "other facade cannot use departed picker" + ) + game.stack:push(require("src.ui." .. partyClass[version]).new(game, { + onCancel = function() + cancelled = cancelled + 1 + end, + })) + T.check(submit(ui, 2, "back"), "native party back") + T.eq(cancelled, 1, "onCancel delivered") + local bag = require("src.ui." .. bagClass[version]).new(game, {}) + game.stack:push(bag) + local bagSnap = assert(ui:snapshot()) + T.eq(bagSnap.kind, "bag", version .. " bag recognized") + if bagSnap.canPocket then + T.check(submit(ui, 3, "pocket", { direction = 1 }), "native pocket switch") + end + T.check(submit(ui, 4, "back"), "native bag back") + T.eq(#game.stack.states, 0, "bag popped") +end + +-- Walk raw entries, preserving identity and cycles, to detect any gameplay +-- writes from observations and rejected requests (T.same is not cycle-safe). +local function freeze(root) + local graph = {} + local function visit(t) + if type(t) ~= "table" or graph[t] then + return + end + local entry = { mt = getmetatable(t), values = {} } + graph[t] = entry + for k, v in pairs(t) do + entry.values[k] = v + visit(k) + visit(v) + end + end + visit(root) + return function(label) + local same = true + for t, entry in pairs(graph) do + same = same and getmetatable(t) == entry.mt + for k, v in pairs(entry.values) do + same = same and rawget(t, k) == v + end + for k, v in pairs(t) do + same = same and entry.values[k] == v + end + end + T.check(same, label) + end +end +local function refusal(ui, game, intent, message) + local unchanged = freeze(game) + local ok, err = ui:submit(intent) + T.eq(ok, nil, message) + T.check(type(err) == "string", message .. " explains refusal") + unchanged(message .. " leaves game untouched") + return err +end +for _, version in ipairs(versions) do + -- Compare native A/B to the facade for each party submenu and its back path. + local traces = {} + for _, native in ipairs({ true, false }) do + local ui, game = boot(version) + local party = require("src.ui." .. partyClass[version]).new(game, {}) + game.stack:push(party) + local unchanged = freeze(game) + local snap = ui:snapshot() + unchanged("party snapshot is observational") + T.check(snap ~= nil, "native screen available") + if native then + button(game, party, "a") + else + T.check(submit(ui, 1, "select", { index = 1 }), "open party submenu") + end + local stableSubmenu = freeze(game) + snap = ui:snapshot() + stableSubmenu("submenu snapshot remains observational") + T.eq(snap.kind, "party_actions", "party action rows offered") + local swap + for i, r in ipairs(snap.rows) do + if r.action == "switch" or r.action == "switchOrder" then + swap = i + end + end + T.check(swap ~= nil, "native swap row exposed") + if native then + if GameVersion.VERSIONS[version].generation <= 3 then + party.subIndex = swap + else + party.submenu = swap + end + button(game, party, "a") + party.index = 2 + button(game, party, "a") + else + T.check(submit(ui, 2, "select", { index = swap }), "start swap") + T.check(submit(ui, 3, "select", { index = 2 }), "finish swap") + end + traces[#traces + 1] = { + first = game.save.party[1].species, + second = game.save.party[2].species, + depth = #game.stack.states, + index = party.index, + swapFrom = party.swapFrom, + switchFrom = party.switchFrom, + } + if native then + button(game, party, "b") + else + T.check(submit(ui, 4, "back"), "back after swap") + end + T.eq(#game.stack.states, 0, "native party cancellation") + end + T.same(traces[1], traces[2], version .. " swap matches native A/B") + + local ui, game, other, loader = boot(version) + local party = require("src.ui." .. partyClass[version]).new(game, {}) + game.stack:push(party) + local snap = ui:snapshot() + for _, bad in ipairs({ + false, + 1, + "select", + {}, + { id = 1, revision = snap.revision, kind = "unknown" }, + { id = 1, revision = snap.revision, kind = "select", index = 0 }, + { id = 1, revision = snap.revision, kind = "select", index = 1.5 }, + { id = math.huge, revision = snap.revision, kind = "back" }, + { id = 1, revision = snap.revision, kind = "back", extra = true }, + setmetatable({ id = 1, revision = snap.revision, kind = "back" }, {}), + }) do + refusal(ui, game, bad, "invalid intent") + end + refusal(ui, game, { id = 1, revision = snap.revision, kind = "select", index = 999 }, "missing row") + refusal(ui, game, { id = 1, revision = snap.revision, kind = "advance" }, "party cannot advance text") + refusal( + ui, + game, + { id = 1, revision = snap.revision, kind = "pocket", direction = 1 }, + "party has no pockets" + ) + game.save.party[1].hp = game.save.party[1].hp - 1 + T.eq( + refusal(ui, game, { id = 1, revision = snap.revision, kind = "back" }, "HP change stales snapshot"), + "stale UI context", + "stale reason" + ) + snap = ui:snapshot() + local replacement = {} + for k, v in pairs(game.save.party[1]) do + replacement[k] = v + end + game.save.party[1] = replacement + refusal( + ui, + game, + { id = 1, revision = snap.revision, kind = "back" }, + "identical replacement mon stales snapshot" + ) + -- Different screen identity, even with identical values. + snap = ui:snapshot() + local replacementScreen = require("src.ui." .. partyClass[version]).new(game, {}) + game.stack.states[1] = replacementScreen + refusal(ui, game, { id = 1, revision = snap.revision, kind = "back" }, "replacement screen stales snapshot") + party = replacementScreen + -- Whitelist native ownership; never infer an unrelated menu from its rows. + game.stack:push({ game = game, kind = "party", items = { { label = "fake" } } }) + T.eq(ui:snapshot(), nil, "unrelated overlay unsupported") + refusal(ui, game, { id = 1, revision = snap.revision, kind = "back" }, "covered native menu refused") + game.stack:pop() + for _, flag in ipairs({ "heal", "noInput", "script", "closed" }) do + party[flag] = true + snap = ui:snapshot() + T.eq(snap.locked, true, flag .. " locks public UI") + refusal(ui, game, { id = 1, revision = snap.revision, kind = "back" }, flag .. " blocks action") + party[flag] = nil + end + -- Shared battle/UI guard, cross-mod epoch, consumed IDs and partial failures. + snap = ui:snapshot() + Intents.state(game).busy = true + refusal(ui, game, { id = 1, revision = snap.revision, kind = "back" }, "in-progress battle/UI work blocked") + T.eq(ui:snapshot().locked, true, "busy snapshot locked") + Intents.state(game).busy = false + snap = ui:snapshot() + Intents.state(game).epoch = Intents.state(game).epoch + 1 + refusal(ui, game, { id = 1, revision = snap.revision, kind = "back" }, "battle work expires UI snapshot") + local calls, during, observed = 0 + party.onCancel = function() + calls = calls + 1 + game.stack:push(party) -- keep an identical screen for the epoch test + during = other:snapshot() + observed = other:submit({ id = 9, revision = during.revision, kind = "back" }) + error("native callback failure") + end + local otherSnap = other:snapshot() + snap = ui:snapshot() + Runtime.currentMod = "probe" + local ok, err = ui:submit({ id = 10, revision = snap.revision, kind = "back" }) + T.eq(ok, nil, "partial native failure reported") + T.check(err:find("intent consumed", 1, true) ~= nil, "partial failure consumes id") + T.eq(Runtime.currentMod, "probe", "engine attribution restored after error") + Runtime.currentMod = nil + T.eq(calls, 1, "callback invoked once") + T.eq(observed, nil, "callback cannot submit reentrantly") + T.eq(during.locked, true, "snapshot during callback is locked") + refusal(other, game, { id = 9, revision = otherSnap.revision, kind = "back" }, "other mod snapshot expired") + refusal(other, game, { id = 9, revision = during.revision, kind = "back" }, "mid-callback snapshot expired") + refusal( + ui, + game, + { id = 10, revision = ui:snapshot().revision, kind = "back" }, + "failed native action cannot replay" + ) + game.stack.states = { require("src.ui." .. partyClass[version]).new(game, {}) } + T.check(submit(ui, 11, "back"), "guard released after failure") + T.eq(loader.apis.probe.ui.insertBefore, require("src.ui.ModUI").insertBefore, "builder helpers retained") + T.neq(ui, other, "per-mod facade identity") +end + +local TextBox = require("src.render.TextBox") +local ChoiceBox = require("src.ui.ChoiceBox") +local function tickUntil(game, box, predicate) + for _ = 1, 1000 do + if predicate() then + return + end + box:update(1 / 60) + end + error("native UI did not reach expected state") +end +for _, version in ipairs(versions) do + local ui, game = boot(version) + local done = 0 + local box = TextBox.new(game, "First\fSecond", function() + done = done + 1 + end) + game.stack:push(box) + local snap = ui:snapshot() + T.eq(snap.kind, "text", "text recognized") + T.eq(snap.locked, true, "typing text locked") + refusal(ui, game, { id = 1, revision = snap.revision, kind = "advance" }, "typing cannot be skipped") + tickUntil(game, box, function() + return box.waiting and (box.preWait or 0) <= 0 + end) + T.check(submit(ui, 1, "advance"), "manual page advances") + T.check((box.holdFrames or 0) > 0, "page clear hold preserved") + snap = ui:snapshot() + refusal(ui, game, { id = 2, revision = snap.revision, kind = "advance" }, "page hold cannot be skipped") + tickUntil(game, box, function() + return box.done + end) + T.check(submit(ui, 2, "advance"), "final text closes") + T.eq(done, 1, "text callback once") + T.eq(game.stack:top(), nil, "text popped") + for _, opts in ipairs({ { auto = {} }, { stay = {} }, { choice = function() end } }) do + box = TextBox.new(game, "Ready", nil, opts) + game.stack:push(box) + box.done = true + snap = ui:snapshot() + T.eq(snap.canAdvance, false, "automatic/stay/choice text not manually dismissed") + refusal(ui, game, { id = 3, revision = snap.revision, kind = "advance" }, "script-owned text protected") + game.stack:pop() + end + -- Native text -> choice -> delayed answer -> both boxes popped. + local answers = {} + box = TextBox.new(game, "Question?", nil, { + defaultNo = true, + choice = function(yes) + answers[#answers + 1] = yes + end, + }) + game.stack:push(box) + tickUntil(game, box, function() + return box.done + end) + box:update(1 / 60) + local choice = game.stack:top() + T.eq(getmetatable(choice), ChoiceBox, "native choice appears") + T.eq(ui:snapshot().index, 2, "default NO retained") + T.check(submit(ui, 4, "select", { index = 1 }), "YES accepted") + T.eq(#answers, 0, "answer callback waits for native hold") + T.eq(#game.stack.states, 2, "both boxes retained during answer hold") + snap = ui:snapshot() + refusal(ui, game, { id = 5, revision = snap.revision, kind = "back" }, "pending answer locked") + tickUntil(game, choice, function() + return #answers == 1 + end) + T.eq(answers[1], true, "YES delivered") + T.eq(#game.stack.states, 0, "choice closes original text too") + choice = ChoiceBox.new(game, function(yes) + answers[#answers + 1] = yes + end) + game.stack:push(choice) + T.check(submit(ui, 5, "back"), "B selects NO") + T.eq(choice.index, 2, "NO cursor retained while pending") + tickUntil(game, choice, function() + return #answers == 2 + end) + T.eq(answers[2], false, "NO delivered") +end + +-- Pick an actual item through each native bag. API and physical A/B must +-- deliver the same callback and row, including HGSS pages and BW cursors. +for _, version in ipairs(versions) do + local traces = {} + for _, native in ipairs({ true, false }) do + local ui, game = boot(version) + game.data.items.POTION = { + id = "POTION", + name = "POTION", + pocket = "ITEM", + fieldPocket = 0, + bwPocket = 0, + fieldUse = 1, + fieldUseFunc = 1, + battlePocket = 4, + } + game.save.inventory, game.save.bagOrder = { POTION = 2 }, { "POTION" } + local picked = {} + local bag = require("src.ui." .. bagClass[version]).new(game, { + pick = true, + onPick = function(id) + picked[#picked + 1] = id + end, + }) + game.stack:push(bag) + local unchanged = freeze(game) + local snap = ui:snapshot() + unchanged("bag snapshot does not rebuild, sort or load graphics") + local itemRow + for i, row in ipairs(snap.rows) do + if row.item == "POTION" then + itemRow = i + end + end + T.check(itemRow ~= nil, version .. " item exposed") + if native then + if bagClass[version] == "HgssBagMenu" then + bag.page, bag.slot = math.floor((itemRow - 1) / 6), (itemRow - 1) % 6 + 1 + elseif bagClass[version] == "Gen5BagMenu" then + bag.cursor = itemRow + else + bag.index = itemRow + if bag.clampScroll then + bag:clampScroll() + end + end + button(game, bag, "a") + else + T.check(submit(ui, 1, "select", { index = itemRow }), "native bag selection accepted") + end + -- GB's bag has no generic pick option: it opens USE/GIVE/TOSS instead. + local top = game.stack:top() + if bagClass[version] == "BagMenu" then + T.eq(ui:snapshot().kind, "bag_actions", "only factory-owned bag action menus offered") + if native then + button(game, top, "b") + else + T.check(submit(ui, 2, "back"), "back from native item submenu") + end + else + T.same(picked, { "POTION" }, "native pick callback exactly once") + end + traces[#traces + 1] = { picked = picked, count = game.save.inventory.POTION, depth = #game.stack.states } + end + T.same(traces[1], traces[2], version .. " bag confirm matches native input") +end +-- Real medicine effect, target picker and HP animation through the public +-- boundary. The native flow owns consumption and its delayed completion. +for _, version in ipairs({ "red", "crystal", "emerald" }) do + local ui, game = boot(version) + game.data.items.POTION = { id = "POTION", name = "POTION", pocket = "ITEM" } + game.save.inventory, game.save.bagOrder = { POTION = 2 }, { "POTION" } + local mon = game.save.party[1] + mon.hp = mon.stats.hp - 25 + local before = mon.hp + local bag = require("src.ui." .. bagClass[version]).new(game, {}) + game.stack:push(bag) + local snap = ui:snapshot() + local slot + for i, r in ipairs(snap.rows) do + if r.item == "POTION" then + slot = i + end + end + T.check(submit(ui, 1, "select", { index = slot }), "select medicine") + snap = ui:snapshot() + if snap.kind == "bag_actions" then + local use + for i, r in ipairs(snap.rows) do + if r.action == "use" or r.label == "USE" then + use = i + end + end + T.check(submit(ui, 2, "select", { index = use }), "native USE action") + end + local picker = game.stack:top() + T.eq(ui:snapshot().kind, "party", "medicine opens native target picker") + T.check(submit(ui, 3, "select", { index = 1 }), "select medicine target") + T.eq(mon.hp, before + 20, "real native potion restores 20 HP") + T.eq(game.save.inventory.POTION, 1, "real native potion consumes once") + T.eq(picker.heal ~= nil, true, "native heal animation retained") + snap = ui:snapshot() + refusal( + ui, + game, + { id = 4, revision = snap.revision, kind = "select", index = 1 }, + "cannot use again during HP fill" + ) + tickUntil(game, picker, function() + return picker.heal == nil + end) + local box = game.stack:top() + T.eq(getmetatable(box), TextBox, "native result text appears after fill") + tickUntil(game, box, function() + return box.done + end) + T.check(submit(ui, 4, "advance"), "native medicine result dismissed") + T.eq(game.save.inventory.POTION, 1, "result dismissal cannot consume again") +end +-- The GBA action grid keeps its blank cells and script input lock. +do + local ui, game = boot("emerald") + local chosen + local menu = require("src.ui.Gen3ItemMenu").new(game, { + entries = { + { label = "USE", kind = "use" }, + { label = "", kind = "blank" }, + { label = "CANCEL", kind = "cancel" }, + }, + onPick = function(kind) + chosen = kind + end, + }) + game.stack:push(menu) + local snap = ui:snapshot() + T.eq(snap.rows[2].enabled, false, "blank grid cell disabled") + refusal(ui, game, { id = 1, revision = snap.revision, kind = "select", index = 2 }, "blank cell refused") + T.check(submit(ui, 1, "select", { index = 1 }), "native item grid confirm") + T.eq(chosen, "use", "native item action callback") +end +-- DS battle bags admit their native delayed press, never call its callback +-- early. Page slots, absent items and return buttons preserve native indices. +for _, version in ipairs({ "platinum", "heartgold", "black", "white" }) do + local traces = {} + for _, native in ipairs({ true, false }) do + local ui, game = boot(version) + game.data.items.POTION = + { id = "POTION", name = "POTION", fieldPocket = 0, battlePocket = 4, battleMask = 4 } + game.save.inventory, game.save.bagOrder = { POTION = 2 }, { "POTION" } + local ds = GameVersion.VERSIONS[version].generation == 5 and "Gen5BattleBag" or "Gen4BattleBag" + local bag = require("src.ui." .. ds).new(game, {}) + game.gen4CursorActive = true + game.stack:push(bag) + local snap = ui:snapshot() + refusal( + ui, + game, + { id = 1, revision = snap.revision, kind = "select", index = 5 }, + "unavailable last-used row" + ) + local function select(id, index) + if native then + bag.cursor.index = index + button(game, bag, "a") + else + T.check(submit(ui, id, "select", { index = index }), "battle bag native press admitted") + end + T.check(bag.press ~= nil, "native press pending") + T.eq(ui:snapshot().locked, true, "pending press locks UI") + tickUntil(game, bag, function() + return bag.press == nil + end) + end + select(1, 1) + T.eq(bag.screen, "pocket", "native pocket opens after animation") + snap = ui:snapshot() + T.eq(snap.rows[1].item, "POTION", "native battle item row") + refusal( + ui, + game, + { id = 2, revision = snap.revision, kind = "select", index = 6 }, + "empty item slot refused" + ) + select(2, 1) + T.eq(bag.screen, "use", "native use question") + select(3, 2) + T.eq(bag.screen, "pocket", "use back returns to pocket") + select(4, 9) + T.eq(bag.screen, "menu", "pocket back returns to categories") + if native then + button(game, bag, "b") + else + T.check(submit(ui, 5, "back"), "battle bag cancel") + end + T.check(bag.press ~= nil, "cancel animation pending") + tickUntil(game, bag, function() + return bag.press == nil + end) + traces[#traces + 1] = { + depth = #game.stack.states, + count = game.save.inventory.POTION, + page = bag.screen, + closed = bag.closed, + } + end + T.same(traces[1], traces[2], version .. " battle bag matches native A/B") +end + +-- opts.auto/stay/choice govern the final page, not the manual paragraph +-- breaks before it. Do not strand a question that spans several pages. +for _, opts in ipairs({ { auto = {} }, { stay = {} }, { choice = function() end } }) do + local ui, game = boot("crystal") + local box = TextBox.new(game, "First\fLast", nil, opts) + game.stack:push(box) + tickUntil(game, box, function() + return box.waiting and (box.preWait or 0) <= 0 + end) + T.eq(ui:snapshot().canAdvance, true, "script tail still allows manual intermediate page") + T.check(submit(ui, 1, "advance"), "intermediate question/automatic/stay page advances") + tickUntil(game, box, function() + return box.done + end) + T.eq(ui:snapshot().canAdvance, false, "final script-owned page stays protected") +end +-- Wrong Game, unknown overlays, script-owned bags and pending controllers. +do + local ui, game = boot("platinum") + local bag = require("src.ui.Gen4BagMenu").new(game, {}) + game.stack:push(bag) + for _, key in ipairs({ "moving", "trash", "script", "noInput" }) do + bag[key] = true + local snap = ui:snapshot() + T.eq(snap.locked, true, "busy bag locked") + refusal(ui, game, { id = 1, revision = snap.revision, kind = "back" }, "busy bag refusal") + bag[key] = nil + end + bag.battle = { demo = true } + local snap = ui:snapshot() + refusal(ui, game, { id = 1, revision = snap.revision, kind = "back" }, "battle tutorial owns bag") + bag.battle = nil + bag.game = {} + T.eq(ui:snapshot(), nil, "wrong game screen unavailable") +end +-- Existing helpers remain usable with their original dot-call contract. +do + local ui, _, other = boot("red") + local rows = { { label = "a" }, { label = "c" } } + ui.insertBefore(rows, "c", { label = "b" }) + T.eq(rows[2].label, "b", "legacy builder still works") + T.eq(rawget(other, "snapshot") ~= nil, true, "each mod owns its facade methods") +end + +-- API 1 gets the same additive surface without changing its content format. +for _, version in ipairs({ "red", "emerald", "black" }) do + local ui, game, _, loader = boot(version, 1) + game.stack:push(ChoiceBox.new(game, function() end)) + T.eq(ui:snapshot().kind, "choice", "API 1 UI reader") + T.check(submit(ui, 1, "back"), "API 1 UI action") + if loader.apis.probe.supports then + T.eq(loader.apis.probe:supports("ui", "snapshot"), true, "optional reader capability") + T.eq(loader.apis.probe:supports("ui", "submit"), true, "optional action capability") + T.eq(loader.apis.probe:supports("ui", "unknown"), false, "unknown capability declined") + end +end +-- A real battle opens its picker via mod.battle:submit. Targeting the picker +-- consumes the current UI context, then native callbacks resume the battle. +do + local ui, game, _, loader = boot("crystal") + local BattleState = require("src.battle.BattleState") + local b = BattleState.newWild(game, "FIXMON_C", 10) + b:syncSides() + b.phase, b.queue, b.menuIndex = "menu", {}, 1 + game.stack:push(b) + local api = loader.apis.probe.battle + local snapshot = api:snapshot() + T.check( + api:submit({ id = 1, revision = snapshot.revision, kind = "menu", choice = "party" }), + "battle opens native party UI" + ) + for _ = 1, 100 do + if game.stack:top() ~= b then + break + end + b:updateQueue() + end + T.eq(ui:snapshot().kind, "party", "battle picker exposed by UI facade") + local oldBattle = api:snapshot() + T.check(submit(ui, 1, "select", { index = 2 }), "choose switching party slot") + T.eq(ui:snapshot().kind, "party_actions", "native battle switch submenu") + local switch + for i, row in ipairs(ui:snapshot().rows) do + if row.action == "battle_switch" then + switch = i + end + end + T.check(submit(ui, 2, "select", { index = switch }), "native switch callback dispatched") + T.eq(game.stack:top(), b, "native battle resumes") + T.check(#b.queue > 0, "native switch queues battle work") + T.eq( + api:submit({ id = 2, revision = oldBattle.revision, kind = "menu", choice = "fight" }), + nil, + "UI work expires battle context" + ) +end + +-- Link/temporary party arrays can differ from save.party. Track the actual +-- native target, including changes made while its action submenu is open. +do + local ui, game = boot("crystal") + local copy = { Pokemon.new(game.data, "FIXMON_C", 15), Pokemon.new(game.data, "FIXMON_B", 12) } + local party = require("src.ui.PartyMenu").new(game, { party = copy }) + game.stack:push(party) + T.check(submit(ui, 1, "select", { index = 1 }), "open temporary party submenu") + local snapshot = ui:snapshot() + copy[1].hp = copy[1].hp - 1 + T.eq( + refusal( + ui, + game, + { id = 2, revision = snapshot.revision, kind = "back" }, + "temporary party HP invalidates submenu" + ), + "stale UI context", + "actual native party tracked" + ) + snapshot = ui:snapshot() + local replacement = {} + for k, v in pairs(copy[1]) do + replacement[k] = v + end + copy[1] = replacement + refusal( + ui, + game, + { id = 2, revision = snapshot.revision, kind = "back" }, + "temporary party object identity tracked" + ) +end +GameVersion.set(original) +T.finish("UI actions") diff --git a/tests/engine/unified_content.lua b/tests/engine/unified_content.lua new file mode 100644 index 000000000..b7695caa9 --- /dev/null +++ b/tests/engine/unified_content.lua @@ -0,0 +1,470 @@ +-- Synthetic extractor-shaped datasets; all mod changes go through Loader. +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.harness") +local Loader = require("src.mods.Loader") +local Version = require("src.core.GameVersion") +local Manifest = require("src.mods.Manifest") +local Merge = require("src.mods.Merge") +local DELETE = Merge.DELETE +local Registry = require("src.mods.Registry") +local Runtime = require("src.mods.Runtime") +local original = Version.get() +local function raises(fn, fragment) + return T.raises(fn, fragment, "rejects " .. fragment) +end +local versions = { "red", "crystal", "emerald", "platinum", "black" } +local function fixture(gen) + local ds = gen >= 4 + local keys = { mon = ds and 25 or gen == 2 and "SPECIES_025" or "PIKACHU", + into = ds and 26 or gen == 2 and "SPECIES_026" or "RAICHU", + move = ds and 33 or gen == 2 and "MOVE_033" or "TACKLE", + item = ds and 17 or gen == 2 and "ITEM_017" or "POTION", + trainer = ds and 7 or "OPP_PROBE" } + local stats = { hp = 35, attack = 55, defense = 40, speed = 90, special = 50 } + if gen > 1 then stats.spatk, stats.spdef = 50, 60 end + if ds then stats.spAttack, stats.spDefense = 50, 60 end + local mon = { id = keys.mon, name = "Pikachu", dex = 25, types = { "NORMAL" }, + baseStats = stats, catchRate = 190, baseExp = 82, growthRate = "MEDIUM_FAST", + level1Moves = { keys.move }, learnset = { { level = 9, move = keys.move } }, tmhm = { keys.move }, + evolutions = { ds and { method = "level", target = keys.into, param = 20, methodId = 4, paramKind = "level" } + or { method = "LEVEL", species = keys.into, level = 20 } }, + spriteFront = "synthetic-front.png", spriteBack = "synthetic-back.png", frontSize = 7, + customNative = { sentinel = "retained" } } + if gen >= 3 then mon.evYield = { speed = 2 }; mon.abilities = { "STATIC" } end + local into = Merge.deepCopy(mon); into.id, into.name, into.dex, into.evolutions = keys.into, "Raichu", 26, {} + local move = { id = keys.move, name = "Tackle", type = "NORMAL", power = 35, accuracy = 95, pp = 35, + effect = "NO_ADDITIONAL_EFFECT", priority = 0, untouchedTarget = 6 } + if ds then move.class, move.classId, move.typeId, move.gen4Effect = "physical", 0, 0, 0 end + local item = { id = keys.item, name = "Potion", price = 300, tossable = true, heldEffect = 27 } + local party = { { species = keys.mon, level = 12, item = gen > 1 and keys.item or nil, + moves = { keys.move }, customIV = 31 } } + local trainer = { id = keys.trainer, name = "Probe", parties = { party }, baseMoney = 10, aiMods = { 9 } } + if ds then trainer.party = party; trainer.nativeFlag = 17 end + local grass = { rate = 20, slots = { { species = keys.mon, level = 5 }, { species = keys.into, level = 7 } }, + buckets = { 128, 256 } } + if gen == 2 then + grass.byTime = { morn = Merge.deepCopy(grass), nite = Merge.deepCopy(grass) } + grass.byTime.nite.rate = 10 + elseif gen == 3 then + grass.rateMax, grass.bucketSpan, grass.buckets = 180, 100, { 50, 100 } + grass.slots[1].level, grass.slots[1].min, grass.slots[1].max = nil, 4, 6 + end + local data = { pokemon = { [keys.mon] = mon, [keys.into] = into }, moves = { [keys.move] = move }, + items = { [keys.item] = item }, trainers = { [keys.trainer] = trainer }, + encounters = { ROUTE_PROBE = { grass = grass } }, constants = { generation = gen }, audio = {} } + if ds then + data.items.ITEM_017 = item + data.encounters = { [1] = { grassRate = 20, grass = { { species = keys.mon, level = 5, chance = 100 } } } } + end + return data, keys +end +local function run(gen, body, format, extra, supplied, selected) + Version.set(selected or versions[gen]) + local data, keys = fixture(gen) + data = supplied or data + local mods = { probe = { entry = body, format = format or "unified" } } + for id, spec in pairs(extra or {}) do mods[id] = spec end + local loader + local fs = { + read = function(path) + local id = path:match("^mods/([^/]+)/manifest.json$") + local mod = id and mods[id] + if mod then return ('{"id":"%s","name":"Probe","version":"1.0.0","api":2,"entry":"main.lua","content_format":"%s","priority":%d}') + :format(id, mod.format or "unified", mod.priority or 0) end + end, + getDirectoryItems = function(path) + if path ~= "mods" then return {} end + local out = {}; for id in pairs(mods) do out[#out + 1] = id end; return out + end, + getInfo = function(path) + if path:match("main.lua$") or path:match("manifest.json$") then return { type = "file" } end + if path == "mods" or path:match("^mods/[^/]+$") then return { type = "directory" } end + end, + load = function(path) + local id = path:match("^mods/([^/]+)/main.lua$") + if mods[id] then return function(api) return mods[id].entry(api, loader) end end + end, + } + loader = Loader.new({ fs = fs, dev = false }) + loader._game = function() error("content must not load Game") end + loader:load(data) + return loader.apis.probe, loader, data, keys +end +local function ok(value, reason) assert(value, reason) end +local function ops(registry) local n = 0; for _, rows in pairs(registry.ops) do n = n + #rows end; return n end +local function dataOnly(v) + if type(v) == "function" then return "" end + if type(v) ~= "table" then return v end + local out = {}; for k, sub in pairs(v) do out[k] = dataOnly(sub) end; return out +end + +-- Native defaults, manifest rejection and registry numeric-ID confinement. +raises(function() Manifest.validate({ id = "bad", name = "bad", version = "1", entry = "main.lua", api = 1, + content_format = "unified" }, "mods/bad") end, "unified content requires mod API 2") +raises(function() Manifest.validate({ id = "bad", name = "bad", version = "1", entry = "main.lua", api = 2, + content_format = "unifed" }, "mods/bad") end, "content_format must be native or unified") +raises(function() Registry.new("legacy"):patch(25, {}) end, "id is required") + +local savedLove = love; love = nil +for gen = 1, 5 do + local api, loader, data, keys = run(gen, function(mod) + T.eq(mod.contentFormat, "unified", "manifest selects unified content in entry") + if mod.supports then + T.eq(mod:supports("content", "encounters"), gen <= 3, "host query follows selected content format") + T.eq(mod:supports("hooks", "battle.damage"), true, "other capability surfaces retain host query") + end + local pokemon = assert(mod.content.pokemon:get("PIKACHU")) + T.eq(pokemon.id, "PIKACHU", "canonical species identity") + T.eq(pokemon.level1Moves[1], "TACKLE", "canonical starting move") + T.eq(pokemon.evolutions[1].species, "RAICHU", "canonical evolution target") + T.eq(pokemon.baseStats.special, 50, "canonical Special") + T.eq(pokemon.baseStats.specialDefense, gen > 1 and 60 or nil, "split Special retained") + pokemon.baseStats.hp = 99 + pokemon.native.customNative.sentinel = "local" + T.eq(mod.content.pokemon:get("PIKACHU").baseStats.hp, 35, "read is detached") + local _, why = mod.content.pokemon:patch("PIKACHU", pokemon) + T.check(why and why:find("read%-only"), "native diagnostics cannot be written") + ok(mod.content.pokemon:patch("PIKACHU", { baseStats = { special = 70 } })) + ok(mod.content.moves:patch("TACKLE", { power = 45 })) + ok(mod.content.items:patch("POTION", { price = 100 })) + local trainerId = gen >= 4 and "7" or "OPP_PROBE" + local trainer = assert(mod.content.trainers:get(trainerId)) + T.eq(trainer.parties[1][1].species, "PIKACHU", "canonical trainer species") + T.eq(trainer.parties[1][1].moves[1], "TACKLE", "canonical trainer moves") + trainer.parties[1][1].level = 18 + ok(mod.content.trainers:patch(trainerId, { parties = trainer.parties })) + local _, whyMissing = mod.content.moves:patch("ABSENT", { power = 5 }) + T.check(whyMissing ~= nil, "absent patch declines with reason") + local encounter = mod.content.encounters + if gen <= 3 then + T.check(encounter:supports(), "GB/GBA encounter adapter available") + local row = assert(encounter:get("ROUTE_PROBE")) + row.grass.slots[1].species = "RAICHU" + ok(encounter:patch("ROUTE_PROBE", { grass = { slots = row.grass.slots } })) + else + local available, why = encounter:supports() + T.check(available == false and type(why) == "string", "DS encounters explicitly unsupported") + local result, err = encounter:patch("1", { grass = { rate = 10 } }) + T.check(result == nil and type(err) == "string", "DS encounter writes decline safely") + end + end) + T.eq(loader.mods.probe.state, "loaded", versions[gen] .. ": real entry/merge succeeds") + T.eq(#loader.errors, 0, versions[gen] .. ": no cross-reference errors") + T.eq(data.pokemon[keys.mon].baseStats.special, 70, "native Special alias updated") + T.eq(data.pokemon[keys.mon].baseStats.spdef, gen > 1 and 70 or nil, "native Special Defense updated") + T.eq(data.pokemon[keys.mon].customNative.sentinel, "retained", "native extensions survive") + T.eq(data.moves[keys.move].power, 45, "native move power updated") + T.eq(data.moves[keys.move].untouchedTarget, 6, "native move targeting preserved") + T.eq(data.items[keys.item].price, 100, "native item price updated") + T.eq(data.items[keys.item].heldEffect, 27, "native held effect preserved") + T.eq(data.trainers[keys.trainer].parties[1][1].level, 18, "native trainer party updated") + T.eq(data.trainers[keys.trainer].parties[1][1].customIV, 31, "native trainer metadata preserved") + T.eq(api.content.pokemon:get("PIKACHU").baseStats.special, 70, "read works after merge/freeze") + if gen >= 4 then + T.eq(data.items.ITEM_017.price, 100, "DS item string alias updated together with numeric ID") + T.eq(api.content.items:get("POTION").price, 100, "DS alias identity survives merge") + T.same(data.trainers[keys.trainer].party, data.trainers[keys.trainer].parties[1], "DS party aliases stay synchronized") + end + raises(function() api.content.moves:patch("TACKLE", { power = 99 }) end, "frozen") + local count = 0 + for id, row in api.content.items:each() do count = count + 1; T.eq(id, row.id, "iterator canonical ID") end + T.eq(count, 1, "iterator coalesces native aliases") +end +love = savedLove or require("tests.love_stub") + +-- No-op canonical round trips preserve every native field and native format. +for gen = 1, 5 do + local _, baseline = run(gen, function() end, "native") + local _, canonical, data = run(gen, function(mod) + local kinds = { "pokemon", "moves", "items", "trainers" } + if gen <= 3 then kinds[#kinds + 1] = "encounters" end + for _, kind in ipairs(kinds) do + for id, record in mod.content[kind]:each() do ok(mod.content[kind]:override(id, record)) end + end + end) + T.eq(canonical.mods.probe.state, "loaded", "round-trip load succeeds") + T.same(dataOnly(data), dataOnly(baseline.baseData), versions[gen] .. ": full native round-trip parity") +end + +-- Unsupported/malformed operations must append nothing and preserve input. +for gen = 1, 5 do + local _, loader = run(gen, function(mod) + local pokemon = mod.content.pokemon + local before = mod.content.pokemon:get("PIKACHU") + local invalid = { baseStats = { hp = 0 } } + raises(function() pokemon:patch("PIKACHU", invalid) end, "invalid") + T.eq(invalid.baseStats.hp, 0, "invalid caller payload untouched") + raises(function() pokemon:patch("PIKACHU", { base_stats = {} }) end, "unknown") + raises(function() pokemon:patch("PIKACHU", { baseStats = DELETE }) end, "required") + raises(function() pokemon:patch("PIKACHU", { learnset = { [2] = { level = 4, move = "TACKLE" } } }) end, "contiguous") + local cyclic = {}; cyclic.foo = cyclic + raises(function() pokemon:patch("PIKACHU", cyclic) end, "cyclic") + raises(function() mod.content.moves:patch("TACKLE", { power = 0 / 0 }) end, "non-finite") + local result, reason = pokemon:patch("PIKACHU", { level1Moves = { "MISSING" } }) + T.check(result == nil and reason:find("unknown moves ID", 1, true), "unknown cross-generation reference refuses") + T.same(pokemon:get("PIKACHU"), before, "failed operations leave effective record intact") + end) + T.eq(loader.mods.probe.state, "loaded", "caught refusals do not disable mod") + T.eq(ops(loader.content.pokemon), 0, "refused operations leave no Pokemon op log") +end + +-- Empty lists, append/prepend, DELETE and stacking with native mods. +local _, lists, data = run(2, function(mod) + ok(mod.content.pokemon:patch("PIKACHU", { learnset = {} })) + T.eq(#mod.content.pokemon:get("PIKACHU").learnset, 0, "empty list clears immediately") + ok(mod.content.pokemon:patch("PIKACHU", { learnset = { __append = { { level = 10, move = "TACKLE" } } } })) + ok(mod.content.pokemon:patch("PIKACHU", { learnset = { __prepend = { { level = 5, move = "TACKLE" } } } })) + ok(mod.content.pokemon:patch("PIKACHU", { tmhm = mod.DELETE })) +end, "unified", { + earlier = { format = "native", priority = -1, entry = function(mod) + mod.content.pokemon:patch("SPECIES_025", { baseStats = { attack = 80 } }) + end }, + later = { format = "native", priority = 1, entry = function(mod) + mod.content.pokemon:patch("SPECIES_025", { baseStats = { defense = 75 } }) + end }, +}) +T.eq(lists.mods.probe.state, "loaded", "list and native stacking load") +T.eq(data.pokemon.SPECIES_025.learnset[1].level, 5, "prepend before append") +T.eq(data.pokemon.SPECIES_025.learnset[2].level, 10, "append retains order") +T.eq(#data.pokemon.SPECIES_025.tmhm, 0, "DELETE materializes empty native list") +T.eq(data.pokemon.SPECIES_025.baseStats.attack, 80, "earlier native patch preserved") +T.eq(data.pokemon.SPECIES_025.baseStats.defense, 75, "later native patch preserved") + +-- An entry failure rolls every canonical/native alias operation back. +for gen = 1, 5 do + local _, loader, data, keys = run(gen, function(mod) + ok(mod.content.items:patch("POTION", { price = 1 })) + ok(mod.content.pokemon:patch("PIKACHU", { baseStats = { hp = 100 } })) + error("intentional entry failure") + end) + T.eq(loader.mods.probe.state, "failed", "entry failure attributed") + T.eq(data.items[keys.item].price, 300, "failed item edit rolled back") + T.eq(data.pokemon[keys.mon].baseStats.hp, 35, "failed species edit rolled back") + if gen >= 4 then T.eq(data.items.ITEM_017.price, 300, "failed DS alias edit rolled back") end +end + +-- Native consumers see the result, rather than merely passing schema checks. +local _, nativeLoader, data, keys = run(2, function(mod) + ok(mod.content.pokemon:patch("PIKACHU", { baseStats = { specialAttack = 90, specialDefense = 95 } })) + ok(mod.content.trainers:patch("OPP_PROBE", { parties = { { { species = "RAICHU", level = 18, moves = { "TACKLE" } } } } })) + ok(mod.content.encounters:patch("ROUTE_PROBE", { grass = { rate = 32, + slots = { { species = "RAICHU", level = 8 }, { species = "PIKACHU", level = 9 } } } })) +end) +T.eq(nativeLoader.mods.probe.state, "loaded", "consumer probe loads") +local Pokemon = require("src.pokemon.Pokemon") +local mon = Pokemon.new(data, keys.mon, 10, function(a) return a end) +T.check(mon.stats.spatk ~= nil and mon.stats.spdef ~= nil, "native Pokemon constructor keeps split stat model") +local Encounter = require("src.world.Encounter") +for _, time in ipairs({ "MORNING", "DAY", "NIGHT" }) do + local tableAtTime = Encounter.atTime(data.encounters.ROUTE_PROBE.grass, time) + local encountered = Encounter.rollTable(tableAtTime, function(a) return a end) + T.eq(encountered.species, keys.into, time .. ": native encounter chooses translated species") + T.eq(encountered.level, 8, time .. ": native encounter uses translated level") +end +local Battle = require("src.battle.BattleState") +local game = { data = data, save = { options = {}, player = {}, party = {} } } +local party = Battle.buildTrainerParty(game, keys.trainer, 1, data.trainers[keys.trainer].parties[1]) +T.eq(party[1].species, keys.into, "native trainer builder receives translated species") +T.eq(party[1].level, 18, "native trainer builder receives translated level") + +-- Every registered version selects the same format using its own host +-- generation. The facade captures that generation before entry execution. +for _, version in ipairs(Version.ORDER) do + local gen = Version.generation(version) + local _, loader, data, key = run(gen, function(mod) + T.eq(mod.gameVersion, version, "selected host identity: " .. version) + mod.generation, mod.host.generation = 99, 99 + Version.set("red") + ok(mod.content.moves:patch("TACKLE", { power = 42 })) + end, "unified", nil, nil, version) + T.eq(loader.mods.probe.state, "loaded", version .. ": captured host format works") + T.eq(data.moves[key.move].power, 42, version .. ": same portable patch reaches native data") +end + +-- Canonical IDs are based on this ROM's names, not official numeric IDs. +local hacked = fixture(2) +hacked.pokemon.SPECIES_777 = hacked.pokemon.SPECIES_025 +hacked.pokemon.SPECIES_025 = nil +local _, hackLoader, hackData = run(2, function(mod) + ok(mod.content.pokemon:patch("PIKACHU", { baseStats = { hp = 65 } })) +end, "unified", nil, hacked) +T.eq(hackLoader.mods.probe.state, "loaded", "relocated ROM-hack species ID resolves") +T.eq(hackData.pokemon.SPECIES_777.baseStats.hp, 65, "no hard-coded National Dex lookup") + +local ambiguous = fixture(2) +ambiguous.pokemon.SPECIES_999 = Merge.deepCopy(ambiguous.pokemon.SPECIES_025) +local _, ambiguity = run(2, function(mod) + local value, why = mod.content.pokemon:get("PIKACHU") + T.check(value == nil and why:find("ambiguous", 1, true), "ambiguous name has a diagnostic") + local changed, reason = mod.content.pokemon:patch("PIKACHU", { catchRate = 1 }) + T.check(changed == nil and reason:find("ambiguous", 1, true), "ambiguous writes cannot choose a record") + raises(function() mod.content.pokemon:each() end, "ambiguous") +end, "unified", nil, ambiguous) +T.eq(ops(ambiguity.content.pokemon), 0, "ambiguity leaves no ops") + +-- New string-key records can be registered without changing legacy schema +-- behavior. Existing numeric records can be patched, not allocated blindly. +for gen = 1, 5 do + local _, loader, data = run(gen, function(mod) + local new = { id = "CUSTOM_ITEM", name = "Custom item", price = 42 } + local saved = Merge.deepCopy(new) + local changed, why = mod.content.items:register("CUSTOM_ITEM", new) + if gen < 4 then + T.check(changed == true, "string-key item registration succeeds") + T.eq(mod.content.items:get("CUSTOM_ITEM").price, 42, "registration immediately readable") + raises(function() mod.content.items:register("CUSTOM_ITEM", new) end, "already registered") + else T.check(changed == nil and why:find("storage", 1, true), "DS allocation declines without a native slot") end + T.same(new, saved, "registration input remains detached") + local payload = { price = 123 } + ok(mod.content.items:patch("POTION", payload)) + payload.price = 999 + T.eq(mod.content.items:get("POTION").price, 123, "later caller mutation cannot edit op log") + local record = assert(mod.content.pokemon:get("PIKACHU")) + local changed, reason = mod.content.pokemon:patch("PIKACHU", { baseStats = { specialAttack = 99 } }) + if gen == 1 then + T.check(changed == nil and reason:find("split", 1, true), "Gen 1 split stat rejected atomically") + T.same(mod.content.pokemon:get("PIKACHU"), record, "rejected split retains native record") + else T.eq(changed, true, "later generation accepts split stat") end + raises(function() mod.content.pokemon:patch("PIKACHU", { types = mod.DELETE }) end, "cannot be deleted") + raises(function() mod.content.pokemon:patch("PIKACHU", { baseStats = { specialDefense = mod.DELETE } }) end, "cannot delete") + end) + T.eq(loader.mods.probe.state, "loaded", "registration/refusal cases survive load") + T.eq(data.items.CUSTOM_ITEM and data.items.CUSTOM_ITEM.price, gen < 4 and 42 or nil, "only supported registrations reach Data") +end + +-- Probability units are uniform (a fraction out of 256) and lossless. +local _, rates, rateData = run(3, function(mod) + local before = mod.content.encounters:get("ROUTE_PROBE") + T.eq(before.grass.rate, 20 * 256 / 180, "GBA rate is projected into common units") + local changed, why = mod.content.encounters:patch("ROUTE_PROBE", { grass = { rate = 31 } }) + T.check(changed == nil and why:find("representable", 1, true), "unrepresentable GBA rate rejected") + T.same(mod.content.encounters:get("ROUTE_PROBE"), before, "rejected rate leaves slots/weights intact") + ok(mod.content.encounters:patch("ROUTE_PROBE", { grass = { rate = 30 * 256 / 180 } })) +end) +T.eq(rateData.encounters.ROUTE_PROBE.grass.rate, 30, "representable rate maps exactly to native span") + +-- Native DS parser output is edited through the same facade. Its class +-- selector and numeric evolution target remain consistent for real consumers. +local parsed = fixture(4) +local bytes = { 0, 0, 0, 35, 0, 95, 35, 0, 0, 0, 0, 0, 0, 0, 0, 0 } +local chars = {}; for i, byte in ipairs(bytes) do chars[i] = string.char(byte) end +parsed.moves[33] = assert(require("src.import.Gen4Moves").parse(table.concat(chars))) +parsed.moves[33].id, parsed.moves[33].name = 33, "Tackle" +local _, parsedLoader, parsedData = run(4, function(mod) + ok(mod.content.moves:patch("TACKLE", { category = "special", power = 50 })) + ok(mod.content.pokemon:patch("PIKACHU", { evolutions = { { method = "LEVEL", level = 15, species = "RAICHU" } } })) +end, "unified", nil, parsed) +T.eq(parsedLoader.mods.probe.state, "loaded", "native parser-produced data accepts canonical edits") +T.eq(parsedData.moves[33].class, "special", "native DS class updated") +T.eq(parsedData.moves[33].classId, 1, "native DS class selector updated") +local Evolution = require("src.pokemon.Evolution") +local target = Evolution.pendingFor({ data = parsedData, save = { options = {} } }, + { species = 25, level = 15 }, { kind = "levelup" }) +T.eq(target, 26, "native evolution consumer follows translated numeric target") + +-- Removing a target retains post-merge reference checks, including numeric +-- trainer references and the otherwise hidden Gen 2 night slot table. +for _, gen in ipairs({ 2, 4 }) do + local _, loader = run(gen, function(mod) ok(mod.content.pokemon:remove("RAICHU")) end) + T.check(#loader.errors > 0, "removed native evolution/encounter target is diagnosed") + T.check(table.concat(loader.errors, "\n"):find("probe", 1, true) ~= nil, "reference failure attributed to removing mod") +end +local _, removal, removedData = run(4, function(mod) + local trainer = mod.content.trainers:get("7") + trainer.parties[1][1].item = nil + ok(mod.content.trainers:patch("7", { parties = trainer.parties })) + ok(mod.content.items:remove("POTION")) + T.eq(mod.content.items:has("POTION"), false, "tombstone immediately hidden in canonical view") +end) +T.eq(#removal.errors, 0, "valid alias removal has no dangling reference") +T.check(removedData.items[17] == nil and removedData.items.ITEM_017 == nil, "numeric/string aliases tombstoned together") + +-- Preferences must still choose original patch operations, not snapshots of +-- earlier mods' changes captured into an adapter's replacement record. +local _, arbitration, arbitrationData = run(2, function(mod, loader) + ok(mod.content.pokemon:patch("PIKACHU", { baseStats = { hp = 75 } })) + loader.content.pokemon:preferOwner("SPECIES_025", "probe") +end, "unified", { + earlier = { format = "native", priority = -1, entry = function(mod) + mod.content.pokemon:patch("SPECIES_025", { baseStats = { attack = 80 } }) + end }, +}) +T.eq(arbitrationData.pokemon.SPECIES_025.baseStats.hp, 75, "preferred canonical patch remains") +T.eq(arbitrationData.pokemon.SPECIES_025.baseStats.attack, 55, "set-aside native patch does not leak through a snapshot") + +-- The extractor keeps abbreviated GBA type keys, not the Gen 1 keys. +local types = fixture(3) +types.type_chart = { types = { NORMAL = { category = "physical" }, PSYCHC = { category = "special" } }, matchups = {} } +types.moves.TACKLE.type = "PSYCHC" +local _, typeLoader, typeData = run(3, function(mod) + T.eq(mod.content.moves:get("TACKLE").type, "PSYCHIC_TYPE", "abbreviated GBA type is canonical on read") + ok(mod.content.moves:patch("TACKLE", { type = "PSYCHIC_TYPE" })) + ok(mod.content.pokemon:patch("PIKACHU", { types = { "PSYCHIC_TYPE" } })) +end, "unified", nil, types) +T.eq(#typeLoader.errors, 0, "canonical type resolves to actual native registry") +T.eq(typeData.moves.TACKLE.type, "PSYCHC", "native abbreviated move type retained") +T.eq(typeData.pokemon.PIKACHU.types[1], "PSYCHC", "species type references translated") + +-- A pre-existing unmapped native move selector must not become a dangling +-- registry ID merely because a balance mod changes its power. +local opaque = fixture(4) +opaque.moves[33].effect, opaque.moves[33].gen4Effect = 999, 999 +local _, opaqueLoader, opaqueData = run(4, function(mod) + local record = mod.content.moves:get("TACKLE") + T.check(record.effect == nil and record.native.effect == 999, "opaque selector exposed only in native diagnostics") + ok(mod.content.moves:patch("TACKLE", { power = 65 })) +end, "unified", nil, opaque) +T.eq(#opaqueLoader.errors, 0, "untouched numeric effect selector has no false reference error") +T.eq(opaqueData.moves[33].gen4Effect, 999, "opaque native selector survives patch") + +-- Default/explicit native mode continues to expose live native records. +local default = Manifest.validate({ id = "native", name = "Native", version = "1", entry = "main.lua", api = 2 }, "mods/native") +T.eq(default.content_format, "native", "omitted format remains native") +local _, native = run(2, function(mod) + T.eq(mod.contentFormat, "native", "native manifest uses original facade") + T.check(mod.content.pokemon:get("PIKACHU") == nil, "native facade does not invent aliases") + mod.content.pokemon:patch("SPECIES_025", { baseStats = { attack = 99 } }) +end, "native") +T.eq(native.baseData.pokemon.SPECIES_025.baseStats.attack, 99, "native patch still has original behavior") + +-- Full read/modify/write of one time must not flatten the others to DAY. +local timeData = fixture(2) +timeData.encounters.ROUTE_PROBE.grass.byTime.morn.rate = 5 +timeData.encounters.ROUTE_PROBE.grass.byTime.morn.slots[1].level = 2 +local _, timeLoader, editedTimes = run(2, function(mod) + local record = mod.content.encounters:get("ROUTE_PROBE") + record.grass.times.NITE.slots[1].level = 11 + ok(mod.content.encounters:override("ROUTE_PROBE", record)) +end, "unified", nil, timeData) +T.eq(timeLoader.mods.probe.state, "loaded", "time-specific detached edit succeeds") +T.eq(editedTimes.encounters.ROUTE_PROBE.grass.rate, 20, "time edit preserves day rate") +T.eq(editedTimes.encounters.ROUTE_PROBE.grass.byTime.morn.rate, 5, "time edit preserves distinct morning rate") +T.eq(editedTimes.encounters.ROUTE_PROBE.grass.byTime.morn.slots[1].level, 2, "time edit preserves distinct morning slots") +T.eq(editedTimes.encounters.ROUTE_PROBE.grass.byTime.nite.slots[1].level, 11, "time edit changes only requested night slot") + +local _, mixedTimeLoader, mixedTimeData = run(2, function(mod) + local record = mod.content.encounters:get("ROUTE_PROBE") + record.grass.rate = 30 + record.grass.times.NITE.slots[1].level = 12 + ok(mod.content.encounters:override("ROUTE_PROBE", record)) +end) +T.eq(mixedTimeData.encounters.ROUTE_PROBE.grass.byTime.nite.rate, 30, "inherited night rate cannot undo shared edit") +T.eq(mixedTimeData.encounters.ROUTE_PROBE.grass.byTime.nite.slots[1].level, 12, "explicit night slot edit survives shared rate") + +local _, conflictingStats = run(2, function(mod) + raises(function() mod.content.pokemon:patch("PIKACHU", { baseStats = { special = 50, specialAttack = 80 } }) end, "conflicting") +end) +T.eq(ops(conflictingStats.content.pokemon), 0, "conflicting stat aliases leave no ops") + +-- The unsupported DS writer must not disable native reference auditing. +local seasonal = fixture(5) +seasonal.encounters[1].seasons = { winter = { grass = { { species = 26, level = 8, chance = 100 } } } } +local _, seasonalLoader = run(5, function(mod) + ok(mod.content.pokemon:patch("PIKACHU", { evolutions = {} })) + ok(mod.content.pokemon:remove("RAICHU")) +end, "unified", nil, seasonal) +T.check(table.concat(seasonalLoader.errors, "\n"):find("seasons.winter.grass", 1, true) ~= nil, + "DS seasonal encounter target is included in removal diagnostics") + +Version.set(original) +T.finish("unified content") diff --git a/tests/engine/unified_mod_callbacks.lua b/tests/engine/unified_mod_callbacks.lua new file mode 100644 index 000000000..a4059d663 --- /dev/null +++ b/tests/engine/unified_mod_callbacks.lua @@ -0,0 +1,634 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.harness") +local Loader = require("src.mods.Loader") +local Manifest = require("src.mods.Manifest") +local Version = require("src.core.GameVersion") +local Runtime = require("src.mods.Runtime") +local Catalog = require("tests.modkit.catalog") +local C = require("src.mods.UnifiedCallbacks") +local saved = { Version.get(), Runtime.events, Runtime.hooks, Runtime.errors, love } +love = nil +local function manifest(format, api) + return { + id = "probe", + name = "Probe", + version = "1.0.0", + entry = "main.lua", + api = api or 2, + callback_format = format, + } +end +T.eq(Manifest.validate(manifest()).callback_format, "native", "existing manifests keep native callbacks") +T.eq(Manifest.validate(manifest("unified")).callback_format, "unified", "opt-in accepted") +T.raises(function() + Manifest.validate(manifest("unified", 1)) +end, "requires mod API 2", "unified requires API 2") +T.raises(function() + Manifest.validate(manifest("other")) +end, "callback_format", "unknown format rejected") +for _, invalid in ipairs({ false, 42, {} }) do + T.raises(function() + Manifest.validate(manifest(invalid)) + end, "callback_format", "malformed format rejected") +end +local function set(names) + local out = {} + for _, n in ipairs(names) do + out[n] = true + end + return out +end +T.same(C.hooks, set(Catalog.hooks()), "every native hook has an explicit boundary decision") +T.same(C.events, set(Catalog.events()), "every native event has an explicit boundary decision") +local function boot(version, format, entry) + Version.set(version) + local numeric = Version.generation() >= 4 + local a, b, m, n, item = + numeric and 501 or "ALPHA", + numeric and 502 or "BETA", + numeric and 601 or "TACKLE", + numeric and 602 or "EMBER", + numeric and 701 or "POTION" + local data = { + pokemon = { + [a] = { + id = a, + name = "Alpha", + baseStats = { hp = 40, attack = 45, defense = 40, speed = 30, special = 35 }, + types = { + "NORMAL", + }, + }, + [b] = { id = b, name = "Beta", baseStats = { hp = 50 }, types = { "NORMAL" } }, + }, + moves = { + [m] = { id = m, name = "Tackle", pp = 35, power = 40 }, + [n] = { + id = n, + name = "Ember", + pp = 25, + power = 40, + }, + }, + items = { [item] = { id = item, name = "Potion" } }, + trainers = {}, + encounters = {}, + } + if numeric then + data.pokemon.SPECIES_501 = data.pokemon[a] + data.moves.MOVE_601 = data.moves[m] + end + local json = require("src.link.Json") + local files = { + ["mods/probe/manifest.json"] = json.encode(manifest(format)), + ["mods/probe/main.lua"] = entry or "return function(mod) mod.exports.api=mod end", + } + local fs = { + read = function(p) + return files[p] + end, + load = function(p) + return loadstring(files[p], p) + end, + getInfo = function(p) + if p == "mods" or p == "mods/probe" then + return { type = "directory" } + end + if files[p] then + return { type = "file" } + end + end, + getDirectoryItems = function(p) + return p == "mods" and { "probe" } or {} + end, + } + local loader = Loader.new({ fs = fs, dev = false }) + loader._game = function() + error("callback opt-in must not construct Game") + end + local loaded = loader:load(data) + if not entry then + assert(loaded) + assert(loader.apis.probe, tostring(loader.errors.probe)) + end + return loader.apis.probe, loader, data, { a, b, m, n, item } +end +local function mon(ids) + return { + species = ids[1], + item = ids[5], + level = 10, + hp = 25, + status = "PSN", + stats = { hp = 40, attack = 21, defense = 22, speed = 23, special = 24, spatk = 24, spdef = 25 }, + moves = { { id = ids[3], pp = 12 } }, + ivs = { hp = 17 }, + personality = 123, + custom = { keep = true }, + } +end +for _, version in ipairs(Version.ORDER) do + local api, loader, data, ids = boot(version, "unified") + local label = version .. ": " + T.eq(api.callbackFormat, "unified", label .. "format exposed before Game construction") + local raw = mon(ids) + if Version.generation() == 1 then + raw.stats.spatk = nil + raw.stats.spdef = nil + end + local unchanged = require("src.mods.Merge").deepCopy(raw) + local noop = api.hooks:wrap("battle.damage", function(next) + return next() + end) + loader.hooks:call("battle.damage", function() + return 5 + end, { user = { mon = raw } }) + T.same(raw, unchanged, label .. "no-op unified callback leaves native record exactly unchanged") + T.eq(raw.isEgg, nil, label .. "false view flag does not materialize native field") + noop() + local originalStats, originalMoves, originalSlot = raw.stats, raw.moves, raw.moves[1] + local battler = + { mon = raw, isPlayer = true, curStats = raw.stats, curMoves = raw.moves, stages = { attack = 0 } } + local battle = { tag = "native handle" } + local count = 0 + local off = api.hooks:wrap("battle.damage", function(next, ctx) + T.eq(ctx.battle, battle, label .. "battle stays native") + T.eq(ctx.user.mon, ctx.target.mon, label .. "shared mon preserves public alias") + T.eq(ctx.user.mon.species, "ALPHA", label .. "canonical species") + T.eq(ctx.user.mon.item, "POTION", label .. "canonical held item") + T.eq(ctx.user.mon.moves[1].id, "TACKLE", label .. "canonical moves") + T.eq(ctx.user.mon.status, "poison", label .. "canonical status") + T.eq(ctx.user.mon.maxHp, 40, label .. "maxHp reads stats.hp") + T.eq(ctx.user.mon.stats.specialAttack, 24, label .. "canonical special attack") + ctx.user.mon.hp = 24 + ctx.user.mon.status = "burn" + ctx.user.mon.nickname = "Test" + ctx.user.mon.stats.attack = 26 + ctx.user.mon.moves[1].id = "EMBER" + ctx.user.mon.moves[1].pp = 20 + local n, hole, last = next() + T.eq(ctx.user.mon.hp, 19, label .. "native downstream changes refreshed") + T.eq(select("#", next()), 3, label .. "cached next retains nil holes") + ctx.user.mon.hp = 18 + return n + 1, hole, last + end) + local n, hole, last = loader.hooks:call("battle.damage", function(ctx) + count = count + 1 + T.eq(ctx.user, battler, label .. "native battler identity restored") + T.eq(ctx.user.mon, raw, label .. "native mon identity restored") + T.eq(raw.hp, 24, label .. "pre-next mutation committed") + T.eq(raw.status, "BRN", label .. "status written in native vocabulary") + T.eq(raw.moves[1].id, ids[4], label .. "move ID restored for host") + raw.hp = 19 + return 5, nil, "tail" + end, { battle = battle, user = battler, target = battler, move = data.moves[ids[3]] }) + T.eq(n, 6, label .. "scalar result postprocessing") + T.eq(last, "tail", label .. "result tail preserved") + T.eq(hole, nil, label .. "nil preserved") + T.eq(count, 1, label .. "vanilla runs once") + T.eq(raw.hp, 18, label .. "post-next edit committed") + T.eq(raw.stats, originalStats, label .. "stats table identity preserved") + T.eq(raw.moves, originalMoves, label .. "moves list identity preserved") + T.eq(raw.moves[1], originalSlot, label .. "move slot identity preserved") + T.eq(raw.personality, 123, label .. "hidden personality retained") + T.eq(raw.ivs.hp, 17, label .. "hidden IVs retained") + off() + + -- Mutations across separate bound records preflight as one transaction. + local other = mon(ids) + off = api.hooks:wrap("battle.damage", function(_, ctx) + ctx.user.mon.hp = 4 + ctx.target.mon.level = 101 + return 99 + end) + count = 0 + T.eq( + loader.hooks:call("battle.damage", function() + count = count + 1 + return 3 + end, { user = battler, target = { mon = other } }), + 3, + label .. "invalid edit skips wrapper" + ) + T.eq(raw.hp, 18, label .. "earlier record edit rolled back") + T.eq(other.level, 10, label .. "invalid record unchanged") + T.eq(count, 1, label .. "failure before next falls through once") + off() + off = api.hooks:wrap("battle.damage", function(next, ctx) + local n = next() + ctx.user.mon.hp = -1 + return n + 20 + end) + count = 0 + T.eq( + loader.hooks:call("battle.damage", function() + count = count + 1 + raw.hp = 16 + return 7 + end, { user = battler }), + 7, + label .. "bad post-next edit keeps downstream result" + ) + T.eq(raw.hp, 16, label .. "downstream mutation retained") + T.eq(count, 1, label .. "bad post-next never retries") + off() + off = api.hooks:wrap("battle.damage", function(next, ctx) + next() + ctx.user.mon.hp = 2 + error("after next") + end) + T.eq( + loader.hooks:call("battle.damage", function() + return 8 + end, { user = battler }), + 8, + label .. "throw after next preserves result" + ) + T.eq(raw.hp, 16, label .. "throw after next discards late edits") + off() + + local sentinel = { error = "vanilla" } + off = api.hooks:wrap("battle.damage", function(next) + pcall(next) + pcall(next) + return 999 + end) + count = 0 + local ok, err = pcall(function() + loader.hooks:call("battle.damage", function() + count = count + 1 + error(sentinel) + end, { user = battler }) + end) + T.check(not ok and err == sentinel, label .. "vanilla error identity survives swallowed next failure") + T.eq(count, 1, label .. "throwing vanilla never replayed") + off() + off = api.hooks:wrap("battle.damage", function(next) + pcall(next) + return 999 + end) + local savedMoves = raw.moves + local okBroken, errBroken = pcall(function() + loader.hooks:call("battle.damage", function() + raw.moves = 42 -- a failed native operation can leave an unreadable record + error(sentinel) + end, { user = battler }) + end) + T.check(not okBroken and errBroken == sentinel, label .. "refresh cannot mask a native error") + raw.moves = savedMoves + off() + + -- Event records are detached. The native handles deliberately remain handles. + local seen = 0 + off = api.events:once("battle.exp_gained", function(payload) + seen = seen + 1 + T.eq(payload.mon.species, "ALPHA", label .. "event mon normalized") + payload.mon.hp = 1 + payload.mon.native.ivs.hp = 1 + end) + loader.events:emit("battle.exp_gained", { mon = raw, battle = battle, gained = 20 }) + loader.events:emit("battle.exp_gained", { mon = raw, battle = battle, gained = 20 }) + T.eq(seen, 1, label .. "once retained") + T.eq(raw.hp, 16, label .. "event mutation detached") + T.eq(raw.ivs.hp, 17, label .. "native diagnostic snapshot detached") + off() + local gift = { species = ids[1], level = 5, ctx = { opaque = true } } + off = api.events:on("pokemon.before_give", function(p) + p.species = "BETA" + p.level = 15 + p.nickname = "Gift" + end) + loader.events:emit("pokemon.before_give", gift) + T.eq(gift.species, ids[2], label .. "gift ID restored") + T.eq(gift.level, 15, label .. "gift level committed") + T.eq(gift.nickname, "Gift", label .. "gift nickname committed") + off() + off = api.events:on("pokemon.before_give", function(p) + p.species = "ALPHA" + p.level = 101 + end) + loader.events:emit("pokemon.before_give", gift) + T.eq(gift.species, ids[2], label .. "invalid gift atomic species") + T.eq(gift.level, 15, label .. "invalid gift atomic level") + off() + + off = api.hooks:wrap("battle.turn_order", function(next, p, pm, e, em, ctx) + T.eq(pm.id, "TACKLE", label .. "move definition canonical ID") + T.eq(next(), p, label .. "native result maps to original public battler") + return e + end) + local enemy = { mon = other, isPlayer = false } + T.eq( + loader.hooks:call("battle.turn_order", function(p) + return p + end, battler, data.moves[ids[3]], enemy, data.moves[ids[4]], {}), + enemy, + label .. "selected battler restores identity" + ) + off() + off = api.hooks:wrap("encounter.species", function(next, enc) + enc.species = "BETA" + enc.level = 20 + return next() + end) + local encounter = { species = ids[1], level = 10, form = 2 } + T.eq( + loader.hooks:call("encounter.species", function(enc) + return enc + end, encounter, {}), + encounter, + label .. "encounter identity retained" + ) + T.eq(encounter.species, ids[2], label .. "encounter species converted") + T.eq(encounter.form, 2, label .. "encounter hidden field retained") + off() + off = api.hooks:wrap("trainer.party", function(next, cl, index, party) + T.eq(party[1].species, "ALPHA", label .. "trainer slot canonical species") + T.eq(party[1].moves[1], "TACKLE", label .. "trainer slot moves are IDs") + party[1].species = "BETA" + party[1].moves = { "EMBER" } + party[1].level = 20 + return next() + end) + local party = { { species = ids[1], level = 10, moves = { ids[3] }, dvs = { attack = 15 } } } + T.eq( + loader.hooks:call("trainer.party", function(_, _, p) + return p + end, "CLASS", 1, party), + party, + label .. "trainer party native identity" + ) + T.eq(party[1].species, ids[2], label .. "trainer species restored") + T.eq(party[1].moves[1], ids[4], label .. "trainer move restored") + T.eq(party[1].dvs.attack, 15, label .. "trainer hidden DVs retained") + off() + + count = 0 + off = api.hooks:wrap("battle.exp_award", function(_, ctx) + local p = ctx.alive[1] + p.hp = 15 + ctx.applyShare(p, 2, false) + T.eq(p.level, 11, label .. "applyShare refreshes public mon") + p.hp = 14 + end) + loader.hooks:call("battle.exp_award", function() + error("wrapper replaces vanilla") + end, { + battle = battle, + alive = { raw }, + applyShare = function(p, divisor, announce) + count = count + 1 + T.eq(p, raw, label .. "helper gets native identity") + T.eq(p.hp, 15, label .. "helper sees pending edits") + T.eq(divisor, 2, label .. "helper scalar args") + T.eq(announce, false, label .. "helper false arg") + p.level = 11 + end, + }) + T.eq(count, 1, label .. "helper called once") + T.eq(raw.hp, 14, label .. "helper postprocessing committed") + off() + + off = api.hooks:wrap("fieldmove.eligibility", function(next, id) + T.eq(id, "TACKLE", label .. "field move argument normalized") + local p = next() + T.eq(p.species, "ALPHA", label .. "field move result normalized") + return p + end) + T.eq( + loader.hooks:call("fieldmove.eligibility", function(id) + T.eq(id, ids[3], label .. "field move argument restored") + return raw + end, ids[3], {}), + raw, + label .. "field move mon result identity restored" + ) + off() + off = api.hooks:wrap("battle.enemy_action", function(next) + local action = next() + T.eq(action.id, "TACKLE", label .. "enemy action normalized") + action.id = "EMBER" + return action + end) + local action = { id = ids[3], index = 1 } + T.eq( + loader.hooks:call("battle.enemy_action", function() + return action + end, battle, battler), + action, + label .. "enemy action identity restored" + ) + T.eq(action.id, ids[4], label .. "enemy action ID restored") + off() + off = api.hooks:wrap("encounter.roll", function() + return { species = "BETA", level = 30 } + end) + local enc = loader.hooks:call("encounter.roll", function() + error("suppressed") + end, nil, {}) + T.eq(enc.species, ids[2], label .. "new encounter result native species") + T.eq(enc.level, 30, label .. "new encounter level") + off() + off = api.hooks:wrap("encounter.roll", function() + return { species = "MISSING", level = 30 } + end) + T.eq( + loader.hooks:call("encounter.roll", function() + return "fallback" + end, nil, {}), + "fallback", + label .. "unknown species rejected" + ) + off() + off = api.hooks:wrap("battle.damage", function(_, ctx) + ctx.move.power = 999 + return 88 + end) + T.eq( + loader.hooks:call("battle.damage", function() + return 4 + end, { move = data.moves[ids[3]] }), + 4, + label .. "definition mutation rejected" + ) + T.eq(data.moves[ids[3]].power, 40, label .. "definition unchanged") + off() + off = api.hooks:wrap("battle.damage", function(next, ctx) + ctx.user.mon.maxHp = 50 + ctx.user.mon.stats.specialAttack = 28 + next() + return 0 + end) + loader.hooks:call("battle.damage", function() + T.eq(raw.stats.hp, 50, label .. "maxHp writes native stats.hp") + T.eq(raw.stats.special, 28, label .. "special alias kept") + T.eq( + Version.generation() == 1 and raw.stats.special or raw.stats.spatk, + 28, + label .. "split special stat writes correctly" + ) + end, { user = battler }) + off() + off = api.hooks:wrap("battle.damage", function(_, ctx) + ctx.user.mon.native.ivs.hp = 9 + ctx.user.mon.hp = 2 + return 88 + end) + T.eq( + loader.hooks:call("battle.damage", function() + return 3 + end, { user = battler }), + 3, + label .. "native snapshot mutation rejected" + ) + T.eq(raw.hp, 14, label .. "snapshot rejection atomic") + off() + local nativeCalls = 0 + local undoNative = loader.hooks:wrap("battle.damage", function(next, ctx) + nativeCalls = nativeCalls + 1 + T.eq(ctx.user, battler, label .. "native mod sees native battler") + return next() + end, -5, "native") + off = api.hooks:wrap("battle.damage", function(next, ctx) + -- Explicit argument packs accept bound records inside a bound context. + ctx.extra = 123 + return next(ctx) + end, 5) + loader.hooks:call("battle.damage", function(ctx) + T.eq(ctx.extra, 123, label .. "explicit next args committed") + end, { user = battler }) + T.eq(nativeCalls, 1, label .. "mixed native/unified chain") + off() + undoNative() + local order = {} + local a = api.events:on("battle.started", function() + order[#order + 1] = "low" + end, -1) + local b = api.events:on("battle.started", function() + order[#order + 1] = "high" + end, 1) + loader.events:emit("battle.started", {}) + T.same(order, { "high", "low" }, label .. "event priority retained") + a() + b() + local actionView + off = api.events:on("battle.turn_started", function(p) + actionView = p.playerAction + end) + loader.events:emit("battle.turn_started", { playerAction = { id = ids[3], index = 1 } }) + T.eq(actionView.id, "TACKLE", label .. "turn event action ID normalized") + off() + + off = api.hooks:wrap("battle.damage", function(_, ctx) + ctx.user.mon.moves = { [2] = { id = "TACKLE", pp = 1, ppUps = 0 } } + ctx.user.mon.hp = 1 + return 99 + end) + T.eq( + loader.hooks:call("battle.damage", function() + return 2 + end, { user = battler }), + 2, + label .. "sparse move list rejected" + ) + T.eq(raw.hp, 14, label .. "sparse move list fails atomically") + off() + local retainedNext, retainedShare + off = api.hooks:wrap("battle.exp_award", function(next, ctx) + retainedNext = next + retainedShare = ctx.applyShare + end) + loader.hooks:call("battle.exp_award", function() + error("must not run") + end, { + alive = { raw }, + applyShare = function() + error("must not run") + end, + }) + T.raises(function() + retainedNext() + end, "only valid during", label .. "retained next cannot run after callback") + T.raises(function() + retainedShare(raw) + end, "bound Pokemon", label .. "helper rejects native mon") + off() + local heldMon + off = api.hooks:wrap("battle.exp_award", function(_, ctx) + heldMon = ctx.alive[1] + retainedShare = ctx.applyShare + end) + loader.hooks:call("battle.exp_award", function() end, { + alive = { raw }, + applyShare = function() + error("must not run") + end, + }) + T.raises(function() + retainedShare(heldMon) + end, "closed", label .. "retained helper cannot write after callback") + off() + + local custom = { mon = raw } + local got + off = api.events:on("mod.other.ready", function(p) + got = p + end) + loader.events:emit("mod.other.ready", custom) + T.eq(got, custom, label .. "custom payload identity untouched") + off() + T.raises(function() + api.events:emit("mod.other.ready", {}) + end, "only emit", label .. "cannot spoof other mod") + T.raises(function() + api.events:emit("mod.probe.", {}) + end, "only emit", label .. "empty custom suffix rejected") + T.raises(function() + api.hooks:wrap("invented.hook", function() end) + end, "unknown unified", label .. "unknown hook rejected") + T.raises(function() + api.events:on("invented.event", function() end) + end, "unknown unified", label .. "unknown event rejected") +end +-- The native path must not install any wrapper or copy its payloads. +local api, loader = boot("crystal") +T.eq(api.callbackFormat, "native", "default API native") +local raw = { mon = { species = 42 } } +api.events:on("custom.engine", function(p) + T.eq(p, raw, "native event identity unchanged") +end) +loader.events:emit("custom.engine", raw) +api.hooks:wrap("custom.hook", function(next, ctx) + T.eq(ctx, raw, "native hook identity unchanged") + return next() +end) +T.eq( + loader.hooks:call("custom.hook", function(ctx) + return ctx + end, raw), + raw, + "native result identity unchanged" +) +-- Entry rollback removes unified subscriptions using the same native owner. +local _, failed = boot( + "crystal", + "unified", + [[return function(mod) + mod.events:on('battle.started',function() end) + mod.hooks:wrap('battle.damage',function() end) + error('rollback probe') +end]] +) +T.check(#failed.errors > 0, "failed entry attributed") +T.check( + not failed.events.listeners["battle.started"] or #failed.events.listeners["battle.started"] == 0, + "failed entry event removed" +) +T.check( + not failed.hooks.chains["battle.damage"] or #failed.hooks.chains["battle.damage"] == 0, + "failed entry hook removed" +) +Version.set(saved[1]) +Runtime.install(saved[2], saved[3], saved[4]) +love = saved[5] +T.finish() diff --git a/tests/engine/unified_move_effects.lua b/tests/engine/unified_move_effects.lua new file mode 100644 index 000000000..0130bae06 --- /dev/null +++ b/tests/engine/unified_move_effects.lua @@ -0,0 +1,212 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local Runtime = require("src.mods.Runtime") +local Version = require("src.core.GameVersion") +local saved = { Version.get(), Runtime.events, Runtime.hooks, Runtime.errors, Runtime.currentMod, love } +local E = require("src.mods.UnifiedEffects") +local C = require("src.mods.MoveEffectContract") +local Native = require("src.battle.MoveEffects").RECORDS +local function registered(id) + return Native[id] ~= nil +end +local accepted = 0 +for id, bindings in pairs(C.bindings) do + for g, row in pairs(bindings) do + local gen = tonumber(g) + local old = { id = row.semantic == 37 and "REST" or row.semantic == 130 and "SONICBOOM" or "POUND" } + local plan, why = E.write(id, gen, old, registered) + if plan then + accepted = accepted + 1 + T.check(registered(plan.effect), "accepted effect has actual handler " .. g .. ":" .. id) + if gen > 1 then + plan.effectChance = plan.probability and math.floor(plan.probability * 256 / 100 + 0.5) + end + T.eq( + E.read(plan.effect, gen, plan), + ({ HIT = "NO_ADDITIONAL_EFFECT", SLEEP = "SLEEP_EFFECT" })[id] or id, + "authored canonical effect read " .. g .. ":" .. id + ) + else + T.check(type(why) == "string", "unrepresentable mapping has reason") + end + end +end +T.check(accepted > 300, "reference matrix exercises hundreds of native bindings") +local Contract = require("tests.api3_contract.effects") +for _, v in ipairs({ "red", "blue", "yellow", "gold", "silver", "crystal", "firered", "emerald" }) do + local ok, why = pcall(Contract.run, "tests/api3_contract", "gen2", v) + T.check(ok, v .. " shared effect contract: " .. tostring(why)) +end +for gen = 1, 3 do + local custom = assert(E.write("mod:custom", gen, nil, function(id) + return id == "mod:custom" + end)) + T.eq(custom.effect, "mod:custom", "registered custom native effect selected") + T.eq(E.read(custom.effect, gen, custom), "mod:custom", "custom canonical identity") + T.eq(E.write("unknown", gen, nil, registered), nil, "unknown handler refused") + T.eq(E.write("native:" .. gen .. ":made_up", gen, nil, registered), nil, "forged opaque effect refused") + T.eq( + E.write("native:" .. gen .. ":unhandled", gen, { effect = "unhandled" }, registered).effect, + "unhandled", + "original opaque identity retained" + ) +end +for _, id in ipairs({ + "MAGIC_COAT", + "SNATCH", + "IMPRISON", + "ASSIST", + "NATURE_POWER", + "SECRET_POWER", +}) do + T.eq(E.write(id, 3, nil, registered), nil, "missing native Gen3 handler refused " .. id) +end +local helpingHand = assert(E.write("HELPING_HAND", 3, nil, registered)) +T.eq(helpingHand.effect, "HELPING_HAND_EFFECT", "canonical Helping Hand selects the native handler") +T.eq(helpingHand.gen3Effect, 176, "canonical Helping Hand keeps native semantic number") +local followMe = assert(E.write("FOLLOW_ME", 3, nil, registered)) +T.eq(followMe.effect, "FOLLOW_ME_EFFECT", "canonical Follow Me selects newly implemented native handler") +T.eq(followMe.gen3Effect, 172, "canonical Follow Me keeps native effect number") +for _, v in ipairs({ "gold", "firered" }) do + Version.set(v) + local gen = Version.generation() + local plan = assert(E.write("SPECIAL_UP1_EFFECT", gen, { id = "POUND" }, registered)) + local user = { name = "TEST", isPlayer = true, mon = { hp = 20, stats = { hp = 20 } }, stages = {} } + local target = { name = "TARGET", mon = { hp = 20, stats = { hp = 20 } }, stages = {} } + Native[plan.effect].run({ battle = { data = {} }, user = user, target = target, move = plan }) + T.eq(user.stages.spatk, 1, "actual handler raises Special Attack") + T.eq(user.stages.special, nil, "actual handler avoids Gen1 Special stage") + T.eq(user.stages.spdef, nil, "actual handler does not raise Special Defense") + plan = assert(E.write("SPECIAL_DOWN_SIDE_EFFECT", gen, { id = "POUND" }, registered)) + plan.effectChance = 85 + Native[plan.effect].run({ battle = { + data = {}, + rng = function() + return 0 + end, + }, user = user, target = target, move = plan }) + T.eq(target.stages.spdef, -1, "actual handler lowers Special Defense") + plan = assert(E.write("BURN_SIDE_EFFECT1", gen, { id = "POUND" }, registered)) + plan.effectChance = math.floor(plan.probability * 256 / 100 + 0.5) + plan.id, plan.type = "POUND", "FIRE" + local battle = { + data = {}, + rng = function() + return 25 + end, + } + Native[plan.effect].run({ battle = battle, user = user, target = target, move = plan }) + T.eq(target.mon.status, "BRN", "native burn lands below converted threshold") + target.mon.status = nil + battle.rng = function() + return 26 + end + Native[plan.effect].run({ battle = battle, user = user, target = target, move = plan }) + T.eq(target.mon.status, nil, "native burn misses at converted threshold") +end +for gen = 2, 3 do + local old = { id = "POUND", effect = "BURN_SIDE_EFFECT1", effectChance = 27 } + local public = E.read(old.effect, gen, old) + local plan, changed = E.write(public, gen, old, registered) + T.eq(plan.effect, old.effect, "original raw effect roundtrip") + T.eq(changed, false, "roundtrip does not rewrite original odds") + T.eq(E.chance(old, gen), 11, "native fraction projected to whole percent") + T.eq( + E.write("NO_ADDITIONAL_EFFECT", 4, old, registered), + nil, + "later generation changing effect still unavailable" + ) + T.eq(E.read(999, 4, {}), nil, "DS opaque selector retains diagnostic-only behavior") +end +-- Registry operations use the adapter before journaling; refusals are atomic. +local function load(gen, entry, odds) + Version.set(gen == 1 and "red" or gen == 2 and "gold" or "firered") + local data = require("tests.api3_contract.run").dataFor("gen2", gen) + local loader + local fs = { + read = function(path) + if path:match("manifest.json$") then + return '{"id":"probe","name":"Probe","version":"1.0.0","api":3,"games":["gen1","gen2","gen3"],"entry":"main.lua"}' + end + end, + getInfo = function(path) + if path:match("^mods/probe/.+%.%w+$") then + return { type = "file" } + end + if path == "mods" or path == "mods/probe" then + return { type = "directory" } + end + end, + getDirectoryItems = function(path) + return path == "mods" and { "probe" } or {} + end, + load = function() + return function(api) + entry(api, loader, data) + end + end, + } + data.moves.POUND.effect = "BURN_SIDE_EFFECT1" + data.moves.POUND.effectChance = odds or 27 + if gen == 3 then + data.moves.POUND.gen3Effect = 4 + data.moves.POUND.secondaryChance = 11 + end + loader = require("src.mods.Loader").new({ fs = fs, dev = false, generation = gen, version = Version.get() }) + loader._game = function() + error("content must not bootstrap Game") + end + loader:load(data) + T.eq(#loader.errors, 0, "public effect mod loads") + return data +end +for gen = 2, 3 do + local data = load(gen, function(mod, loader) + local moves = mod.content.moves + local row = assert(moves:get("POUND")) + T.eq(row.secondaryChance, 11, "public native odds projection") + assert(moves:override("POUND", row)) + T.eq(moves:get("POUND").native.effectChance, 27, "detached override keeps exact native odds") + local operations = #loader.content.moves.ops.POUND + T.eq(moves:patch("POUND", { effect = "SNATCH" }), nil, "unimplemented effect operation refused") + T.eq(#loader.content.moves.ops.POUND, operations, "failed mapping adds no operation") + T.check(not pcall(function() + moves:patch("POUND", { secondaryChance = 101 }) + end), "invalid percent rejected") + T.eq(#loader.content.moves.ops.POUND, operations, "invalid chance adds no operation") + assert(moves:patch("POUND", { effect = "FLINCH_SIDE_EFFECT2", secondaryChance = 5 })) + T.eq(moves:get("POUND").effect, "FLINCH_SIDE_EFFECT2", "authored alias persists after odds edit") + assert(moves:patch("POUND", { effect = "BURN_SIDE_EFFECT1", secondaryChance = 0 })) + T.eq(moves:get("POUND").secondaryChance, 0, "zero probability retained") + end) + local target = { name = "TARGET", mon = { hp = 20, stats = { hp = 20 } }, stages = {} } + Native[data.moves.POUND.effect].run({ + battle = { + data = {}, + rng = function() + return 0 + end, + }, + user = {}, + target = target, + move = data.moves.POUND, + }) + T.eq(target.mon.status, nil, "actual handler respects explicit zero instead of native default") +end +for gen = 2, 3 do + local data = load(gen, function(mod) + local moves = mod.content.moves + assert(moves:override("POUND", assert(moves:get("POUND")))) + T.eq( + moves:get("POUND").native._api3EffectChance, + nil, + "untouched zero-byte roundtrip preserves native fallback" + ) + assert(moves:patch("POUND", { secondaryChance = 0 })) + T.eq(moves:get("POUND").native._api3EffectChance, true, "explicit zero patch arms native zero semantics") + end, 0) +end +Version.set(saved[1]) +Runtime.events, Runtime.hooks, Runtime.errors, Runtime.currentMod = saved[2], saved[3], saved[4], saved[5] +love = saved[6] +T.finish() diff --git a/tests/engine/world_actions_api.lua b/tests/engine/world_actions_api.lua new file mode 100644 index 000000000..bd029b204 --- /dev/null +++ b/tests/engine/world_actions_api.lua @@ -0,0 +1,582 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.harness") +local Loader = require("src.mods.Loader") +local WorldAPI = require("src.world.WorldAPI") +local Actions = require("src.world.WorldActions") +local GameVersion = require("src.core.GameVersion") +local Runtime = require("src.mods.Runtime") +local Merge = require("src.mods.Merge") +local Json = require("src.link.Json") +local G4Fly = require("src.world.Gen4Fly") +local Gen3Show = require("src.world.Gen3FieldMove") +local saved = { GameVersion.get(), Runtime.events, Runtime.hooks, Runtime.errors, love, Gen3Show.show } +love = nil +-- Deterministic no-picture native fallback for the cross-version cases. +Gen3Show.show = function() + return false +end +local function boot(version) + GameVersion.set(version) + local gen = GameVersion.generation() + local data = { + pokemon = {}, + moves = {}, + items = {}, + constants = { badges = {} }, + tilesets = { TEST = { blockCells = gen < 3 and 2 or 1 } }, + maps = { + HOME = { + id = "HOME", + width = 20, + height = 20, + tileset = "TEST", + outdoor = true, + mapType = "ROUTE", + allowFly = true, + gen5Flags = 0x2000, + }, + DEST = { id = "DEST", width = 20, height = 20, tileset = "TEST", outdoor = true, mapType = "CITY" }, + }, + field = { flyOrder = { "DEST" }, flyWarps = { DEST = { x = 3, y = 4 } } }, + } + for i = 1, 16 do + data.constants.badges[i] = { id = "BADGE_" .. i } + end + local save = { + party = { + { species = "A", hp = 1, moves = { { id = gen >= 4 and 19 or "FLY" } } }, + { + species = "B", + hp = 0, + moves = {}, + }, + }, + visited = { DEST = true }, + lastHeal = { map = "DEST", x = 3, y = 4 }, + flags = {}, + badges = {}, + } + for i = 1, 16 do + save.badges["BADGE_" .. i] = true + end + if gen == 4 then + data.gen4_version = + { family = (version == "heartgold" or version == "soulsilver") and "hgss" or "platinum" } + local row = { index = 0, firstArrival = 1, fly = { map = "DEST", x = 3, y = 4 } } + data.field.healLocations = { row } + save.flags[G4Fly.rowFlagKey(data, row)] = true + elseif gen == 5 then + data.maps.Z001 = data.maps.DEST + data.gen5_town_map = + { locations = { { zone = 1, flyPoint = true, spawn = { main = true, x = 3, z = 4 } } } } + save.gen5FlyZones = { ["1"] = true } + end + local calls = {} + local ow = { + isOverworld = true, + map = { id = "HOME", def = data.maps.HOME }, + player = { cellX = 2, cellY = 3, facing = "up" }, + scriptMoves = {}, + pendingScripts = {}, + runner = { + isRunning = function() + return false + end, + }, + } + ow.player.facingCell = function(p) + return p.cellX, p.cellY - 1 + end + local game + ow.partyKnows = function(_, move) + if move == "FLY" then + return game.save.party[1] + end + end + ow.flyTo = function(_, id, mon) + calls[#calls + 1] = { kind = "fly", id = id, mon = mon } + ow.flyAnim = {} + end + ow.gen4FlyTo = function(_, dest, mon) + calls[#calls + 1] = { kind = "fly", id = dest.map, x = dest.x, y = dest.y, mon = mon } + ow.flyAnim = {} + end + local stack = { states = { ow } } + function stack:top() + return self.states[#self.states] + end + function stack:push(state) + self.states[#self.states + 1] = state + end + function stack:pop() + return table.remove(self.states) + end + game = { data = data, save = save, overworld = ow, stack = stack } + local files = { + ["mods/probe/manifest.json"] = Json.encode({ + id = "probe", + name = "Probe", + version = "1.0.0", + api = 2, + entry = "main.lua", + }), + ["mods/probe/main.lua"] = "return function(mod) mod.exports.api=mod end", + } + local fs = { + read = function(p) + return files[p] + end, + load = function(p) + return loadstring(files[p], p) + end, + getDirectoryItems = function(p) + return p == "mods" and { "probe" } or {} + end, + getInfo = function(p) + if p == "mods" or p == "mods/probe" then + return { type = "directory" } + end + if files[p] then + return { type = "file" } + end + end, + } + local loader = Loader.new({ fs = fs, dev = false }) + local resolved = 0 + loader._game = function() + resolved = resolved + 1 + return game + end + assert(loader:load(data), table.concat(loader.errors, "; ")) + return loader.apis.probe, game, ow, calls, function() + return resolved + end +end +for _, version in ipairs(GameVersion.ORDER) do + local api, game, ow, calls, resolved = boot(version) + local label = version .. ": " + T.eq(resolved(), 0, label .. "building API does not construct Game") + if api.supports then + for _, name in ipairs({ + "canReorderParty", + "reorderParty", + "availableFieldActions", + "useFieldAction", + "canFly", + "flyTo", + }) do + T.eq(api:supports("world", name), true, label .. "capability " .. name) + end + T.eq(resolved(), 0, label .. "capability probes do not construct Game") + end + local world = api.world + local before = Merge.deepCopy(game) + T.eq(world:canReorderParty(), true, label .. "free-roam party can reorder") + T.eq(world:canFly(), true, label .. "native flight checks pass") + T.same(game, before, label .. "availability does not alter save/world") + local p = game.save.party + local a, b = p[1], p[2] + T.eq(world:reorderParty(1, 2), true, label .. "swap accepted") + T.eq(game.save.party, p, label .. "party list identity retained") + T.eq(p[1], b, label .. "fainted party member may move") + T.eq(p[2], a, label .. "Pokemon identity retained") + T.eq(world:reorderParty(1, 2), true, label .. "swap back accepted") + T.eq(world:reorderParty(1, 1), true, label .. "same-slot no-op accepted") + for _, slot in ipairs({ 0, -1, 3, 7, 1.5, "1", math.huge, 0 / 0 }) do + before = Merge.deepCopy(game) + local ok, reason = world:reorderParty(slot, 1) + T.check(ok == nil and reason == "invalid party slot", label .. "invalid slot refused") + T.same(game, before, label .. "bad swap atomic") + end + local locks = { + "transitioning", + "flyAnim", + "teleportOut", + "engaging", + "emote", + "pikaHop", + "healAnim", + "whirlSpin", + "gen4Waterfall", + "gen5SeamPending", + "gen3Decorate", + "frameScriptThisTick", + "_modScriptLock", + } + for _, lock in ipairs(locks) do + ow[lock] = true + before = Merge.deepCopy(game) + T.eq(world:canReorderParty(), false, label .. lock .. " blocks reorder probe") + T.eq(world:canFly(), false, label .. lock .. " blocks fly probe") + T.eq(world:reorderParty(1, 2), nil, label .. lock .. " blocks swap") + T.eq(world:flyTo("DEST"), nil, label .. lock .. " blocks flight") + T.eq(world:useFieldAction("surf"), nil, label .. lock .. " blocks field use") + T.same(world:availableFieldActions(), {}, label .. lock .. " suppresses actions") + T.same(game, before, label .. lock .. " refusal leaves game unchanged") + ow[lock] = nil + end + for _, lock in ipairs({ "moving", "inputLocked", "stairExit", "spinning", "fieldMove" }) do + ow.player[lock] = true + before = Merge.deepCopy(game) + T.eq(world:reorderParty(1, 2), nil, label .. "player " .. lock .. " blocks swap") + T.eq(world:flyTo("DEST"), nil, label .. "player " .. lock .. " blocks fly") + T.same(game, before, label .. "player " .. lock .. " no mutation") + ow.player[lock] = nil + end + game.stack:push({ isBattle = true }) + T.eq(world:reorderParty(1, 2), nil, label .. "battle/screen above world blocks swap") + T.eq(world:flyTo("DEST"), nil, label .. "battle/screen blocks fly") + game.stack:pop() + ow.runner.isRunning = function() + return true + end + T.eq(world:reorderParty(1, 2), nil, label .. "running VM blocks swap") + ow.runner.isRunning = function() + return false + end + ow.pendingScripts[1] = {} + T.eq(world:flyTo("DEST"), nil, label .. "pending scene blocks flight") + ow.pendingScripts = {} + ow.scriptMoves[1] = {} + T.eq(world:reorderParty(1, 2), nil, label .. "scripted movement blocks swap") + ow.scriptMoves = {} + game.linkSession = {} + T.eq(world:flyTo("DEST"), nil, label .. "link lock blocks flight") + game.linkSession = nil + game.linkNet = { closed = false } + T.eq(world:reorderParty(1, 2), nil, label .. "live link net blocks swap") + game.linkNet = nil + local destination = GameVersion.generation() == 5 and "Z001" or "DEST" + before = Merge.deepCopy(game) + T.eq(world:flyTo("MISSING"), nil, label .. "unknown destination refused") + T.eq(world:flyTo({}), nil, label .. "non-string destination refused") + T.same(game, before, label .. "invalid destinations leave save/world unchanged") + T.eq(#calls, 0, label .. "no gameplay handlers ran on refusal") + T.eq(world:flyTo(destination), true, label .. "flight accepted") + T.eq(#calls, 1, label .. "one native departure") + T.eq(calls[1].id, destination, label .. "native destination selected") + T.eq(calls[1].mon, a, label .. "native carrier identity retained") + T.eq(world:flyTo(destination), nil, label .. "in-flight repeat refused") + T.eq(#calls, 1, label .. "repeat does not replay departure") + ow.flyAnim = nil + a.isEgg = true + T.eq(world:canFly(), false, label .. "egg cannot carry flight") + a.isEgg = nil + if GameVersion.generation() == 4 then + game.save.flags.FLAG_G4_0961 = true + T.eq(world:canFly(), false, label .. "native partner restriction retained") + game.save.flags.FLAG_G4_0961 = nil + if version == "heartgold" or version == "soulsilver" then + game.save.hgssRocketCostume = true + T.eq(world:canFly(), false, label .. "Rocket costume restriction retained") + game.save.hgssRocketCostume = nil + end + end + game.save = nil + T.eq(world:canReorderParty(), false, label .. "no save probe safe") + T.eq(world:reorderParty(1, 2), nil, label .. "no save swap safe") + T.eq(world:canFly(), false, label .. "no save fly probe safe") + T.eq(world:flyTo(destination), nil, label .. "no save fly safe") +end + +-- Exercise the actual native carrier/badge lookup and departure methods, +-- including their input lock and animation/landing state, without graphics. +local OW = require("src.world.OverworldController") +love = {} -- native sound entry points expect a host table, but need no audio device +local gameUpvalue, oldGame +for i = 1, 30 do + local name, value = debug.getupvalue(OW.flyTo, i) + if name == "Game" then + gameUpvalue = i + oldGame = value + break + end +end +assert(gameUpvalue, "native Game binding") +for _, version in ipairs(GameVersion.ORDER) do + local api, game, ow = boot(version) + debug.setupvalue(OW.flyTo, gameUpvalue, game) + ow.partyKnows = OW.partyKnows + ow.flyTo = OW.flyTo + ow.gen4FlyTo = OW.gen4FlyTo + ow.closeToMap = OW.closeToMap + ow.clearBikeFlags = function() end + ow.hasFlyBird = function() + return false + end + ow.syncSurfingPikachu = function() end + local gen = GameVersion.generation() + if gen <= 2 then + game.data.constants.hmBadges = { FLY = { badge = "FLY_ALLOWED" } } + game.save.flags.FLY_ALLOWED = true + elseif gen == 3 then + game.data.constants.gen3FieldMoves = { FLY = { badgeFlag = 0x820 } } + game.save.flags.FLAG_G3_0820 = true + end + local destination = gen == 5 and "Z001" or "DEST" + T.eq(api.world:canFly(), true, version .. " actual native eligibility") + local before = Merge.deepCopy(game) + if gen <= 2 then + game.save.flags.FLY_ALLOWED = nil + elseif gen == 3 then + game.save.flags.FLAG_G3_0820 = nil + elseif gen == 4 then + for k in pairs(game.save.badges) do + game.save.badges[k] = nil + end + end + if gen < 5 then + T.eq(api.world:canFly(), false, version .. " native missing badge refuses flight") + T.eq(api.world:flyTo(destination), nil, version .. " missing badge has no departure") + T.eq(ow.flyAnim, nil, version .. " denied departure has no animation") + end + game.save = Merge.deepCopy(before.save) + if gen <= 3 then + game.save.visited.DEST = nil + elseif gen == 4 then + game.save.flags = {} + else + game.save.gen5FlyZones = {} + end + T.eq(api.world:canFly(), false, version .. " unvisited/locked destinations unavailable") + T.eq(api.world:flyTo(destination), nil, version .. " locked destination refuses native handler") + game.save = Merge.deepCopy(before.save) + -- Validate the landing before any departure-side state is changed. + if gen <= 3 then + game.data.field.flyWarps.DEST.x = -1 + elseif gen == 4 then + game.data.field.healLocations[1].fly.x = -1 + else + game.data.gen5_town_map.locations[1].spawn.x = -1 + end + T.eq(api.world:flyTo(destination), nil, version .. " invalid landing refused") + T.eq(ow.flyAnim, nil, version .. " invalid landing leaves animation absent") + if gen <= 3 then + game.data.field.flyWarps.DEST.x = 3 + elseif gen == 4 then + game.data.field.healLocations[1].fly.x = 3 + else + game.data.gen5_town_map.locations[1].spawn.x = 3 + end + game.save.onBike = true + ow.player.surfing = true + T.eq(api.world:flyTo(destination), true, version .. " native departure accepted") + T.eq(ow.flyAnim.frames, 48, version .. " native flight timing preserved") + T.eq(ow.flyDest.map, destination, version .. " native landing map preserved") + T.eq(ow.flyDest.x, 3, version .. " native landing x preserved") + T.eq(ow.flyDest.y, 4, version .. " native landing y preserved") + T.eq(ow.player.inputLocked, true, version .. " native input lock applied") + T.eq(ow.player.surfing, false, version .. " native surfing cleared") + T.eq(game.save.onBike, false, version .. " native bicycle state cleared") +end +for _, version in ipairs({ "red", "gold", "emerald" }) do + local api, game, ow = boot(version) + debug.setupvalue(OW.flyTo, gameUpvalue, game) + local gen = GameVersion.generation() + ow.partyKnows = OW.partyKnows + ow.beginTeleportOut = OW.beginTeleportOut + ow.syncSurfingPikachu = function() end + ow.escapePoint = function() + return { map = "DEST", x = 3, y = 4 } + end + game.save.party[1].moves = { { id = "DIG" }, { id = "TELEPORT" } } + ow.map.def.tileset = "CAVERN" + ow.map.def.environment = 4 + ow.map.def.allowEscaping = true + T.eq(api.world:useFieldAction("dig"), true, version .. " actual native Dig starts") + T.eq(ow.teleportOut.frames, 48, version .. " native Dig animation timing") + T.eq(ow.teleportOut.escape, gen >= 2 and true or nil, version .. " native Dig escape destination semantics") + T.eq(ow.player.inputLocked, true, version .. " native Dig input lock") + ow.teleportOut = nil + ow.player.inputLocked = false + ow.player.spinning = false + ow.map.def.tileset = "OVERWORLD" + ow.map.def.environment = 1 + T.eq(api.world:useFieldAction("teleport"), true, version .. " actual native Teleport starts") + T.eq(ow.teleportOut.frames, 48, version .. " native Teleport animation timing") + T.eq(ow.teleportOut.escape, nil, version .. " native Teleport uses last heal point") +end +debug.setupvalue(OW.flyTo, gameUpvalue, oldGame) +love = nil + +-- Field dispatch is tested separately from presentation, using native IDs +-- and the exact overworld methods called by the existing party menus. +for _, version in ipairs({ "red", "gold", "emerald", "platinum", "black" }) do + local api, game, ow, calls = boot(version) + local world = api.world + local gen = GameVersion.generation() + local mon = game.save.party[1] + ow.partyKnows = function() + return mon + end + ow.useSurfFieldMove = function() + return "ok" + end + ow.useCutFieldMove = function() + return "ok" + end + ow.trySurf = function(_, x, y, close, carrier) + calls[#calls + 1] = { kind = "surf", carrier = carrier, x = x, y = y } + game.stack:push({ isText = true }) + end + ow.tryCut = function(_, x, y) + calls[#calls + 1] = { kind = "cut", x = x, y = y } + game.stack:push({ isText = true }) + return true + end + ow.beginTeleportOut = function(_, done, opts) + calls[#calls + 1] = { kind = "escape", escape = opts.escape } + ow.teleportOut = {} + end + ow.escapePoint = function() + return { map = "DEST", x = 3, y = 4 } + end + local before = Merge.deepCopy(game) + local list = world:availableFieldActions() + T.same(game, before, version .. " field availability is data-only with native pure gates") + local names = {} + for _, row in ipairs(list) do + names[row.id] = true + end + T.eq(names.surf, gen <= 3 and true or nil, version .. " verified surf coverage") + T.eq(names.cut, gen <= 2 and true or nil, version .. " verified cut coverage") + T.eq(names.teleport, gen <= 3 and true or nil, version .. " verified teleport coverage") + if list[1] then + list[1].id = "MUTATED" + T.check(world:availableFieldActions()[1].id ~= "MUTATED", version .. " action rows detached") + end + T.eq(world:useFieldAction("unknown"), nil, version .. " unknown action refused") + T.eq(world:useFieldAction("surf", { sourceSlot = 1 }), nil, version .. " unsupported options refused") + if gen <= 3 then + T.eq(world:useFieldAction("surf"), true, version .. " native surf started") + T.eq(calls[#calls].carrier, mon, version .. " surf uses validated carrier") + T.eq(world:useFieldAction("surf"), nil, version .. " active dialogue prevents replay") + game.stack:pop() + ow.player.surfing = true + T.eq(world:useFieldAction("surf"), nil, version .. " no invented dismount action") + ow.player.surfing = false + T.eq(world:useFieldAction("teleport"), true, version .. " native Teleport started") + T.eq(calls[#calls].escape, nil, version .. " Teleport keeps heal-point semantics") + ow.teleportOut = nil + ow.map.def.tileset = "CAVERN" + ow.map.def.environment = 4 + ow.map.def.allowEscaping = true + T.eq(world:useFieldAction("dig"), true, version .. " native Dig started") + T.eq( + calls[#calls].escape, + gen >= 2 and true or nil, + version .. " Dig keeps generation-specific destination" + ) + ow.teleportOut = nil + if gen <= 2 then + T.eq(world:useFieldAction("cut"), true, version .. " native Cut started") + game.stack:pop() + end + else + T.eq(world:useFieldAction("surf"), nil, version .. " unsupported script adapter is explicit") + T.eq(#calls, 0, version .. " unsupported field dispatch has no effects") + end +end + +-- Deferred Gen 3 cut-ins own the request until completion; callbacks may be +-- invoked twice or outlive a replaced map/save, but effects must not replay. +local api, game, ow, calls = boot("emerald") +local world = api.world +local finish +Gen3Show.show = function(g, mon, done) + finish = done + g.stack:push({ isCutin = true }) + return true +end +T.eq(world:flyTo("DEST"), true, "cut-in queued") +T.eq(#calls, 0, "no departure before cut-in finishes") +T.eq(world:reorderParty(1, 2), nil, "cut-in blocks competing action") +game.stack:pop() +finish() +finish() +T.eq(#calls, 1, "cut-in completion departs once") +ow.flyAnim = nil +T.eq(world:flyTo("DEST"), true, "second independent request queued") +game.stack:pop() +game.save = Merge.deepCopy(game.save) +finish() +T.eq(#calls, 1, "save replacement cancels pending effect") +T.eq(world:canReorderParty(), true, "cancelled request releases its lock") +T.eq(world:flyTo("DEST"), true, "request can resume after cancellation") +game.stack:pop() +ow.map = { id = "OTHER", def = game.data.maps.HOME } +finish() +T.eq(#calls, 1, "map replacement cancels pending effect") +T.eq(world:canReorderParty(), true, "map cancellation releases lock") +T.eq(world:flyTo("DEST"), true, "abandoned presentation queued") +game.stack:pop() -- simulate native UI being dismissed without completion +T.eq(world:canReorderParty(), true, "abandoned cut-in does not leave a permanent API lock") +finish() +T.eq(#calls, 1, "abandoned callback cannot later run") +Gen3Show.show = function(g, mon, done) + done() + error("after completion") +end +local ok, err = pcall(function() + world:flyTo("DEST") +end) +T.check( + not ok and tostring(err):find("after completion", 1, true) ~= nil, + "native presentation error is propagated" +) +T.eq(#calls, 2, "presentation failure never retries a completed departure") +Gen3Show.show = function() + return false +end +ow.flyAnim = nil +-- Hooks cannot recursively start a second action or move the source unnoticed. +ow.partyKnows = function() + T.eq(world:reorderParty(1, 2), nil, "nested mutation during eligibility is refused") + return game.save.party[1] +end +T.eq(world:canFly(), true, "recursive capability probe terminates") +ow.partyKnows = function() + ow.player.cellX = ow.player.cellX + 1 + return game.save.party[1] +end +T.eq(world:flyTo("DEST"), nil, "eligibility hook changing source position cancels action") +T.eq(#calls, 2, "changed source never reaches native handler") + +local nestedApi, nestedGame, nestedWorld = boot("red") +nestedWorld.flyTo = function() + T.eq(nestedApi.world:reorderParty(1, 2), nil, "native dispatch callback cannot reorder concurrently") + T.eq(nestedApi.world:flyTo("DEST"), nil, "native dispatch callback cannot start a second flight") + nestedWorld.flyAnim = {} +end +T.eq(nestedApi.world:flyTo("DEST"), true, "guarded native dispatch still starts once") +nestedWorld.flyAnim = nil +local nativeError = { native = true } +nestedWorld.flyTo = function() + error(nativeError) +end +local okNative, errNative = pcall(function() + nestedApi.world:flyTo("DEST") +end) +T.check(not okNative and errNative == nativeError, "native error identity retained") +T.eq(nestedApi.world:canReorderParty(), true, "throwing handler releases only the API dispatch lock") + +local empty = WorldAPI.new(nil, "empty") +T.eq(empty:reorderParty(1, 2), nil, "missing game mutation safe") +T.eq(empty:canFly(), false, "missing game probe safe") +T.same(empty:availableFieldActions(), {}, "missing game has no field actions") +for _, name in ipairs({ + "canReorderParty", + "reorderParty", + "availableFieldActions", + "useFieldAction", + "canFly", + "flyTo", +}) do + T.check(Actions.supports(name), "action capability " .. name) +end +T.eq(Actions.supports("party"), false, "reader capability remains separate") +GameVersion.set(saved[1]) +Runtime.install(saved[2], saved[3], saved[4]) +love = saved[5] +Gen3Show.show = saved[6] +T.finish() diff --git a/tests/engine/world_overview_api.lua b/tests/engine/world_overview_api.lua new file mode 100644 index 000000000..dab0d78bd --- /dev/null +++ b/tests/engine/world_overview_api.lua @@ -0,0 +1,660 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local Map = require("src.world.Map") +local WorldAPI = require("src.world.WorldAPI") +local Encounter = require("src.world.Encounter") +local Runtime = require("src.mods.Runtime") +local Hooks = require("src.mods.Hooks") +local GameVersion = require("src.core.GameVersion") +local Assets = require("src.render.Assets") +local Loader = require("src.mods.Loader") +local Json = require("src.link.Json") +local savedVersion, savedHooks = GameVersion.get(), Runtime.hooks +local function copy(t, seen) + if type(t) ~= "table" then + return t + end + seen = seen or {} + if seen[t] then + return seen[t] + end + local out = {} + seen[t] = out + for k, v in pairs(t) do + out[copy(k, seen)] = copy(v, seen) + end + return out +end +-- Exact reference/key/metatable freeze; cycles (Game -> world -> Game) are real. +local function freeze(root) + local graph = {} + local function walk(t) + if type(t) ~= "table" or graph[t] then + return + end + local keys = {} + graph[t] = { keys = keys, mt = getmetatable(t) } + for k, v in pairs(t) do + keys[k] = v + walk(k) + walk(v) + end + end + walk(root) + return function(label) + for t, before in pairs(graph) do + T.eq(getmetatable(t), before.mt, label .. " metatable") + for k, v in pairs(before.keys) do + T.eq(rawget(t, k), v, label .. " value") + end + for k, v in pairs(t) do + T.eq(before.keys[k], v, label .. " no new key") + end + end + end +end +local function pack(...) + local out = {} + for _, n in ipairs({ ... }) do + out[#out + 1] = string.char(n % 256, math.floor(n / 256)) + end + return table.concat(out) +end +local function uniform(n) + local out = {} + for i = 1, 16 do + out[i] = n + end + return out +end +local function scene(version) + GameVersion.set(version) + local gen = GameVersion.generation() + local def = { + id = "ACTIVE", + width = 2, + height = 2, + borderBlock = 0, + blocks = { 1, 2, 3, 4 }, + warps = { { x = 1, y = 1 }, { x = -1, y = 1 } }, + signs = { + { x = 0, y = 1, item = "POTION", eventFlag = "FOUND" }, + { + x = 1, + y = 0, + item = "POTION", + eventFlag = "HIDDEN", + }, + }, + } + local ts = { + blockCells = 2, + blockTiles = 4, + blocks = { uniform(0), uniform(0), uniform(1), uniform(2), uniform(3) }, + walkable = { 0, 2 }, + waterTiles = { 1 }, + shoreTiles = {}, + warpTiles = { 3 }, + image = "overview-test.png", + tilesPerRow = 4, + } + if gen == 2 then + ts.collision = { 255, 255, 255, 255, 0, 0, 0, 0, 1, 1, 1, 1, 2, 2, 2, 2, 3, 3, 3, 3 } + elseif gen >= 3 then + def.blocks = pack(1 + 1024, 2 + 4096, 3, 4) + def.collisionCells = { 0, 0, 1, 0 } + def.elevationCells = gen == 3 and { 3, 1, 3, 3 } or nil + ts.blockCells, ts.blockTiles, ts.blocks, ts.collision = 1, 2, nil, { 0, 0, 1, 2, 3 } + ts.behaviourBytes = true + ts.warpsAreEvents = true + if gen >= 4 then + def.generation = 4 + def.gen5 = gen == 5 or nil + def.behaviorCells = string.char(0, gen == 5 and 0x3D or 0x10, 0, 0) + end + end + local map = Map.new(def, ts) + local ow = { + isOverworld = true, + map = map, + npcs = { + { def = { item = "POTION" }, cellX = 0, cellY = 0 }, + { def = { item = "POTION" }, cellX = 1, cellY = 0, hidden = true }, + { def = { item = "POTION", fruitTree = true }, cellX = 0, cellY = 1 }, + { def = { item = 0 }, cellX = 0, cellY = 1 }, + { def = { item = "POTION" }, cellX = 99, cellY = 99 }, + }, + tod = "NITE", + player = { cellX = 0, cellY = 0 }, + } + local game = { + data = { + maps = { ACTIVE = def }, + field = { hiddenItems = { ACTIVE = { { x = 0, y = 0 }, { x = 1, y = 1 } } } }, + }, + save = { flags = { HIDDEN = true }, hiddenTaken = { ACTIVE_1_1 = true } }, + overworld = ow, + stack = { states = { ow, { isMenu = true } } }, + } + ow.game = game + return WorldAPI.new(game, "probe"), game, ow +end +local savedLoveRandom, savedMathRandom = love.math.random, math.random +local draws = 0 +love.math.random = function() + draws = draws + 1 + error("query consumed love RNG") +end +math.random = function() + draws = draws + 1 + error("query consumed math RNG") +end +Runtime.hooks = Hooks.new() +local callbacks = 0 +for _, name in ipairs({ "encounter.roll", "encounter.species", "encounter.table" }) do + Runtime.hooks:wrap(name, function() + callbacks = callbacks + 1 + error("query executed encounter hook") + end) +end +for _, version in ipairs({ + "red", + "gold", + "silver", + "crystal", + "emerald", + "firered", + "platinum", + "heartgold", + "black", + "white", +}) do + local api, game, ow = scene(version) + for _, warm in ipairs({ false, true }) do + if warm then + ow.map:blockAt(0, 0) + end + local stable = freeze(game) + local out = assert(api:mapOverview()) + stable(version .. " overview") + T.eq(out.mapId, "ACTIVE", version .. " map ID") + T.eq(out.width, ow.map.widthCells, version .. " width") + T.eq(#out.rows, out.height, version .. " rows") + -- Native classifiers on an independent map give the exact terrain contract. + local native = Map.new(copy(ow.map.def), copy(ow.map.tileset)) + for y = 0, out.height - 1 do + T.eq(#out.rows[y + 1], out.width, version .. " row width") + for x = 0, out.width - 1 do + local expected = native:isWarpTileCell(x, y) and "+" + or native:isWaterCell(x, y) and "~" + or native:isWalkableCell(x, y) and "." + or " " + T.eq(out.rows[y + 1]:sub(x + 1, x + 1), expected, version .. " native terrain") + end + end + T.same(out.markers, { + { kind = "warp", x = 1, y = 1 }, + { kind = "item", x = 0, y = 0 }, + { kind = "hidden", x = 0, y = 0 }, + { + kind = "hidden", + x = 0, + y = 1, + }, + }, version .. " only active markers") + out.markers[1].x = 999 + out.rows[1] = "corrupted" + T.eq(api:mapOverview().markers[1].x, 1, version .. " detached markers") + end + local old = assert(api:mapOverview()) + ow.npcs = {} + game.save.flags.FOUND = true + game.save.hiddenTaken.ACTIVE_0_0 = true + T.eq(#api:mapOverview().markers, 1, version .. " live removals reflected") + T.eq(#old.markers, 4, version .. " old snapshot remains detached") + ow.map:setBlock(0, 0, 0, true) + local stable = freeze(game) + local patched = assert(api:mapOverview()) + stable(version .. " patched map") + T.eq(patched.rows[1]:sub(1, 1), " ", version .. " live shut cell") + if GameVersion.generation() >= 4 then + ow.map.def.behaviorCells = nil + local result, why = api:mapOverview() + T.eq(result, nil, version .. " streamed raster refused") + T.check(why:find("streamed", 1, true), version .. " streamed reason") + end +end +-- Literal terrain reference: whitespace is an impassable cell, not a missing row. +do + local api = scene("red") + T.same(api:mapOverview().rows, { "..~~", "..~~", "..++", "..++" }, "GB literal terrain grid") +end +-- GB visuals use actual 8x8 pixel means/quadrants; no placeholder art on failure. +do + local api, game, ow = scene("gold") + local saved = love.image + love.image = {} + local reads = 0 + love.image.newImageData = function(path) + reads = reads + 1 + T.eq(path, "overview-test.png", "resolved image path") + return { + getDimensions = function() + return 32, 8 + end, + getPixel = function(_, x, y) + local shade = math.floor(x / 8) + if shade == 2 then + shade = (x % 8 < 4 and 0 or 3) + end + local v = 1 - shade / 3 + return v, v, v, 1 + end, + } + end + Assets.invalidate() + local stable = freeze(game) + local out = assert(api:mapOverview()) + stable("visual pure") + T.eq(out.tileRows[1], "00001111", "literal GB tile shades") + T.eq(out.tileRows[5], "22223333", "literal averaged split tile") + T.eq(out.tileDetailRows[9], "0303030333333333", "literal GB quadrants") + T.eq(out.tileWidth, 8, "2x tile width") + T.eq(out.tileDetailWidth, 16, "4x detail width") + out.tileRows[1] = "broken" + out.tileDetailRows[1] = "broken" + T.eq(api:mapOverview().tileRows[1], "00001111", "detached visual rows") + T.eq(reads, 1, "visual cache reused") + Assets.invalidate() + api:mapOverview() + T.eq(reads, 2, "asset reload invalidates visual cache") + love.image.newImageData = function() + error("missing image") + end + Assets.invalidate() + T.eq(api:mapOverview().tileRows, nil, "unavailable art is omitted") + love.image = saved + Assets.invalidate() +end +local function tableDef(rate, buckets, species) + local slots = {} + for i, id in ipairs(species or { "RATTATA", "PIDGEY", "RATTATA" }) do + slots[i] = { species = id, min = 2, max = 7 } + end + return { rate = rate, buckets = buckets or { 51, 102, 256 }, slots = slots } +end +local function encounters(version) + GameVersion.set(version) + local a = tableDef(60) + a.byTime = { + morn = tableDef(80, nil, { "PIDGEY", "PIDGEY", "RATTATA" }), + nite = tableDef(20, nil, { "HOOTHOOT", "RATTATA", "HOOTHOOT" }), + } + local maps = { + A = { id = "A", label = "RouteA", encounters = GameVersion.generation() >= 4 and 7 or nil }, + B = { + id = "B", + }, + } + local data = { + maps = maps, + encounters = { + [maps.A.encounters or "A"] = { + grass = a, + water = tableDef(10, { 128, 256 }, { "MAGIKARP", "POLIWAG" }), + }, + }, + field = {}, + pokemon = { + RATTATA = { name = "Rattata" }, + PIDGEY = { name = "Pidgey" }, + HOOTHOOT = { name = "Hoothoot" }, + MAGIKARP = { name = "Magikarp" }, + POLIWAG = { name = "Poliwag" }, + }, + } + local game = { data = data, save = { party = {}, flags = {} }, stack = { states = {} } } + return WorldAPI.new(game, "probe"), game, a +end +local function nativeParity(api, game, opts) + local hour = opts.daytime == "NITE" and 22 or opts.daytime == "MORNING" and 6 or 12 + local enc = Encounter.forMap(game.data, game.data.maps.A, "A", hour, game.save) + local slots = Encounter.atTime(enc.grass, opts.daytime) + local counts = {} + Encounter.load(game.data) + for pick = 0, (slots.bucketSpan or 256) - 1 do + local n = 0 + local result = Encounter.chooseTable(slots, function(lo) + n = n + 1 + if n == 1 then + return pick + end + return lo + end) + if result then + counts[tostring(result.species)] = (counts[tostring(result.species)] or 0) + 1 + end + end + local stable = freeze(game) + local out = assert(api:effectiveEncounters("A", "grass", opts)) + stable("encounter graph") + T.same(out.dist, counts, "native exhaustive bucket distribution") + local passed = 0 + for gate = 0, (slots.rateMax or 256) - 1 do + local n = 0 + local result = Encounter.rollTable(slots, function(lo) + n = n + 1 + if n == 1 then + return gate + end + return lo + end) + if result then + passed = passed + 1 + end + end + T.eq(out.chance, passed / (slots.rateMax or 256), "native exhaustive rate gate") + return out +end +for _, version in ipairs({ "red", "gold", "silver", "crystal", "emerald", "firered" }) do + local api, game = encounters(version) + for _, period in ipairs({ "MORNING", "DAY", "NITE" }) do + nativeParity(api, game, { daytime = period }) + end + local out = api:effectiveEncounters("A", "grass", { daytime = "DAY" }) + T.same(out.dist, { RATTATA = 205, PIDGEY = 51 }, version .. " duplicate species aggregated") + out.dist.RATTATA = 0 + T.eq(api:effectiveEncounters("A", "grass", { daytime = "DAY" }).dist.RATTATA, 205, "distribution detached") + T.same( + api:effectiveEncounters("A", "indoor", { daytime = "NITE" }), + api:effectiveEncounters("A", "grass", { daytime = "NITE" }), + "indoor grass alias" + ) + T.same( + api:effectiveEncounters("A", "water", { daytime = "NITE" }).dist, + { MAGIKARP = 128, POLIWAG = 128 }, + "water table" + ) + T.same(api:effectiveEncounters("B", "grass"), { chance = 0, dist = {} }, "known map with no encounters") + T.eq(api:effectiveEncounters("missing", "grass"), nil, "unknown map refused") + for _, bad in ipairs({ "fishing", "darkGrass", false }) do + T.eq(api:effectiveEncounters("A", bad), nil, "terrain refused") + end + T.eq(api:effectiveEncounters("A", "grass", "NITE"), nil, "options must be table") + T.eq(api:effectiveEncounters("A", "grass", { daytime = "dusk" }), nil, "invalid time refused") + T.same( + api:effectiveEncounters("A", "grass", { daytime = "night" }), + api:effectiveEncounters("A", "grass", { daytime = "NITE" }), + "time aliases" + ) + game.data.encounters = nil + T.eq(api:effectiveEncounters("A", "grass"), nil, "unloaded data refused") +end +-- Gen2 outbreaks use the same native map selection for all three cartridges. +for _, version in ipairs({ "gold", "silver", "crystal" }) do + local api, game = encounters(version) + game.data.field.gen2Swarms = { + maps = { RouteA = { grass = tableDef(200, nil, { "HOOTHOOT", "HOOTHOOT", "HOOTHOOT" }) } }, + flags = { [0] = 17 }, + } + game.save.g2Swarm = { kind = 0, map = "A" } + if version == "crystal" then + game.save.flags[require("src.script.Gen2Flags").scriptFlag(17)] = true + end + nativeParity(api, game, { daytime = "DAY" }) + T.same( + api:effectiveEncounters("A", "grass", { daytime = "DAY" }).dist, + { HOOTHOOT = 256 }, + version .. " outbreak table" + ) + game.save.g2Swarm = nil + T.eq( + api:effectiveEncounters("A", "grass", { daytime = "DAY" }).dist.RATTATA, + 205, + "outbreak ends immediately" + ) +end +-- DS rates/bucket spans, time substitutions, daily replacements and seasons. +for _, version in ipairs({ "platinum", "heartgold", "black", "white" }) do + local api, game = encounters(version) + local slots = {} + for i, chance in ipairs({ 20, 20, 10, 10, 10, 10, 5, 5, 4, 4, 1, 1 }) do + slots[i] = { species = i % 2 == 0 and "PIDGEY" or "RATTATA", level = 5, chance = chance } + end + local raw = { + grassRate = 30, + grass = slots, + surf = { + rate = 20, + slots = { { species = "MAGIKARP", level = 5, chance = 100 } }, + }, + } + game.data.encounters[7] = raw + if version == "black" or version == "white" then + raw.family = "bw" + raw.seasons = + { winter = { grassRate = 50, grass = { { species = "HOOTHOOT", level = 5, chance = 100 } } } } + game.save.gen5Season = 3 + end + for _, period in ipairs({ "MORNING", "DAY", "NITE" }) do + nativeParity(api, game, { daytime = period }) + end + if raw.family == "bw" then + T.same(api:effectiveEncounters("A", "grass").dist, { HOOTHOOT = 100 }, "BW current season") + game.save.gen5Season = 0 + T.eq(api:effectiveEncounters("A", "grass").chance, 0.3, "BW season changes immediately") + end +end +-- Cold DS resolver views: timed slots and daily replacements are reflected +-- without initializing save state or exposing mutable source slot records. +for _, version in ipairs({ "platinum", "heartgold" }) do + local api, game = encounters(version) + local slots = {} + for i, chance in ipairs({ 20, 20, 10, 10, 10, 10, 5, 5, 4, 4, 1, 1 }) do + slots[i] = { species = "RATTATA", level = 5, chance = chance } + end + local raw = { grassRate = 30, grass = slots, day = { 25, 25 }, night = { 163, 163 }, swarm = { 129, 129 } } + if version == "heartgold" then + raw.timed = { day = {}, night = {} } + for i = 1, 12 do + raw.timed.day[i] = 25 + raw.timed.night[i] = 163 + end + end + game.data.pokemon[25] = { name = "Pikachu" } + game.data.pokemon[163] = { name = "Hoothoot" } + game.data.pokemon[129] = { name = "Magikarp" } + game.data.encounters[7] = raw + local stable = freeze(game) + local day = assert(api:effectiveEncounters("A", "grass", { daytime = "DAY" })) + local night = assert(api:effectiveEncounters("A", "grass", { daytime = "NITE" })) + T.eq(day.dist.PIKACHU, version == "heartgold" and 100 or 20, "DS time species day") + T.eq(night.dist.HOOTHOOT, version == "heartgold" and 100 or 20, "DS time species night") + stable("cold DS timed query") + local Swarms = require("src.world.Gen4Swarms") + game.data.maps.A.header = Swarms.HEADERS[1] + game.save.gen4Swarm = { enabled = true, daily = 0, rand = 123 } + stable = freeze(game) + local outbreak = assert(api:effectiveEncounters("A", "grass", { daytime = "DAY" })) + T.eq(outbreak.dist.MAGIKARP, 40, "DS daily swarm slots") + stable("DS daily swarm read") + outbreak.dist.MAGIKARP = 0 + T.eq( + api:effectiveEncounters("A", "grass", { daytime = "DAY" }).dist.MAGIKARP, + 40, + "DS distribution detached" + ) + game.save.gen4Swarm.enabled = false + if version == "platinum" then + game.data.maps.A.header = 287 + game.save.pokedex = { national = true } + game.save.gen4TrophyGarden = { slot1 = 0, slot2 = 1 } + game.data.gen4_special_encounters = { trophyGarden = { 25, 163 } } + stable = freeze(game) + local garden = assert(api:effectiveEncounters("A", "grass", { daytime = "MORNING" })) + T.eq(garden.dist.PIKACHU, 5, "garden first daily slot") + T.eq(garden.dist.HOOTHOOT, 5, "garden second daily slot") + stable("DS garden read") + end +end +-- A water table is not time-swapped by the native step path. +do + local api, game = encounters("gold") + game.data.encounters.A.water.byTime = { nite = tableDef(255, { 256 }, { "HOOTHOOT" }) } + T.same( + api:effectiveEncounters("A", "water", { daytime = "NITE" }).dist, + { MAGIKARP = 128, POLIWAG = 128 }, + "water ignores land time selector" + ) +end +-- Numeric IDs follow the shared public name contract, including loader aliases. +do + local api, game = encounters("platinum") + game.data.pokemon[25] = { id = 25, name = "Pikachu" } + game.data.encounters[7] = { grass = tableDef(50, { 256 }, { 25 }) } + T.same(api:effectiveEncounters("A", "grass").dist, { PIKACHU = 256 }, "numeric species -> portable ID") +end +-- Default Gen2 period follows the clock, and queries never run world.tod hooks. +do + local api, game = encounters("gold") + local date = os.date + os.date = function(format) + if format == "%H" then + return "23" + end + return date(format) + end + T.same( + api:effectiveEncounters("A", "grass"), + api:effectiveEncounters("A", "grass", { daytime = "NITE" }), + "live Gen2 clock" + ) + Runtime.hooks:wrap("world.tod", function() + callbacks = callbacks + 1 + end) + game.overworld = { isOverworld = true, map = { id = "A" }, tod = "MORNING" } + game.stack.states = { game.overworld } + T.same( + api:effectiveEncounters("A", "grass"), + api:effectiveEncounters("A", "grass", { daytime = "MORNING" }), + "cached effective mod period" + ) + os.date = date +end +-- Malformed tables and exceptional positional tables fail explicitly. +do + local api, game, a = encounters("red") + for _, bad in ipairs({ 0, -1, math.huge, 0 / 0 }) do + a.rateMax = bad + T.eq(api:effectiveEncounters("A", "grass"), nil, "invalid denominator") + end + a.rateMax = nil + a.buckets = { 51, 50, 256 } + T.eq(api:effectiveEncounters("A", "grass"), nil, "descending buckets refused") + a.buckets = { 51, 102, 257 } + T.eq(api:effectiveEncounters("A", "grass"), nil, "unreachable bucket refused") + a.buckets = "bad" + T.eq(api:effectiveEncounters("A", "grass"), nil, "invalid bucket type refused") + a.buckets = { 51, 102, 256 } + local originalSlots = a.slots + a.slots = "bad" + T.eq(api:effectiveEncounters("A", "grass"), nil, "invalid slot type refused") + a.slots = originalSlots + a.buckets = { 51, 102, 256 } + a.rate = 0 + T.same(api:effectiveEncounters("A", "grass"), { chance = 0, dist = {} }, "zero rate") + a.rate = 999 + T.eq(api:effectiveEncounters("A", "grass").chance, 1, "saturated rate") + GameVersion.set("heartgold") + local S = require("src.world.HgssSafariZone") + game.data.maps.A.layout = S.LAYOUT + game.data.maps.A.cameraFamily = "hgss" + local stable = freeze(game) + T.eq(api:effectiveEncounters("A", "grass"), nil, "Safari query refused") + stable("Safari does not seed save") + GameVersion.set("emerald") + for _, id in ipairs({ + require("src.script.Gen3BattlePike").WILD_ROOM, + require("src.script.Gen3BattlePyramid").FLOOR, + }) do + game.data.maps[id] = { id = id } + T.eq(api:effectiveEncounters(id, "grass"), nil, "facility query refused") + end +end +-- API1/2 native mod loading, lazy capability queries and live table patches. +for _, apiVersion in ipairs({ 1, 2 }) do + GameVersion.set("gold") + local data = T.fixtures.fresh() + local files = { + ["mods/probe/manifest.json"] = Json.encode({ + id = "probe", + name = "Probe", + version = "1.0.0", + api = apiVersion, + entry = "main.lua", + content_format = os.getenv("WORLD_OVERVIEW_ALL_FORMATS") == "1" and apiVersion == 2 and "unified" + or nil, + callback_format = os.getenv("WORLD_OVERVIEW_ALL_FORMATS") == "1" and apiVersion == 2 and "unified" + or nil, + script_format = os.getenv("WORLD_OVERVIEW_ALL_FORMATS") == "1" and apiVersion == 2 and "unified" or nil, + }), + ["mods/probe/main.lua"] = "return function(mod) mod.exports.api=mod end", + } + local fs = { + read = function(p) + return files[p] + end, + load = function(p) + return loadstring(files[p], p) + end, + getDirectoryItems = function(p) + return p == "mods" and { "probe" } or {} + end, + getInfo = function(p) + if p == "mods" or p == "mods/probe" then + return { type = "directory" } + elseif files[p] then + return { type = "file" } + end + end, + } + local loader = Loader.new({ fs = fs, dev = false }) + local resolves = 0 + loader._game = function(self) + resolves = resolves + 1 + return self.game + end + T.check(loader:load(data), "real loader succeeds") + local mod = loader.apis.probe + if os.getenv("WORLD_OVERVIEW_ALL_FORMATS") == "1" and apiVersion == 2 then + T.eq(mod.contentFormat, "unified", "unified content facade") + T.eq(mod.callbackFormat, "unified", "unified callback facade") + T.eq(mod.scriptFormat, "unified", "portable script facade") + end + T.eq(resolves, 0, "entry and ID context stay lazy") + if mod.supports then + T.eq(mod:supports("world", "mapOverview"), true, "overview capability") + T.eq(mod:supports("world", "effectiveEncounters"), true, "encounter capability") + T.eq(resolves, 0, "capability query stays lazy") + end + loader.game = { data = data, save = {}, stack = { states = {} } } + local world = mod.world + T.eq(world:mapOverview(), nil, "title screen overview is quiet") + local out = assert(world:effectiveEncounters("FIX_ROUTE", "grass")) + T.check(next(out.dist) ~= nil, "query available at title screen") + local before = out.chance + local id = next(out.dist) + data.pokemon[id].name = "Renamed presentation" + T.check( + world:effectiveEncounters("FIX_ROUTE", "grass").dist[id] ~= nil, + "public identity survives name patch" + ) + data.encounters.FIX_ROUTE.grass.rate = 100 + T.check(world:effectiveEncounters("FIX_ROUTE", "grass").chance ~= before, "live data patch reflected") +end +T.eq(draws, 0, "all queries consume zero RNG draws") +T.eq(callbacks, 0, "all queries execute zero hook callbacks") +love.math.random, math.random = savedLoveRandom, savedMathRandom +Runtime.hooks = savedHooks +GameVersion.set(savedVersion) +T.finish("world overview and encounter API") diff --git a/tests/engine/world_read_api.lua b/tests/engine/world_read_api.lua new file mode 100644 index 000000000..b7beda07f --- /dev/null +++ b/tests/engine/world_read_api.lua @@ -0,0 +1,415 @@ +-- ROM-free tests through the real loader and real runtime Map objects. +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.harness") +local Loader = require("src.mods.Loader") +local WorldAPI = require("src.world.WorldAPI") +local Map = require("src.world.Map") +local MapLoader = require("src.world.MapLoader") +local Merge = require("src.mods.Merge") +local Runtime = require("src.mods.Runtime") +local GameVersion = require("src.core.GameVersion") +local Json = require("src.link.Json") +local Reads = require("src.mods.WorldReads") +local savedLove, savedVersion = love, GameVersion.get() +local savedEvents, savedHooks, savedErrors = Runtime.events, Runtime.hooks, Runtime.errors +love = nil +local function pack16(...) + local out = {} + for _, v in ipairs({ ... }) do + out[#out + 1] = string.char(v % 256, math.floor(v / 256)) + end + return table.concat(out) +end +local function map(id, cells, packed, ds) + return Map.new({ + id = id, + width = 3, + height = 2, + blocks = packed and pack16(0, 1 + 3 * 1024, 2 + 9 * 4096, 3, 4, 1023) or { 0, 1, 2, 3, 4, 1023 }, + borderBlock = 999, + generation = ds and 4 or nil, + }, { blockCells = cells, blockTiles = cells * 2, walkable = {}, blocks = {} }) +end +local function boot(version, entry) + GameVersion.set(version) + local gen = GameVersion.generation() + local s, m, item = gen >= 4 and 25 or "PIKACHU", gen >= 4 and 33 or "TACKLE", gen >= 4 and 13 or "POTION" + local species = { id = s, name = "Pikachu" } + local move = { id = m, name = "Tackle", pp = 35 } + local data = { + pokemon = { [s] = species }, + moves = { [m] = move }, + items = { + [item] = { id = item, name = "Potion" }, + }, + } + if gen >= 4 then + data.pokemon.SPECIES_25 = species + data.moves.MOVE_33 = move + end + local mon = { + species = s, + item = item, + nickname = "Buddy", + name = "native name", + level = 7, + hp = 19, + status = "PSN", + stats = { hp = 23, attack = 10, defense = 11, speed = 12, special = 13 }, + moves = { { id = m, pp = 29, ppUps = 1, hidden = false } }, + ivs = { hp = 27 }, + evs = { speed = 3 }, + personality = 12345, + extension = { flag = false }, + isEgg = false, + } + if gen > 1 then + mon.stats.spatk = 13 + mon.stats.spdef = 14 + end + local active = map("ACTIVE", gen <= 2 and 2 or 1, gen >= 3, gen >= 4) + local neighbor = map("NORTH", gen <= 2 and 2 or 1, gen >= 3, gen >= 4) + local ow = { isOverworld = true, map = active, neighbors = { { map = neighbor, cx = -2, cy = -4 } } } + local game = { + data = data, + save = { party = { mon }, money = 500 }, + stack = { states = { ow, { isMenu = true } } }, + overworld = ow, + } + local files = { + ["mods/probe/manifest.json"] = Json.encode({ + id = "probe", + name = "Probe", + version = "1.0.0", + api = 2, + entry = "main.lua", + }), + ["mods/probe/main.lua"] = entry or "return function(mod) mod.exports.api=mod end", + } + local fs = { + read = function(p) + return files[p] + end, + load = function(p) + return loadstring(files[p], p) + end, + getDirectoryItems = function(p) + return p == "mods" and { "probe" } or {} + end, + getInfo = function(p) + if p == "mods" or p == "mods/probe" then + return { type = "directory" } + end + if files[p] then + return { type = "file" } + end + end, + } + local loader = Loader.new({ fs = fs, dev = false }) + local resolves = 0 + loader._game = function() + resolves = resolves + 1 + return game + end + assert(loader:load(data), table.concat(loader.errors, "; ")) + local api = assert(loader.apis.probe) + return api, loader, game, ow, { s, m, item }, function() + return resolves + end +end + +-- Fail if any reader tries to load a map, generate a Pokemon or use RNG. +local oldMapLoad = MapLoader.load +local Pokemon = require("src.pokemon.Pokemon") +local oldNew = Pokemon.new +local oldRandom = math.random +local function forbidden() + error("read crossed into gameplay") +end +MapLoader.load, Pokemon.new, math.random = forbidden, forbidden, forbidden + +for _, version in ipairs(GameVersion.ORDER) do + local api, loader, game, ow, ids, resolves = boot(version) + local label = version .. ": " + T.eq(resolves(), 0, label .. "entry and read metadata do not instantiate Game") + if api.supports then + for _, name in ipairs({ "party", "liveMaps", "activeBlockAt" }) do + T.eq(api:supports("world", name), true, label .. "capability " .. name) + end + T.eq(resolves(), 0, label .. "capability probes do not instantiate Game") + end + local before = Merge.deepCopy(game) + local world = api.world + T.eq(api.world, world, label .. "same cached facade") + local party = world:party() + local mon = party[1] + T.eq(mon.species, "PIKACHU", label .. "symbolic species") + T.eq(mon.item, "POTION", label .. "symbolic item") + T.eq(mon.moves[1].id, "TACKLE", label .. "symbolic move") + T.eq(mon.moves[1].pp, 29, label .. "current PP") + T.eq(mon.moves[1].maxPp, 42, label .. "PP Ups use native rounding") + T.eq(mon.moves[1].ppUps, 1, label .. "PP Ups exposed") + T.eq(mon.maxHp, 23, label .. "max HP from stats.hp") + T.eq(mon.stats.specialAttack, 13, label .. "special attack") + T.eq(mon.stats.specialDefense, GameVersion.generation() == 1 and 13 or 14, label .. "special defense") + T.eq(mon.status, "poison", label .. "status normalized") + T.eq(mon.isEgg, false, label .. "false flags preserved") + T.eq(mon.native.extension.flag, false, label .. "native false extension preserved") + T.eq(mon.native.ivs.hp, 27, label .. "native IV diagnostics") + T.eq(getmetatable(mon), nil, label .. "plain detached mon") + mon.species = "OTHER" + mon.stats.hp = 1 + mon.moves[1].pp = 0 + mon.native.ivs.hp = 0 + party[2] = { species = "INJECTED" } + T.same(game, before, label .. "party mutations do not reach game") + local nextParty = world:party() + T.eq(nextParty[1].stats.hp, 23, label .. "fresh nested stat snapshot") + T.eq(nextParty[1].moves[1].pp, 29, label .. "fresh move snapshot") + T.eq(#nextParty, 1, label .. "party membership detached") + game.save.party[1].hp = 18 + T.eq(world:party()[1].hp, 18, label .. "reads current native HP") + game.save = { party = { { species = ids[1], hp = 1, stats = { hp = 2 }, moves = {} } } } + T.eq(world:party()[1].hp, 1, label .. "held facade follows a replaced save") + game.save = before.save + + local cells = ow.map.blockCells + before = Merge.deepCopy(game) + local id, metadata = world:activeBlockAt("ACTIVE", 0, 0) + T.eq(id, 0, label .. "block zero is present") + T.eq(metadata.tileSize, cells * 16, label .. "block pixel size from runtime map") + T.eq(metadata.cellSize, 16, label .. "walk-cell pixel size") + T.eq(metadata.coordinateBase, 0, label .. "zero-based block coordinates") + T.eq(metadata.width, 3, label .. "block width") + T.eq(metadata.height, 2, label .. "block height") + local gen = GameVersion.generation() + T.eq( + metadata.kind, + gen >= 4 and "runtime_cell" or gen >= 3 and "metatile" or "block", + label .. "honest block kind" + ) + T.eq(world:activeBlockAt("ACTIVE", 1, 0), 1, label .. "packed collision bits stripped") + T.eq(world:activeBlockAt("ACTIVE", 2, 0), 2, label .. "packed elevation bits stripped") + T.eq(world:activeBlockAt("ACTIVE", 2, 1), 1023, label .. "last block / full 10-bit ID") + T.eq(ow.map.def._blockArray, nil, label .. "cold packed grid remains undecoded") + metadata.width = 999 + T.eq(select(2, world:activeBlockAt("ACTIVE", 0, 0)).width, 3, label .. "metadata detached") + for _, xy in ipairs({ + { -1, 0 }, + { 0, -1 }, + { 3, 0 }, + { 0, 2 }, + { 0.5, 0 }, + { 0, 0.5 }, + { math.huge, 0 }, + { + 0, + 0 / 0, + }, + { + "1", + 0, + }, + }) do + local value, reason = world:activeBlockAt("ACTIVE", xy[1], xy[2]) + T.check(value == nil and type(reason) == "string", label .. "invalid/off-map coordinates rejected") + end + local missing, reason = world:activeBlockAt("NORTH", 0, 0) + T.check(missing == nil and reason == "map is not active", label .. "stale map rejected") + T.same(game, before, label .. "block reads do not alter world or decoder cache") + ow.map:setBlock(1, 0, 0) + local patched = Merge.deepCopy(game) + T.eq(world:activeBlockAt("ACTIVE", 1, 0), 0, label .. "live zero-valued block patch wins") + T.same(game, patched, label .. "patch read remains pure") + ow.map.blockPatch[2] = 777 + T.eq(world:activeBlockAt("ACTIVE", 1, 0), 777, label .. "reads current runtime patch") + local oldMap = ow.map + ow.map = ow.neighbors[1].map + T.eq(world:activeBlockAt("ACTIVE", 0, 0), nil, label .. "held facade rejects map after warp") + T.eq(world:activeBlockAt("NORTH", 0, 0), 0, label .. "held facade reads new active map") + ow.map = oldMap + + before = Merge.deepCopy(game) + local legacy = world:loadedMaps() + local maps = world:liveMaps() + T.eq(#maps, 2, label .. "only loaded maps returned") + T.eq(maps[1].mapId, "ACTIVE", label .. "active first") + T.eq(maps[1].active, true, label .. "active tagged") + T.eq(maps[1].width, 3 * cells, label .. "map dimensions in cells") + T.eq(maps[2].mapId, "NORTH", label .. "loaded neighbor included") + T.eq(maps[2].x, -2, label .. "neighbor x offset in cells") + T.eq(maps[2].y, -4, label .. "neighbor y offset in cells") + T.eq(maps[2].block.tileSize, cells * 16, label .. "per-map block units") + maps[1].mapId = "MUTATED" + maps[2].block.width = 9 + maps[2].x = 123 + T.same(game, before, label .. "live map snapshots detached") + T.same(world:loadedMaps(), legacy, label .. "legacy loadedMaps shape unchanged") + T.eq(world:liveMaps()[2].x, -2, label .. "fresh map snapshots") + + game.stack.states = {} + game.overworld = nil + T.eq(world:activeBlockAt("ACTIVE", 0, 0), nil, label .. "no active world block result") + T.eq(select(2, world:activeBlockAt("ACTIVE", 0, 0)), "no overworld", label .. "no world reason") + T.eq(world:liveMaps(), nil, label .. "no active world maps result") + T.check(world:party() ~= nil, label .. "party available without world") + game.save = nil + T.eq(world:party(), nil, label .. "no save party result") + T.eq(select(2, world:party()), "no save", label .. "no save reason") +end + +MapLoader.load, Pokemon.new, math.random = oldMapLoad, oldNew, oldRandom +-- Trap LÖVE's RNG too when the host provides one; queries remain ordinary +-- data reads in a host with no graphics/filesystem surface. +love = { math = { random = forbidden } } +local rngApi = WorldAPI.new( + { data = {}, save = { party = {} }, overworld = { isOverworld = true, map = map("RNG", 1, true, false) } }, + "rng" +) +T.check(rngApi:party() ~= nil, "party never calls love.math.random") +T.eq(rngApi:activeBlockAt("RNG", 0, 0), 0, "block never calls love.math.random") +T.eq(#rngApi:liveMaps(), 1, "map list never calls love.math.random") +love = nil +-- Direct engine construction also produces symbolic records without a loader. +local data = { + pokemon = { [999] = { name = "Mr. Mime" } }, + moves = { [888] = { name = "Ember", pp = 25 } }, + items = { + [777] = { name = "Potion" }, + }, +} +local raw = { + species = 999, + item = 777, + hp = 3, + stats = { hp = 9 }, + moves = { { id = 888, pp = 5 } }, + extension = { + flag = false, + }, +} +local direct = WorldAPI.new({ data = data, save = { party = { raw } } }, "direct") +local before = Merge.deepCopy(data) +local p = direct:party() +T.eq(p[1].species, "MR_MIME", "standalone numeric species resolves by imported name") +T.eq(p[1].moves[1].id, "EMBER", "standalone numeric move resolves") +T.eq(p[1].native.extension.flag, false, "standalone false diagnostics retained") +T.same(data, before, "standalone resolver does not mutate data") +raw.self = raw +raw.callback = function() end +raw[{}] = { hidden = true } +local cyclic = direct:party()[1] +T.eq(cyclic.native.self, cyclic.native, "cyclic diagnostics detached consistently") +T.eq(cyclic.native.callback, nil, "diagnostics do not expose functions") +local tableKey = false +for k in pairs(cyclic.native) do + if type(k) == "table" then + tableKey = true + end +end +T.eq(tableKey, false, "diagnostics do not retain native table keys") +raw.self = nil +raw.callback = nil + +-- Metadata comes from the actual tileset even when its size is unusual. +local mixed = map("MIXED", 3, false, false) +local mixedApi = WorldAPI.new({ overworld = { isOverworld = true, map = mixed } }, "mixed") +T.eq( + select(2, mixedApi:activeBlockAt("MIXED", 0, 0)).tileSize, + 48, + "custom block size is not inferred from generation" +) +local missingMap = map("SHORT", 1, true, false) +missingMap.def.blocks = "\1" +local shortApi = WorldAPI.new({ overworld = { isOverworld = true, map = missingMap } }, "short") +T.eq(shortApi:activeBlockAt("SHORT", 0, 0), nil, "truncated packed block refused") +missingMap.def.blocks = {} +T.eq(shortApi:activeBlockAt("SHORT", 0, 0), nil, "missing array cell refused") +missingMap.def.blocks = { -1 } +T.eq(shortApi:activeBlockAt("SHORT", 0, 0), nil, "negative block ID refused") +missingMap.def.blocks = pack16(1) +missingMap.def._blockArray = { 7 } +T.eq(shortApi:activeBlockAt("SHORT", 0, 0), 7, "warm decoder cache matches Map:blockAt") +local cache = missingMap.def._blockArray +T.eq(cache[1], 7, "warm cache unmodified") +missingMap.blockTiles = 3 +T.eq(shortApi:activeBlockAt("SHORT", 0, 0), nil, "inconsistent runtime geometry refused") +local placements = { isOverworld = true, map = map("HOME", 1, false, false) } +local repeated = map("NORTH", 1, false, false) +placements.neighbors = { + { map = repeated, cx = 0, cy = -2 }, + { map = repeated, cx = 0, cy = -2 }, + { map = repeated, cx = 3, cy = 0 }, + { map = placements.map, cx = 0, cy = 0 }, + { map = map("OTHER", 2, false, false), cx = -4, cy = 0 }, +} +local list = WorldAPI.new({ overworld = placements }, "placements"):liveMaps() +T.eq(#list, 4, "duplicate placements collapse, different placements of the same map remain") +T.eq(list[2].mapId, list[3].mapId, "two placements share map identity") +T.eq(list[3].x, 3, "second placement offset preserved") +T.eq(list[4].block.tileSize, 32, "mixed-neighborhood block sizes describe each map") +T.eq(list[4].width, 6, "mixed-neighborhood width remains in walk cells") +local empty = WorldAPI.new(nil, "empty") +T.eq(empty:party(), nil, "no Game party read safe") +T.eq(empty:liveMaps(), nil, "no Game map read safe") +T.eq(empty:activeBlockAt("MAP", 0, 0), nil, "no Game block read safe") + +-- IDs stay bound to pre-merge identity despite presentation patches and +-- registrations made after the API/context was constructed. +local api, loader, game = boot( + "platinum", + [[return function(mod) + mod.exports.api=mod + mod.content.pokemon:patch("SPECIES_25",{name="A localized display name"}) + mod.content.pokemon:register("CUSTOMMON",{ + id="CUSTOMMON",dex=999,name="Another display name",types={"NORMAL"}, + catchRate=45,baseExp=64,level1Moves={},growthRate="MEDIUM_FAST", + learnset={},evolutions={},spriteFront="front.png",spriteBack="back.png",frontSize=5, + baseStats={hp=40,attack=40,defense=40,speed=40,special=40}, + }) +end]] +) +game.save.party[1].species = "SPECIES_25" +game.save.party[2] = { species = "CUSTOMMON", level = 1, stats = {}, moves = {} } +T.eq(api.world:party()[1].species, "PIKACHU", "patched alias keeps canonical pre-merge ID") +T.eq(api.world:party()[2].species, "CUSTOMMON", "late mod registration uses its authored ID") +local emptyParty = game.save.party +game.save.party = {} +T.same(api.world:party(), {}, "empty saved party returns an empty snapshot") +game.save.party = emptyParty +game.save.party[1].item = 0 +game.save.party[1].status = nil +game.save.party[1].isEgg = true +T.eq(api.world:party()[1].item, nil, "no-item native sentinel is absent") +T.eq(api.world:party()[1].status, nil, "healthy status is absent") +T.eq(api.world:party()[1].isEgg, true, "egg flag exposed") +-- Optional integration gate against #92's actual event record encoder. +if pcall(require, "src.mods.UnifiedCallbacks") then + local Unified = require("src.mods.UnifiedCallbacks") + Unified.install(loader, api, "probe", 4) + local eventMon + api.events:on("pokemon.received", function(event) + eventMon = event.mon + end) + loader.events:emit("pokemon.received", { mon = game.save.party[1] }) + T.same(api.world:party()[1], eventMon, "party/event live record shapes match exactly") +end + +-- Verify both normal RNG streams independently, without relying only on the +-- tripwire above. Lua's sequence must remain unchanged by the real readers. +math.randomseed(24680) +local expected = { math.random(), math.random(), math.random() } +math.randomseed(24680) +api.world:party() +api.world:activeBlockAt("ACTIVE", 1, 0) +api.world:liveMaps() +T.same({ math.random(), math.random(), math.random() }, expected, "reader calls leave RNG sequence unchanged") +for _, name in ipairs({ "party", "activeBlockAt", "liveMaps" }) do + T.check(Reads.supports(name), "new capability " .. name) +end +T.check(not Reads.supports("flyTo") and not Reads.supports({}), "reader capability list stays narrow") + +GameVersion.set(savedVersion) +Runtime.install(savedEvents, savedHooks, savedErrors) +love = savedLove +T.finish() diff --git a/tests/follow_me/public_effect.lua b/tests/follow_me/public_effect.lua new file mode 100644 index 000000000..2ca274189 --- /dev/null +++ b/tests/follow_me/public_effect.lua @@ -0,0 +1,50 @@ +-- Composed proof: requires the canonical effect adapter from PR #121. +-- Run explicitly after integrating both PRs; it must fail without either one. +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +require("src.mods.UnifiedEffects") +for _, version in ipairs({ "firered", "emerald" }) do + local entry = [[return function(mod) + assert(mod.content.moves:patch("TACKLE", { + effect = "FOLLOW_ME", power = 0 + })) + mod.exports.effect = assert(mod.content.moves:get("TACKLE")).effect + mod.exports.hits = {} + mod.hooks:wrap("battle.enemy_action", function() + return { move = "SCRATCH", target = 2 } + end) + mod.events:on("battle.damage_dealt", function(e) + mod.exports.hits[#mod.exports.hits + 1] = e.target.mon.nickname + end) + end]] + local function configure(data, _, ids) + local move = data.moves[ids.TACKLE] + move.target, move.priority, move.protectAffected = 16, 3, false + end + local f = require("tests.enemy_actions.fixture").new(".", "gen2", version, 3, "unified", configure, entry) + local e = f.e + T.eq(#f.result.errors, 0, version .. " real API-3 effect mod loads") + T.eq(e.effect, "FOLLOW_ME", "public canonical read matches authored effect") + T.eq(f.data.moves[f.ids.TACKLE].effect, "FOLLOW_ME_EFFECT", "canonical patch selects real native handler") + T.eq(f.data.moves[f.ids.TACKLE].gen3Effect, 172, "canonical patch preserves native semantic number") + local b = f.fresh("TACKLE", { enemy = true }).battle + b.double = true + local right = require("src.battle.BattleState").makeBattler( + f.data, + require("src.mods.Merge").deepCopy(b.player.mon), + true + ) + b:placeBattler(2, right) + b:syncSides() + b.player.mon.nickname, right.mon.nickname = "CENTER", "ORIGINAL" + b.player.curMoves = { { id = f.ids.TACKLE, pp = 10 } } + b.enemy.curMoves = { { id = f.ids.SCRATCH, pp = 10 } } + b.player.mon.moves, b.enemy.mon.moves = b.player.curMoves, b.enemy.curMoves + b:executeAction(b.player, b.player, b.player.curMoves[1]) + b:executeAction(b.enemy, right, b:enemyAction(b.enemy)) + T.same(e.hits, { "CENTER" }, "public effect patch redirects a real mod-selected native attack") + T.eq(b.player.curMoves[1].pp, 9, "canonical producer spends owned PP") + T.eq(b.enemy.curMoves[1].pp, 9, "redirected action spends caller PP") + f.close() +end +T.finish("composed public Follow Me effect") diff --git a/tests/gba_damage_base/cases.lua b/tests/gba_damage_base/cases.lua new file mode 100644 index 000000000..e2fdf1b9b --- /dev/null +++ b/tests/gba_damage_base/cases.lua @@ -0,0 +1,139 @@ +-- Compare the actual ordinary GBA damage calculators with equal session inputs. +local C = {} +function C.run(backend, version) + local T = require("tests.modkit") + require("src.core.GameVersion").set(version) + local native = backend == "gen1" + local D = require(native and "src.core.game3.battle.damage" or "src.battle.Damage") + local rules = not native and require("src.battle.rulesets.gen3_emerald") + local ids = { NORMAL = 0, GHOST = 7, STEEL = 8, FIRE = 10, WATER = 11, GRASS = 12 } + local data = { + items = {}, + constants = { generation = 3 }, + type_chart = { + types = {}, + matchups = { + { attacker = "NORMAL", defender = "GHOST", multiplier = 0 }, + { attacker = "NORMAL", defender = "STEEL", multiplier = 5 }, + }, + }, + } + for name, id in pairs(ids) do + data.type_chart.types[name] = { id = name, index = id, category = id <= 8 and "physical" or "special" } + end + if not native then + require("src.battle.TypeChart").load(data) + end + local contexts = { + { name = "normal-stab" }, + { name = "no-stab", userType = "FIRE" }, + { name = "burn", status = "BRN" }, + { name = "reflect", reflect = true }, + { name = "double-reflect", reflect = true, double = true }, + { name = "spread", spread = true }, + { name = "spread-reflect", spread = true, reflect = true, double = true }, + { name = "resistance", targetType = "STEEL" }, + { name = "immunity", targetType = "GHOST" }, + { name = "rain-fire", moveType = "FIRE", weather = "RAIN" }, + { name = "sun-fire", moveType = "FIRE", weather = "SUN" }, + { name = "rain-water", moveType = "WATER", weather = "RAIN" }, + { name = "sun-water", moveType = "WATER", weather = "SUN" }, + { name = "light-screen", moveType = "FIRE", lightScreen = true }, + { name = "double-light-screen", moveType = "FIRE", lightScreen = true, double = true }, + { name = "rain-solarbeam", moveType = "GRASS", weather = "RAIN", solar = true }, + { name = "sand-solarbeam", moveType = "GRASS", weather = "SAND", solar = true }, + { name = "hail-solarbeam", moveType = "GRASS", weather = "HAIL", solar = true }, + } + local function battler(level, attack, defense, kind, status) + if native then + return { + mon = { + level = level, + hp = 6000, + maxHp = 6000, + attack = attack, + defense = defense, + spAtk = attack, + spDef = defense, + speed = 50, + ability = 0, + status = status, + }, + type1 = ids[kind], + type2 = ids[kind], + ability = 0, + stages = { attack = 0, defense = 0, spAtk = 0, spDef = 0 }, + } + end + return { + mon = { level = level, hp = 6000, status = status }, + curStats = { attack = attack, defense = defense, spatk = attack, spdef = defense, special = attack }, + curTypes = { kind }, + stages = { attack = 0, defense = 0, spatk = 0, spdef = 0 }, + } + end + local rows = {} + for _, ctx in ipairs(contexts) do + for _, level in ipairs({ 1, 5, 29, 30, 50, 100 }) do + for _, power in ipairs({ 2, 35, 120, 250 }) do + for _, stats in ipairs({ { 50, 50 }, { 255, 257 }, { 300, 50 }, { 500, 10 } }) do + for _, roll in ipairs({ 85, 100 }) do + local kind = ctx.moveType or "NORMAL" + local user = battler(level, stats[1], 50, ctx.userType or kind, ctx.status) + local target = battler(level, 50, stats[2], ctx.targetType or "NORMAL") + local result + if native then + result = D.calc(user, target, { + id = "PORTABLE_BASE", + numId = 33, + power = power, + type = ids[kind], + effect = ctx.solar and 151 or 0, + }, { + forceCrit = false, + forceRoll = roll, + weather = ctx.weather, + spread = ctx.spread, + reflect = ctx.reflect, + lightScreen = ctx.lightScreen, + doubleScreens = ctx.double, + }) + else + result = D.compute(rules, user, target, { + id = "PORTABLE_BASE", + power = power, + type = kind, + effect = ctx.solar and "SOLARBEAM_EFFECT" or "NO_ADDITIONAL_EFFECT", + gen3Effect = ctx.solar and 151 or 0, + }, { + forceCrit = false, + data = data, + rng = function() + return roll + end, + weather = ctx.weather, + spread = ctx.spread, + screens = { reflect = ctx.reflect, lightScreen = ctx.lightScreen }, + doublesScreens = ctx.double, + }) + end + T.check(type(result) == "number" and result >= 0, "ordinary damage is nonnegative") + T.eq(user.mon.hp, 6000, "calculation cannot spend attacker HP") + T.eq(target.mon.hp, 6000, "calculation cannot spend target HP") + rows[#rows + 1] = { + context = ctx.name, + level = level, + power = power, + attack = stats[1], + defense = stats[2], + roll = roll, + damage = result, + } + end + end + end + end + end + return rows +end +return C diff --git a/tests/gba_damage_base/worker.lua b/tests/gba_damage_base/worker.lua new file mode 100644 index 000000000..7a2fdc5d0 --- /dev/null +++ b/tests/gba_damage_base/worker.lua @@ -0,0 +1,5 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local root, backend, version = unpack(arg) +local rows = dofile(root .. "/tests/gba_damage_base/cases.lua").run(backend, version) +print("GBA_DAMAGE_BASE " .. require("src.link.Json").encode(rows)) +require("tests.modkit").finish("ordinary GBA base damage") diff --git a/tests/gen3_ai_item_knowledge/cases.lua b/tests/gen3_ai_item_knowledge/cases.lua new file mode 100644 index 000000000..7984cf713 --- /dev/null +++ b/tests/gen3_ai_item_knowledge/cases.lua @@ -0,0 +1,634 @@ +-- Real GBA interpreters and native held-effect/damage producers. +local C = {} +function C.run(backend, version) + local T, source = require("tests.modkit"), backend == "gen1" + require("src.core.GameVersion").set(version) + local VM = source and require("src.core.game3.battle.ai_vm") + local AI = not source and require("src.battle.Gen3AI") + local HostState = not source and require("src.battle.BattleState") + local Adapter = source and require("src.core.game3.battle.adapter") + local NativeHeld = source and require("src.core.game3.battle.held_items") + local restores, defs, raw = {}, {}, {} + local function patch(t, key, value) + local old = t[key] + t[key] = value + restores[#restores + 1] = function() + t[key] = old + end + end + local function addItem(id, effect, parameter, name) + local effectName = ({ + [0] = "NONE", + [1] = "RESTORE_HP", + [22] = "EVASION_UP", + [39] = "FOCUS_BAND", + [43] = "LEFTOVERS", + })[effect] + raw[id] = { + name = name or "ITEM " .. id, + pocket = "ITEMS", + holdEffect = effect, + holdEffectParam = parameter or 0, + } + defs["ITEM_" .. id] = { + index = id, + name = raw[id].name, + gen3HoldEffect = effect, + holdEffect = effectName or effect, + holdEffectParam = parameter or 0, + } + end + for i = 0, 66 do + addItem(1000 + i, i) + end + addItem(39, 0) + addItem(43, 0) + addItem(54, 0) + addItem(139, 1, 10, "ORAN BERRY") + addItem(196, 39, 10, "FOCUS BAND") + addItem(200, 43, 10, "LEFTOVERS") + addItem(0x1234, 43) + if source then + local Items = require("src.core.game3.items_data") + patch(Items, "_byId", raw) + patch(Items, "_byName", {}) + patch(Items, "_pack", { items = raw }) + patch(Adapter, "textSink", function(id) + return id + end) + local Moves, Pokemon = require("src.core.game3.battle.moves"), require("src.core.game3.pokemon") + local move = { + [32] = { effect = 38, power = 0, type = 0, accuracy = 30, pp = 5, target = 0, priority = 0, flags = 2 }, + [33] = { + effect = 0, + power = 35, + type = 0, + accuracy = 100, + pp = 35, + target = 0, + priority = 0, + flags = 0, + }, + } + patch(Moves, "_rom", move) + patch(Moves, "_romLoaded", true) + patch(Moves, "_linkRows", nil) + patch(Moves, "BY_NUM", { [32] = "HORN_DRILL", [33] = "TACKLE" }) + patch(Moves, "_numByName", { HORN_DRILL = 32, TACKLE = 33 }) + patch(Pokemon, "_moveNames", { [32] = "HORN DRILL", [33] = "TACKLE" }) + patch(Pokemon, "_romMoveNames", Pokemon._moveNames) + patch(Pokemon, "_battleMoves", move) + end + local function fresh(roll) + local b = { + battlers = {}, + double = true, + absent = {}, + over = false, + turn = 1, + playerSide = { hazards = {} }, + enemySide = { hazards = {} }, + trainer = { aiFlags = 1 }, + data = { items = defs, constants = {}, moves = { TEST = { index = 33, power = 35, type = "NORMAL" } } }, + queue = {}, + game = { save = { options = { animations = false } } }, + } + for id = 0, 3 do + b.battlers[id] = { + id = id, + position = id, + side = id % 2 == 0 and "player" or "enemy", + isPlayer = id % 2 == 0, + name = "TEST", + ability = 0, + abilityOverride = false, + type1 = 0, + type2 = 0, + curTypes = { "NORMAL" }, + stages = {}, + shownHP = 100, + items = defs, + item = 0, + mon = { + nickname = "TEST", + hp = 100, + maxHp = 100, + level = 30, + speed = 100 - id * 10, + item = 0, + ability = 0, + moves = { 33 }, + pp = { 10 }, + stats = { hp = 100, speed = 100 - id * 10 }, + }, + } + end + b.player, b.enemy = b.battlers[0], b.battlers[1] + b.playerParty, b.foeParty = + { b.battlers[0].mon, b.battlers[2].mon }, { b.battlers[1].mon, b.battlers[3].mon } + b.sides = + { { battlers = { b.battlers[0], b.battlers[2] } }, { battlers = { b.battlers[1], b.battlers[3] } } } + local draws = {} + b.rng = function(lo, hi) + draws[#draws + 1] = { lo, hi } + return roll or 0 + end + if not source then + setmetatable(b, { __index = HostState }) + end + return b, draws + end + local function item(b, id, value) + local who = b.battlers[id] + if source then + who.item, who.mon.item = value, value + else + who.mon.item = value ~= 0 and "ITEM_" .. value or 0 + end + end + local function history(b) + local h = b._aiHistory and b._aiHistory.itemEffects or {} + return { h[0] or 0, h[1] or 0, h[2] or 0, h[3] or 0 } + end + local function heal(b, id, moveTurn) + if source then + return NativeHeld.normal(Adapter.new(b), b.battlers[id], moveTurn) + end + return b:holdItemTrigger(b.battlers[id], moveTurn and "action" or "turn") + end + local function hit(b, id, damage, substitute, endure) + local target, user = b.battlers[id], b.battlers[id % 2 == 0 and 1 or 0] + target.substituteHP = substitute + if source then + target.expEnduring = endure + local M = require("src.core.game3.battle.engine").newContext( + user, + target, + 33, + 1, + Adapter.new(b), + b, + {}, + { hits = {} }, + {} + ) + require("src.core.game3.battle.effects.hit").applySetDamage(M, damage) + else + target.enduring = endure + b:applyDamage(target, damage, true) + end + end + local function query(b, which, value, getter, expected, label, targetId, userId) + local user, target = b.battlers[userId or 1], b.battlers[targetId or 0] + local scores = { 100 } + if source then + local ops = getter + and { + { op = "get_hold_effect", battler = which }, + { op = "if_equal", value = value, target = "bonus" }, + { op = "end" }, + } + or { { op = "if_holds_item", battler = which, item = value, target = "bonus" }, { op = "end" } } + local pack = { scripts = { main = ops, bonus = { { op = "score", delta = 5 }, { op = "end" } } } } + VM.run( + VM.new({ + pack = pack, + st = b, + user = user, + target = target, + scores = scores, + movesetIndex = 1, + rng = b.rng, + }), + "main" + ) + else + local code = getter + and { + { name = "get_hold_effect", args = { which }, next = 2 }, + { name = "if_equal", args = { value, 4 }, next = 3 }, + { name = "end", args = {} }, + { name = "score", args = { 5 }, next = 5 }, + { name = "end", args = {} }, + } + or { + { name = "if_holds_item", args = { which, value, 3 }, next = 2 }, + { name = "end", args = {} }, + { name = "score", args = { 5 }, next = 4 }, + { name = "end", args = {} }, + } + local program = { entries = { [0] = 1 }, code = code } + b.data.constants.gen3BattleAI = program + T.eq(AI.runs(program, 1), true, label .. " whole program is supported") + scores = AI.scoreMoves(b, user, target, { { id = "TEST", pp = 10 } }) + end + T.eq(scores and scores[1], expected and 105 or 100, label .. " branch result") + return scores and scores[1] or false + end + local rows = {} + for effect = 0, 66 do + local b, draws = fresh() + item(b, 1, 1000 + effect) + local result = query(b, 1, effect, true, true, "declared native effect " .. effect) + T.eq(#draws, 0, "metadata query does not roll") + T.eq(b._aiHistory, nil, "metadata query cannot reveal an item") + rows[#rows + 1] = { name = "own-effect-" .. effect, score = result, rng = draws } + end + for _, userId in ipairs({ 1, 3 }) do + for _, targetId in ipairs({ 0, 2 }) do + for _, seen in ipairs({ false, true }) do + for which = 0, 3 do + local b, draws = fresh() + for id = 0, 3 do + item(b, id, 200) + end + if seen then + b.battlers[targetId].mon.hp = 90 + T.eq(heal(b, targetId), true, "real Leftovers activates") + T.eq(b.battlers[targetId].mon.hp, 96, "real Leftovers heals max HP / 16") + end + local own = which == 1 + local expected = own and 43 or seen and version == "firered" and 43 or 0 + local label = "roles/" .. userId .. "/" .. targetId .. "/" .. which .. "/" .. tostring(seen) + local before = history(b) + local score = query(b, which, expected, true, true, label, targetId, userId) + T.same(history(b), before, "getter cannot learn/clear history") + T.eq(#draws, 0, "getter and Leftovers consume no RNG") + rows[#rows + 1] = { name = label, score = score, expected = expected, history = before } + end + end + end + end + for _, spec in ipairs({ + { name = "leftovers-active", item = 200, hp = 90, active = true, known = 43, hpAfter = 96 }, + { name = "leftovers-at-full", item = 200, hp = 100, active = false, known = 0, hpAfter = 100 }, + { + name = "leftovers-between-actions", + item = 200, + hp = 90, + moveTurn = true, + active = false, + known = 0, + hpAfter = 90, + }, + { name = "visible-berry-is-not-recorded", item = 139, hp = 50, active = true, known = 0, hpAfter = 60 }, + }) do + for id = 0, 3 do + local b, draws = fresh() + item(b, id, spec.item) + b.battlers[id].mon.hp = spec.hp + T.eq(heal(b, id, spec.moveTurn), spec.active, spec.name .. " actual activation") + T.eq(b.battlers[id].mon.hp, spec.hpAfter, spec.name .. " HP") + local h = history(b) + local slot = version == "firered" and 1 or id + 1 + local known = version == "firered" and id % 2 == 1 and 0 or spec.known + T.eq(h[slot], known, spec.name .. " profile history") + if known == 0 then + T.eq(b._aiHistory, nil, "non-recording events do not create history") + end + T.eq(#draws, 0, "normal item phase adds no random calls") + rows[#rows + 1] = { name = spec.name .. "/" .. id, hp = b.battlers[id].mon.hp, history = h } + end + end + for _, spec in ipairs({ + { name = "lethal-success", damage = 120, roll = 0, hp = 1, known = 39 }, + { name = "lethal-failure", damage = 120, roll = 10, hp = 0, known = 0 }, + { name = "nonlethal-success", damage = 10, roll = 0, hp = 90, known = 39 }, + { name = "nonlethal-failure", damage = 10, roll = 99, hp = 90, known = 0 }, + { name = "substitute-success", damage = 120, roll = 0, sub = 25, hp = 100, known = 39 }, + { name = "endure-success", damage = 120, roll = 0, endure = true, hp = 1, known = 39 }, + { name = "endure-failure", damage = 120, roll = 99, endure = true, hp = 1, known = 0 }, + }) do + for id = 0, 3 do + local b, draws = fresh(spec.roll) + item(b, id, 196) + hit(b, id, spec.damage, spec.sub, spec.endure) + local h = history(b) + local slot = version == "firered" and 1 or id + 1 + local known = version == "firered" and id % 2 == 1 and 0 or spec.known + T.eq(b.battlers[id].mon.hp, spec.hp, spec.name .. " actual adjusted HP") + T.eq(h[slot], known, spec.name .. " actual Focus Band observation") + T.same(draws, { { 0, 99 } }, spec.name .. " native adjustment roll even without lethal damage") + rows[#rows + 1] = { + name = spec.name .. "/" .. id, + hp = b.battlers[id].mon.hp, + sub = b.battlers[id].substituteHP or 0, + history = h, + rng = draws, + } + end + end + -- Real attack-canceller paths must record self-hit procs as well. + for _, spec in ipairs({ + { name = "self-hit-low-variance", confusion = 0, variance = 85, band = 0, hp = 89, known = 39 }, + { name = "self-hit-high-variance", confusion = 0, variance = 100, band = 0, hp = 87, known = 39 }, + { name = "self-hit-failed-band", confusion = 0, variance = 100, band = 99, hp = 87, known = 0 }, + { name = "self-hit-substitute", confusion = 0, variance = 100, band = 0, sub = 25, hp = 87, known = 39 }, + { name = "self-hit-lethal-band", confusion = 0, variance = 100, band = 0, start = 1, hp = 1, known = 39 }, + { name = "confused-but-moves", confusion = 1, hp = 100, known = 0 }, + }) do + local b, draws = fresh() + local user, target = b.player, b.enemy + item(b, 0, 196) + user.mon.hp = spec.start or 100 + user.substituteHP = spec.sub + user.mon.attack, user.mon.defense, user.mon.spAtk, user.mon.spDef = 50, 50, 50, 50 + user.mon.stats.attack, user.mon.stats.defense, user.mon.stats.special = 50, 50, 50 + user.mon.stats.spatk, user.mon.stats.spdef = 50, 50 + user.curStats, user.curTypes, user.abilityOverride = user.mon.stats, { "NORMAL" }, false + user.def = { types = { "NORMAL" }, baseStats = { speed = 100 } } + b.rng = function(lo, hi) + draws[#draws + 1] = { lo, hi } + if lo == 0 and hi == 1 then + return spec.confusion + end + if lo == 85 and hi == 100 then + return spec.variance + end + if lo == 0 and hi == 99 then + return spec.band + end + return lo -- unexpected ranges remain visible in the complete trace + end + local ok, spent = pcall(function() + if source then + user.confusionTurns = 3 + local Engine = require("src.core.game3.battle.engine") + local M = Engine.newContext(user, target, 33, 1, Adapter.new(b), b, {}, { hits = {} }, {}) + return Engine.canceller(M) == false + end + user.confusedTurns = 3 + b.ruleset = require("src.battle.rulesets.gen3_emerald") + return b:statusInterrupt(user, target) + end) + T.eq(ok, true, spec.name .. " real canceller completes") + if not ok then + spent = false + end + local h = history(b) + T.eq(spent, spec.confusion == 0, spec.name .. " real canceller result") + T.eq(user.mon.hp, spec.hp, spec.name .. " real self-hit HP") + T.eq(user.substituteHP or 0, spec.sub or 0, spec.name .. " self-hit bypasses Substitute") + T.eq(h[1], spec.known, spec.name .. " held proc observation") + local expectedDraws = spec.confusion == 0 and { { 0, 1 }, { 85, 100 }, { 0, 99 } } or { { 0, 1 } } + T.same(draws, expectedDraws, spec.name .. " complete canceller RNG") + rows[#rows + 1] = { + name = spec.name, + hp = user.mon.hp, + sub = user.substituteHP or 0, + spent = spent, + history = h, + rng = draws, + } + end + do + local b, draws = fresh() + item(b, 0, 196) + if source then + Adapter.new(b):applyHpLoss(b.player, 10) + else + b:applyDamage(b.player, 10, false) + end + T.eq(b.player.mon.hp, 90, "non-move HP loss remains ordinary damage") + T.eq(b._aiHistory, nil, "non-move HP loss cannot reveal Focus Band") + T.eq(#draws, 0, "non-move HP loss does not roll Focus Band") + rows[#rows + 1] = { name = "non-move-damage", hp = b.player.mon.hp, history = history(b), rng = draws } + end + -- The real OHKO effect path must observe its roll before Sturdy and + -- chance/level failure, and must not roll a second time on a landed hit. + if not source then + require("src.battle.TypeChart").load({ + type_chart = { + types = {}, + matchups = { + { attacker = "NORMAL", defender = "GHOST", multiplier = 0 }, + { attacker = "GROUND", defender = "FLYING", multiplier = 0 }, + }, + }, + }) + end + for _, spec in ipairs({ + { name = "ohko-landed-band", hp = 1, known = 39 }, + { name = "ohko-faster-target", speed = 200, hp = 1, known = 39 }, + { name = "ohko-band-before-sturdy", ability = "STURDY", hp = 100, known = 39, noHitRoll = true }, + { name = "ohko-band-before-level", level = 20, hp = 100, known = 39 }, + { name = "ohko-band-before-miss", hitRoll = 30, hp = 100, known = 39 }, + { name = "ohko-failed-band-miss", band = 99, hitRoll = 99, hp = 100, known = 0 }, + { name = "ohko-endure-failed-band", band = 99, endure = true, hp = 1, known = 0 }, + { name = "ohko-substitute-band", sub = 25, hp = 100, known = 39 }, + { name = "ohko-immune-before-band", ghost = true, hp = 100, known = 0, noRoll = true }, + { name = "ohko-protect-before-band", protect = true, hp = 100, known = 0, noRoll = true }, + { name = "ohko-semi-invulnerable", hidden = true, hp = 100, known = 0, noRoll = true }, + { name = "ohko-lock-on", hidden = true, lock = true, hp = 1, known = 39, noHitRoll = true }, + }) do + local b, draws = fresh() + local user, target = b.enemy, b.player + item(b, 0, 196) + user.mon.level = spec.level or 30 + target.mon.speed, target.mon.stats.speed = spec.speed or 90, spec.speed or 90 + target.substituteHP = spec.sub + target.expProtected, target.protecting = spec.protect, spec.protect + target.expEnduring, target.enduring = spec.endure, spec.endure + target.semiInvulnerable, target.invulnerable = spec.hidden and "air" or nil, spec.hidden + if spec.ghost then + target.type1, target.type2, target.curTypes = 7, 7, { "GHOST" } + end + if spec.ability then + target.ability, target.abilityOverride = 5, spec.ability + end + if spec.lock then + target.expLockedOn, target.expLockedOnById = 1, 1 + user.lockedOn = target + end + b.rng = function(lo, hi) + draws[#draws + 1] = { lo, hi } + if lo == 0 and hi == 99 then + return spec.band or 0 + end + if lo == 1 and hi == 100 then + return spec.hitRoll or 1 + end + return lo + end + local ok, err = pcall(function() + if source then + user.mon.moves[1] = 32 + require("src.core.game3.battle.engine").resolveMove(user, target, 32, 1, Adapter.new(b), b, {}, {}) + else + b.ruleset = require("src.battle.rulesets.gen3_emerald") + local move = { + id = "OHKO", + index = 32, + power = 0, + type = "NORMAL", + accuracy = 30, + effect = "OHKO_EFFECT", + gen3Effect = 38, + protectAffected = true, + } + b.data.moves.OHKO = move + local Registry = require("src.battle.EffectRegistry") + local ctx = Registry.makeCtx(b, user, target, move, { id = "OHKO", pp = 5 }, false) + Registry.runDamaging(b, ctx, b:effectRecord("OHKO_EFFECT")) + end + end) + T.eq(ok, true, spec.name .. " actual effect path completes") + if not ok then + print("OHKO_ERROR " .. tostring(err)) + end + local expected = {} + if not spec.noRoll then + expected[1] = { 0, 99 } + if not spec.noHitRoll then + expected[2] = { 1, 100 } + end + end + T.eq(target.mon.hp, spec.hp, spec.name .. " HP") + T.eq(history(b)[1], spec.known, spec.name .. " observation") + T.same(draws, expected, spec.name .. " full effect RNG") + rows[#rows + 1] = { + name = spec.name, + ok = ok, + hp = target.mon.hp, + sub = target.substituteHP or 0, + history = history(b), + rng = draws, + } + end + -- A zero-percent Focus Band still spends the native adjustment draw. + raw[196].holdEffectParam, defs.ITEM_196.holdEffectParam = 0, 0 + do + local b, draws = fresh() + item(b, 0, 196) + hit(b, 0, 10) + T.eq(b.battlers[0].mon.hp, 90, "zero-percent item cannot endure") + T.eq(b._aiHistory, nil, "failed zero-percent item cannot reveal") + T.same(draws, { { 0, 99 } }, "zero-percent native item spends its roll") + rows[#rows + 1] = { name = "zero-percent-band", history = history(b), rng = draws } + end + raw[196].holdEffectParam, defs.ITEM_196.holdEffectParam = 10, 10 + for _, spec in ipairs({ + { name = "own-physical-item", which = 1, value = 200, expected = true }, + { name = "own-partner-item", which = 3, value = 200, expected = true }, + { name = "unseen-target-item", which = 0, value = 200, expected = false }, + { name = "unseen-target-partner", which = 2, value = 200, expected = false }, + { name = "unseen-target-zero", which = 0, value = 0, expected = true }, + { name = "observed-byte-not-item", which = 0, value = 200, expected = false, seen = true }, + { name = "observed-byte-compares", which = 0, value = 43, expected = true, seen = true }, + { name = "own-word-fold", which = 1, value = 0x1234, own = 54, expected = true }, + { name = "own-unfolded-word", which = 1, value = 0x1234, own = 0x1234, expected = false }, + }) do + local b, draws = fresh() + for id = 0, 3 do + item(b, id, 200) + end + if spec.own then + item(b, 1, spec.own) + end + if spec.seen then + b.player.mon.hp = 90 + heal(b, 0) + end + local score = query(b, spec.which, spec.value, false, spec.expected, spec.name) + T.eq(#draws, 0, "membership query adds no RNG") + rows[#rows + 1] = { name = spec.name, score = score, history = history(b) } + end + -- Named legacy metadata and live writes must agree with the numeric ABI. + for _, row in ipairs({ + { "RESTORE_HP", 1 }, + { "EVASION_UP", 22 }, + { "FOCUS_BAND", 39 }, + { "LEFTOVERS", 43 }, + }) do + local id, name, effect = 1200 + row[2], row[1], row[2] + addItem(id, effect) + defs["ITEM_" .. id].gen3HoldEffect = nil + defs["ITEM_" .. id].holdEffect = name + local b, draws = fresh() + item(b, 1, id) + local score = query(b, 1, effect, true, true, "named-metadata-" .. name) + T.eq(#draws, 0, "named metadata query adds no random calls") + rows[#rows + 1] = { name = "named-metadata-" .. name, score = score } + end + -- Clearing a physical item's live metadata must defeat the legacy table. + raw[200].holdEffect, defs.ITEM_200.gen3HoldEffect, defs.ITEM_200.holdEffect = 0, 0, "NONE" + do + local b = fresh() + item(b, 0, 200) + item(b, 1, 200) + b.player.mon.hp = 90 + T.eq(heal(b, 0), false, "explicit NONE metadata disables the native trigger") + local score = query(b, 1, 0, true, true, "live-metadata-cleared") + T.eq(b._aiHistory, nil, "cleared effect cannot reveal an item") + rows[#rows + 1] = { name = "live-metadata-cleared", score = score, hp = b.player.mon.hp } + end + raw[200].holdEffect, defs.ITEM_200.gen3HoldEffect, defs.ITEM_200.holdEffect = 43, 43, "LEFTOVERS" + do + local b, draws = fresh() + item(b, 0, 200) + b.player.mon.hp = 90 + heal(b, 0) + item(b, 0, 196) -- A changed current item is not a new observation. + local expected = version == "firered" and 43 or 0 + local score = query(b, 0, expected, true, true, "cached-effect-does-not-peek") + local freshBattle = fresh() + local unknown = query(freshBattle, 0, 0, true, true, "fresh-battle-isolation") + T.eq(#draws, 0, "cached query is read-only") + rows[#rows + 1] = + { name = "cached-effect-does-not-peek", score = score, fresh = unknown, history = history(b) } + end + -- FireRed shares the player-side byte; Emerald's slots remain separate. + do + local b, draws = fresh() + item(b, 0, 200) + b.player.mon.hp = 90 + heal(b, 0) + item(b, 2, 196) + hit(b, 2, 10) + local h = history(b) + T.same( + h, + version == "firered" and { 39, 0, 0, 0 } or { 43, 0, 39, 0 }, + "native double-slot history ownership" + ) + T.same(draws, { { 0, 99 } }, "only the actual Focus Band trigger rolls") + rows[#rows + 1] = { name = "double-history-ownership", history = h, rng = draws } + end + do + local b, draws = fresh() + item(b, 0, 196) + if source then + NativeHeld.of(b.player, "check", b) + else + require("src.battle.HoldItems").effectOf(b.player, "check") + end + T.eq(b._aiHistory, nil, "pure held-item reads do not reveal effects") + T.eq(#draws, 0, "pure held-item reads do not roll") + rows[#rows + 1] = { name = "pure-held-read", history = history(b), rng = draws } + end + do + local b, draws = fresh() + b.battlers[3], b.sides[2].battlers[2] = nil, nil + local score = query(b, 3, 0, false, true, "absent-partner-zero") + T.eq(b._aiHistory, nil, "absent partner cannot create knowledge") + T.eq(#draws, 0, "absent partner adds no RNG") + rows[#rows + 1] = { name = "absent-partner-zero", score = score } + end + -- Emerald's second ITEM lookup must read current metadata, not fake zero. + raw[43].holdEffect, defs.ITEM_43.gen3HoldEffect, defs.ITEM_43.holdEffect = 22, 22, "EVASION_UP" + do + local b = fresh() + item(b, 0, 200) + b.player.mon.hp = 90 + heal(b, 0) + local expected = version == "emerald" and 22 or 43 + local score = query(b, 0, expected, true, true, "cached-byte-item-lookup") + rows[#rows + 1] = { name = "cached-byte-item-lookup", score = score, expected = expected } + end + raw[43].holdEffect, defs.ITEM_43.gen3HoldEffect, defs.ITEM_43.holdEffect = 0, 0, "NONE" + for i = #restores, 1, -1 do + restores[i]() + end + return rows +end +return C diff --git a/tests/gen3_ai_item_knowledge/worker.lua b/tests/gen3_ai_item_knowledge/worker.lua new file mode 100644 index 000000000..37a63bc70 --- /dev/null +++ b/tests/gen3_ai_item_knowledge/worker.lua @@ -0,0 +1,5 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local root, backend, version = unpack(arg) +local rows = dofile(root .. "/tests/gen3_ai_item_knowledge/cases.lua").run(backend, version) +print("GEN3_AI_ITEM_KNOWLEDGE " .. require("src.link.Json").encode(rows)) +require("tests.modkit").finish("native GBA AI item knowledge") diff --git a/tests/gen3_ai_order/cases.lua b/tests/gen3_ai_order/cases.lua new file mode 100644 index 000000000..31d7c212c --- /dev/null +++ b/tests/gen3_ai_order/cases.lua @@ -0,0 +1,191 @@ +-- Actual VM programs; the checker additionally evaluates the original C function. +local C = {} +function C.run(root, version) + local T = require("tests.modkit") + require("src.core.GameVersion").set(version) + local AI = require("src.battle.Gen3AI") + local O = require("src.battle.Gen3AIOrder") + local Core = require("src.battle.Gen3OrderCore") + local copy = require("src.mods.Merge").deepCopy + local raw, defs = {}, {} + local rows = {} + local function at(values, id, default) + return values and values[id + 1] ~= nil and values[id + 1] or default + end + for _, spec in ipairs(dofile(root .. "/tests/gen3_ai_order/scenarios.lua")) do + for k in pairs(raw) do + raw[k] = nil + end + for k in pairs(defs) do + defs[k] = nil + end + local flags = spec.mode == "link" and 1 + or spec.mode == "recorded" and 2 + or spec.mode == "frontier" and 4 + or 0 + local b = { + battlers = {}, + double = spec.double == true, + badges = { [3] = spec.badge == true }, + kinds = { frontier = flags == 4, recordedLink = flags == 2 }, + link = flags == 1, + weather = spec.weather or 0, + randomTurnNumber = spec.word or 0, + absent = {}, + data = { items = defs, constants = {}, moves = {} }, + trainer = { aiFlags = 1 }, + } + local input = { + spec.user or 1, + spec.target or 0, + b.double and 4 or 2, + b.weather, + flags, + spec.badge and 1 or 0, + b.randomTurnNumber, + spec.tie or 0, + } + for id = 0, 3 do + local speed, stage, ability = + at(spec.speeds, id, 100), at(spec.stages, id, 0), at(spec.abilities, id, 0) + local effect, parameter = at(spec.effects, id, 0), at(spec.parameters, id, 0) + local item = spec.enigma and 175 or effect ~= 0 and (180 + id) or 0 + raw[item] = { + name = "ORDER ITEM", + pocket = "ITEMS", + holdEffect = spec.enigma and 0 or effect, + holdEffectParam = spec.enigma and 0 or parameter, + } + defs["ITEM_" .. item] = { + index = item, + gen3HoldEffect = raw[item].holdEffect, + holdEffect = Core.effectNames[raw[item].holdEffect], + holdEffectParam = raw[item].holdEffectParam, + } + local status = at(spec.statuses, id, 0) + local statusWord = type(status) == "number" and status + or (status == "PAR" or status == "PARALYSIS") and 64 + or 0 + local hp, priority = at(spec.hps, id, 100), at(spec.priorities, id, 0) + local berry = { holdEffect = effect, holdEffectParam = parameter } + local actor = { + id = id, + position = id, + isPlayer = id % 2 == 0, + side = id % 2 == 0 and "player" or "enemy", + ability = ability, + abilityOverride = ability, + status = status, + item = item, + items = defs, + stages = { speed = stage }, + curStats = { speed = speed }, + enigmaBerry = spec.enigma and berry or nil, + mon = { + speed = speed, + stats = { speed = speed }, + hp = hp, + moves = { 33 + id }, + pp = { 10 }, + status = status, + item = "ITEM_" .. item, + }, + } + b.battlers[id] = actor + b.data.moves["TEST_" .. id] = { index = 33 + id, power = 35, priority = priority } + for _, value in ipairs({ + speed, + stage + 6, + ability, + statusWord, + item, + raw[item].holdEffect, + raw[item].holdEffectParam, + spec.enigma and effect or 0, + spec.enigma and parameter or 0, + hp, + priority, + }) do + input[#input + 1] = value + end + end + b.player, b.enemy = b.battlers[0], b.battlers[1] + local user, target = b.battlers[spec.user or 1], b.battlers[spec.target or 0] + local observations = {} + for _, wanted in ipairs({ 0, 1, 2, 3, 255 }) do + for _, inverse in ipairs({ false, true }) do + local draws = {} + b.rng = function(lo, hi) + draws[#draws + 1] = { lo, hi } + return spec.tie or 0 + end + local before = copy(b.battlers) + local score + local p = { + entries = { [0] = 1 }, + code = { + { name = inverse and "if_user_doesnt_go" or "if_user_goes", args = { wanted, 3 }, next = 2 }, + { name = "end", args = {} }, + { name = "score", args = { 5 }, next = 4 }, + { name = "end", args = {} }, + }, + } + b.data.constants.gen3BattleAI = p + T.eq(AI.runs(p, 1), true, "native order predicate is reachable") + local scores = AI.scoreMoves(b, user, target, { { id = "TEST_" .. user.id, pp = 10 } }) + score = scores and scores[1] or false + if spec.expected ~= nil then + T.eq( + score, + ((spec.expected == wanted) ~= inverse) and 105 or 100, + spec.name .. " literal native result" + ) + end + T.same(b.battlers, before, spec.name .. " preserves native actor state") + T.check(#draws <= 1, "query never draws a new Quick Claw word") + for _, draw in ipairs(draws) do + T.same(draw, { 0, 65535 }, "native tie RNG ABI") + end + observations[#observations + 1] = { wanted = wanted, inverse = inverse, score = score, draws = draws } + end + end + local a, z = O.speed(user, b), O.speed(target, b) + local TurnOrder = require("src.battle.TurnOrder") + local actualDraws = {} + local function actualRng(lo, hi) + actualDraws[#actualDraws + 1] = { lo, hi } + return (spec.tie or 0) % 2 + end + local first = TurnOrder.firstMover( + user, + b.data.moves["TEST_" .. user.id], + target, + b.data.moves["TEST_" .. target.id], + actualRng, + false, + b + ) + local entries = {} + for id = 0, b.double and 3 or 1 do + entries[#entries + 1] = { battler = b.battlers[id], move = b.data.moves["TEST_" .. id] } + end + local sortDraws = {} + TurnOrder.order(entries, function(lo, hi) + sortDraws[#sortDraws + 1] = { lo, hi } + return (spec.tie or 0) % 2 + end, b) + local order = {} + for _, entry in ipairs(entries) do + order[#order + 1] = entry.battler.id + end + rows[#rows + 1] = { + name = spec.name, + native = input, + speeds = { a, z }, + observations = observations, + actual = { first = first, draws = actualDraws, order = order, sortDraws = sortDraws }, + } + end + return rows +end +return C diff --git a/tests/gen3_ai_order/planning.lua b/tests/gen3_ai_order/planning.lua new file mode 100644 index 000000000..dcfea73c4 --- /dev/null +++ b/tests/gen3_ai_order/planning.lua @@ -0,0 +1,196 @@ +-- Real BattleState constructors, selection, planning, action queue and EOT. +-- A registered enemy-action consumer supplies legal fixture choices; no owned +-- engine functions are replaced. Metadata cases inspect the planner's actual +-- queued entries (not full trainer item/switch controller behavior). +local M = {} +function M.run(version) + local T = require("tests.modkit") + local Version = require("src.core.GameVersion") + local Battle = require("src.battle.BattleState") + local Pokemon = require("src.pokemon.Pokemon") + local Save = require("src.core.SaveData") + local Runtime = require("src.mods.Runtime") + local Order = require("src.battle.Gen3AIOrder") + local Events, Hooks = require("src.mods.Events"), require("src.mods.Hooks") + local events, hooks = Events.new(), Hooks.new() + local oldEvents, oldHooks, oldErrors = Runtime.events, Runtime.hooks, Runtime.errors + Runtime.install(events, hooks) + Version.set(version) + local function setup(double, words, tie) + local data = T.fixtures.fresh() + data.constants.gen = 3 + data.constants.gen3BadgeFlags = { 0x820, 0x821, 0x822 } + data.moves.ORDER_HIGH = require("src.mods.Merge").deepCopy(data.moves.FIX_TACKLE) + data.moves.ORDER_HIGH.id, data.moves.ORDER_HIGH.priority = "ORDER_HIGH", 1 + data.items.ORDER_CLAW = + { index = 217, holdEffect = "QUICK_CLAW", gen3HoldEffect = 26, holdEffectParam = 20 } + local save = Save.newGame() + save.party = { Pokemon.new(data, "FIXMON_A", 30) } + local game = { data = data, save = save, stack = { top = function() end, push = function() end } } + local b = Battle.newWild(game, "FIXMON_C", 30) + b.double = double + if double then + b:placeBattler(2, Battle.makeBattler(data, Pokemon.new(data, "FIXMON_A", 30), true, save)) + b:placeBattler(3, Battle.makeBattler(data, Pokemon.new(data, "FIXMON_C", 30), false)) + end + b:syncSides() + b.queue, b.testChoices, b.testDraws, b.testWords, b.testComparisons, b.testMoves = {}, {}, {}, {}, {}, {} + for _, actor in b:activeBattlers() do + actor.mon.hp, actor.mon.stats.hp, actor.curStats.hp = 1000, 1000, 1000 + actor.mon.status, actor.mon.item, actor.abilityOverride = nil, nil, false + actor.curStats.speed, actor.mon.stats.speed = 100, 100 + actor.curMoves = { { id = "FIX_TACKLE", pp = 100 } } + actor.mon.moves = actor.curMoves + end + local wordIndex = 0 + b.rng = function(lo, hi) + b.testDraws[#b.testDraws + 1] = { lo, hi } + if lo == 0 and hi == 65535 then + wordIndex = wordIndex + 1 + return words[wordIndex] or words[#words] + end + if lo == 0 and hi == 1 then + return tie or 0 + end + return lo + end + return b + end + hooks:wrap("battle.enemy_action", function(_, b, actor) + T.eq(b._nativeOrderReady, true, "turn word prepared before public enemy-action consumer") + b.testWords[#b.testWords + 1] = { actor.position, b.randomTurnNumber, b.turnCount or 0 } + return b.testChoices[actor.position] or actor.curMoves[1] + end, 0, "order-fixture") + hooks:wrap("battle.turn_order", function(next, a, am, b, bm, ctx) + local battle = ctx.battle + T.eq(a, battle:battlerAt(a.position), "turn-order hook keeps native first actor") + T.eq(b, battle:battlerAt(b.position), "turn-order hook keeps native second actor") + battle.testComparisons[#battle.testComparisons + 1] = { a.position, b.position } + return next(a, am, b, bm, ctx) + end, 0, "order-fixture") + events:on("battle.move_used", function(e) + e.battle.testMoves[#e.battle.testMoves + 1] = e.user.position + end, 0, "order-fixture") + local function pump(b) + local guard = 0 + while b.queue[1] and guard < 1000 do + guard = guard + 1 + local row = table.remove(b.queue, 1) + if row.fn then + b.nextInsert = 0 + row.fn() + end + end + T.check(guard < 1000, "real action queue drains") + end + local function scheduled(b) + local ids = {} + for _, row in ipairs(b.queue) do + if row.fn then + local n = 1 + while true do + local name, entry = debug.getupvalue(row.fn, n) + if not name then + break + end + if name == "entry" then + ids[#ids + 1] = entry[1].position + end + n = n + 1 + end + end + end + return ids + end + local b = setup(false, { 0, 65535 }) + b.player.curStats.speed, b.enemy.curStats.speed = 10, 200 + b.player.mon.item = "ORDER_CLAW" + T.eq(b:enemyAction(b.enemy), b.enemy.curMoves[1], "choice keeps native move handle") + b:enemyAction(b.enemy) + T.same(b.testDraws, { { 0, 65535 } }, "repeated selection shares one turn word") + b:resolveTurn(b.player.curMoves[1]) + T.same(b.testDraws, { { 0, 65535 } }, "planner reuses selection word without separate claw roll") + T.same(scheduled(b), { 0, 1 }, "successful claw is used by actual planner") + pump(b) + T.same(b.testMoves, { 0, 1 }, "successful claw reaches actual move execution") + T.eq(b._nativeOrderReady, false, "EOT releases selection word") + b.testMoves = {} + b:resolveTurn(b.player.curMoves[1]) + T.eq(b.randomTurnNumber, 65535, "next actual turn samples fresh word") + T.same(scheduled(b), { 1, 0 }, "failed claw follows physical speed") + pump(b) + T.same(b.testMoves, { 1, 0 }, "failed claw reaches actual move execution") + + local priority = setup(false, { 0 }) + priority.player.mon.item = "ORDER_CLAW" + priority.testChoices[1] = { id = "ORDER_HIGH", pp = 10 } + priority:resolveTurn(priority.player.curMoves[1]) + T.same(scheduled(priority), { 1, 0 }, "Quick Claw cannot override greater move priority") + T.same(priority.testDraws, { { 0, 65535 } }, "unequal priorities do not draw a tie") + + local both = setup(false, { 0 }, 1) + both.player.mon.item, both.enemy.mon.item = "ORDER_CLAW", "ORDER_CLAW" + both:resolveTurn(both.player.curMoves[1]) + T.same(scheduled(both), { 1, 0 }, "two successful claws use native tie result") + T.same(both.testDraws, { { 0, 65535 }, { 0, 1 } }, "shared claw word followed by one actual tie draw") + + local doubles = setup(true, { 0 }, 1) + doubles.pendingActions = { [2] = doubles:battlerAt(2).curMoves[1] } + doubles:resolveTurn(doubles.player.curMoves[1]) + T.same(scheduled(doubles), { 3, 2, 1, 0 }, "real double planner follows nested native comparisons") + T.eq(#doubles.testComparisons, 6, "double planner invokes six actual comparison hooks") + T.eq(#doubles.testDraws, 7, "double planner uses one word plus six tie draws") + T.same(doubles.testWords, { { 1, 0, 0 }, { 3, 0, 0 } }, "both enemy choices precede new-turn flag reset") + pump(doubles) + T.same(doubles.testMoves, { 3, 2, 1, 0 }, "double order reaches actual action execution") + + local forwarded = setup(true, { 0 }, 1) + forwarded.pendingActions = { [2] = forwarded:battlerAt(2).curMoves[1] } + local undo = hooks:wrap("battle.turn_order", function(next, a, am, b, bm, ctx) + if a == forwarded.player then + local changed = require("src.mods.Merge").deepCopy(am) + changed.priority = 7 + return next(a, changed, b, bm, ctx) + end + return next(a, am, b, bm, ctx) + end, 10, "forwarding-fixture") + forwarded:resolveTurn(forwarded.player.curMoves[1]) + T.same(scheduled(forwarded), { 0, 3, 2, 1 }, "double comparator honors forwarded hook arguments") + T.eq(#forwarded.testDraws, 4, "forwarded priority suppresses only its own tie draws") + undo() + + local meta = setup(true, { 0 }, 1) + meta.testChoices[1] = { special = "aiItem", item = "POTION" } + meta.pendingActions = { [2] = { special = "playerSwitch", mon = meta.player.mon } } + meta:resolveTurn(meta.player.curMoves[1]) + T.same(scheduled(meta), { 1, 2, 3, 0 }, "item/switch stable prefix is preserved before move sorting") + T.eq(#meta.testComparisons, 1, "metadata actions are never speed-compared") + T.eq(#meta.testDraws, 2, "metadata actions consume no extra tie draws") + + local weather = setup(true, { 0 }) + weather.weather = "RAIN" + weather.enemy.abilityOverride, weather.enemy.curStats.speed = "SWIFT_SWIM", 75 + weather:battlerAt(2).abilityOverride = "CLOUD_NINE" + T.eq(Order.speed(weather.enemy, weather), 75, "real right-flank living ability suppresses weather") + weather:battlerAt(2).mon.hp = 0 + T.eq(Order.speed(weather.enemy, weather), 150, "fainted right-flank ability does not suppress weather") + weather.double = false + weather:battlerAt(2).mon.hp = 1000 + T.eq(Order.speed(weather.enemy, weather), 150, "unused right-flank storage is ignored in singles") + weather.game.save.flags = { FLAG_G3_0822 = true } + T.eq(Order.speed(weather.player, weather), 110, "real save flag grants native third-badge boost") + weather.gen3Frontier = true + T.eq( + Order.speed(weather.player, weather), + version == "emerald" and 100 or 110, + "actual frontier flag has profile-specific badge policy" + ) + + local planless = setup(false, { 10, 20 }) + planless:enemyAction(planless.enemy) + planless:endOfTurn() + planless:enemyAction(planless.enemy) + T.eq(planless.randomTurnNumber, 20, "planless EOT releases word without relying on turn counters") + Runtime.install(oldEvents, oldHooks, oldErrors) +end +return M diff --git a/tests/gen3_ai_order/scenarios.lua b/tests/gen3_ai_order/scenarios.lua new file mode 100644 index 000000000..c3d8dfa6a --- /dev/null +++ b/tests/gen3_ai_order/scenarios.lua @@ -0,0 +1,107 @@ +-- Declared native inputs, independent of either engine's order calculation. +local cases = {} +local function add(name, values) + values = values or {} + values.name = name + cases[#cases + 1] = values +end +add("user-faster", { speeds = { 100, 101 }, expected = 0 }) +add("target-faster", { speeds = { 101, 100 }, expected = 1 }) +add("tie-even", { expected = 0, tie = 0 }) +add("tie-odd", { expected = 2, tie = 1 }) +add("tie-high-even", { expected = 0, tie = 65534 }) +add("tie-high-odd", { expected = 2, tie = 65535 }) +for stage = -6, 6 do + for _, speed in ipairs({ 0, 1, 3, 7, 51, 101, 255, 65535 }) do + add( + "stage-" .. stage .. "-speed-" .. speed, + { speeds = { speed, 101 }, stages = { stage, -stage }, tie = 1 } + ) + end +end +for _, weather in ipairs({ 0, 1, 2, 4, 7, 32, 64, 96, 97 }) do + for _, ability in ipairs({ 0, 33, 34 }) do + add( + "weather-" .. weather .. "-ability-" .. ability, + { speeds = { 7, 5 }, weather = weather, abilities = { ability, ability }, stages = { 1, -1 } } + ) + end +end +for _, ability in ipairs({ 13, 76 }) do + for _, hp in ipairs({ 0, 100 }) do + add("right-weather-suppressor-" .. ability .. "-hp-" .. hp, { + double = true, + weather = 7, + speeds = { 101, 100 }, + abilities = { 0, 33, ability, 0 }, + hps = { 100, 100, hp, 100 }, + }) + add("unused-slot-not-suppressor-" .. ability .. "-hp-" .. hp, { + double = false, + weather = 7, + speeds = { 101, 100 }, + abilities = { 0, 33, ability, 0 }, + hps = { 100, 100, hp, 100 }, + }) + end +end +for _, mode in ipairs({ "ordinary", "link", "frontier", "recorded" }) do + for _, player in ipairs({ 0, 2 }) do + add( + "badge-" .. mode .. "-player-" .. player, + { double = true, user = 1, target = player, speeds = { 100, 109, 100, 109 }, badge = true, mode = mode } + ) + end +end +for _, status in ipairs({ 0, 64, 72, 0x80000040, "PAR", "PARALYSIS", "PSN", "SLP" }) do + add("paralysis-" .. tostring(status), { speeds = { 10, 39 }, statuses = { 0, status } }) +end +for _, word in ipairs({ 0, 130, 131, 13106, 13107, 13108, 65534, 65535 }) do + for _, chance in ipairs({ 0, 1, 20, 100, 255 }) do + add("claw-word-" .. word .. "-chance-" .. chance, { + speeds = { 100, 10 }, + effects = { 0, 26 }, + parameters = { 0, chance }, + word = word, + }) + add("double-claw-word-" .. word .. "-chance-" .. chance, { + speeds = { 100, 10 }, + effects = { 26, 26 }, + parameters = { chance, chance }, + word = word, + tie = 1, + }) + end +end +add("brace-weather-paralysis-flooring", { + speeds = { 3, 7 }, + effects = { 24, 24 }, + abilities = { 0, 33 }, + weather = 7, + statuses = { 0, "PAR" }, + stages = { 1, -1 }, +}) +add( + "enigma-per-battler", + { speeds = { 100, 10 }, enigma = true, effects = { 24, 26 }, parameters = { 0, 20 }, word = 0 } +) +add( + "enigma-threshold", + { speeds = { 100, 10 }, enigma = true, effects = { 24, 26 }, parameters = { 0, 20 }, word = 13107 } +) +add("priority-is-ignored", { speeds = { 100, 10 }, priorities = { -7, 7 }, expected = 1 }) +add("priority-does-not-break-speed-tie", { priorities = { -7, 7 }, expected = 2, tie = 1 }) +for user = 0, 3 do + for target = 0, 3 do + add("roles-" .. user .. "-" .. target, { + double = true, + user = user, + target = target, + speeds = { 97, 99, 101, 103 }, + effects = { 24, 0, 26, 0 }, + parameters = { 0, 0, 20, 0 }, + word = 13107, + }) + end +end +return cases diff --git a/tests/gen3_ai_order/worker.lua b/tests/gen3_ai_order/worker.lua new file mode 100644 index 000000000..d103ecc6f --- /dev/null +++ b/tests/gen3_ai_order/worker.lua @@ -0,0 +1,5 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local root, version = unpack(arg) +local rows = dofile(root .. "/tests/gen3_ai_order/cases.lua").run(root, version) +print("GEN3_AI_ORDER " .. require("src.link.Json").encode(rows)) +require("tests.modkit").finish("native GBA AI order") diff --git a/tests/goldens/fixture_fingerprint.txt b/tests/goldens/fixture_fingerprint.txt index 3ab0752dc..f9744c50c 100644 --- a/tests/goldens/fixture_fingerprint.txt +++ b/tests/goldens/fixture_fingerprint.txt @@ -1 +1 @@ -7de3051a7b3c108e +a000e3862d11a73a diff --git a/tests/held_item/cases.lua b/tests/held_item/cases.lua new file mode 100644 index 000000000..b413c7fcd --- /dev/null +++ b/tests/held_item/cases.lua @@ -0,0 +1,340 @@ +local M = {} +function M.run(f) + local T, e, rows = f.T, f.e, {} + local samples = { + { "LEFTOVERS", "residual", "HELD_LEFTOVERS", 10 }, + { "QUICK_CLAW", "priority", "HELD_QUICK_CLAW", f.gen == 3 and 51 or 20 }, + { "BRIGHTPOWDER", "accuracy", "HELD_BRIGHTPOWDER", f.gen == 3 and 25 or 10 }, + { "FOCUS_BAND", "endure", "HELD_FOCUS_BAND", f.gen == 3 and 25 or 10 }, + { "KINGS_ROCK", "flinch", "HELD_FLINCH", f.gen == 3 and 25 or 10 }, + { "SCOPE_LENS", "damage", "HELD_CRITICAL_UP", 0 }, + { "ORAN_BERRY", "residual", "HELD_BERRY", 10 }, + { "PERSIM_BERRY", f.gen == 2 and "status" or "residual", "HELD_HEAL_CONFUSION", 0 }, + { "LEPPA_BERRY", f.gen == 2 and "pp" or "residual", "HELD_RESTORE_PP", 10 }, + { "CHARCOAL", "damage", "HELD_FIRE_BOOST", 10 }, + } + for _, sample in ipairs(samples) do + local item, trigger, name, param = unpack(sample) + f.reset(item) + T.eq(f.read(trigger), f.effect(name), "native reader preserves held effect") + T.eq(e.state.calls, 1, "native held reader dispatches once") + local ctx = e.state.last + T.eq(ctx.effect, name, "shared HELD name") + T.eq(ctx.parameter, param, "shared byte/proportion parameter") + T.eq(ctx.trigger, trigger, "native comparison role") + T.eq(ctx.battle, f.battle, "opaque actual battle controller") + if f.api >= 3 then + T.eq(ctx.item, item, "API-3 canonical item ID") + T.eq(ctx.def.id, item, "API-3 item definition role") + T.eq(ctx.mon.species, "CHARMANDER", "API-3 canonical holder species") + end + rows[#rows + 1] = { + label = item .. ":" .. trigger, + effect = ctx.effect, + parameter = ctx.parameter, + trigger = ctx.trigger, + holder = ctx.mon.nickname, + } + end + f.reset("NO_EFFECT") + e.state.mode, e.state.effect, e.state.parameter = "override", "HELD_BERRY", 7 + local effect, amount = f.read("check") + T.eq(effect, f.effect("HELD_BERRY"), "an ordinary held item may acquire an effect") + T.eq(amount, 7, "ordinary item substitution parameter") + T.check(e.state.last.def ~= nil, "ordinary held item retains definition even without native effect") + rows[#rows + 1] = { label = "ordinary item override", parameter = amount } + f.reset(nil) + e.state.mode, e.state.effect, e.state.parameter, e.state.requiresItem = "override", "HELD_BERRY", 7, false + effect, amount = f.read("check") + T.eq(effect, f.effect("HELD_BERRY"), "empty-hand reads may acquire a public effect") + T.eq(amount, 7, "empty-hand override parameter") + T.eq(e.state.last.def, nil, "empty hand has no item definition") + rows[#rows + 1] = { label = "empty-hand override", parameter = amount } + e.state.requiresItem = true + for _, trigger in ipairs({ + "priority", + "damage", + "endure", + "flinch", + "accuracy", + "confuse", + "residual", + "status", + "pp", + "run", + "check", + }) do + f.reset("LEFTOVERS") + f.read(trigger, "enemy") + T.eq(e.state.last.mon.nickname, "ENEMY", "opponent's actual mon crosses read boundary") + T.eq(e.state.last.trigger, trigger, "arbitrary comparison role passes through") + rows[#rows + 1] = { label = "opponent:" .. trigger, trigger = e.state.last.trigger } + end + for _, chosen in ipairs({ "HELD_BERRY", "HELD_CRITICAL_UP", "HELD_QUICK_CLAW", "HELD_FIRE_BOOST" }) do + f.reset("LEFTOVERS") + e.state.mode, e.state.effect, e.state.parameter = "override", chosen, 128 + local effect, parameter = f.read("damage") + T.eq(effect, f.effect(chosen), "public substitution reaches native effect ID") + local wanted = f.gen == 3 and chosen == "HELD_QUICK_CLAW" and 50 or 128 + T.eq(parameter, wanted, "substitution converts byte odds to GBA native percent") + rows[#rows + 1] = { label = "substitute:" .. chosen, parameter = parameter } + end + for _, mode in ipairs({ "suppress", "invalid", "mutate", "fallback" }) do + f.reset("LEFTOVERS") + e.state.mode = mode + if mode == "invalid" then + e.state.mode, e.state.effect = "override", 7 + end + if mode == "fallback" then + e.state.mode, e.state.effect, e.state.parameter = "override", "HELD_LEFTOVERS", "invalid" + end + if mode == "mutate" then + e.state.effect, e.state.parameter = "HELD_BERRY", "7" + end + local effect, parameter = f.read("residual") + local expected = mode == "mutate" and "HELD_BERRY" or mode == "fallback" and "HELD_LEFTOVERS" or nil + T.eq(effect, f.effect(expected), "return/mutation decision reaches native reader") + T.eq( + parameter, + mode == "mutate" and 7 or mode == "fallback" and 10 or 0, + "fallback preserves native parameter" + ) + rows[#rows + 1] = { label = "decision:" .. mode, parameter = parameter } + end + for _, after in ipairs({ false, true }) do + f.reset("LEFTOVERS") + local off = e.api.hooks:wrap("held_item.trigger", function(next, ctx) + if after then + next(ctx) + end + error("intentional held-item wrapper failure") + end, 100) + T.eq(f.read("residual"), f.effect("HELD_LEFTOVERS"), "wrapper error retains native effect") + T.eq(e.state.calls, 1, "wrapper recovery delegates once") + off() + rows[#rows + 1] = { label = "error:" .. tostring(after), calls = e.state.calls } + end + if f.api >= 3 then + f.reset("LEFTOVERS") + local off = e.api.hooks:wrap("held_item.trigger", function(next, ctx) + local a, b = next(ctx) + local c, d = next(ctx) + T.eq(a, c, "API-3 replay preserves first effect") + T.eq(b, d, "API-3 replay preserves second result") + return a, b + end, 100) + f.read("residual") + off() + T.eq(e.state.calls, 1, "API-3 replay executes downstream read once") + rows[#rows + 1] = { label = "replay", calls = e.state.calls } + end + for _, mode in ipairs({ "delegate", "suppress", "override" }) do + f.reset("LEFTOVERS") + f.mon("player").hp = 100 + e.state.mode, e.state.trigger = mode, "residual" + e.state.requiresItem = true + e.state.effect, e.state.parameter = "HELD_BERRY", 7 + f.residual() + local healed = f.mon("player").hp - 100 + T.eq( + healed, + mode == "delegate" and 25 or mode == "override" and 7 or 0, + "real residual applies public held decision" + ) + local held = f.mon("player").item + local remains = held ~= nil and held ~= 0 + T.eq(remains, mode ~= "override", "native effect owns item consumption") + T.check(e.state.seen.residual ~= nil, "real residual code dispatches held read") + rows[#rows + 1] = { label = "residual:" .. mode, healed = healed, held = remains } + end + for _, active in ipairs({ false, true }) do + f.reset("LEFTOVERS") + if active then + e.state.mode, e.state.trigger, e.state.effect, e.state.parameter = + "override", "priority", "HELD_QUICK_CLAW", 256 + end + T.eq(f.priority(), active, "native move order follows held priority substitution") + rows[#rows + 1] = { label = "priority:" .. tostring(active), first = active } + end + local damages = {} + for _, active in ipairs({ false, true }) do + f.reset("LEFTOVERS") + if active then + e.state.mode, e.state.trigger, e.state.effect, e.state.parameter = + "override", "damage", "HELD_NORMAL_BOOST", 100 + end + f.attack() + damages[#damages + 1] = 400 - f.mon("enemy").hp + end + T.check(damages[1] > 0 and damages[2] > damages[1], "real native move damage responds to held type boost") + rows[#rows + 1] = { label = "damage boost", increased = damages[2] > damages[1] } + for _, active in ipairs({ false, true }) do + f.reset(nil) + f.hold("enemy", "LEFTOVERS") + if active then + e.state.mode, e.state.trigger, e.state.effect, e.state.parameter = + "override", "accuracy", "HELD_BRIGHTPOWDER", 256 + end + f.attack() + local missed = f.mon("enemy").hp == 400 + T.eq(missed, active, "native 100-percent move honors held accuracy modifier") + rows[#rows + 1] = { label = "accuracy:" .. tostring(active), missed = missed } + end + for _, active in ipairs({ false, true }) do + f.reset(nil) + f.hold("enemy", "LEFTOVERS") + f.mon("enemy").hp = 1 + if active then + e.state.mode, e.state.trigger, e.state.effect, e.state.parameter = + "override", "endure", "HELD_FOCUS_BAND", 256 + end + f.attack() + T.eq(f.mon("enemy").hp, active and 1 or 0, "native lethal move on one HP honors held endurance") + rows[#rows + 1] = { label = "endure:" .. tostring(active), hp = f.mon("enemy").hp } + end + for _, hp in ipairs({ 199, 200 }) do + f.reset("ORAN_BERRY") + f.mon("player").hp = hp + f.residual() + local wanted = hp == 199 or f.gen == 3 + T.eq(f.mon("player").hp - hp, wanted and 10 or 0, "native generation berry threshold") + rows[#rows + 1] = { label = "berry threshold:" .. hp, healed = f.mon("player").hp - hp } + end + if f.gen == 3 then + for _, active in ipairs({ false, true }) do + f.reset("LEFTOVERS") + f.hold("enemy", "LEFTOVERS") + if active then + e.state.mode, e.state.trigger, e.state.holder = "override", "priority", "ENEMY" + e.state.effect, e.state.parameter = "HELD_MACHO_BRACE", 0 + end + T.eq(f.priority(75, 100), active, "native GBA speed honors held Macho Brace substitution") + rows[#rows + 1] = { label = "macho:" .. tostring(active), first = active } + f.reset("LEFTOVERS") + if active then + e.state.mode, e.state.trigger, e.state.effect = "override", "run", "HELD_ESCAPE" + end + local escaped = f.runEffect() + T.eq(escaped, active, "native GBA escape honors held read") + rows[#rows + 1] = { label = "escape:" .. tostring(active), escaped = escaped } + end + f.reset("LEFTOVERS") + e.state.mode, e.state.trigger, e.state.effect = "override", "check", "HELD_AMULET_COIN" + f.entry() + local prize = f.battle.amuletCoin == true or f.battle.moneyMultiplier == 2 + T.eq(prize, true, "native entry held read sets money multiplier") + rows[#rows + 1] = { label = "entry prize", prize = prize } + f.reset("LEFTOVERS") + f.battle.player.stages.attack = -1 + e.state.mode, e.state.trigger, e.state.effect = "override", "check", "HELD_RESTORE_STATS" + f.entry() + T.eq(f.battle.player.stages.attack, 0, "native entry held read restores stat stages") + local held = f.mon("player").item + T.check(held == nil or held == 0, "native entry consumes substituted White Herb") + rows[#rows + 1] = { label = "entry herb", stage = f.battle.player.stages.attack } + f.reset(nil) + f.hold("enemy", "LEFTOVERS") + e.state.mode, e.state.trigger, e.state.effect = "override", "check", "HELD_AMULET_COIN" + f.entry("enemy") + prize = f.battle.amuletCoin == true or f.battle.moneyMultiplier == 2 + T.eq(prize, require("src.core.GameVersion").get() ~= "emerald", "native GBA enemy prize policy") + rows[#rows + 1] = { label = "enemy entry prize", prize = prize } + f.reset("LEFTOVERS") + f.mon("player").hp = 300 + e.state.mode, e.state.trigger, e.state.effect, e.state.parameter = + "override", "flinch", "HELD_SHELL_BELL", 8 + f.attack() + local damage, healed = 400 - f.mon("enemy").hp, f.mon("player").hp - 300 + T.eq(healed, math.max(1, math.floor(damage / 8)), "native Shell Bell read heals from actual dealt damage") + rows[#rows + 1] = { label = "shell bell", correctShare = healed == math.max(1, math.floor(damage / 8)) } + end + for _, active in ipairs({ false, true }) do + f.reset("LEFTOVERS") + if active then + e.state.mode, e.state.trigger, e.state.effect, e.state.parameter = + "override", "flinch", "HELD_FLINCH", 256 + end + f.attack() + T.eq(f.flinched(), active, "actual native move honors held King's Rock substitution") + rows[#rows + 1] = { label = "flinch:" .. tostring(active), flinched = f.flinched() } + f.reset("PERSIM_BERRY") + f.confused(3) + if not active then + e.state.mode, e.state.trigger = "suppress", f.gen == 2 and "status" or "residual" + end + f.residual() + T.eq(f.confused(), not active, "actual native residual honors held confusion cure/suppression") + rows[#rows + 1] = { label = "confusion cure:" .. tostring(active), confused = f.confused() } + f.reset("LEPPA_BERRY") + f.pp(0) + if not active then + e.state.mode, e.state.trigger = "suppress", f.gen == 2 and "pp" or "residual" + end + f.residual() + local expected = active and (f.gen == 2 and 5 or 10) or 0 + T.eq(f.pp(), expected, "actual native held PP restore uses generation's own amount") + rows[#rows + 1] = { label = "PP restore:" .. tostring(active), pp = f.pp() } + if f.gen == 2 then + f.reset(nil) + f.hold("enemy", "LEFTOVERS") + if active then + e.state.mode, e.state.trigger, e.state.effect = "override", "confuse", "HELD_PREVENT_CONFUSE" + end + local confused = f.inflictConfusion() + T.eq(confused, not active, "native Gold confusion gate honors held prevention") + rows[#rows + 1] = { label = "confusion prevent:" .. tostring(active), confused = confused } + end + end + for _, item in ipairs(f.catalog) do + f.reset(item[1]) + local effect, param = f.read("check") + T.eq(effect, f.backend == "gen1" and item[6] or item[3], "all native GBA enum names round-trip") + T.eq(param, 16, "all native GBA default parameters round-trip without quantization") + rows[#rows + 1] = { + label = "GBA enum:" .. item[3], + effect = e.state.last.effect, + parameter = e.state.last.parameter, + } + end + if f.api >= 3 then + f.reset("LEFTOVERS") + local off = e.api.hooks:wrap("held_item.trigger", function(next, ctx) + ctx.def.price, ctx.def.heldEffect, ctx.def.heldParameter = 999, "HELD_BERRY", 7 + return next(ctx) + end, 100) + T.eq( + f.read("residual"), + f.effect("HELD_LEFTOVERS"), + "definition edits do not rewrite captured current effect" + ) + off() + T.eq(e.state.last.def.price, 999, "downstream sees committed public definition edits") + local effect, param = f.read("residual") + T.eq( + effect, + f.effect(f.gen == 2 and "HELD_BERRY" or "HELD_LEFTOVERS"), + "definition persistence follows native producer" + ) + T.eq(param, f.gen == 2 and 7 or 10, "definition parameter persistence follows native producer") + rows[#rows + 1] = { + label = "definition metadata", + price = e.state.last.def.price, + parameter = param, + effect = e.state.last.effect, + } + local restore = e.api.hooks:wrap("held_item.trigger", function(next, ctx) + ctx.def.price, ctx.def.heldEffect, ctx.def.heldParameter = 100, "HELD_LEFTOVERS", 10 + return next(ctx) + end, 100) + f.read("check") + restore() + end + f.reset("LEFTOVERS") + e.stop() + e.stop() + T.eq(f.read("residual"), f.effect("HELD_LEFTOVERS"), "unsubscribe restores native reader") + T.eq(e.state.calls, 0, "unsubscribe removes held callback") + rows[#rows + 1] = { label = "unsubscribed", calls = e.state.calls } + return rows +end +return M diff --git a/tests/held_item/damage.lua b/tests/held_item/damage.lua new file mode 100644 index 000000000..ad0fdfb8b --- /dev/null +++ b/tests/held_item/damage.lua @@ -0,0 +1,203 @@ +-- Shared inputs; execute each engine's actual calculator and public hook chain. +local M = {} +function M.run(root, backend, version) + local native = backend == "gen1" + local f = dofile(root .. "/tests/held_item/fixture.lua").new(root, backend, version, 3, "unified") + local T, rows = f.T, {} + local scenarios = { + { name = "empty" }, + { name = "normal-boost", item = "NORMAL_POWER" }, + { name = "choice-band", item = "CHOICE_BAND" }, + { name = "thick-club", item = "THICK_CLUB", species = 104 }, + { name = "thick-club-wrong-species", item = "THICK_CLUB" }, + { name = "metal-powder", targetItem = "METAL_POWDER", targetSpecies = 132 }, + { name = "metal-powder-wrong-species", targetItem = "METAL_POWDER" }, + { name = "fire-boost", item = "FIRE_POWER", special = true }, + { name = "light-ball", item = "LIGHT_BALL", species = 25, special = true }, + { name = "light-ball-wrong-category", item = "LIGHT_BALL", species = 25 }, + { name = "deep-sea-tooth", item = "DEEP_SEA_TOOTH", species = 373, special = true }, + { name = "deep-sea-scale", targetItem = "DEEP_SEA_SCALE", targetSpecies = 373, special = true }, + { + name = "soul-dew-both", + item = "SOUL_DEW", + targetItem = "SOUL_DEW", + species = 407, + targetSpecies = 408, + special = true, + }, + { name = "override-empty", effect = "HELD_NORMAL_BOOST", parameter = 13 }, + { name = "override-choice", effect = "HELD_CHOICE_BAND" }, + { name = "suppress-choice", item = "CHOICE_BAND", suppress = true }, + { name = "stateful-read", effect = "HELD_NORMAL_BOOST", parameter = 13, stateful = true }, + { name = "huge-power-club", item = "THICK_CLUB", species = 104, ability = "HUGE_POWER" }, + { name = "pure-power-band", item = "CHOICE_BAND", ability = "PURE_POWER" }, + { name = "hustle-band", item = "CHOICE_BAND", ability = "HUSTLE" }, + { name = "guts-burn-band", item = "CHOICE_BAND", ability = "GUTS", status = "BRN" }, + { name = "burn-band", item = "CHOICE_BAND", status = "BRN" }, + { + name = "marvel-scale", + targetItem = "METAL_POWDER", + targetSpecies = 132, + targetAbility = "MARVEL_SCALE", + targetStatus = "BRN", + }, + { name = "thick-fat-boost", item = "FIRE_POWER", special = true, targetAbility = "THICK_FAT" }, + { name = "blaze-boost", item = "FIRE_POWER", special = true, ability = "BLAZE", pinch = true }, + { + name = "plus-light-ball", + item = "LIGHT_BALL", + species = 25, + special = true, + ability = "PLUS", + targetAbility = "MINUS", + }, + { name = "rain-fire", item = "FIRE_POWER", special = true, weather = "RAIN" }, + { name = "sun-fire", item = "FIRE_POWER", special = true, weather = "SUN" }, + { name = "water-sport", item = "FIRE_POWER", special = true, sport = true }, + { name = "screen-band", item = "CHOICE_BAND", screen = true }, + { name = "screen-fire", item = "FIRE_POWER", special = true, screen = true }, + { + name = "blaze-water-sport", + item = "FIRE_POWER", + special = true, + ability = "BLAZE", + pinch = true, + sport = true, + }, + { name = "flash-fire", item = "FIRE_POWER", special = true, ability = "FLASH_FIRE", flash = true }, + { name = "charged-boost", item = "ELECTRIC_POWER", special = true, electric = true, charged = true }, + } + local function item(effect) + for _, row in ipairs(f.catalog) do + if row[3] == effect then + return row[1] + end + end + assert(effect == nil, "fixture item missing: " .. tostring(effect)) + end + for _, scenario in ipairs(scenarios) do + for _, stat in ipairs({ 17, 51, 257 }) do + for _, stage in ipairs({ -3, -1, 0, 1, 3 }) do + for _, crit in ipairs({ false, true }) do + local b = f.reset(item(scenario.item)) + f.hold("enemy", item(scenario.targetItem)) + local user, target = b.player, b.enemy + user.mon.status, target.mon.status = scenario.status, scenario.targetStatus + if scenario.pinch then + user.mon.hp = 100 + end + if native then + user.type1, user.type2, target.type1, target.type2 = 0, 0, 0, 0 + user.expTracedAbility, target.expTracedAbility = scenario.ability, scenario.targetAbility + user.expFlashFire, user.expCharged = scenario.flash, scenario.charged + else + user.curTypes, target.curTypes = { "NORMAL" }, { "NORMAL" } + user.abilityOverride, target.abilityOverride = scenario.ability, scenario.targetAbility + user.flashFire, user.charged = scenario.flash, scenario.charged + end + local attackStat, defenseStat = + scenario.special and "spAtk" or "attack", scenario.special and "spDef" or "defense" + if native then + user.mon[attackStat], target.mon[defenseStat] = stat, 51 + user.stages[attackStat], target.stages[defenseStat] = stage, -stage + else + attackStat, defenseStat = attackStat:lower(), defenseStat:lower() + user.curStats[attackStat], target.curStats[defenseStat] = stat, 51 + user.stages[attackStat], target.stages[defenseStat] = stage, -stage + end + user.species, target.species = scenario.species or 4, scenario.targetSpecies or 4 + local trace, reads, draws = {}, 0, {} + local off = f.e.api.hooks:wrap("held_item.trigger", function(nextRead, ctx) + trace[#trace + 1] = { + holder = ctx.mon.nickname, + item = ctx.item, + effect = ctx.effect, + parameter = ctx.parameter, + trigger = ctx.trigger, + definition = ctx.def and ctx.def.id, + actualBattle = ctx.battle == b, + } + if ctx.mon.nickname == "PLAYER" then + reads = reads + 1 + if scenario.suppress then + return nil, 0 + end + if scenario.effect then + if scenario.stateful and reads % 2 == 0 then + return "HELD_CHOICE_BAND", 0 + end + return scenario.effect, scenario.parameter or 0 + end + end + return nextRead(ctx) + end) + local function rng(lo, hi) + draws[#draws + 1] = { lo, hi, hi } + return hi + end + local function calculate() + if native then + return require("src.core.game3.battle.damage").calc(user, target, { + id = "TACKLE", + numId = 33, + power = 35, + type = scenario.electric and 13 or scenario.special and 10 or 0, + effect = 0, + }, { + forceCrit = crit, + rng = rng, + adapter = require("src.core.game3.battle.adapter").new(b), + st = b, + weather = scenario.weather, + waterSport = scenario.sport, + reflect = scenario.screen, + lightScreen = scenario.screen, + }) + else + return b:computeDamage(user, target, { + id = f.ids.TACKLE, + index = 33, + power = 35, + type = scenario.electric and "ELECTRIC" or scenario.special and "FIRE" or "NORMAL", + effect = "NO_ADDITIONAL_EFFECT", + gen3Effect = 0, + }, { + forceCrit = crit, + rng = rng, + weather = scenario.weather, + sports = scenario.sport and { "FIRE" } or nil, + screens = scenario.screen and { reflect = true, lightScreen = true } or nil, + }) + end + end + local damage = calculate() + local repeated = scenario.stateful and calculate() or nil + off() + if scenario.name == "blaze-water-sport" and stat == 257 and stage == 0 and not crit then + T.eq(damage, 42, "unchanged reference anchor: sport floors before pinch ability") + end + T.eq(#trace, scenario.stateful and 4 or 2, "one held read per actor in each GBA base command") + T.eq(reads, scenario.stateful and 2 or 1, "answers expire before the next native command") + T.eq(trace[1] and trace[1].holder, "PLAYER", "attacker held read first") + T.eq(trace[2] and trace[2].holder, "ENEMY", "defender held read second") + T.eq(#f.result.errors, 0, "public callback errors remain empty") + rows[#rows + 1] = { + case = scenario.name, + stat = stat, + stage = stage, + crit = crit, + damage = damage, + repeated = repeated, + trace = trace, + draws = draws, + userHp = user.mon.hp, + targetHp = target.mon.hp, + } + end + end + end + end + f.close() + return rows +end +return M diff --git a/tests/held_item/damage_worker.lua b/tests/held_item/damage_worker.lua new file mode 100644 index 000000000..2676c9626 --- /dev/null +++ b/tests/held_item/damage_worker.lua @@ -0,0 +1,5 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local root, backend, version = unpack(arg) +local rows = dofile(root .. "/tests/held_item/damage.lua").run(root, backend, version) +print("HELD_DAMAGE " .. require("src.link.Json").encode(rows)) +require("tests.modkit").finish("native GBA held damage command") diff --git a/tests/held_item/fixture.lua b/tests/held_item/fixture.lua new file mode 100644 index 000000000..1b66f3073 --- /dev/null +++ b/tests/held_item/fixture.lua @@ -0,0 +1,367 @@ +local M = {} +function M.new(root, backend, version, api, format, noMod, failing) + local items = { + { "NO_EFFECT", 371, "NONE", 0, 0 }, + { "LEFTOVERS", 200, "LEFTOVERS", 3, 10 }, + { "QUICK_CLAW", 183, "QUICK_CLAW", 74, 20 }, + { "BRIGHTPOWDER", 179, "EVASION_UP", 77, 10 }, + { "FOCUS_BAND", 196, "FOCUS_BAND", 79, 10 }, + { "KINGS_ROCK", 187, "FLINCH", 75, 10 }, + { "SCOPE_LENS", 198, "SCOPE_LENS", 73, 0 }, + { "ORAN_BERRY", 139, "RESTORE_HP", 1, 10 }, + { "PERSIM_BERRY", 140, "CURE_CONFUSION", 16, 0 }, + { "LEPPA_BERRY", 138, "RESTORE_PP", 6, 10 }, + { "CHARCOAL", 215, "FIRE_POWER", 58, 10 }, + } + local catalog = {} + local gbaNames = [[ +RESTORE_HP CURE_PAR CURE_SLP CURE_PSN CURE_BRN CURE_FRZ RESTORE_PP CURE_CONFUSION CURE_STATUS +CONFUSE_SPICY CONFUSE_DRY CONFUSE_SWEET CONFUSE_BITTER CONFUSE_SOUR ATTACK_UP DEFENSE_UP +SPEED_UP SP_ATTACK_UP SP_DEFENSE_UP CRITICAL_UP RANDOM_STAT_UP EVASION_UP RESTORE_STATS +MACHO_BRACE EXP_SHARE QUICK_CLAW FRIENDSHIP_UP CURE_ATTRACT CHOICE_BAND FLINCH BUG_POWER +DOUBLE_PRIZE REPEL SOUL_DEW DEEP_SEA_TOOTH DEEP_SEA_SCALE CAN_ALWAYS_RUN PREVENT_EVOLVE +FOCUS_BAND LUCKY_EGG SCOPE_LENS STEEL_POWER LEFTOVERS DRAGON_SCALE LIGHT_BALL GROUND_POWER +ROCK_POWER GRASS_POWER DARK_POWER FIGHTING_POWER ELECTRIC_POWER WATER_POWER FLYING_POWER +POISON_POWER ICE_POWER GHOST_POWER PSYCHIC_POWER FIRE_POWER DRAGON_POWER NORMAL_POWER +UP_GRADE SHELL_BELL LUCKY_PUNCH METAL_POWDER THICK_CLUB STICK +]] + local goldNames = { + [1] = "BERRY", + [16] = "HEAL_CONFUSION", + [73] = "CRITICAL_UP", + [77] = "BRIGHTPOWDER", + [58] = "FIRE_BOOST", + } + local f = dofile(root .. "/tests/charge_required/fixture.lua").new( + root, + backend, + version, + api, + format, + noMod, + failing, + "portable_held_item", + function(data, gen, _, rom, names) + if backend == "gen2" then + data.constants.generation = gen + end + if gen == 3 then + if backend == "gen2" then + -- Match the native-shaped reference Tackle record. The command + -- tests its actual bit flag, independently of public hook output. + data.moves.MOVE_033.flags = 51 + end + for _, move in ipairs({ { "SURF", 57, 95, 8, 0, "NO_ADDITIONAL_EFFECT" }, + { "MIRROR_MOVE", 119, 0, 0, 9, "MIRROR_MOVE_EFFECT" } }) do + local name, number, power, target, effect, hostEffect = unpack(move) + if backend == "gen1" then + data.gen3Moves._rom[number] = { effect = effect, power = power, type = 0, + accuracy = 100, pp = 10, target = target, priority = 0, flags = 51 } + data.gen3Pokemon._moveNames[number] = name + rom[number], names[number] = data.gen3Moves._rom[number], name + else + local id = ("MOVE_%03d"):format(number) + data.moves[id] = { id = id, index = number, name = name, power = power, + type = "NORMAL", accuracy = 100, pp = 10, target = target, + priority = 0, effect = hostEffect, kingsRockAffected = true } + end + end + for name in gbaNames:gmatch("%S+") do + local index = #catalog + 1 + local row = { "PORTABLE_HOLD_" .. index, 500 + index, name, 0, 16, index } + catalog[#catalog + 1] = row + items[#items + 1] = row + end + end + for _, row in ipairs(items) do + local name, id, effect, gold, param = unpack(row) + if backend == "gen1" and gen == 3 then + data.gen3Items._byId[id] = { + name = name:gsub("_", " "), + pocket = "ITEMS", + price = 100, + holdEffect = row[6] or ({ + NONE = 0, + LEFTOVERS = 43, + QUICK_CLAW = 26, + EVASION_UP = 22, + FOCUS_BAND = 39, + FLINCH = 30, + SCOPE_LENS = 41, + RESTORE_HP = 1, + CURE_CONFUSION = 8, + RESTORE_PP = 7, + FIRE_POWER = 58, + })[effect], + holdEffectParam = param, + } + else + local key = backend == "gen2" and gen == 3 and ("ITEM_%03d"):format(id) or name + data.items[key] = { id = key, index = id, name = name:gsub("_", " "), price = 100 } + if backend == "gen1" and gen == 2 then + data.items[key].heldEffect = "HELD_" .. (goldNames[gold] or effect) + data.items[key].heldParameter = param + elseif gen == 2 then + data.items[key].heldEffect, data.items[key].heldParam = gold, param + end + if gen >= 3 then + data.items[key].holdEffect, data.items[key].holdEffectParam = effect, param + end + end + end + end + ) + f.catalog = catalog + if backend == "gen1" and f.gen == 3 then + -- Supply native extractor-shaped item data to the real fallback reader. + local Items = require("src.core.game3.items_data") + local previous, close = Items._byId, f.close + Items._byId = f.data.gen3Items._byId + f.close = function() + Items._byId = previous + close() + end + end + local native = f.fresh + local b, step + function f.reset(item, double) + step = native("TACKLE") + b = step.battle + if double then + local copy = require("src.mods.Merge").deepCopy + if backend == "gen1" then + b = require("src.core.game3.battle.state").new({ double = true, wild = true, + playerParty = { b.player.mon, copy(b.player.mon) }, + foeParty = { b.enemy.mon, copy(b.enemy.mon) } }) + b.rng = function(lo) return lo or 0 end + else + local extra = native("TACKLE").battle + b.playerParty, b.enemyParty = { b.player.mon, extra.player.mon }, { b.enemy.mon, extra.enemy.mon } + f.game.save.party, b.double = b.playerParty, true + b:placeBattler(2, extra.player) + b:placeBattler(3, extra.enemy) + b:syncSides() + end + end + if backend == "gen1" and f.gen == 2 then + b.random = function() + return 0 + end + end + f.battle = b + for _, side in ipairs({ "player", "enemy" }) do + local mon = backend == "gen1" and f.gen == 2 and b[side] or b[side].mon + mon.hp, mon.maxHp, mon.stats = 400, 400, mon.stats or {} + mon.stats.hp, mon.nickname = 400, side == "player" and "PLAYER" or "ENEMY" + end + b.queue, b.phase, b.turnCount = {}, "menu", 1 + if f.e then + f.e.state.mode, f.e.state.trigger, f.e.state.holder = "delegate", nil, nil + f.e.state.calls, f.e.state.seen, f.e.state.views = 0, {}, {} + end + f.hold("player", item) + f.hold("enemy", nil) + return b + end + function f.mon(side) + return backend == "gen1" and f.gen == 2 and b[side] or b[side].mon + end + function f.hold(side, name) + local id + for _, row in ipairs(items) do + if row[1] == name then + id = row[2] + end + end + local key = name + if f.gen == 3 then + key = backend == "gen1" and (id or 0) or (id and ("ITEM_%03d"):format(id)) + end + f.mon(side).item = key + if backend == "gen1" and f.gen == 3 then + b[side].item = key + end + return key + end + function f.read(trigger, side) + side = side or "player" + if backend == "gen1" and f.gen == 2 then + return b:heldEffect(b[side], trigger) + end + if backend == "gen1" and f.gen == 3 then + return require("src.core.game3.battle.held_items").of(b[side], trigger, b) + end + if f.gen == 2 then + return require("src.battle.HeldItems").effect(f.data, b[side], trigger) + end + return require("src.battle.HoldItems").effectOf(b[side], trigger) + end + function f.residual() + if backend == "gen1" and f.gen == 2 then + b:betweenTurnEffects() + elseif backend == "gen1" and f.gen == 3 then + require("src.core.game3.battle.engine") + return require("src.core.game3.battle.residuals").runTurn( + require("src.core.game3.battle.adapter").new(b) + ) + else + b:endOfTurn() + end + end + function f.attack(called) + return step.use(called) + end + function f.resolve(name, execute) + local number = ({ TACKLE = 33, SURF = 57, MIRROR_MOVE = 119 })[name] + if backend == "gen1" then + b.player.mon.moves, b.player.mon.pp = { number }, { 10 } + return require("src.core.game3.battle.engine").resolveMove(b.player, b.enemy, number, 1, + require("src.core.game3.battle.adapter").new(b), b, {}, {}) + end + local action = { id = ("MOVE_%03d"):format(number), pp = 10 } + b.player.mon.moves, b.player.curMoves = { action }, { action } + if execute then return b:executeAction(b.player, b.enemy, action) end + return b:performMove(b.player, b.enemy, action) + end + function f.actor(id) + if backend == "gen1" then return require("src.core.game3.battle.state").battler(b, id) end + return b:battlerAt(id) + end + function f.flinched() + if backend == "gen1" and f.gen == 2 then + return not not b:volatile(b.enemy).flinched + end + return not not b.enemy.flinched + end + function f.confused(turns) + if backend == "gen1" and f.gen == 2 then + if turns then + b:volatile(b.player).confuseCount = turns + end + return (b:volatile(b.player).confuseCount or 0) > 0 + elseif backend == "gen1" and f.gen == 3 then + if turns then + b.player.confusionTurns = turns + end + return (b.player.confusionTurns or 0) > 0 + end + if turns then + b.player.confusedTurns = turns + end + return (b.player.confusedTurns or 0) > 0 + end + function f.pp(value) + local mon = f.mon("player") + if backend == "gen1" and f.gen == 3 then + if value then + mon.pp[1] = value + end + return mon.pp[1] + end + if value then + mon.moves[1].pp = value + end + return mon.moves[1].pp + end + function f.inflictConfusion() + if backend == "gen1" then + b:applyConfusion(b.enemy, 3, b.player) + return (b:volatile(b.enemy).confuseCount or 0) > 0 + end + require("src.battle.MoveEffects").primary.CONFUSION_EFFECT(b, b.player, b.enemy) + return (b.enemy.confusedTurns or 0) > 0 + end + function f.priority(playerSpeed, enemySpeed) + playerSpeed, enemySpeed = playerSpeed or 10, enemySpeed or 100 + if backend == "gen1" and f.gen == 2 then + b.player.stats.speed, b.enemy.stats.speed = playerSpeed, enemySpeed + return b:orderOf(f.ids.TACKLE, f.ids.TACKLE) == "player" + elseif backend == "gen1" and f.gen == 3 then + b.player.mon.speed, b.enemy.mon.speed, b.randomTurnNumber = playerSpeed, enemySpeed, 0 + local Engine = require("src.core.game3.battle.engine") + local ad = require("src.core.game3.battle.adapter").new(b) + return Engine.speedOf(b.player, b, ad) > Engine.speedOf(b.enemy, b, ad) + end + b.player.curStats.speed, b.enemy.curStats.speed = playerSpeed, enemySpeed + if f.gen == 3 and b:quickClawWins(b.player) then + return true + end + return require("src.battle.TurnOrder").firstMover( + b.player, + f.data.moves[f.ids.TACKLE], + b.enemy, + f.data.moves[f.ids.TACKLE], + b.rng, + nil, + b, + f.data + ) + end + function f.entry(side) + side = side or "player" + if backend == "gen1" then + return require("src.core.game3.battle.held_items").onSwitchIn( + require("src.core.game3.battle.adapter").new(b), + b[side] + ) + end + return require("src.battle.HeldItems").onEntry(b, b[side]) + end + function f.runEffect() + if backend == "gen1" then + b.player.mon.speed, b.enemy.mon.speed = 10, 100 + b.rng = function(lo, hi) + return hi or lo or 0 + end + return require("src.core.game3.battle.engine").tryFlee( + b, + require("src.core.game3.battle.adapter").new(b), + b.player + ) + end + b.rng = function(lo, hi) + return hi or lo or 0 + end + return b:runRoll(0, 255) + end + function f.effect(kind) + if backend == "gen1" and f.gen == 2 then + return kind + end + if f.gen == 2 then + return ({ + HELD_NONE = 0, + HELD_LEFTOVERS = 3, + HELD_BERRY = 1, + HELD_QUICK_CLAW = 74, + HELD_BRIGHTPOWDER = 77, + HELD_FOCUS_BAND = 79, + HELD_FLINCH = 75, + HELD_CRITICAL_UP = 73, + HELD_FIRE_BOOST = 58, + HELD_HEAL_CONFUSION = 16, + HELD_RESTORE_PP = 6, + HELD_PREVENT_CONFUSE = "PREVENT_CONFUSE", + })[kind] or 0 + end + local key = ({ + HELD_LEFTOVERS = "LEFTOVERS", + HELD_BERRY = "RESTORE_HP", + HELD_QUICK_CLAW = "QUICK_CLAW", + HELD_BRIGHTPOWDER = "EVASION_UP", + HELD_FOCUS_BAND = "FOCUS_BAND", + HELD_FLINCH = "FLINCH", + HELD_CRITICAL_UP = "SCOPE_LENS", + HELD_FIRE_BOOST = "FIRE_POWER", + HELD_HEAL_CONFUSION = "CURE_CONFUSION", + HELD_RESTORE_PP = "RESTORE_PP", + })[kind] + if backend == "gen2" then + return key + end + return key and require("src.core.game3.battle.held_items").HOLD[key] or 0 + end + f.reset("LEFTOVERS") + return f +end +return M diff --git a/tests/held_item/scheduling.lua b/tests/held_item/scheduling.lua new file mode 100644 index 000000000..782044723 --- /dev/null +++ b/tests/held_item/scheduling.lua @@ -0,0 +1,293 @@ +local M = {} +function M.run(f) + if f.gen ~= 3 then + return {} + end + local T, rows, trace = f.T, {}, {} + local off = f.e.api.hooks:wrap("held_item.trigger", function(next, ctx) + if ctx.trigger == "residual" then + trace[#trace + 1] = ctx.mon.nickname + end + return next(ctx) + end, 100) + local function reset(item) + f.reset(item) + trace = {} + end + local function observe(label, expected) + for key, value in pairs(expected) do + local got = key == "reads" and table.concat(trace, ",") + or key == "player" and f.mon("player").hp + or key == "enemy" and f.mon("enemy").hp + or key == "pp" and f.pp() + T.eq(got, value, "native schedule " .. label .. ":" .. key) + end + local row = { label = label } + for key, value in pairs(expected) do + row[key] = value + end + rows[#rows + 1] = row + end + local function grant(effect, parameter, holder) + local s = f.e.state + s.mode, s.trigger, s.holder, s.requiresItem = "override", "residual", holder, false + s.effect, s.parameter = effect, parameter + end + local function speed(player, enemy) + if f.backend == "gen1" then + f.mon("player").speed, f.mon("enemy").speed = player, enemy + else + f.battle.player.curStats.speed, f.battle.enemy.curStats.speed = player, enemy + end + end + local function poison() + f.mon("player").status = "PSN" + if f.backend == "gen1" then + f.battle.player.status = "PSN" + end + end + local function synthetic(effect) + for _, row in ipairs(f.catalog) do + if row[3] == effect then + return row[1] + end + end + error("missing effect " .. effect) + end + reset() + speed(100, 10) + f.mon("player").hp = 100 + grant("HELD_BERRY", 7, "PLAYER") + f.residual() + observe("residual retry", { player = 135, reads = "PLAYER,ENEMY,PLAYER,PLAYER,PLAYER,PLAYER" }) + + reset() + speed(100, 10) + f.mon("player").hp, f.mon("enemy").hp = 100, 100 + grant("HELD_BERRY", 7) + f.residual() + observe( + "first successful slot restarts", + { player = 135, enemy = 107, reads = "PLAYER,ENEMY,PLAYER,PLAYER,PLAYER,PLAYER" } + ) + + reset() + f.mon("player").hp = 100 + grant("HELD_BERRY", 7, "PLAYER") + f.attack() + observe("move then after action", { player = 128, reads = "PLAYER,ENEMY,PLAYER,PLAYER,PLAYER,PLAYER" }) + + reset() + f.mon("player").hp = 100 + grant("HELD_BERRY", 7, "PLAYER") + f.attack(true) + observe("called move has no separate action", { player = 100, reads = "PLAYER,ENEMY" }) + + reset("LEPPA_BERRY") + f.pp(1) + f.attack() + observe("PP restores after move", { pp = 10 }) + T.check(not f.mon("player").item or f.mon("player").item == 0, "native Leppa consumed") + + reset() + f.pp(0) + grant("HELD_RESTORE_PP", 16, "PLAYER") + f.residual() + observe("PP restoration respects move cap", { pp = 10 }) + + reset() + f.mon("player").hp = 100 + grant("HELD_BERRY", 7, "PLAYER") + f.mon("player").status = "PAR" + if f.backend == "gen1" then + f.battle.player.status = "PAR" + end + f.resolve("TACKLE", true) + observe( + "canceled action still checks items", + { player = 128, reads = "PLAYER,ENEMY,PLAYER,PLAYER,PLAYER,PLAYER" } + ) + + reset(synthetic("CURE_PSN")) + f.mon("player").hp = 20 + poison() + f.residual() + observe("cure precedes lethal poison", { player = 20 }) + T.check(not f.mon("player").status, "native poison cure") + + reset("ORAN_BERRY") + f.mon("player").hp = 20 + poison() + f.residual() + observe("HP berry cannot revive poison faint", { player = 0 }) + T.check(not f.mon("player").item or f.mon("player").item == 0, "berry consumed before poison faint") + + reset() + f.mon("player").hp = 200 + poison() + grant("HELD_BERRY", 7, "PLAYER") + f.residual() + -- Normal Berry triggers at 200, poison subtracts 50, then four retries. + observe("normal item before chip and retries after", { player = 185 }) + + reset("LEFTOVERS") + f.mon("player").hp = 20 + if f.backend == "gen1" then + f.battle.player.expIngrain = true + else + f.battle.player.ingrained = true + end + poison() + f.residual() + observe("Ingrain item then poison", { player = 20 }) + + reset("LEFTOVERS") + f.mon("player").hp, f.mon("enemy").hp = 200, 200 + poison() + if f.backend == "gen1" then + f.battle.player.expSeeded, f.battle.player.expSeedSource = true, f.battle.enemy + else + f.battle.player.leechSeeded, f.battle.player.leechSeedSource = true, f.battle.enemy + end + f.residual() + observe("item then seed then poison", { player = 125, enemy = 250 }) + + reset("LEFTOVERS") + f.mon("player").hp, f.mon("enemy").hp = 200, 100 + local previous = f.battle.enemy + local replacement = require("src.mods.Merge").deepCopy(previous.mon) + if f.backend == "gen1" then + f.battle.player.expSeeded, f.battle.player.expSeedSource = true, previous + f.battle.enemy = require("src.core.game3.battle.state").makeBattler(replacement, "enemy", { id = 1 }) + else + f.battle.player.leechSeeded, f.battle.player.leechSeedSource = true, previous + f.battle.enemy = require("src.battle.BattleState").makeBattler(f.data, replacement, false, nil, f.battle) + f.battle:syncSides() + end + f.residual() + T.eq(previous.mon.hp, 100, "withdrawn seed source does not receive healing") + observe("seed heals current occupant", { player = 175, enemy = 150 }) + + reset() + speed(100, 10) + f.mon("player").hp, f.mon("enemy").hp = 100, 20 + if f.backend == "gen1" then + f.battle.player.expSeeded, f.battle.player.expSeedSource = true, f.battle.enemy + f.battle.player.ability = "LIQUID_OOZE" + else + f.battle.player.leechSeeded, f.battle.player.leechSeedSource = true, f.battle.enemy + f.battle.player.abilityOverride = "LIQUID_OOZE" + end + f.residual() + observe("Liquid Ooze faints seed source", { player = 50, enemy = 0 }) + if f.backend == "gen2" then + T.check(f.battle.enemy.faintQueued, "seed source uses native faint queue") + end + + reset("LEFTOVERS") + speed(100, 10) + f.mon("player").hp = 20 + if f.backend == "gen1" then + f.battle.playerSide.tokens = { { id = "EXP_WISH", turns = 1 } } + f.battle.weather, f.battle.weatherTurns = "SAND", 2 + else + f.battle.player.wishTurns = 1 + f.battle.field.weather, f.battle.field.weatherTurns = "SANDSTORM", 2 + end + f.residual() + observe("Wish before weather and Leftovers", { player = 220, enemy = 375 }) + + for _, speeds in ipairs({ + { 100, 10, "PLAYER,ENEMY,PLAYER,ENEMY" }, + { 10, 100, "ENEMY,PLAYER,PLAYER,ENEMY" }, + { 50, 50, "PLAYER,ENEMY,PLAYER,ENEMY" }, + }) do + reset() + speed(speeds[1], speeds[2]) + f.residual() + observe("residual speed " .. speeds[1] .. ":" .. speeds[2], { reads = speeds[3] }) + end + + for _, effect in ipairs({ "ATTACK_UP", "RANDOM_STAT_UP", "CRITICAL_UP" }) do + reset(synthetic(effect)) + f.mon("player").hp = 10 + local p = f.battle.player + for _, stat in ipairs({ "attack", "defense", "speed", "spatk", "spdef", "spAtk", "spDef" }) do + p.stages[stat] = 6 + end + p.focusEnergy = true + local held = f.mon("player").item + f.residual() + T.eq(f.mon("player").item, held, "ineligible pinch effect is not consumed: " .. effect) + observe("ineligible " .. effect, { player = 10 }) + end + reset() + f.mon("player").hp = 100 + speed(100, 10) + grant("HELD_BERRY", 0, "PLAYER") + f.residual() + observe( + "zero heal still activates bounded retries", + { player = 100, reads = "PLAYER,ENEMY,PLAYER,PLAYER,PLAYER,PLAYER" } + ) + reset() + f.mon("player").hp = 100 + grant("HELD_BERRY", 7, "PLAYER") + if f.backend == "gen1" then + f.battle.player.expLastTakenMove = 33 + else + f.battle.player.gbaLastTakenMove = f.ids.TACKLE + end + f.resolve("MIRROR_MOVE", true) + observe( + "nested Mirror Move has one action sweep", + { player = 128, reads = "PLAYER,ENEMY,PLAYER,PLAYER,PLAYER,PLAYER" } + ) + + for _, spread in ipairs({ false, true }) do + f.reset(nil, true) + trace = {} + for id = 0, 3 do + local actor = f.actor(id) + actor.mon.hp, actor.mon.maxHp = 400, 400 + actor.mon.stats = actor.mon.stats or {} + actor.mon.stats.hp = 400 + actor.mon.nickname = ({ "PLAYER", "ENEMY", "ALLY", "FOE" })[id + 1] + end + f.mon("player").hp = 100 + grant("HELD_BERRY", 7, "PLAYER") + f.resolve(spread and "SURF" or "TACKLE", true) + local selective = "PLAYER,ENEMY,ALLY,FOE," + observe( + spread and "spread has one action sweep" or "double action slot order", + { player = 128, reads = selective .. (spread and selective or "") .. "PLAYER,PLAYER,PLAYER,PLAYER" } + ) + end + f.reset(nil, true) + trace = {} + for id = 0, 3 do + local actor = f.actor(id) + actor.mon.hp, actor.mon.maxHp = 100, 400 + actor.mon.stats = actor.mon.stats or {} + actor.mon.stats.hp = 400 + actor.mon.nickname = ({ "PLAYER", "ENEMY", "ALLY", "FOE" })[id + 1] + if f.backend == "gen1" then + actor.mon.speed = 100 - id * 10 + else + actor.curStats.speed = 100 - id * 10 + end + end + grant("HELD_BERRY", 7) + f.residual() + observe( + "double residual retries first slot", + { player = 135, enemy = 107, reads = "PLAYER,ENEMY,ALLY,FOE,PLAYER,PLAYER,PLAYER,PLAYER" } + ) + T.eq(f.actor(2).mon.hp, 107, "ally receives normal item phase") + T.eq(f.actor(3).mon.hp, 107, "second foe receives normal item phase") + + off() + f.e.state.requiresItem = true + return rows +end +return M diff --git a/tests/held_item/worker.lua b/tests/held_item/worker.lua new file mode 100644 index 000000000..b8a7f9b92 --- /dev/null +++ b/tests/held_item/worker.lua @@ -0,0 +1,38 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local root, backend, version, api, format = unpack(arg) +local Fixture = dofile(root .. "/tests/held_item/fixture.lua") +local f = Fixture.new(root, backend, version, tonumber(api), format) +local rows = dofile(root .. "/tests/held_item/scheduling.lua").run(f) +for _, row in ipairs(dofile(root .. "/tests/held_item/cases.lua").run(f)) do rows[#rows + 1] = row end +f.close() +-- Keep the raw native policy in the report as well as the paired action/ +-- residual cases. Strict CI compares the complete record, including reads. +local policy = Fixture.new(root, backend, version, tonumber(api), format) +policy.e.state.mode, policy.e.state.trigger, policy.e.state.holder = "override", "residual", "PLAYER" +policy.e.state.effect, policy.e.state.parameter, policy.e.state.requiresItem = "HELD_BERRY", 7, false +policy.mon("player").hp = 100 +policy.residual() +local nativePolicy = { + unrestricted_residual_heal = policy.mon("player").hp - 100, + residual_reads = policy.e.state.seen.residual, +} +policy.close() +print("HELD_ITEM_NATIVE_POLICY " .. require("src.link.Json").encode(nativePolicy)) +for _, failing in ipairs({ false, true }) do + local bare = Fixture.new(root, backend, version, tonumber(api), format, not failing, failing) + if failing then + bare.T.check(#bare.result.errors > 0, "entry failure recorded") + else + bare.T.eq(bare.result.loader.exports.portable_held_item, nil, "genuine no-mod fixture") + end + for _, link in ipairs(require("src.mods.Runtime").hooks.chains["held_item.trigger"] or {}) do + bare.T.check(link.owner ~= "portable_held_item", "rollback removes held owner") + end + bare.mon("player").hp = 100 + bare.residual() + bare.T.eq(bare.mon("player").hp, 125, "no-mod/rollback native Leftovers") + bare.close() + rows[#rows + 1] = { label = failing and "rollback" or "no mod", healed = 25 } +end +print("HELD_ITEM_CONTRACT " .. require("src.link.Json").encode(rows)) +f.T.finish("portable held-effect decisions and native residuals") diff --git a/tests/helping_hand/public_effect.lua b/tests/helping_hand/public_effect.lua new file mode 100644 index 000000000..d0fa133e1 --- /dev/null +++ b/tests/helping_hand/public_effect.lua @@ -0,0 +1,62 @@ +-- Composed proof: requires the canonical public effect adapter from #121. +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +require("src.mods.UnifiedEffects") +for _, version in ipairs({ "firered", "emerald" }) do + local entry = [[return function(mod) + assert(mod.content.moves:patch("TACKLE", { effect = "HELPING_HAND", power = 0 })) + mod.exports.effect = assert(mod.content.moves:get("TACKLE")).effect + mod.exports.hits = {} + mod.hooks:wrap("battle.crit", function() return false end) + mod.hooks:wrap("battle.enemy_action", function() return { move = "SCRATCH", target = 0 } end) + mod.events:on("battle.damage_dealt", function(e) + mod.exports.hits[#mod.exports.hits+1] = { target = e.target.mon.nickname, amount = e.damage } + end) + end]] + local function configure(data, _, ids) + local move = data.moves[ids.TACKLE] + move.target, move.priority, move.protectAffected = 16, 5, false + end + local f = require("tests.enemy_actions.fixture").new(".", "gen2", version, 3, "unified", configure, entry) + T.eq(f.e.effect, "HELPING_HAND", "public read keeps canonical Helping Hand effect") + T.eq( + f.data.moves[f.ids.TACKLE].effect, + "HELPING_HAND_EFFECT", + "canonical effect selects real native handler" + ) + T.eq(f.data.moves[f.ids.TACKLE].gen3Effect, 176, "canonical effect selects native semantic 176") + local b = f.fresh("TACKLE", { enemy = true }).battle + b.double = true + b.rng = function(lo, hi) + if lo == 85 and hi == 100 then + return 100 + end + return lo or 0 + end + local right = require("src.battle.BattleState").makeBattler( + f.data, + require("src.mods.Merge").deepCopy(b.enemy.mon), + false + ) + b:placeBattler(3, right) + b:syncSides() + for _, a in ipairs({ b.player, b.enemy, right }) do + a.curTypes, a.curStats = + { "NORMAL" }, { hp = 400, attack = 50, defense = 50, speed = 50, special = 50, spatk = 50, spdef = 50 } + a.mon.stats, a.abilityOverride = a.curStats, false + end + b.player.mon.nickname = "TARGET" + right.curMoves = { { id = f.ids.SCRATCH, pp = 10 } } + right.mon.moves = right.curMoves + b:executeAction(b.enemy, b.enemy, b.enemy.curMoves[1]) + b:executeAction(right, b.player, b:enemyAction(right)) + T.same( + f.e.hits, + { { target = "TARGET", amount = 28 } }, + "canonical public patch boosts a real mod-selected partner attack" + ) + T.eq(b.enemy.curMoves[1].pp, 9, "helper consumes owned PP") + T.eq(right.curMoves[1].pp, 9, "supported action consumes caller PP") + f.close() +end +T.finish("composed public Helping Hand effect") diff --git a/tests/modkit/catalog.lua b/tests/modkit/catalog.lua index 5fce4fd91..427560439 100644 --- a/tests/modkit/catalog.lua +++ b/tests/modkit/catalog.lua @@ -58,8 +58,8 @@ end function Catalog.events() if not events then eventSites = scan({ "src" }, { - 'Runtime%.emit%("([%w%._]+)"', - 'events:emit%("([%w%._]+)"', + 'Runtime%.emit%(%s*"([%w%._]+)"', + 'events:emit%(%s*"([%w%._]+)"', }) events = sortedKeys(eventSites) end @@ -69,8 +69,8 @@ end function Catalog.hooks() if not hooks then hookSites = scan({ "src" }, { - 'Runtime%.call%("([%w%._]+)"', - 'hooks:call%("([%w%._]+)"', + 'Runtime%.call%(%s*"([%w%._]+)"', + 'hooks:call%(%s*"([%w%._]+)"', }) hooks = sortedKeys(hookSites) end diff --git a/tests/native_bgm/fanfares.lua b/tests/native_bgm/fanfares.lua new file mode 100644 index 000000000..fa1341f9b --- /dev/null +++ b/tests/native_bgm/fanfares.lua @@ -0,0 +1,87 @@ +local M = {} +function M.install(root, backend, version, f, options) + options = options or {} + local native = backend == "gen1" + local h = dofile(root .. "/tests/native_bgm/fixture.lua").install(root, backend, version, f) + local Audio = native and require("src.core.game3.audio") + local function resolve(name) + return native and Audio.resolveSong(name) or require("src.core.Gen3SongIds").resolve(name, version) + end + local ids = { resolve("MUS_LEVEL_UP"), resolve("MUS_OBTAIN_ITEM") } + local rows = { + LEVEL_UP = { index = ids[1], frames = options.frames or 4 }, + ITEM = { index = ids[2], frames = 6 }, + } + if options.emptyTable then + rows = {} + end + if native then + Audio._ready = false + Audio._fanfareFrames, Audio._fanfareRestore, Audio._bgmPaused = 0, nil, false + Audio._waitFanfareCb = nil + Audio._pack.index.fanfares = nil + Audio._pack.index.songs = {} + if not options.noTable then + Audio._pack.index.fanfares = {} + for _, row in pairs(rows) do + Audio._pack.index.fanfares[row.index] = row + end + end + if options.songFrames then + Audio._pack.index.songs[ids[1]] = { fanfareFrames = options.songFrames } + end + else + f.data.audio.gen3 = nil + f.data.constants.gen3Fanfares = nil + if not options.noTable then + f.data.constants.gen3Fanfares = { fanfares = rows } + end + if options.songFrames then + f.data.audio.songs[("SONG_%03X"):format(ids[1])] = { fanfareFrames = options.songFrames } + end + end + local Music = require("src.core.Music") + return { + ids = ids, + bgmIds = h.ids, + start = h.start, + tick = h.tick, + update = h.update, + trace = h.trace, + state = function() + local state = h.state() + if native then + state.fanfare = { + active = Audio._fanfareActive, + frames = Audio._fanfareFrames, + restore = Audio._fanfareRestore, + paused = Audio._bgmPaused, + } + state.finished = Audio.isFanfareFinished() + state.clock = Audio._cryClock + else + state.fanfare = Music.nativeFanfareSnapshot and Music.nativeFanfareSnapshot() or nil + if Music.nativeFanfareFinished then + state.finished = Music.nativeFanfareFinished() + else + state.finished = not Music.oneShotPlaying() + end + state.clock = require("src.core.NativeCryPlayer").snapshot().clock + end + return state + end, + value = function(runner, id) + if native then + return require("src.core.game3.scripting.flags").getVar(runner.store, runner.ctx, id or 0x4001) + end + return require("src.script.Gen3Commands").getVar(f.game.save, id or 0x4001) + end, + clear = function() + if not native then + require("src.script.Gen3Commands").setVar(f.game.save, 0x4001, 0) + end + end, + close = h.close, + } +end +return M diff --git a/tests/native_bgm/fixture.lua b/tests/native_bgm/fixture.lua new file mode 100644 index 000000000..64ee73e68 --- /dev/null +++ b/tests/native_bgm/fixture.lua @@ -0,0 +1,227 @@ +-- Owned mod music files exercise actual native host adapters and shared music. +local M = {} +function M.install(root, backend, version, f, options) + options = options or {} + local native = backend == "gen1" + local hardware = dofile(root .. "/tests/native_cry/fixture.lua").install(native, f) + local Music = require("src.core.Music") + Music.reload() + local songs = { + MUS_TITLE = { file = "owned-bgm-title.ogg" }, + MUS_CYCLING = { file = "owned-bgm-cycle.ogg" }, + MUS_SURF = { file = "owned-bgm-surf.ogg" }, + } + if options.policy then + for _, name in ipairs({ + "MUS_DESERT", + "MUS_ABNORMAL_WEATHER", + "MUS_ENCOUNTER_MAGMA", + "MUS_MT_CHIMNEY", + "MUS_ROUTE110", + "MUS_ROUTE119", + }) do + songs[name] = { file = "owned-bgm-" .. name .. ".ogg" } + end + end + local ids = version == "firered" and { 278, 282, 305 } or { 413, 403, 365 } + local mapId = (version == "firered" and "FR_" or "EM_") .. "OWNED_BGM_MAP" + f.data.maps = { [mapId] = { id = mapId, music = ids[1], mapType = 1 } } + local world = { map = { id = mapId }, player = { cellX = 2, cellY = 3 }, npcs = {} } + local savedRuntime, runtime, audio, savedAudio, selected, savedMod + local space, savedStore, player, savedX, savedY + if native then + runtime = require("src.core.game3.runtime") + savedRuntime = { session = runtime.session, game = runtime._game } + runtime.session, runtime._game = { version = version, map = mapId, x = 2, y = 3 }, f.game + local Song = require("src.core.game3.song_ids") + selected = Song.current + Song.select(version) + audio = require("src.core.game3.audio") + savedAudio = {} + for key, value in pairs(audio) do + if type(value) ~= "function" then + savedAudio[key] = value + end + end + audio._currentSong, audio._mapSong, audio._savedSong, audio._bgmSource = nil, ids[1], nil, nil + audio._fadeOut, audio._fadeIn, audio._fanfareActive, audio._fanfareDeferred = nil, nil, false, nil + local Mod = require("src.core.game3.mod_audio") + savedMod = {} + for key, value in pairs(Mod) do + if type(value) ~= "function" then + savedMod[key] = value + end + end + Mod.install({ audio = { songs = songs } }) + audio.applyEngineOptions({ musicVol = 7, sfxVol = 7 }) + audio.playSong(ids[1]) + if options.policy then + space = require("src.core.game3.scripting.space") + savedStore, space.store = space.store, { flags = {}, vars = {} } + player = require("src.core.game3.player") + savedX, savedY = player.cellX, player.cellY + require("src.core.game3.weather") + end + else + f.data.audio.songs, f.data.audio.mapSongs = songs, { [mapId] = "MUS_TITLE" } + Music.applyOptions({ musicVol = 7 }) + Music.playMap(f.data, mapId, false, false) + end + local sources, originalBuilder = {}, love.audio.newSource + -- Initial source came from the actual startup above; retain all source + -- records through the hardware trace rather than replacing music functions. + love.audio.newSource = function(...) + local source = originalBuilder(...) + sources[#sources + 1] = source + return source + end + local function resolve(label) + if native then + return audio.resolveSong(label) + end + return require("src.core.Gen3SongIds").resolve(label, version) + end + return { + ids = ids, + hardware = hardware, + location = function(input) + local id = native and (version == "firered" and "FR_" or "EM_") .. input.name + or ("MAP_G%02d_N%02d"):format(input.group, input.number) + f.data.maps[id] = { + id = id, + group = input.group, + number = input.number, + music = input.music, + mapType = 1, + } + world.map, world.player.cellX, world.player.cellY = { id = id }, input.x or 2, 3 + if native then + runtime.session.map, runtime.session.x, runtime.session.y = id, world.player.cellX, 3 + runtime.session.savedWeather = input.weather or 0 + player.cellX, player.cellY = world.player.cellX, 3 + space.store.flags, space.store.vars = {}, {} + local Flags = require("src.core.game3.scripting.flags") + for key, value in pairs(input.flags or {}) do + Flags.setFlag(space.store, nil, key, value) + end + for key, value in pairs(input.vars or {}) do + Flags.setVar(space.store, nil, key, value) + end + else + f.game.save.gen3Weather = input.weather or 0 + f.game.save.gen3WeatherActive = input.activeWeather or 0 + f.game.save.flags, f.game.save.gen3Vars = {}, {} + for key, value in pairs(input.flags or {}) do + f.game.save.flags[("FLAG_G3_%04X"):format(key)] = value + end + for key, value in pairs(input.vars or {}) do + f.game.save.gen3Vars[key] = value + end + end + end, + start = function(rows) + local runner + if native then + local ir = {} + for _, row in ipairs(rows) do + local copy = { op = row[1] } + for i = 2, #row do + copy[i - 1] = row[i] + end + ir[#ir + 1] = copy + end + runner = require("src.core.game3.scripting.vm").new({ + scripts = { OWNED_BGM = ir }, + adapters = require("src.core.game3.scripting.adapters").host(nil, f.game, world), + }) + assert(runner:start("OWNED_BGM")) + else + local code = assert(require("src.script.Gen3ScriptVM").compile({ + map_scripts = { + source = "RomExtractorGen3", + scripts = { OWNED_BGM = rows }, + }, + }, "OWNED_BGM")) + runner = require("src.script.ScriptRunner").new(f.game, world) + runner:run(code, { source = { modId = "portable_held_item", strict = true } }) + end + return runner + end, + state = function() + if native then + local function copy(fade) + if not fade then + return nil + end + local out = {} + for k, v in pairs(fade) do + if k ~= "gen" then + out[k] = v + end + end + return out + end + return { + song = audio._currentSong and audio._currentSong.id, + mapSong = resolve(audio._mapSong), + savedSong = audio._savedSong, + currentMapMusic = audio.currentMapMusic(), + fadeOut = copy(audio._fadeOut), + fadeIn = copy(audio._fadeIn), + deferred = audio._fanfareDeferred, + physicalSong = Music.current(), + voices = hardware.voices(), + } + end + local state = assert(Music.nativeSnapshot()) + state.level = nil + state.physicalSong = Music.current() + state.voices = hardware.voices() + return state + end, + update = hardware.update, + tick = hardware.tick, + trace = hardware.trace, + options = function(level) + local opts = type(level) == "table" and level or { musicVol = level, sfxVol = 7 } + if native then + audio.applyEngineOptions(opts) + else + Music.applyOptions(opts) + end + end, + close = function() + love.audio.newSource = originalBuilder + if native then + if space then + space.store, player.cellX, player.cellY = savedStore, savedX, savedY + end + audio.stopAll() + require("src.core.game3.mod_audio").reset() + for key, value in pairs(audio) do + if type(value) ~= "function" then + audio[key] = nil + end + end + for key, value in pairs(savedAudio) do + audio[key] = value + end + local Mod = require("src.core.game3.mod_audio") + for key, value in pairs(Mod) do + if type(value) ~= "function" then + Mod[key] = nil + end + end + for key, value in pairs(savedMod) do + Mod[key] = value + end + runtime.session, runtime._game = savedRuntime.session, savedRuntime.game + require("src.core.game3.song_ids").select(selected) + else + Music.reload() + end + hardware.close() + end, + } +end +return M diff --git a/tests/native_bgm/sounddata.lua b/tests/native_bgm/sounddata.lua new file mode 100644 index 000000000..295c7d40e --- /dev/null +++ b/tests/native_bgm/sounddata.lua @@ -0,0 +1,45 @@ +-- Match LÖVE's userdata admission while retaining the headless sample store. +-- Only hardware constructors change; renderers and native audio stay intact. +local M = {} +function M.install() + assert(type(newproxy) == "function", "native audio hardware tests require LuaJIT/newproxy") + local sound, source = love.sound.newSoundData, love.audio.newSource + local storage = setmetatable({}, { __mode = "k" }) + local state = { created = 0, staticInputs = 0, sources = {} } + love.sound.newSoundData = function(...) + local data = sound(...) + local proxy, methods = newproxy(true), {} + storage[proxy] = data + getmetatable(proxy).__index = function(_, key) + local value = data[key] + if type(value) ~= "function" then + return value + end + if not methods[key] then + methods[key] = function(_, ...) + return value(data, ...) + end + end + return methods[key] + end + state.created = state.created + 1 + return proxy + end + love.audio.newSource = function(input, ...) + if storage[input] then + assert(type(input) == "userdata") + state.staticInputs = state.staticInputs + 1 + end + -- The CPU Source records all samples from the backing store. + local made = source(storage[input] or input, ...) + if storage[input] and made then + state.sources[#state.sources + 1] = made + end + return made + end + state.close = function() + love.sound.newSoundData, love.audio.newSource = sound, source + end + return state +end +return M diff --git a/tests/native_bgm/streams.lua b/tests/native_bgm/streams.lua new file mode 100644 index 000000000..570a3c188 --- /dev/null +++ b/tests/native_bgm/streams.lua @@ -0,0 +1,357 @@ +-- Real native/host sequencers with owned tracks and queueable audio hardware. +local M = {} +function M.install(root, backend, version, f, options) + options = options or {} + local native = backend == "gen1" + local h = dofile(root .. "/tests/native_bgm/fixture.lua").install(root, backend, version, f) + local Music = require("src.core.Music") + Music.stop() + local soundData = dofile(root .. "/tests/native_bgm/sounddata.lua").install() + local oldThread, oldQueue = love.thread, love.audio.newQueueableSource + local voices, tick = {}, 0 + local queueMode = options.queueFailure + love.thread = nil + local Queue = {} + Queue.__index = Queue + function Queue:stop() + self.playing = false + self.queued = {} + end + function Queue:pause() + self.playing = false + end + function Queue:isPlaying() + return self.playing or false + end + function Queue:setVolume(value) + self.volume = value + end + function Queue:setLooping() end + function Queue:setFilter() end + function Queue:play() + self.playing = true + self.plays[#self.plays + 1] = { tick = tick, volume = self.volume } + end + function Queue:getFreeBufferCount() + return self.capacity - #self.queued + end + function Queue:queue(sd) + self.queued[#self.queued + 1] = sd + local samples = {} + for i = 0, sd:getSampleCount() - 1 do + samples[#samples + 1] = { sd:getSample(i, 1), sd:getSample(i, 2) } + end + self.buffers[#self.buffers + 1] = { tick = tick, rate = sd:getSampleRate(), samples = samples } + end + local function queueBuilder(rate, bits, channels, capacity) + if queueMode == "error" then + error("owned queue device unavailable") + end + if queueMode == "nil" then + return nil + end + local voice = setmetatable({ + rate = rate, + bits = bits, + channels = channels, + capacity = capacity, + queued = {}, + buffers = {}, + plays = {}, + }, Queue) + voices[#voices + 1] = voice + return voice + end + if queueMode == "missing" then + love.audio.newQueueableSource = nil + else + love.audio.newQueueableSource = queueBuilder + end + local programs = {} + for i = 1, 3 do + programs[i] = string.char(0xBD, 0, 0xBE, 127, 0xBF, 64, 0xCF, 57 + i * 3, 127) + .. string.rep(string.char(0xB0), options.finite and 0 or 100) + .. string.char(0xB1) + end + local audio = native and require("src.core.game3.audio") + if native then + require("src.core.game3.mod_audio").reset() + audio._worker, audio._cmdCh, audio._outCh, audio._bgmSource = nil, nil, nil, nil + audio._currentSong, audio._bgmLocal = nil, nil + audio._pack = { + root = "owned-native-bgm", + samplesBin = "", + samples = {}, + index = { songs = {} }, + voicegroups = { + [1] = { + [0] = { + type = 1, + key = 60, + attack = 15, + decay = 0, + sustain = 15, + release = 1, + wavParam = 2, + }, + }, + }, + songCache = {}, + } + for i, id in ipairs(h.ids) do + audio._pack.index.songs[id] = { voicegroupId = 1, loop = not options.finite } + audio._pack.songCache[id] = { + tracks = 1, + blocks = 0, + priority = 0, + reverb = 0, + voicegroup = 0, + trackData = { programs[i] }, + trackEntries = { 0 }, + } + end + if options.initialShared then + require("src.core.game3.mod_audio").install({ + audio = { songs = { MUS_TITLE = { file = "owned-bgm-title.ogg" } } }, + }) + end + assert(audio.playSong(h.ids[1])) + if not options.initialShared then + assert(audio._bgmLocal and audio._bgmLocal.seq, "actual native sequencer starts") + end + else + local bytes = {} + for i = 1, 32 do + bytes[i] = 0 + end + bytes[1], bytes[2], bytes[5], bytes[9], bytes[11], bytes[12] = 1, 60, 2, 15, 15, 1 + local chars = {} + for i, value in ipairs(bytes) do + chars[i] = string.char(value) + end + love.filesystem.write("owned-native-bgm-image.bin", table.concat(chars) .. table.concat(programs)) + f.data.audio = { gen3 = { base = 0, file = "owned-native-bgm-image.bin" }, songs = {}, mapSongs = {} } + local offset = 32 + for i, id in ipairs(h.ids) do + local label = ("SONG_%03X"):format(id) + f.data.audio.songs[label] = { index = id, tracks = { offset }, voicegroup = 0 } + if i == 1 then + f.data.audio.mapSongs.OWNED_NATIVE_BGM_MAP = label + end + offset = offset + #programs[i] + end + if options.initialShared then + f.data.audio.songs.MUS_TITLE = { file = "owned-bgm-title.ogg" } + f.data.audio.mapSongs.OWNED_NATIVE_BGM_MAP = "MUS_TITLE" + end + require("src.core.M4ASynth").invalidate() + Music.playMap(f.data, "OWNED_NATIVE_BGM_MAP", false, false) + end + local function inventory() + local rows = {} + for _, voice in ipairs(voices) do + rows[#rows + 1] = { + playing = voice:isPlaying(), + volume = voice.volume, + queued = #voice.queued, + produced = #voice.buffers, + plays = #voice.plays, + } + end + return rows + end + return { + ids = h.ids, + soundData = soundData, + invalidateQueues = function() + local restore = {} + for _, voice in ipairs(voices) do + local methods = {} + for _, key in ipairs({ + "getFreeBufferCount", + "queue", + "isPlaying", + "setVolume", + "play", + "stop", + "pause", + }) do + methods[key] = voice[key] + voice[key] = function() + error("owned invalidated audio source: " .. key) + end + end + voice.playing = false + restore[#restore + 1] = function() + for key, value in pairs(methods) do + voice[key] = value + end + end + end + return function() + for _, fn in ipairs(restore) do + fn() + end + end + end, + endFiles = h.hardware.endSources, + queueDevice = function(mode) + queueMode = mode + if mode == "missing" then + love.audio.newQueueableSource = nil + else + love.audio.newQueueableSource = queueBuilder + end + end, + fanfares = function() + f.data.constants = f.data.constants or {} + local resolve = native and audio.resolveSong or require("src.core.Gen3SongIds").resolve + local ids = { resolve("MUS_LEVEL_UP"), resolve("MUS_OBTAIN_ITEM") } + local rows = { + LEVEL_UP = { index = ids[1], frames = 4 }, + ITEM = { index = ids[2], frames = 6 }, + } + f.data.constants.gen3Fanfares = { fanfares = rows } + local image = not native and assert(love.filesystem.read("owned-native-bgm-image.bin")) or nil + if native then + audio._fanfareActive, audio._fanfareFrames, audio._fanfareRestore, audio._bgmPaused = + false, 0, nil, false + audio._fanfareSource, audio._fanfareSrc, audio._fanfareSd, audio._waitFanfareCb = nil, {}, {}, nil + audio._pack.index.fanfares = {} + end + for i, id in ipairs(ids) do + local program = string.char(0xBD, 0, 0xBE, 127, 0xBF, 64, 0xFF, 69 + i * 3, 127, 0xB0, 0xB1) + if native then + audio._pack.index.fanfares[id] = rows[i == 1 and "LEVEL_UP" or "ITEM"] + audio._pack.index.songs[id] = { kind = "fanfare", voicegroupId = 1, player = i + 1 } + audio._pack.songCache[id] = { + tracks = 1, + blocks = 0, + priority = 0, + reverb = 0, + voicegroup = 0, + trackData = { program }, + trackEntries = { 0 }, + } + else + f.data.audio.songs[("SONG_%03X"):format(id)] = { + kind = "fanfare", + index = id, + tracks = { #image }, + voicegroup = 0, + player = i + 1, + } + image = image .. program + end + end + if not native then + love.filesystem.write("owned-native-bgm-image.bin", image) + require("src.core.M4ASynth").invalidate() + end + return ids + end, + fanfareState = function(ids) + local Sound = not native and require("src.core.Sound") + local state = native + and { + active = audio._fanfareActive, + frames = audio._fanfareFrames, + restore = audio._fanfareRestore, + paused = audio._bgmPaused, + } + or Music.nativeFanfareSnapshot() + if native then + state.finished = audio.isFanfareFinished() + else + state.finished = Music.nativeFanfareFinished() + end + local query = function(id) + if native then + return audio.isSePlaying(id) + end + return Sound.isSePlaying(f.data, id) + end + state.se = { any = query(), first = query(ids[1]), second = query(ids[2]) } + return state + end, + value = function(runner) + if native then + return require("src.core.game3.scripting.flags").getVar(runner.store, runner.ctx, 0x4001) + end + return require("src.script.Gen3Commands").getVar(f.game.save, 0x4001) + end, + clear = function() + if not native then + require("src.script.Gen3Commands").setVar(f.game.save, 0x4001, 0) + end + end, + start = h.start, + options = h.options, + override = function(kind) + local def = { file = "owned-bgm-stream-override.ogg" } + if kind == "chip" then + def = require("src.audio.ChipAsm").song({ + channels = { + { + hw = 1, + program = { + { notetype = { speed = 12, volume = 12, fade = 0 } }, + { octave = 4 }, + { note = "C", len = 8 }, + { loop = { count = 0, to = 1 } }, + }, + }, + }, + }) + end + if native then + require("src.core.game3.mod_audio").install({ audio = { songs = { MUS_CYCLING = def } } }) + audio.applyEngineOptions({ musicVol = 7, sfxVol = 7 }) + else + f.data.audio.songs.MUS_CYCLING = def + end + end, + tick = function(value) + tick = value + h.tick(value) + end, + state = function() + return { native = h.state(), queues = inventory() } + end, + update = h.update, + drain = function() + for _, voice in ipairs(voices) do + if #voice.queued > 0 then + table.remove(voice.queued, 1) + end + if #voice.queued == 0 then + voice.playing = false + end + end + end, + trace = function() + local rows = {} + for _, voice in ipairs(voices) do + rows[#rows + 1] = { + rate = voice.rate, + bits = voice.bits, + channels = voice.channels, + capacity = voice.capacity, + buffers = voice.buffers, + plays = voice.plays, + } + end + return { queues = rows, files = h.trace() } + end, + close = function() + love.thread, love.audio.newQueueableSource = oldThread, oldQueue + soundData.close() + h.close() + if not native then + require("src.core.M4ASynth").invalidate() + love.filesystem.remove("owned-native-bgm-image.bin") + end + end, + } +end +return M diff --git a/tests/native_bgm/worker.lua b/tests/native_bgm/worker.lua new file mode 100644 index 000000000..be8be6a62 --- /dev/null +++ b/tests/native_bgm/worker.lua @@ -0,0 +1,130 @@ +local M = {} +-- Execute the actual worker body with owned PCM and controlled channels. +-- No worker handler or synthesis method is replaced. +function M.actual(data, header, T) + local commands, output, packets, sleeps = {}, {}, {}, 0 + local cmd = { + pop = function() + return table.remove(commands, 1) + end, + } + local out = { + clear = function() + output = {} + end, + getCount = function() + return #output + end, + push = function(_, value) + output[#output + 1] = value + if value.sd then + packets[#packets + 1] = value + commands[#commands + 1] = #packets == 1 and { cmd = "nativePause", gen = 41, nativeEpoch = 1 } + or { cmd = "quit" } + end + end, + } + commands[1] = + { cmd = "play", gen = 41, header = header, synth = "m4a", allowLoops = true, audio = data.audio } + local workerLove = { + thread = { + getChannel = function(name) + return name == "chipaudio_cmd" and cmd or out + end, + }, + sound = love.sound, + filesystem = { + load = function(path) + local name = path:gsub("%.lua$", ""):gsub("/", ".") + return function() + return require(name) + end + end, + }, + timer = { + sleep = function() + sleeps = sleeps + 1 + assert(sleeps <= 2, "native worker failed to resume") + T.eq(#packets, 1, "paused real worker produces no PCM, including after a stale resume") + commands[#commands + 1] = { cmd = "nativeResume", gen = 41, nativeEpoch = sleeps == 1 and 0 or 1 } + end, + }, + } + local environment = setmetatable({ love = workerLove, package = { preload = {} } }, { __index = _G }) + environment.require = function(name) + local component = name:match("^love%.(.+)$") + if component then + return workerLove[component] + end + return require(name) + end + local chunk = assert(loadfile("src/core/chip_worker.lua")) + setfenv(chunk, environment) + chunk() + T.eq(sleeps, 2, "real worker ignores the stale pause epoch") + T.eq(#packets, 2, "real worker emits only pre-pause and resumed PCM") + T.eq(packets[1].nativeEpoch, 0, "initial real worker PCM retains its native epoch") + T.eq(packets[2].nativeEpoch, 1, "resumed real worker PCM has the new native epoch") + T.eq(packets[2].sd:getSampleCount(), 8192, "resumed worker uses the actual M4A renderer") +end + +function M.transport(h, f, ff, T) + local Music = require("src.core.Music") + Music.stop() + local savedChip, oldThread = package.loaded["src.core.ChipAudio"], love.thread + local channels, gen = {}, nil + local function channel(name) + if not channels[name] then + channels[name] = { + values = {}, + push = function(self, value) + if type(value) == "table" and value.cmd == "play" then + gen = value.gen + end + self.values[#self.values + 1] = value + end, + pop = function(self) + return table.remove(self.values, 1) + end, + clear = function(self) + self.values = {} + end, + } + end + return channels[name] + end + love.thread = { + getChannel = channel, + newThread = function() + return { start = function() end, getError = function() end, wait = function() end } + end, + } + package.loaded["src.core.ChipAudio"] = nil + local chip = require("src.core.ChipAudio") + Music.playMap(f.data, "OWNED_NATIVE_BGM_MAP", false, false) + local count = #h.state().queues + local def = f.data.audio.songs[("SONG_%03X"):format(h.ids[1])] + local synth = require("src.core.M4ASynth") + local sd = synth.soundData(synth.newEngine(f.data, def), 8192, 2) + channel("chipaudio_out"):push({ gen = gen, nativeEpoch = 0, sd = sd }) + h.update() + T.eq(h.state().queues[count].queued, 1, "owned worker stream is sounding before the fanfare") + h.start({ { "playfanfare", ff[1] }, { "end" } }) + T.eq(chip.nativeBgmState().paused, true, "native fanfare pauses the actual worker stream") + T.eq(h.state().queues[count].queued, 0, "native pause clears already queued PCM") + channel("chipaudio_out"):push({ gen = gen, nativeEpoch = 0, sd = sd }) + for _ = 1, 4 do + h.update() + end + T.eq(chip.nativeBgmState().paused, false, "native timer releases its worker stream") + T.eq(h.state().queues[count].queued, 0, "packet from before the pause cannot re-enter the resumed source") + channel("chipaudio_out"):push({ gen = gen, nativeEpoch = 0, sd = sd }) + channel("chipaudio_out"):push({ gen = gen, nativeEpoch = 1, sd = sd }) + h.update() + T.eq(h.state().queues[count].queued, 1, "resumed source accepts only its current pause epoch") + T.eq(h.state().queues[count].produced, 2, "fanfare pause consumes no extra worker PCM") + chip.shutdown() + package.loaded["src.core.ChipAudio"] = savedChip + love.thread = oldThread +end +return M diff --git a/tests/native_cry/fixture.lua b/tests/native_cry/fixture.lua new file mode 100644 index 000000000..eac31a87f --- /dev/null +++ b/tests/native_cry/fixture.lua @@ -0,0 +1,241 @@ +local M = {} +function M.install(native, f, options) + options = options or {} + local oldAudio, tick, trace, sources = love.audio, 0, {}, {} + local Source = {} + Source.__index = Source + function Source:stop() + self.playing = false + end + function Source:isPlaying() + return self.playing or false + end + function Source:setVolume(value) + self.volume = value + end + function Source:setLooping() end + function Source:play() + self.playing = true + local samples, channels, rate + if type(self.input) == "table" then + channels, rate = self.input:getChannelCount(), self.input:getSampleRate() + samples = {} + for i = 0, self.input:getSampleCount() - 1 do + local frame = {} + for c = 1, channels do + frame[c] = self.input:getSample(i, c) + end + samples[#samples + 1] = frame + end + end + trace[#trace + 1] = { + tick = tick, + file = type(self.input) == "string" and self.input or nil, + channels = channels, + rate = rate, + samples = samples, + volume = self.volume, + } + end + love.audio = { + newSource = function(input) + local source = setmetatable({ input = input }, Source) + sources[#sources + 1] = source + return source + end, + } + local pcm = {} + for i = 0, 1023 do + pcm[#pcm + 1] = string.char((i * 37 + i * i * 13) % 256) + end + pcm = table.concat(pcm) + local audio = native and require("src.core.game3.audio") or nil + local saved, savedPlayer + if native then + saved = { + ready = audio._ready, + pack = audio._pack, + clock = audio._cryClock, + cache = audio._cryCache, + cacheCount = audio._cryCacheN, + slot = audio._crySlot, + params = audio._cryParams, + source = audio._crySource, + untilFrame = audio._cryUntil, + mono = audio._mono, + volume = audio._sfxVolume, + modData = require("src.core.game3.mod_audio").data, + modPokemon = require("src.core.game3.mod_audio").pokemon, + duck = audio._duck, + hold = audio._duckHold, + restore = audio._duckRestore, + } + require("src.core.Sound").invalidate() + audio.stopCry() + audio._cryClock, audio._cryCache, audio._cryCacheN = 0, {}, 0 + audio._cryParams, audio._crySource, audio._cryUntil = nil, nil, nil + audio._ready, audio._mono, audio._sfxVolume = not options.noPack, options.mono or false, 1 + audio._pack = { + index = { + cries = { [3] = { sampleId = 1 }, [273] = { sampleId = 2 } }, + cryIds = { [4] = 3, [277] = 273 }, + }, + samplesBin = pcm .. pcm:reverse(), + samples = { + { offset = 0, size = #pcm, freq = 8000 * 1024 }, + { offset = #pcm, size = #pcm, freq = 8000 * 1024 }, + }, + } + require("src.core.game3.mod_audio").reset() + else + local Sound = require("src.core.Sound") + Sound.invalidate() + Sound.stopCry() + savedPlayer = package.loaded["src.core.NativeCryPlayer"] + package.loaded["src.core.NativeCryPlayer"] = nil + Sound.applyOptions({ sfxVol = 7, stereo = not options.mono }) + local bytes = {} + for i = 1, 388 * 12 + 2 * (16 + #pcm) do + bytes[i] = 0 + end + local function put(offset, value, count) + for i = 0, count - 1 do + bytes[offset + i + 1] = math.floor(value / 256 ^ i) % 256 + end + end + for n, index in ipairs({ 3, 273 }) do + local wave = 388 * 12 + (n - 1) * (16 + #pcm) + put(index * 12 + 1, 60, 1) + put(index * 12 + 4, 0x08000000 + wave, 4) + put(index * 12 + 8, 255, 1) + put(index * 12 + 9, 255, 1) + put(index * 12 + 10, 255, 1) + put(wave + 4, 8000 * 1024, 4) + put(wave + 12, #pcm, 4) + local input = n == 1 and pcm or pcm:reverse() + for i = 1, #input do + bytes[wave + 16 + i] = input:byte(i) + end + end + local chars = {} + for i, value in ipairs(bytes) do + chars[i] = string.char(value) + end + love.filesystem.write("owned-native-cries.bin", table.concat(chars)) + f.data.audio = { + gen3 = options.noPack and nil or { base = 0, file = "owned-native-cries.bin" }, + gen3Cries = { table = 0, count = 388, mapping = "native-species-v1" }, + cries = { CHARMANDER = { m4a = 3 }, TREECKO = { m4a = options.legacyMapping and 251 or 273 } }, + } + if options.noPack then + f.data.audio.gen3 = nil + end + f.data.constants.speciesOrder = { [4] = "CHARMANDER", [277] = "TREECKO" } + require("src.core.M4ASynth").invalidate() + end + return { + override = function(species, kind) + local name = species == 277 and "TREECKO" or "CHARMANDER" + local file = "owned-cry-override.wav" + local def = kind == "file" and { file = file } or kind == "string" and file or false + if native then + require("src.core.Sound").invalidate() + require("src.core.game3.mod_audio").install({ + audio = { cries = { [name] = def } }, + gen3Pokemon = { _names = { [4] = "CHARMANDER", [277] = "TREECKO" } }, + }) + else + f.data.audio.cries[name] = def + f.data.audio._owners = { cries = { [name] = "owned_cry_override" } } + end + end, + play = function(species, options) + if native then + return audio.playCry(species, options) + end + return require("src.core.Sound").playCry(f.data, species, options) + end, + update = function() + if native then + audio.update(1 / 60) + else + require("src.core.Sound").updateCry() + require("src.core.Music").update(f.data) + require("src.core.Sound").update() + end + end, + stop = function() + if native then + audio.stopCry() + else + require("src.core.Sound").stopCry() + end + end, + snapshot = function() + if native then + return { + finished = audio.isCryFinished(), + clock = audio._cryClock, + untilFrame = audio._cryUntil, + mode = audio._cryParams and audio._cryParams.mode, + mix = { level = audio._duck, hold = audio._duckHold or 0, restore = audio._duckRestore or false }, + } + end + return { + finished = require("src.core.Sound").isCryFinished(), + clock = require("src.core.NativeCryPlayer").snapshot().clock, + untilFrame = require("src.core.NativeCryPlayer").snapshot().untilFrame, + mode = require("src.core.NativeCryPlayer").snapshot().controls.mode, + mix = require("src.core.Music").nativeCryMixSnapshot(), + } + end, + tick = function(value) + tick = value + end, + trace = function() + return trace + end, + voices = function() + local rows = {} + for _, voice in ipairs(sources) do + rows[#rows + 1] = { + file = type(voice.input) == "string" and voice.input or nil, + playing = voice:isPlaying(), + volume = voice.volume, + } + end + return rows + end, + endSources = function() + for _, source in ipairs(sources) do + source:stop() + end + end, + active = function() + local values = {} + for _, source in ipairs(sources) do + values[#values + 1] = source:isPlaying() + end + return values + end, + close = function() + if native then + audio.stopCry() + audio._ready, audio._pack, audio._cryClock = saved.ready, saved.pack, saved.clock + audio._cryCache, audio._cryParams, audio._crySource = saved.cache, saved.params, saved.source + audio._cryCacheN, audio._crySlot = saved.cacheCount, saved.slot + audio._cryUntil, audio._mono, audio._sfxVolume = saved.untilFrame, saved.mono, saved.volume + audio._duck, audio._duckHold, audio._duckRestore = saved.duck, saved.hold, saved.restore + require("src.core.game3.mod_audio").data = saved.modData + require("src.core.game3.mod_audio").pokemon = saved.modPokemon + else + require("src.core.Sound").invalidate() + require("src.core.Sound").stopCry() + package.loaded["src.core.NativeCryPlayer"] = savedPlayer + love.filesystem.remove("owned-native-cries.bin") + end + love.audio = oldAudio + end, + } +end +return M diff --git a/tests/native_sound/fixture.lua b/tests/native_sound/fixture.lua new file mode 100644 index 000000000..79afea0d3 --- /dev/null +++ b/tests/native_sound/fixture.lua @@ -0,0 +1,242 @@ +-- Owned waveform hardware and native M4A carrier. This does not replace +-- either engine's sound functions or assert equality of their synthesizers. +local M = {} +function M.install(root, native, f) + local previousAudio, currentTick = love.audio, 0 + local trace, carrier = {}, nil + local allSources, lastSource = {}, nil + local roleIds = {} + local Audio = native and require("src.core.game3.audio") + local Source = {} + Source.__index = Source + function Source:stop() + self.playing = false + end + function Source:isPlaying() + return self.playing + end + function Source:setVolume(value) + self.volume = value + end + function Source:setLooping() end + function Source:clone() + local copy = setmetatable({ input = self.input }, Source) + allSources[#allSources + 1] = copy + return copy + end + function Source:play() + self.playing = true + local id, pan, mono + if type(self.input) == "string" then + id = assert(tonumber(self.input:match("owned%-effect%-(%d+)%.wav")), "owned file identity") + pan, mono = 0, false + elseif native then + local meta = assert(Audio._seMeta[self], "native source retains its actual sound metadata") + id, pan, mono = meta.id, meta.pan, meta.mono and true or false + else + -- Measure the panorama at the actual output source. The owned M4A + -- carrier has equal channels; every emitted sample must obey the gain. + mono = self.input:getChannelCount() == 1 + for i = 0, self.input:getSampleCount() - 1 do + local l, r = self.input:getSample(i, 1), self.input:getSample(i, 2) + if mono then + local original = carrier:getSample(i, 1) + if math.abs(original) > 0.0001 then + if l == original / 2 then + pan = -64 + else + assert(l == (original / 64 + original) / 2, "native panorama folds before mono output") + pan = 63 + end + end + elseif math.abs(l) + math.abs(r) > 0.0001 then + if r == 0 then + assert(l ~= 0) + pan = -64 + elseif l == r then + pan = 0 + else + assert(l == r / 64, "opponent sound must retain its native stereo gain") + pan = 63 + end + end + end + assert(pan ~= nil, "effect sound must be audible") + end + self.observed = { tick = currentTick, id = id, pan = pan, mono = mono } + trace[#trace + 1] = self.observed + lastSource = self + end + love.audio = { + newSource = function(input) + local source = setmetatable({ input = input }, Source) + allSources[#allSources + 1] = source + return source + end, + } + local off, saved + if native then + saved = { + ready = Audio._ready, + pack = Audio._pack, + mono = Audio._mono, + modData = require("src.core.game3.mod_audio").data, + } + Audio._ready, Audio._pack = true, { index = { songs = {} } } + for _, id in ipairs({ 141, 148, 164, 171 }) do + Audio._pack.index.songs[id] = { kind = "sfx", player = 1 } + Audio._seRawPut(id, false, { 0.25 }, { 0.25 }) + end + else + local bytes = {} + for i = 1, 64 do + bytes[i] = 0 + end + bytes[1], bytes[2], bytes[11], bytes[12] = 1, 60, 15, 1 + for i, byte in ipairs({ 0xBD, 0, 0xBE, 127, 0xBF, 64, 0xD0, 60, 127, 0x81, 0xB1 }) do + bytes[32 + i] = byte + end + local chars = {} + for i, byte in ipairs(bytes) do + chars[i] = string.char(byte) + end + love.filesystem.write("owned-effect-carrier.bin", table.concat(chars)) + f.data.audio = { sfx = {}, songs = {}, gen3 = { base = 0, file = "owned-effect-carrier.bin" } } + for _, id in ipairs({ 141, 148, 164, 171 }) do + f.data.audio.songs[("SONG_%03X"):format(id)] = { + index = id, + tracks = { 32 }, + voicegroup = 0, + sfxName = "OWNED_NATIVE_" .. id, + } + end + require("src.core.Sound").invalidate() + carrier = assert(require("src.core.M4ASynth").renderEffect(f.data, f.data.audio.songs.SONG_08D)) + off = f.e.api.events:on("sound.played", function(event) + local id = tonumber(event.name:match("^OWNED_NATIVE_(%d+)$")) or roleIds[event.name] + if id then + assert(trace[#trace]).id = id + assert(lastSource).observed.id = id + end + end) + end + return { + override = function(id, kind) + local file = "owned-effect-" .. id .. ".wav" + local def = kind == "file" and { file = file } or kind == "string" and file or false + if native then + local effects = {} + for key, value in pairs(require("src.core.game3.se_ids")) do + if value == id and key:match("^SE_") then + effects[key] = def + end + end + require("src.core.game3.mod_audio").install({ audio = { sfx = effects } }) + else + local name = "OWNED_NATIVE_" .. id + f.data.audio.sfx[name] = def + require("src.core.Sound").invalidate(name) + end + end, + role = function(id, name) + if not native then + roleIds[name] = id + f.data.audio.sfx[name] = { m4a = id } + end + end, + player = function(id, player) + if native then + Audio._pack.index.songs[id].player = player + else + f.data.audio.songs[("SONG_%03X"):format(id)].player = player + end + end, + clear = function() + if native then + Audio.stopSe() + require("src.core.Sound").invalidate() + require("src.core.game3.mod_audio").reset() + else + require("src.core.Sound").invalidate() + for key in pairs(f.data.audio.sfx) do + f.data.audio.sfx[key] = nil + end + end + -- The native PCM source cache survives stopSe. Keep stopped hardware + -- objects in the inventory so reuse is observed on later commands. + trace, lastSource = {}, nil + end, + active = function() + local active = {} + for _, source in ipairs(allSources) do + if source.playing then + local state = assert(source.observed) + active[#active + 1] = { id = assert(state.id), pan = state.pan, mono = state.mono } + end + end + table.sort(active, function(a, b) + if a.id ~= b.id then + return a.id < b.id + end + if a.pan ~= b.pan then + return a.pan < b.pan + end + return tostring(a.mono) < tostring(b.mono) + end) + return active + end, + volumes = function() + local out = {} + for _, source in ipairs(allSources) do + if source.playing then + out[#out + 1] = source.volume + end + end + return out + end, + stereo = function(value) + if native then + Audio._mono = not value + else + f.game.save.options.stereo = value + end + end, + tick = function(value) + currentTick = value + end, + reset = function() + trace = {} + currentTick = 0 + end, + trace = function() + return trace + end, + queries = function() + local out = {} + for _, id in ipairs({ 141, 148, 164, 171 }) do + out[tostring(id)] = native and Audio.isSePlaying(id) + or not native and require("src.core.Sound").isSePlaying(f.data, id) + or false + end + out.any = native and Audio.isSePlaying() + or not native and require("src.core.Sound").isSePlaying(f.data) + or false + return out + end, + close = function() + if off then + off() + end + if native then + Audio._ready, Audio._pack, Audio._mono = saved.ready, saved.pack, saved.mono + require("src.core.game3.mod_audio").data = saved.modData + Audio._seRawClear() + else + require("src.core.Sound").invalidate() + require("src.core.M4ASynth").invalidate() + end + love.audio = previousAudio + end, + } +end +return M diff --git a/tests/pokemon_rolls/cases.lua b/tests/pokemon_rolls/cases.lua new file mode 100644 index 000000000..db576b63b --- /dev/null +++ b/tests/pokemon_rolls/cases.lua @@ -0,0 +1,214 @@ +local C = {} +function C.run(root, backend, version, api, format) + local Fixture = dofile(root .. "/tests/pokemon_rolls/fixture.lua") + local V = require("src.core.GameVersion") + local gen = V.generation(version) + if gen ~= 2 and gen ~= 3 then + local f = Fixture.new(root, backend, version, api, format, "delegate") + f.T.eq(f.e.available, false, "identity hooks unavailable outside Gen 2/3") + f.close() + return { { mode = "unavailable", available = false } } + end + local rows = {} + for _, mode in ipairs({ + "delegate", + "force", + "suppress", + "nil", + "invalid", + "steer", + "replacement", + "iv_edit", + "threshold", + "invalid_threshold", + "ratio_only", + "new_threshold", + "error_before", + "error_after", + "rollback", + "no-mod", + }) do + for _, natural in ipairs({ false, true }) do + local f = Fixture.new(root, backend, version, api, format, mode) + f.T.eq(#f.result.errors > 0, mode == "rollback", "identity entry outcome") + local mon = f.make(natural) + local before = { + personality = mon.personality, + ivs = f.copy(mon.ivs), + dvs = f.copy(mon.dvs), + otId = mon.otId, + otSecretId = mon.otSecretId, + } + local queryRandomCalls = 0 + local randomMath, randomLove = math.random, love.math.random + math.random = function(...) + queryRandomCalls = queryRandomCalls + 1 + return randomMath(...) + end + love.math.random = function(...) + queryRandomCalls = queryRandomCalls + 1 + return randomLove(...) + end + local rngBefore = backend == "gen1" and gen == 3 and f.copy(require("src.core.game3.rng").getState()) + or nil + local shiny, gender = f.shiny(mon), f.gender(mon) + math.random, love.math.random = randomMath, randomLove + if rngBefore then + f.T.same( + require("src.core.game3.rng").getState(), + rngBefore, + "native GBA RNG state unchanged by identity queries" + ) + end + f.T.eq(queryRandomCalls, 0, "identity queries do not draw random numbers") + local row = { + mode = mode, + natural = natural, + shiny = shiny, + gender = gender, + birthGender = ({ M = "male", F = "female", U = "unknown" })[mon.gender] or mon.gender, + } + if mode == "force" then + f.T.eq(shiny, true, "hook forces actual shiny read") + f.T.eq(gender, "female", "hook forces actual gender read") + end + if mode == "suppress" then + f.T.eq(shiny, false, "hook suppresses actual shiny read") + f.T.eq(gender, "male", "hook suppresses actual gender read") + end + if mode == "invalid" then + f.T.eq(shiny, true, "Lua zero is truthy for shiny") + f.T.eq(gender, natural and "female" or "male", "invalid gender falls back to native") + end + if mode == "nil" then + f.T.eq(shiny, false, "nil shiny becomes false") + f.T.eq(gender, natural and "female" or "male", "nil gender falls back to native") + end + if mode == "new_threshold" or mode == "threshold" then + f.T.eq(gender, "female", "definition threshold reaches native gender predicate") + end + if mode == "invalid_threshold" then + f.T.eq( + gender, + gen == 2 and "female" or natural and "female" or "male", + "invalid gender fallback keeps native generation timing" + ) + end + if mode == "ratio_only" then + f.T.eq( + gender, + natural and "female" or "male", + "ratio metadata alone does not replace definition threshold" + ) + end + if mode == "steer" or mode == "replacement" then + f.T.eq(shiny, true, "DV/PID context can steer native shiny predicate") + f.T.eq(gender, "female", "DV/PID context can steer native gender predicate") + end + local after = { + personality = mon.personality, + ivs = f.copy(mon.ivs), + dvs = f.copy(mon.dvs), + otId = mon.otId, + otSecretId = mon.otSecretId, + } + f.T.same(after, before, "identity queries do not mutate saved genes") + local e = f.e + if not (e and e.calls) then + row.birthGender = nil + end + if e and e.calls then + row.shinyCalls, row.genderCalls = e.calls["shiny.roll"] or 0, e.calls["gender.roll"] or 0 + if api == 3 then + local obs = e.observations["gender.roll"] + f.T.eq(obs.species, "CHARMANDER", "API-3 species ID is common") + f.T.eq(obs.defId, "CHARMANDER", "API-3 definition ID is common") + row.genderContext = obs + row.shinyContext = e.observations["shiny.roll"] + end + e.stopShiny() + e.stopGender() + local calls = f.copy(e.calls) + if gen == 2 then + if backend == "gen1" then + require("src.battle.gen2.Mon").syncIdentity(mon, f.data) + else + require("src.pokemon.Pokemon").syncIdentity(f.data, mon) + end + end + row.retiredShiny, row.retiredGender = f.shiny(mon), f.gender(mon, true) + f.T.same(e.calls, calls, "unsubscribe stops later query and refresh dispatch") + end + rows[#rows + 1] = row + f.close() + end + end + for _, forced in ipairs({ true, false }) do + local f = Fixture.new(root, backend, version, api, format, forced and "suppress" or "force") + local mon = f.make(false, forced) + local value = f.shiny(mon) + f.T.eq(value, gen == 2 and true or forced, "forced shiny precedence follows native generation") + rows[#rows + 1] = + { mode = "forced", forced = forced, shiny = value, shinyCalls = f.e.calls["shiny.roll"] or 0 } + f.close() + end + if gen == 3 then + local edges = { + { pid = 0, ot = 0, yes = true }, + { pid = 7, ot = 0, yes = true }, + { pid = 8, ot = 0, yes = false }, + { pid = 65536, ot = 65536, yes = true }, + { pid = 65536, ot = 0, sid = 1, yes = true }, + { pid = 0, ot = 0, sid = 65535, yes = false }, + { pid = 0, trainer = 7, yes = true }, + { pid = 4294967295, ot = 0, yes = true }, + } + for _, mode in ipairs({ "no-mod", "delegate" }) do + for index, edge in ipairs(edges) do + local f = Fixture.new(root, backend, version, api, format, mode) + local mon = f.make(false) + mon.personality, mon.otId, mon.otSecretId, mon.trainerId = edge.pid, edge.ot, edge.sid, edge.trainer + local value = f.shiny(mon) + f.T.eq(value, edge.yes, "native GBA shiny XOR threshold and trainer-ID layouts") + rows[#rows + 1] = { mode = "gba_trainer_identity", policy = mode, case = index, shiny = value } + f.close() + end + end + end + if version == "gold" then + local f = Fixture.new(root, backend, version, api, format, "no-mod") + local def = f.data.pokemon[backend == "gen1" and "CHARMANDER" or "SPECIES_004"] + local native = backend == "gen1" and require("src.battle.gen2.Mon") or require("src.pokemon.DayCare") + local bit = require("bit") + local hash, female, male, unknown = 0, 0, 0, 0 + local outcomes = {} + for ratio = 0, 255 do + def.genderRatio = ratio + for attack = 0, 15 do + for speed = 0, 15 do + local dvs = { attack = attack, speed = speed } + local g = backend == "gen1" and native.gender(def, dvs) + or native.gender(f.data, { species = "SPECIES_004", dvs = dvs }) + local code = g == "female" and 1 or g == "male" and 2 or 3 + female = female + (code == 1 and 1 or 0) + male = male + (code == 2 and 1 or 0) + unknown = unknown + (code == 3 and 1 or 0) + hash = bit.bxor(bit.rol(hash, 5), code) + outcomes[#outcomes + 1] = tostring(code) + end + end + end + rows[#rows + 1] = { + mode = "native_gender_matrix", + hash = hash, + female = female, + male = male, + unknown = unknown, + cases = 65536, + outcomes = table.concat(outcomes), + } + f.close() + end + return rows +end +return C diff --git a/tests/pokemon_rolls/fixture.lua b/tests/pokemon_rolls/fixture.lua new file mode 100644 index 000000000..85d40ecb3 --- /dev/null +++ b/tests/pokemon_rolls/fixture.lua @@ -0,0 +1,99 @@ +local F = {} +function F.new(root, backend, version, api, format, mode) + local f = dofile(root .. "/tests/charge_required/fixture.lua").new( + root, + backend, + version, + api, + format, + mode == "no-mod", + mode == "rollback", + "portable_pokemon_rolls", + function(data, gen) + if backend == "gen2" then + data.constants.generation = gen + local def = data.pokemon.CHARMANDER + def.genderRatio = gen == 3 and 31 or 127 + if gen == 3 then + def.baseStats = { hp = 80, attack = 50, defense = 50, speed = 50, spatk = 50, spdef = 50 } + def.evYield = { hp = 0, attack = 0, defense = 0, speed = 0, spatk = 0, spdef = 0 } + end + def.id = "SPECIES_004" + data.pokemon.SPECIES_004, data.pokemon.CHARMANDER = def, nil + elseif gen == 3 then + data.gen3Pokemon._speciesMeta[4].genderRatio = 31 + end + end, + function(files) + local J = require("src.link.Json") + local base = "mods/portable_pokemon_rolls/" + local manifest = J.decode(files[base .. "manifest.json"]) + manifest.games = { version } + manifest.content_format = format + files[base .. "manifest.json"] = J.encode(manifest) + files[base .. "case.txt"] = mode + end + ) + f.copy = require("src.mods.Merge").deepCopy + function f.make(shiny, forced) + local dvs = shiny and { attack = 2, defense = 10, speed = 10, special = 10 } + or { attack = 15, defense = 0, speed = 0, special = 0 } + if backend == "gen1" and f.gen == 2 then + return require("src.battle.gen2.Mon").new(f.data, "CHARMANDER", 5, { dvs = dvs, shiny = forced }) + elseif backend == "gen1" and f.gen == 3 then + local session = + { party = {}, name = "PROBE", version = version, trainerId = 0, secretId = 0, mapSec = 1 } + local ok, _, mon = require("src.core.game3.party").giveMon( + session, + 4, + 5, + "", + { fixedPersonality = shiny and 0 or 65791 } + ) + assert(ok and mon, "native GBA Party.giveMon failed") + if forced ~= nil then + mon.isShiny = forced + end + return mon + end + local sequence = { dvs.attack, dvs.defense, dvs.speed, dvs.special } + if f.gen == 3 then + sequence[5], sequence[6] = shiny and 0 or 1, shiny and 0 or 255 + end + local index = 0 + local mon = require("src.pokemon.Pokemon").new(f.data, "SPECIES_004", 5, function(lo) + index = index + 1 + return sequence[index] or lo or 0 + end, nil, { shiny = forced }) + if f.gen == 3 then + mon.otId, mon.otSecretId = 0, 0 + if forced ~= nil then + mon.isShiny = forced + end + end + return mon + end + function f.shiny(mon) + if backend == "gen1" then + if f.gen == 2 then + return mon.shiny + end + return require("src.core.game3.pokemon").isShiny(mon) + end + return require("src.pokemon.Pokemon").isShiny(mon) + end + function f.gender(mon, query) + if backend == "gen1" then + if f.gen == 2 then + return query and require("src.core.gen2.Breeding").genderOf(f.data, mon) or mon.gender + end + local g = require("src.core.game3.pokemon").gender(mon.species, mon.personality) + return ({ M = "male", F = "female", U = "unknown" })[g] + end + local g = (query or f.gen == 3) and require("src.pokemon.DayCare").gender(f.data, mon) + or require("src.pokemon.Pokemon").genderOf(f.data, mon) + return g == "none" and "unknown" or g + end + return f +end +return F diff --git a/tests/pokemon_rolls/worker.lua b/tests/pokemon_rolls/worker.lua new file mode 100644 index 000000000..9c976fc28 --- /dev/null +++ b/tests/pokemon_rolls/worker.lua @@ -0,0 +1,6 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local root, backend, version, api, format = unpack(arg) +local rows = + dofile(root .. "/tests/pokemon_rolls/cases.lua").run(root, backend, version, tonumber(api), format) +print("POKEMON_ROLL_CONTRACT " .. require("src.link.Json").encode(rows)) +require("tests.modkit").finish("native Pokemon identity rolls") diff --git a/tests/post_hit_items/command-cases.json b/tests/post_hit_items/command-cases.json new file mode 100644 index 000000000..a182302e6 --- /dev/null +++ b/tests/post_hit_items/command-cases.json @@ -0,0 +1,793 @@ +{ + "schema_version": 1, + "rng_values": [ + 0, + 6, + 7, + 9, + 10, + 15, + 16, + 49, + 50, + 63, + 64, + 99 + ], + "command_cases": [ + { + "label": "zero-first-damage", + "first_damage": 0, + "target_damaged": true, + "no_effect": false, + "move_flags": 32, + "user_hp": 200, + "user_max_hp": 400, + "target_hp": 200, + "same_actor": false, + "item_effect": null, + "hook": "delegate" + }, + { + "label": "no-item", + "first_damage": 32, + "target_damaged": true, + "no_effect": false, + "move_flags": 32, + "user_hp": 200, + "user_max_hp": 400, + "target_hp": 200, + "same_actor": false, + "item_effect": null, + "hook": "delegate" + }, + { + "label": "physical-kings-rock", + "first_damage": 32, + "target_damaged": true, + "no_effect": false, + "move_flags": 32, + "user_hp": 200, + "user_max_hp": 400, + "target_hp": 200, + "same_actor": false, + "item_effect": null, + "hook": "delegate", + "physical_item": 187 + }, + { + "label": "physical-kings-rock-no-flag", + "first_damage": 32, + "target_damaged": true, + "no_effect": false, + "move_flags": 0, + "user_hp": 200, + "user_max_hp": 400, + "target_hp": 200, + "same_actor": false, + "item_effect": null, + "hook": "delegate", + "physical_item": 187 + }, + { + "label": "physical-kings-rock-target-fainted", + "first_damage": 32, + "target_damaged": true, + "no_effect": false, + "move_flags": 32, + "user_hp": 200, + "user_max_hp": 400, + "target_hp": 0, + "same_actor": false, + "item_effect": null, + "hook": "delegate", + "physical_item": 187 + }, + { + "label": "physical-kings-rock-user-fainted", + "first_damage": 32, + "target_damaged": true, + "no_effect": false, + "move_flags": 32, + "user_hp": 0, + "user_max_hp": 400, + "target_hp": 200, + "same_actor": false, + "item_effect": null, + "hook": "delegate", + "physical_item": 187 + }, + { + "label": "physical-kings-rock-no-effect", + "first_damage": 32, + "target_damaged": true, + "no_effect": true, + "move_flags": 32, + "user_hp": 200, + "user_max_hp": 400, + "target_hp": 200, + "same_actor": false, + "item_effect": null, + "hook": "delegate", + "physical_item": 187 + }, + { + "label": "physical-kings-rock-no-real-target-damage", + "first_damage": 32, + "target_damaged": false, + "no_effect": false, + "move_flags": 32, + "user_hp": 200, + "user_max_hp": 400, + "target_hp": 200, + "same_actor": false, + "item_effect": null, + "hook": "delegate", + "physical_item": 187 + }, + { + "label": "substituted-flinch", + "first_damage": 32, + "target_damaged": true, + "no_effect": false, + "move_flags": 32, + "user_hp": 200, + "user_max_hp": 400, + "target_hp": 200, + "same_actor": false, + "item_effect": null, + "hook": "replace_attacker", + "hook_effect": "HELD_FLINCH", + "hook_parameter": 128 + }, + { + "label": "suppressed-flinch", + "first_damage": 32, + "target_damaged": true, + "no_effect": false, + "move_flags": 32, + "user_hp": 200, + "user_max_hp": 400, + "target_hp": 200, + "same_actor": false, + "item_effect": null, + "hook": "suppress_attacker", + "physical_item": 187 + }, + { + "label": "changed-first-parameter-wins", + "first_damage": 32, + "target_damaged": true, + "no_effect": false, + "move_flags": 32, + "user_hp": 200, + "user_max_hp": 400, + "target_hp": 200, + "same_actor": false, + "item_effect": null, + "hook": "replace_both_differently", + "physical_item": 187, + "hook_effect": "HELD_FLINCH", + "hook_parameter": 128, + "second_effect": "HELD_FLINCH", + "second_parameter": 256 + }, + { + "label": "unchanged-first-uses-player", + "first_damage": 32, + "target_damaged": true, + "no_effect": false, + "move_flags": 32, + "user_hp": 200, + "user_max_hp": 400, + "target_hp": 200, + "same_actor": false, + "item_effect": null, + "hook": "replace_player", + "physical_item": 187, + "second_effect": "HELD_FLINCH", + "second_parameter": 128 + }, + { + "label": "unchanged-first-uses-nonflinch-player-parameter", + "first_damage": 32, + "target_damaged": true, + "no_effect": false, + "move_flags": 32, + "user_hp": 200, + "user_max_hp": 400, + "target_hp": 200, + "same_actor": false, + "item_effect": null, + "hook": "replace_player", + "physical_item": 187, + "second_effect": "HELD_SHELL_BELL", + "second_parameter": 64 + }, + { + "label": "stateful-read", + "first_damage": 32, + "target_damaged": true, + "no_effect": false, + "move_flags": 32, + "user_hp": 200, + "user_max_hp": 400, + "target_hp": 200, + "same_actor": false, + "item_effect": null, + "hook": "alternate_each_read", + "physical_item": 187 + }, + { + "label": "shell-bell-divisor-0-damage-1", + "first_damage": 1, + "target_damaged": true, + "no_effect": false, + "move_flags": 32, + "user_hp": 200, + "user_max_hp": 400, + "target_hp": 200, + "same_actor": false, + "item_effect": "SHELL_BELL", + "hook": "replace_attacker", + "hook_effect": "HELD_SHELL_BELL", + "hook_parameter": 0 + }, + { + "label": "shell-bell-divisor-0-damage-7", + "first_damage": 7, + "target_damaged": true, + "no_effect": false, + "move_flags": 32, + "user_hp": 200, + "user_max_hp": 400, + "target_hp": 200, + "same_actor": false, + "item_effect": "SHELL_BELL", + "hook": "replace_attacker", + "hook_effect": "HELD_SHELL_BELL", + "hook_parameter": 0 + }, + { + "label": "shell-bell-divisor-0-damage-32", + "first_damage": 32, + "target_damaged": true, + "no_effect": false, + "move_flags": 32, + "user_hp": 200, + "user_max_hp": 400, + "target_hp": 200, + "same_actor": false, + "item_effect": "SHELL_BELL", + "hook": "replace_attacker", + "hook_effect": "HELD_SHELL_BELL", + "hook_parameter": 0 + }, + { + "label": "shell-bell-divisor-1-damage-1", + "first_damage": 1, + "target_damaged": true, + "no_effect": false, + "move_flags": 32, + "user_hp": 200, + "user_max_hp": 400, + "target_hp": 200, + "same_actor": false, + "item_effect": "SHELL_BELL", + "hook": "replace_attacker", + "hook_effect": "HELD_SHELL_BELL", + "hook_parameter": 1 + }, + { + "label": "shell-bell-divisor-1-damage-7", + "first_damage": 7, + "target_damaged": true, + "no_effect": false, + "move_flags": 32, + "user_hp": 200, + "user_max_hp": 400, + "target_hp": 200, + "same_actor": false, + "item_effect": "SHELL_BELL", + "hook": "replace_attacker", + "hook_effect": "HELD_SHELL_BELL", + "hook_parameter": 1 + }, + { + "label": "shell-bell-divisor-1-damage-32", + "first_damage": 32, + "target_damaged": true, + "no_effect": false, + "move_flags": 32, + "user_hp": 200, + "user_max_hp": 400, + "target_hp": 200, + "same_actor": false, + "item_effect": "SHELL_BELL", + "hook": "replace_attacker", + "hook_effect": "HELD_SHELL_BELL", + "hook_parameter": 1 + }, + { + "label": "shell-bell-divisor-3-damage-1", + "first_damage": 1, + "target_damaged": true, + "no_effect": false, + "move_flags": 32, + "user_hp": 200, + "user_max_hp": 400, + "target_hp": 200, + "same_actor": false, + "item_effect": "SHELL_BELL", + "hook": "replace_attacker", + "hook_effect": "HELD_SHELL_BELL", + "hook_parameter": 3 + }, + { + "label": "shell-bell-divisor-3-damage-7", + "first_damage": 7, + "target_damaged": true, + "no_effect": false, + "move_flags": 32, + "user_hp": 200, + "user_max_hp": 400, + "target_hp": 200, + "same_actor": false, + "item_effect": "SHELL_BELL", + "hook": "replace_attacker", + "hook_effect": "HELD_SHELL_BELL", + "hook_parameter": 3 + }, + { + "label": "shell-bell-divisor-3-damage-32", + "first_damage": 32, + "target_damaged": true, + "no_effect": false, + "move_flags": 32, + "user_hp": 200, + "user_max_hp": 400, + "target_hp": 200, + "same_actor": false, + "item_effect": "SHELL_BELL", + "hook": "replace_attacker", + "hook_effect": "HELD_SHELL_BELL", + "hook_parameter": 3 + }, + { + "label": "shell-bell-divisor-8-damage-1", + "first_damage": 1, + "target_damaged": true, + "no_effect": false, + "move_flags": 32, + "user_hp": 200, + "user_max_hp": 400, + "target_hp": 200, + "same_actor": false, + "item_effect": "SHELL_BELL", + "hook": "replace_attacker", + "hook_effect": "HELD_SHELL_BELL", + "hook_parameter": 8 + }, + { + "label": "shell-bell-divisor-8-damage-7", + "first_damage": 7, + "target_damaged": true, + "no_effect": false, + "move_flags": 32, + "user_hp": 200, + "user_max_hp": 400, + "target_hp": 200, + "same_actor": false, + "item_effect": "SHELL_BELL", + "hook": "replace_attacker", + "hook_effect": "HELD_SHELL_BELL", + "hook_parameter": 8 + }, + { + "label": "shell-bell-divisor-8-damage-32", + "first_damage": 32, + "target_damaged": true, + "no_effect": false, + "move_flags": 32, + "user_hp": 200, + "user_max_hp": 400, + "target_hp": 200, + "same_actor": false, + "item_effect": "SHELL_BELL", + "hook": "replace_attacker", + "hook_effect": "HELD_SHELL_BELL", + "hook_parameter": 8 + }, + { + "label": "shell-bell-divisor-64-damage-1", + "first_damage": 1, + "target_damaged": true, + "no_effect": false, + "move_flags": 32, + "user_hp": 200, + "user_max_hp": 400, + "target_hp": 200, + "same_actor": false, + "item_effect": "SHELL_BELL", + "hook": "replace_attacker", + "hook_effect": "HELD_SHELL_BELL", + "hook_parameter": 64 + }, + { + "label": "shell-bell-divisor-64-damage-7", + "first_damage": 7, + "target_damaged": true, + "no_effect": false, + "move_flags": 32, + "user_hp": 200, + "user_max_hp": 400, + "target_hp": 200, + "same_actor": false, + "item_effect": "SHELL_BELL", + "hook": "replace_attacker", + "hook_effect": "HELD_SHELL_BELL", + "hook_parameter": 64 + }, + { + "label": "shell-bell-divisor-64-damage-32", + "first_damage": 32, + "target_damaged": true, + "no_effect": false, + "move_flags": 32, + "user_hp": 200, + "user_max_hp": 400, + "target_hp": 200, + "same_actor": false, + "item_effect": "SHELL_BELL", + "hook": "replace_attacker", + "hook_effect": "HELD_SHELL_BELL", + "hook_parameter": 64 + }, + { + "label": "shell-bell-full-hp", + "first_damage": 32, + "target_damaged": true, + "no_effect": false, + "move_flags": 32, + "user_hp": 400, + "user_max_hp": 400, + "target_hp": 200, + "same_actor": false, + "item_effect": "SHELL_BELL", + "hook": "replace_attacker", + "hook_effect": "HELD_SHELL_BELL", + "hook_parameter": 8 + }, + { + "label": "shell-bell-user-fainted", + "first_damage": 32, + "target_damaged": true, + "no_effect": false, + "move_flags": 32, + "user_hp": 0, + "user_max_hp": 400, + "target_hp": 200, + "same_actor": false, + "item_effect": "SHELL_BELL", + "hook": "replace_attacker", + "hook_effect": "HELD_SHELL_BELL", + "hook_parameter": 8 + }, + { + "label": "shell-bell-target-fainted", + "first_damage": 32, + "target_damaged": true, + "no_effect": false, + "move_flags": 32, + "user_hp": 200, + "user_max_hp": 400, + "target_hp": 0, + "same_actor": false, + "item_effect": "SHELL_BELL", + "hook": "replace_attacker", + "hook_effect": "HELD_SHELL_BELL", + "hook_parameter": 8 + }, + { + "label": "shell-bell-self-target", + "first_damage": 32, + "target_damaged": true, + "no_effect": false, + "move_flags": 32, + "user_hp": 200, + "user_max_hp": 400, + "target_hp": 200, + "same_actor": true, + "item_effect": "SHELL_BELL", + "hook": "replace_attacker", + "hook_effect": "HELD_SHELL_BELL", + "hook_parameter": 8 + }, + { + "label": "shell-bell-no-effect", + "first_damage": 32, + "target_damaged": true, + "no_effect": true, + "move_flags": 32, + "user_hp": 200, + "user_max_hp": 400, + "target_hp": 200, + "same_actor": false, + "item_effect": "SHELL_BELL", + "hook": "replace_attacker", + "hook_effect": "HELD_SHELL_BELL", + "hook_parameter": 8 + }, + { + "label": "shell-bell-substitute", + "first_damage": 32, + "target_damaged": false, + "no_effect": false, + "move_flags": 32, + "user_hp": 200, + "user_max_hp": 400, + "target_hp": 200, + "same_actor": false, + "item_effect": "SHELL_BELL", + "hook": "replace_attacker", + "hook_effect": "HELD_SHELL_BELL", + "hook_parameter": 8 + }, + { + "label": "shell-bell-zero-first", + "first_damage": 0, + "target_damaged": true, + "no_effect": false, + "move_flags": 32, + "user_hp": 200, + "user_max_hp": 400, + "target_hp": 200, + "same_actor": false, + "item_effect": "SHELL_BELL", + "hook": "replace_attacker", + "hook_effect": "HELD_SHELL_BELL", + "hook_parameter": 8 + }, + { + "label": "no-user", + "first_damage": 32, + "target_damaged": true, + "no_effect": false, + "move_flags": 32, + "user_hp": 200, + "user_max_hp": 400, + "target_hp": 200, + "same_actor": false, + "item_effect": null, + "hook": "delegate", + "physical_item": 187, + "no_user": true + }, + { + "label": "no-target", + "first_damage": 32, + "target_damaged": true, + "no_effect": false, + "move_flags": 32, + "user_hp": 200, + "user_max_hp": 400, + "target_hp": 200, + "same_actor": false, + "item_effect": null, + "hook": "delegate", + "physical_item": 187, + "no_target": true + }, + { + "label": "no-move", + "first_damage": 32, + "target_damaged": true, + "no_effect": false, + "move_flags": 32, + "user_hp": 200, + "user_max_hp": 400, + "target_hp": 200, + "same_actor": false, + "item_effect": null, + "hook": "delegate", + "physical_item": 187, + "no_move": true + }, + { + "label": "no-flags", + "first_damage": 32, + "target_damaged": true, + "no_effect": false, + "move_flags": 32, + "user_hp": 200, + "user_max_hp": 400, + "target_hp": 200, + "same_actor": false, + "item_effect": null, + "hook": "delegate", + "physical_item": 187, + "no_flags": true + }, + { + "label": "substitute-blocks-flinch", + "first_damage": 32, + "target_damaged": true, + "no_effect": false, + "move_flags": 32, + "user_hp": 200, + "user_max_hp": 400, + "target_hp": 200, + "same_actor": false, + "item_effect": null, + "hook": "delegate", + "physical_item": 187, + "substitute_hp": 10 + }, + { + "label": "already-moved-target", + "first_damage": 32, + "target_damaged": true, + "no_effect": false, + "move_flags": 32, + "user_hp": 200, + "user_max_hp": 400, + "target_hp": 200, + "same_actor": false, + "item_effect": null, + "hook": "delegate", + "physical_item": 187, + "target_moved": true + }, + { + "label": "inner-focus-target", + "first_damage": 32, + "target_damaged": true, + "no_effect": false, + "move_flags": 32, + "user_hp": 200, + "user_max_hp": 400, + "target_hp": 200, + "same_actor": false, + "item_effect": null, + "hook": "delegate", + "physical_item": 187, + "ability": "INNER_FOCUS" + }, + { + "label": "shield-dust-target", + "first_damage": 32, + "target_damaged": true, + "no_effect": false, + "move_flags": 32, + "user_hp": 200, + "user_max_hp": 400, + "target_hp": 200, + "same_actor": false, + "item_effect": null, + "hook": "delegate", + "physical_item": 187, + "ability": "SHIELD_DUST" + }, + { + "label": "physical-kings-rock-metadata-cleared", + "first_damage": 32, + "target_damaged": true, + "no_effect": false, + "move_flags": 32, + "user_hp": 200, + "user_max_hp": 400, + "target_hp": 200, + "same_actor": false, + "item_effect": null, + "hook": "delegate", + "physical_item": 187, + "metadata_cleared": true + }, + { + "label": "physical-shell-bell", + "first_damage": 32, + "target_damaged": true, + "no_effect": false, + "move_flags": 32, + "user_hp": 200, + "user_max_hp": 400, + "target_hp": 200, + "same_actor": false, + "item_effect": "SHELL_BELL", + "hook": "delegate", + "hook_effect": "HELD_SHELL_BELL", + "hook_parameter": 8, + "physical_item": 219 + }, + { + "label": "physical-shell-bell-metadata-cleared", + "first_damage": 32, + "target_damaged": true, + "no_effect": false, + "move_flags": 32, + "user_hp": 200, + "user_max_hp": 400, + "target_hp": 200, + "same_actor": false, + "item_effect": "SHELL_BELL", + "hook": "delegate", + "hook_effect": "HELD_SHELL_BELL", + "hook_parameter": 8, + "physical_item": 219, + "metadata_cleared": true + }, + { + "label": "shell-bell-heal-cap", + "first_damage": 32, + "target_damaged": true, + "no_effect": false, + "move_flags": 32, + "user_hp": 399, + "user_max_hp": 400, + "target_hp": 200, + "same_actor": false, + "item_effect": "SHELL_BELL", + "hook": "replace_attacker", + "hook_effect": "HELD_SHELL_BELL", + "hook_parameter": 8 + }, + { + "label": "player-berry-parameter", + "first_damage": 32, + "target_damaged": true, + "no_effect": false, + "move_flags": 32, + "user_hp": 200, + "user_max_hp": 400, + "target_hp": 200, + "same_actor": false, + "item_effect": null, + "hook": "delegate", + "physical_item": 187, + "player_item": 139 + }, + { + "label": "player-shell-bell-parameter", + "first_damage": 32, + "target_damaged": true, + "no_effect": false, + "move_flags": 32, + "user_hp": 200, + "user_max_hp": 400, + "target_hp": 200, + "same_actor": false, + "item_effect": null, + "hook": "delegate", + "physical_item": 187, + "player_item": 219 + }, + { + "label": "native-fake-flinch", + "first_damage": 32, + "target_damaged": true, + "no_effect": false, + "move_flags": 32, + "user_hp": 200, + "user_max_hp": 400, + "target_hp": 200, + "same_actor": false, + "item_effect": null, + "hook": "delegate", + "physical_item": 530 + }, + { + "label": "flinch-on-self", + "first_damage": 32, + "target_damaged": true, + "no_effect": false, + "move_flags": 32, + "user_hp": 200, + "user_max_hp": 400, + "target_hp": 200, + "same_actor": true, + "item_effect": null, + "hook": "delegate", + "physical_item": 187 + } + ] +} diff --git a/tests/spit_up/public_effect.lua b/tests/spit_up/public_effect.lua new file mode 100644 index 000000000..84d309bfa --- /dev/null +++ b/tests/spit_up/public_effect.lua @@ -0,0 +1,71 @@ +-- Composed proof: also requires the canonical effect adapter from #121. +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +require("src.mods.UnifiedEffects") +for _, version in ipairs({ "firered", "emerald" }) do + local entry = [[return function(mod) + assert(mod.content.moves:patch("TACKLE",{effect="SPIT_UP",power=100})) + mod.exports.effect=assert(mod.content.moves:get("TACKLE")).effect + local selected="STOCKPILE" + mod.exports.release=function() selected="TACKLE" end + mod.exports.hits,mod.exports.calls={},{crit=0,damage=0} + mod.hooks:wrap("battle.enemy_action",function() return {move=selected,target=0} end) + mod.hooks:wrap("battle.crit",function() mod.exports.calls.crit=mod.exports.calls.crit+1;return true end) + mod.hooks:wrap("battle.damage",function(nextDamage,...) + mod.exports.calls.damage=mod.exports.calls.damage+1;return nextDamage(...) + end) + mod.events:on("battle.damage_dealt",function(e) + mod.exports.hits[#mod.exports.hits+1]={amount=e.damage,crit=e.crit,target=e.target.mon.nickname} + end) + end]] + local function configure(data, _, ids) + ids.STOCKPILE = "MOVE_254" + data.moves.MOVE_254 = { + id = "MOVE_254", + index = 254, + name = "STOCKPILE", + type = "NORMAL", + power = 0, + pp = 20, + accuracy = 100, + accuracyRaw = 255, + effect = "STOCKPILE_EFFECT", + gen3Effect = 160, + target = 16, + protectAffected = false, + } + data.moves[ids.TACKLE].target = 0 + end + local f = require("tests.enemy_actions.fixture").new(".", "gen2", version, 3, "unified", configure, entry) + local b = f.fresh("TACKLE", { enemy = true }).battle + T.eq(f.e.effect, "SPIT_UP", "canonical effect roundtrips through the real loader") + T.eq(f.data.moves[f.ids.TACKLE].gen3Effect, 161, "canonical effect selects native semantic 161") + for _, a in ipairs({ b.player, b.enemy }) do + a.curTypes, a.abilityOverride = { "NORMAL" }, false + a.curStats = { hp = 10000, attack = 50, defense = 50, speed = 50, special = 50, spatk = 50, spdef = 50 } + a.mon.stats, a.mon.hp = a.curStats, 10000 + end + b.player.mon.nickname = "TARGET" + b.enemy.curMoves = { { id = f.ids.STOCKPILE, pp = 20 }, { id = f.ids.TACKLE, pp = 10 } } + b.enemy.mon.moves = b.enemy.curMoves + for i = 1, 2 do + b:executeAction(b.enemy, b.player, b:enemyAction(b.enemy)) + end + f.e.release() + b:executeAction(b.enemy, b.player, b:enemyAction(b.enemy)) + T.same( + f.e.hits, + { { amount = 90, crit = false, target = "TARGET" } }, + "canonical patch releases the real two-stack base" + ) + T.same( + f.e.calls, + { crit = 0, damage = 0 }, + "native set damage does not enter ordinary mod damage or critical hooks" + ) + T.eq(b.enemy.stockpile, 0, "release consumes the counter") + T.eq(b.enemy.curMoves[1].pp, 18, "real Stockpile casts consume their owned PP") + T.eq(b.enemy.curMoves[2].pp, 9, "real release consumes its owned PP") + f.close() +end +T.finish("composed public Spit Up effect") diff --git a/tests/substitute_art/fixture.lua b/tests/substitute_art/fixture.lua new file mode 100644 index 000000000..be9666963 --- /dev/null +++ b/tests/substitute_art/fixture.lua @@ -0,0 +1,41 @@ +-- CPU image storage: actual importer pixels, without a graphics application. +local M = {} +function M.image(width, height) + local image = { width = width, height = height, pixels = {} } + function image:getWidth() + return self.width + end + function image:getHeight() + return self.height + end + function image:getDimensions() + return self.width, self.height + end + function image:setPixel(x, y, r, g, b, a) + self.pixels[y * self.width + x] = { r, g, b, a } + end + function image:getPixel(x, y) + return unpack(self.pixels[y * self.width + x] or { 0, 0, 0, 0 }) + end + function image:mapPixel(fn) + for y = 0, self.height - 1 do + for x = 0, self.width - 1 do + self:setPixel(x, y, fn(x, y, self:getPixel(x, y))) + end + end + end + function image:rgba8() + local bytes = {} + for y = 0, self.height - 1 do + for x = 0, self.width - 1 do + local pixel = { self:getPixel(x, y) } + for i = 1, 4 do + bytes[#bytes + 1] = string.char(math.floor(pixel[i] * 255 + 0.5)) + end + end + end + return table.concat(bytes) + end + return image +end +return M diff --git a/tests/tools/test_gba_substitute_art.py b/tests/tools/test_gba_substitute_art.py new file mode 100644 index 000000000..bdf324291 --- /dev/null +++ b/tests/tools/test_gba_substitute_art.py @@ -0,0 +1,93 @@ +"""Reject ambiguous/invalid loader candidates before publishing import symbols.""" +from pathlib import Path +import struct +import sys +import unittest + +sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "tools")) +from gba_substitute_art import discover +from gen3_discover import Rom, lz77_at + + +def literal(raw): + out = bytearray((len(raw) << 8 | 16).to_bytes(4, "little")) + for i in range(0, len(raw), 8): + out.append(0) + out.extend(raw[i:i + 8]) + return out + + +def loader(data, at=0x100): + for delta, value in ((0, 0x040000D4), (4, 0x84000200), + (-0x5C, 0x08001000), (-12, 0x08002000), + (-4, 0x08004000), (-0x58, 0x0202401C), (-8, 0x0202401C)): + struct.pack_into("=") + 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", "effects"): + values = {} + for backend, engine in (("gen1", gen1), ("gen2", gen2)): + source = scaffold_root if mode == "scaffold" else fixtures + command = [args.lua, str(fixtures / "worker.lua"), str(source), backend, version, mode] + run = subprocess.run(command, cwd=engine, text=True, capture_output=True, timeout=60) + reports = [line.removeprefix("API3_CONTRACT ") for line in run.stdout.splitlines() + if line.startswith("API3_CONTRACT ")] + if run.returncode or len(reports) != 1: + raise RuntimeError(f"{backend}/{version}/{mode} failed:\n{run.stdout}{run.stderr}") + values[backend] = json.loads(reports[0]) + if values["gen1"] != values["gen2"]: + raise RuntimeError(f"contract mismatch for {version}/{mode}: {values}") + results.append({"version": version, "mode": mode, "result": values["gen1"]}) + print(f"PASS {version}/{mode}: identical mod and public result") + report = {"gen1_ref": GEN1_REF, "gen2_ref": git(gen2, "rev-parse", "HEAD"), + "gen2_tracked_changes": bool(git(gen2, "status", "--porcelain", "--untracked-files=no")), + "fixture_sha256": manifest, "scaffold_sha256": generated_hashes, "comparisons": results} + if args.report: + args.report.write_text(json.dumps(report, indent=2) + "\n") + scaffold_temp.cleanup() + print(f"API 3: {len(results)} cross-engine comparisons, {len(results) * 2} isolated loader runs passed") + + +if __name__ == "__main__": + try: + main() + except (RuntimeError, subprocess.SubprocessError, OSError, ValueError, AssertionError) as error: + print(error, file=sys.stderr) + sys.exit(1) diff --git a/tools/charge-required-contract-check/check.py b/tools/charge-required-contract-check/check.py new file mode 100644 index 000000000..b23c0f304 --- /dev/null +++ b/tools/charge-required-contract-check/check.py @@ -0,0 +1,61 @@ +#!/usr/bin/env python3 +"""Compare one public charge-turn consumer through both native battle engines.""" +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", "firered", "emerald"] + for version in versions: + observations = [] + for backend, engine in [("gen1", gen1), ("gen2", gen2)]: + run = subprocess.run([a.lua, str(gen2/"tests/charge_required/worker.lua"), str(gen2), + backend, version, str(a.api), a.format], cwd=engine, + text=True, capture_output=True, timeout=30) + lines = [line[len("CHARGE_REQUIRED_CONTRACT "):] for line in run.stdout.splitlines() + if line.startswith("CHARGE_REQUIRED_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 battle charge decision", + "observations": rows, "native_policy": json.loads(next( + line[len("CHARGE_NATIVE_POLICY "):] for line in run.stdout.splitlines() + if line.startswith("CHARGE_NATIVE_POLICY "))), + "checks": run.stdout.splitlines()[-1]}) + assert observations[0] == observations[1], f"charge decision mismatch: {version}" + print(f"PASS {version}: {len(observations[0])} matching native charge 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_charge_required/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/critical-order-contract-check/README.md b/tools/critical-order-contract-check/README.md new file mode 100644 index 000000000..db19847d0 --- /dev/null +++ b/tools/critical-order-contract-check/README.md @@ -0,0 +1,22 @@ +# Native critical/type contracts + +Use clean Gen1Recomp `535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91`: + +```sh +python3 tools/critical-order-contract-check/check.py --gen1 /path/to/pinned/gen1 --host-api 3 --report /tmp/critical-api3.json +python3 tools/critical-order-contract-check/check.py --gen1 /path/to/pinned/gen1 --host-api 2 --report /tmp/critical-api2.json +``` + +Set `--lua /path/to/luajit` if needed. Each invocation runs four isolated +workers and compares **192 pairs** of observations on FireRed/Emerald. The +source always uses API 3; the host uses API 3 or API 2/unified. Both load the +same public `battle.crit` consumer through their real SDKs, then run native +move pipelines with equivalent stats, live types and random damage rolls. + +Observations include damage, PP and public critical-hook counts for both +attackers, critical/noncritical, STAB, neutral/half/quarter/immune type effects +and rolls 85/92/100. Reports preserve the engine refs and consumer/fixture +hashes; the checker refuses a different or modified reference. These are +synthetic ROM-free fixtures, not native GUI or complete playthrough tests. +The isolated imported-data driver is documented in +[the correction's scope](../../docs/gen3-critical-order.md). diff --git a/tools/critical-order-contract-check/check.py b/tools/critical-order-contract-check/check.py new file mode 100644 index 000000000..691f07c36 --- /dev/null +++ b/tools/critical-order-contract-check/check.py @@ -0,0 +1,64 @@ +#!/usr/bin/env python3 +"""Compare the unchanged public critical-hit consumer through both SDKs.""" +import argparse +import hashlib +import json +import os +from pathlib import Path +import subprocess + +REFERENCE = "535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91" +VERSIONS = ("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", type=Path, required=True) + 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("--host-api", type=int, choices=(2, 3), default=3) + parser.add_argument("--report", type=Path) + args = parser.parse_args() + source, host = args.gen1.resolve(), args.gen2.resolve() + if git(source, "rev-parse", "HEAD") != REFERENCE or git(source, "status", "--porcelain"): + parser.error("reference must be clean at " + REFERENCE) + paths = ["examples/portable_critical_order/manifest.json", "examples/portable_critical_order/main.lua"] + paths += ["tests/critical_order/" + name for name in ("cases.lua", "worker.lua")] + hashes = {name: hashlib.sha256((host / name).read_bytes()).hexdigest() for name in paths} + runs, differences = [], [] + for version in VERSIONS: + pair = [] + for backend, engine in (("gen1", source), ("gen2", host)): + result = subprocess.run( + [args.lua, str(host / "tests/critical_order/worker.lua"), str(host), backend, version, str(3 if backend == "gen1" else args.host_api)], + cwd=engine, capture_output=True, text=True, timeout=45, + ) + lines = [line.removeprefix("CRITICAL_ORDER ") for line in result.stdout.splitlines() + if line.startswith("CRITICAL_ORDER ")] + if result.returncode or len(lines) != 1: + raise RuntimeError(f"{backend}/{version}: {result.stdout}{result.stderr}") + rows = json.loads(lines[0]) + pair.append(rows) + runs.append({"backend": backend, "version": version, "observations": rows}) + if pair[0] != pair[1]: + differences.append({"version": version, "reference": pair[0], "host": pair[1]}) + print("FAIL" if pair[0] != pair[1] else "PASS", version) + assert not git(source, "status", "--porcelain"), "reference changed" + assert hashes == {name: hashlib.sha256((host / name).read_bytes()).hexdigest() for name in paths}, "fixtures changed" + report = {"reference_ref": REFERENCE, "host_ref": git(host, "rev-parse", "HEAD"), + "host_dirty": bool(git(host, "status", "--porcelain")), "reference_api": 3, "host_api": args.host_api, + "format": "unified", "files_sha256": hashes, "runs": runs, "differences": differences} + if args.report: + args.report.write_text(json.dumps(report, indent=2) + "\n") + if differences: + for difference in differences: + print(json.dumps(difference, sort_keys=True)) + raise SystemExit(1) + + +if __name__ == "__main__": + main() diff --git a/tools/enemy-action-contract-check/check.py b/tools/enemy-action-contract-check/check.py new file mode 100644 index 000000000..d9a20b950 --- /dev/null +++ b/tools/enemy-action-contract-check/check.py @@ -0,0 +1,172 @@ +#!/usr/bin/env python3 +"""Compare portable mod and native runtime contracts with a clean read-only Gen1 reference.""" +import argparse +import hashlib +import json +import os +from pathlib import Path +import subprocess +import sys +import time + +GEN1_REF = "535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91" + + +MODES = [ + (3, 'native_bgm_options'), + (3, 'native_bgm_hooks'), + (3, 'native_bgm_devices'), + (3, 'native_fanfare_commands'), + (3, 'native_bgm_commands'), + (3, 'native_bgm_policy'), + (3, 'native_bgm_symbols'), + (3, 'native_cry_lifecycle'), + (3, 'native_cry_commands'), + (3, 'native_cry_pcm'), + (3, 'native_sound_script_waits'), + (3, 'native_sound_lifecycle'), + (3, 'substitute_fade'), + (3, 'battle_effect_presentation'), + (3, 'native_sound_pan'), + (3, 'substitute_multi_hit'), + (3, 'substitute_presentation'), + (3, 'mirror_move'), + (3, 'mirror_move_content'), + (3, 'mirror_move_chained'), + (3, 'assist'), + (3, 'assist_content'), + (3, 'assist_held'), + (3, 'assist_failed_callers'), + (3, 'nature_power'), + (3, 'nature_power_content'), + (3, 'nature_power_chained'), + (3, 'post_hit_items_command'), + (3, 'post_hit_items_execution'), + (3, 'move_used_pp'), + (2, 'choices'), + (3, 'choices'), + (3, 'arguments'), + (3, 'records'), + (3, 'targets'), + (3, 'follow_me'), + (3, 'helping_hand'), + (3, 'spit_up'), + (3, 'beat_up'), + (3, 'beat_up_content'), + (3, 'beat_up_adjustment'), + (3, 'beat_up_side_party'), + (3, 'critical_context'), + (3, 'constrained_party'), + (3, 'accuracy'), + (3, 'accuracy_execution'), + (3, 'secondary'), + (3, 'secondary_execution'), + (3, 'party_codec'), + (3, 'party_codec_hooks'), + (3, 'lock_on'), + (3, 'baton_pass'), + (3, 'encore'), + (3, 'disable'), + (3, 'multi_hit'), + (3, 'rage'), + (3, 'triple_kick'), + (3, 'sleep_talk'), + (3, 'sleep_talk_content'), + (3, 'choice_band_command'), + (3, 'choice_band_selection'), + (3, 'choice_band_planning'), + (3, 'choice_band_wild'), + (3, 'choice_band_calls'), + (3, 'choice_band_content'), + (3, 'choice_band_baton'), + (3, 'choice_band_call_failure'), +] + + +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) + 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", required=True, type=Path) + parser.add_argument("--mode", action="append", choices=sorted({name for _, name in MODES}), + help="Run selected modes; omission runs every contract") + args = parser.parse_args() + source, host = args.gen1.resolve(), args.gen2.resolve() + if git(source, "rev-parse", "HEAD") != GEN1_REF or git(source, "status", "--porcelain"): + parser.error(f"Gen1 must be clean at {GEN1_REF}; the checker never patches it") + paths = [*sorted((host / "examples/portable_enemy_action").glob("*")), + *sorted((host / "tests/post_hit_items").glob("*.json")), + *sorted((host / "tests/enemy_actions").glob("*.lua")), Path(__file__).with_name("worker.lua")] + paths.extend(sorted((host / "tests/native_sound").glob("*.lua"))) + paths.extend(sorted((host / "tests/native_cry").glob("*.lua"))) + paths.extend(sorted((host / "tests/native_bgm").glob("*.lua"))) + report = {"source_ref": GEN1_REF, "host_ref": git(host, "rev-parse", "HEAD"), + "host_dirty": bool(git(host, "status", "--porcelain")), + "fixture_sha256": {str(p.relative_to(host)): hashlib.sha256(p.read_bytes()).hexdigest() + for p in paths}, "runs": [], "comparisons": []} + failed = False + for version in ("firered", "emerald"): + for api, mode in MODES: + if args.mode and mode not in args.mode: + continue + values = {} + for backend, root in (("gen1", source), ("gen2", host)): + cmd = [args.lua, str(host / "tools/enemy-action-contract-check/worker.lua"), + str(host), backend, version, str(api), mode] + # This matrix executes thousands of complete scripts per worker. + # Retain a real failure on timeout, including partial raw output. + # These exhaustive matrices resolve thousands of complete scripts. + # Keep a bounded three-minute budget for their native work, + # including low-priority local runs, rather than a 60s cutoff. + timeout = 180 if mode in {"post_hit_items_execution", "assist", "assist_content", "assist_held", "nature_power"} else 60 + timed_out = None + started = time.monotonic() + try: + run = subprocess.run(cmd, cwd=root, text=True, capture_output=True, timeout=timeout) + except subprocess.TimeoutExpired as error: + def captured(value): + return value.decode("utf-8", errors="replace") if isinstance(value, bytes) else value or "" + timed_out = {"seconds": timeout, + "stdout_hex": error.stdout.hex() if isinstance(error.stdout, bytes) else None, + "stderr_hex": error.stderr.hex() if isinstance(error.stderr, bytes) else None} + run = subprocess.CompletedProcess(cmd, 124, captured(error.stdout), + captured(error.stderr) + "\nWorker timed out and was terminated.\n") + elapsed = time.monotonic() - started + rows = [json.loads(line.removeprefix("ENEMY_ACTION_OBSERVATIONS ")) + for line in run.stdout.splitlines() if line.startswith("ENEMY_ACTION_OBSERVATIONS ")] + # The native VM catches task errors and wait caps internally. + # A zero worker exit must not turn an aborted animation into an oracle. + diagnostics = [line for line in run.stdout.splitlines() + if line.startswith("[battle.anim]")] + report["runs"].append({"backend": backend, "version": version, "api": api, "mode": mode, + "exit": run.returncode, "stdout": run.stdout, "stderr": run.stderr, + "timeout": timed_out, "wall_seconds": round(elapsed, 4), + "native_vm_diagnostics": diagnostics}) + if run.returncode or len(rows) != 1 or diagnostics: + failed = True + else: + values[backend] = rows[0] + equal = len(values) == 2 and values["gen1"] == values["gen2"] + failed |= not equal + report["comparisons"].append({"version": version, "api": api, "mode": mode, "equal": equal, + "observations": values}) + print(f"{'PASS' if equal else 'FAIL'} {version}/API {api}/{mode}: identical complete contract observations") + report["passed"] = not failed + args.report.parent.mkdir(parents=True, exist_ok=True) + args.report.write_text(json.dumps(report, indent=2) + "\n") + if git(source, "status", "--porcelain"): + raise RuntimeError("reference checkout changed during the comparison") + return int(failed) + + +if __name__ == "__main__": + try: + sys.exit(main()) + except (OSError, ValueError, RuntimeError, subprocess.SubprocessError) as error: + print(error, file=sys.stderr) + sys.exit(1) diff --git a/tools/enemy-action-contract-check/imported-assist-smoke.lua b/tools/enemy-action-contract-check/imported-assist-smoke.lua new file mode 100644 index 000000000..6739b5387 --- /dev/null +++ b/tools/enemy-action-contract-check/imported-assist-smoke.lua @@ -0,0 +1,185 @@ +-- Existing local imports, controlled battlers and actual public menu submissions. +-- No normal save or generated asset is written; no graphical acceptance is claimed. +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local cache = assert(arg[1], "pass the existing local Gen2Recomp cache root") +local nested = arg[2] == "--nature-power" +local children = { [0] = 78, 75, 89, 56, 57, 61, 157, 247, 129, 129 } +local rows = {} +for _, version in ipairs({ "firered", "emerald" }) do + for _, authored in ipairs({ false, true }) do + for terrain = 0, 9 do + for _, itemNumber in ipairs({ 186, 219 }) do + local entry = [=[return function(mod) + local choice + mod.hooks:wrap("battle.enemy_action",function(nextAction,...) + if choice then return choice(nextAction,...)end + return nextAction(...) + end) + mod.exports.choose=function(fn)choice=fn end + mod.exports.api=mod + end]=] + if authored then + entry = entry:gsub( + " local choice", + [=[ assert(mod.content.moves:register("PORTABLE_ASSIST",{id="PORTABLE_ASSIST",name="PORTABLE_ASSIST",type="NORMAL",power=0,accuracy=100,pp=10,effect="ASSIST"})) + local choice]=], + 1 + ) + end + local function configure(data, _, ids) + for _, name in ipairs({ "pokemon", "moves", "items", "constants", "type_chart" }) do + data[name] = assert(dofile(cache .. "/" .. version .. "/data/generated/" .. name .. ".lua")) + end + for name, n in pairs({ TACKLE = 33, STRUGGLE = 165, SCRATCH = 10, MIMIC = 102, FLY = 19 }) do + ids[name] = assert(data.constants.moveOrder[n]) + end + require("src.pokemon.Stats").setNatures(data.constants.natures) + require("src.pokemon.Growth").setTables(data.constants.experienceTables) + end + local f = + require("tests.enemy_actions.fixture").new(".", "gen2", version, 3, "unified", configure, entry) + f.game.save.options.ruleset = "gen3_emerald" + T.link.prepare(f.data) + local b = f.fresh("TACKLE").battle + b:syncSides() + b.terrain = terrain + assert(b.ruleset.baseDamageStyle == "gba") + local function id(n) + return assert(f.data.constants.moveOrder[n]) + end + local parent = authored and "PORTABLE_ASSIST" or id(274) + local publicParent = assert(f.e.api.content.moves:get(parent)) + assert(publicParent.effect == "ASSIST") + local benchNumber = nested and 267 or children[terrain] + local child = assert(f.e.api.content.moves:get(id(children[terrain]))) + local selected = parent + local publicSelected = assert(f.e.api.content.moves:get(selected)) + local learned = { { id = selected, pp = 10 }, { id = id(33), pp = 10 } } + b.player.mon.moves, b.player.curMoves = learned, learned + b.enemy.mon.moves = { { id = id(33), pp = 10 } } + b.enemy.curMoves = b.enemy.mon.moves + b.player.mon.nickname, b.enemy.mon.nickname = "PLAYER", "FOE" + for _, a in ipairs({ b.player, b.enemy }) do + a.mon.stats.hp = 4000 + a.mon.hp = 4000 + a.abilityOverride = false + end + b.player.mon.hp = 2000 + local bench = require("src.mods.Merge").deepCopy(b.player.mon) + bench.nickname = "BENCH" + bench.moves = { { id = id(benchNumber), pp = 0 } } + b.playerParty = { b.player.mon, bench } + b.enemyParty = { b.enemy.mon } + f.game.save.party = b.playerParty + + for key, def in pairs(f.data.items) do + if type(def) == "table" and tonumber(def.index) == itemNumber then + b.player.mon.item = key + break + end + end + assert(b.player.mon.item) + b.player.curStats.speed, b.enemy.curStats.speed = 200, 100 + local used, damage, draws = {}, {}, {} + local beforeFoe + f.e.api.events:on("battle.move_used", function(e) + if e.user.mon.nickname == "PLAYER" then + used[#used + 1] = { + move = e.move.id, + called = e.isCalled or false, + pp = e.user.mon.moves[1].pp, + target = e.target.mon.nickname, + } + else + beforeFoe = b.player.mon.hp + end + end) + f.e.api.events:on("battle.damage_dealt", function(e) + if e.user.mon.nickname == "PLAYER" then + damage[#damage + 1] = e.damage + end + end) + f.e.api.hooks:wrap("battle.crit", function() + return false + end) + b.rng = function(lo, hi) + local value = lo == 1 and hi == 100 and 1 or hi + draws[#draws + 1] = { lo, hi, value } + return value + end + f.e.choose(function() + return { move = "TACKLE" } + end) + local game = b.game + game.input = require("src.core.Input") + game.stack.states = { b } + b.phase, b.queue, b.current, b.frame, b.menuIndex, b.moveIndex = "moveSelect", {}, nil, 0, 1, 1 + local snapshot = assert(f.e.api.battle:snapshot()) + local ok, why = + f.e.api.battle:submit({ kind = "move", slot = 1, id = 1, revision = snapshot.revision }) + assert(ok, why) + for _ = 1, 18000 do + if b.phase == "menu" and b.turnCount == 1 then + break + end + require("src.core.Input").pressed = { a = true } + game.stack:update(1 / 60) + end + assert(b.phase == "menu" and b.turnCount == 1, "actual submitted imported turn must finish") + assert(#used == (nested and 3 or 2) and used[1].move == publicSelected.id and not used[1].called) + local final = used[#used] + assert( + final.move == child.id and final.called, + "actual terrain child uses advertised public identity" + ) + assert(used[1].pp == 10, "selected announcement precedes its PP command") + assert(final.pp == 9, "Assist spends its own PP before every called event") + if nested then + assert( + used[2].move == "NATURE_POWER" and used[2].called and used[2].pp == 9, + "native nested Nature Power borrows no PP from the selected Assist" + ) + end + local history = nested and id(267) or parent + assert(b.player.lastMove == history, "innermost native caller preserves move history") + if itemNumber == 186 then + assert( + b.player.choicedMove + == (not nested and require("src.battle.Gen3ChoiceBand").identity(b, history) or nil), + "Choice Band clears an unlearned inner caller and retains learned Assist" + ) + end + assert( + learned[1].pp == 9 and learned[2].pp == 10 and bench.moves[1].pp == 0, + "only selected Assist spends PP, including a zero-PP party child" + ) + local healing = itemNumber == 219 and damage[1] and math.max(1, math.floor(damage[1] / 8)) or 0 + assert(beforeFoe == 2000 + healing, "native move-end healing completes before the next actor") + if terrain == 0 then + assert(b.enemy.mon.status == "PAR" and #damage == 0, "actual Stun Spore status handler executes") + else + assert(#damage > 0 and damage[1] > 0, "actual imported damaging child executes") + end + assert( + #f.result.errors == 0 and #f.result.loader.errors == 0, + "public callbacks and registration remain error-free" + ) + rows[#rows + 1] = { + version = version, + authored = authored, + nested = nested, + terrain = terrain, + item = itemNumber, + used = used, + damage = damage, + draws = draws, + pp = learned[1].pp, + beforeFoe = beforeFoe, + } + f.close() + end + end + end +end +print("IMPORTED_ASSIST_SMOKE " .. require("src.link.Json").encode(rows)) diff --git a/tools/enemy-action-contract-check/imported-mirror-smoke.lua b/tools/enemy-action-contract-check/imported-mirror-smoke.lua new file mode 100644 index 000000000..8c7ee6b5a --- /dev/null +++ b/tools/enemy-action-contract-check/imported-mirror-smoke.lua @@ -0,0 +1,154 @@ +-- Read existing local imports; submit actual public menu intents for two turns. +-- CPU only, with no writes to normal generated data or saves. +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local cache = assert(arg[1], "pass the existing local Gen2Recomp cache root") +local rows = {} +for _, version in ipairs({ "firered", "emerald" }) do + for _, authored in ipairs({ false, true }) do + for _, incoming in ipairs({ 33, 10, 45 }) do + for _, itemNumber in ipairs({ 186, 219 }) do + local entry = [=[return function(mod) + mod.hooks:wrap("battle.enemy_action",function() + return {move="INCOMING"} + end) + mod.exports.api=mod + end]=] + if authored then + entry = entry:gsub( + " mod.exports.api", + [=[ + assert(mod.content.moves:register("PORTABLE_MIRROR",{id="PORTABLE_MIRROR",name="PORTABLE_MIRROR",type="NORMAL",power=0,accuracy=100,pp=10,effect="MIRROR_MOVE_EFFECT"})) + mod.exports.api]=], + 1 + ) + end + local function configure(data, _, ids) + for _, name in ipairs({ "pokemon", "moves", "items", "constants", "type_chart" }) do + data[name] = assert(dofile(cache .. "/" .. version .. "/data/generated/" .. name .. ".lua")) + end + for name, n in pairs({ TACKLE = 33, STRUGGLE = 165, SCRATCH = 10, MIMIC = 102, FLY = 19 }) do + ids[name] = assert(data.constants.moveOrder[n]) + end + require("src.pokemon.Stats").setNatures(data.constants.natures) + require("src.pokemon.Growth").setTables(data.constants.experienceTables) + end + entry = entry:gsub( + '"INCOMING"', + ('"%s"'):format(({ [33] = "TACKLE", [10] = "SCRATCH", [45] = "GROWL" })[incoming]) + ) + local f = + require("tests.enemy_actions.fixture").new(".", "gen2", version, 3, "unified", configure, entry) + f.game.save.options.ruleset = "gen3_emerald" + T.link.prepare(f.data) + local b = f.fresh("TACKLE").battle + b:syncSides() + assert(b.ruleset.baseDamageStyle == "gba") + local function id(n) + return assert(f.data.constants.moveOrder[n]) + end + local parent = authored and "PORTABLE_MIRROR" or id(119) + local public = assert(f.e.api.content.moves:get(parent)) + assert(public.effect == "MIRROR_MOVE_EFFECT") + b.player.mon.moves = { { id = parent, pp = 10 } } + b.enemy.mon.moves = { { id = id(incoming), pp = 10 } } + for i, actor in ipairs({ b.player, b.enemy }) do + actor.mon.nickname = i == 1 and "PLAYER" or "FOE" + actor.mon.stats.hp, actor.mon.hp = 4000, 4000 + actor.curMoves, actor.abilityOverride = actor.mon.moves, false + end + b.player.curStats.speed, b.enemy.curStats.speed = 200, 100 + for key, def in pairs(f.data.items) do + if type(def) == "table" and tonumber(def.index) == itemNumber then + b.player.mon.item = key + break + end + end + assert(b.player.mon.item) + local used, damage, draws = {}, {}, {} + f.e.api.events:on("battle.move_used", function(e) + used[#used + 1] = { + user = e.user.mon.nickname, + move = e.move.id, + called = e.isCalled or false, + pp = e.user.mon.moves[1].pp, + } + end) + f.e.api.events:on("battle.damage_dealt", function(e) + damage[#damage + 1] = { user = e.user.mon.nickname, move = e.move.id, damage = e.damage } + end) + f.e.api.hooks:wrap("battle.crit", function() + return false + end) + b.rng = function(lo, hi) + local value = lo == 1 and hi == 100 and 1 or hi + draws[#draws + 1] = { lo, hi, value } + return value + end + local game, Input = b.game, require("src.core.Input") + game.input, game.stack.states = Input, { b } + b.phase, b.queue, b.current, b.frame, b.menuIndex, b.moveIndex = "menu", {}, nil, 0, 1, 1 + local intentId = 0 + local function submit(intent) + local snapshot = assert(f.e.api.battle:snapshot()) + intentId = intentId + 1 + intent.id, intent.revision = intentId, snapshot.revision + local ok, why = f.e.api.battle:submit(intent) + assert(ok, why) + end + for turn = 1, 2 do + submit({ kind = "menu", choice = "fight" }) + for _ = 1, 10 do + if b.phase == "moveSelect" then + break + end + Input.pressed = {} + game.stack:update(1 / 60) + end + assert(b.phase == "moveSelect", "public fight intent opens the actual move menu") + submit({ kind = "move", slot = 1 }) + for _ = 1, 18000 do + if b.phase == "menu" and b.turnCount == turn then + break + end + Input.pressed = { a = true } + game.stack:update(1 / 60) + end + assert(b.phase == "menu" and b.turnCount == turn, "actual submitted imported turn must finish") + assert( + b.player.gbaLastTakenMove == id(incoming), + "native incoming command records real recipient history" + ) + end + assert( + #used == 5 and used[1].move == public.id and not used[1].called, + "first Mirror fails without received history" + ) + assert(used[3].move == public.id and not used[3].called, "second turn announces public caller") + assert( + used[4].move == f.e.api.content.moves:get(id(incoming)).id and used[4].called, + "second turn executes copied public move" + ) + assert(used[3].pp == 9 and used[4].pp == 8, "only parent command spends PP before copied event") + assert(b.player.mon.moves[1].pp == 8, "two actual selected commands spend exactly two PP") + assert( + #f.result.errors == 0 and #f.result.loader.errors == 0, + "public callbacks and registration stay error-free" + ) + rows[#rows + 1] = { + version = version, + authored = authored, + incoming = incoming, + item = itemNumber, + used = used, + damage = damage, + draws = draws, + received = b.player.gbaLastTakenMove, + pp = b.player.mon.moves[1].pp, + } + f.close() + end + end + end +end +print("IMPORTED_MIRROR_SMOKE " .. require("src.link.Json").encode(rows)) diff --git a/tools/enemy-action-contract-check/imported-native-bgm-smoke.lua b/tools/enemy-action-contract-check/imported-native-bgm-smoke.lua new file mode 100644 index 000000000..0d2c7a57c --- /dev/null +++ b/tools/enemy-action-contract-check/imported-native-bgm-smoke.lua @@ -0,0 +1,118 @@ +-- Real imported music, native world scripts and renderers; no game/device starts. +-- Arguments: host checkout, backend, version, existing cache root, owned ROM. +package.path = "./?.lua;./?/init.lua;" .. package.path +require("tests.modkit") +local root, backend, version, cacheRoot, romPath = unpack(arg) +local native = backend == "gen1" +local function read(path) + local file = assert(io.open(path, "rb")) + local bytes = file:read("*a") + file:close() + return bytes +end +local rom = read(romPath) +local imported = assert(dofile(cacheRoot .. "/" .. version .. "/data/generated/audio.lua")) +local image = read(cacheRoot .. "/" .. version .. "/" .. imported.gen3.file) +assert(image == rom:sub(imported.gen3.base + 1, imported.gen3.base + #image)) +local f = dofile(root .. "/tests/enemy_actions/assist.lua").fixture(root, backend, version) +local Audio = native and require("src.core.game3.audio") +local Player = native and require("src.core.game3.m4a_player") +local pack, cache, constants +if native then + require("src.import.gba.versions").select(version) + local files = {} + cache = { + write = function(_, path, bytes) + files[path] = bytes + return true + end, + read = function(_, path) + return files[path] + end, + } + assert(require("src.import.gba.extract_audio").run(rom, cache, { root = "owned-native-import/audio" })) + pack = assert(Player.loadPack(cache, "owned-native-import/audio")) +else + local Json = require("src.link.Json") + local extractor = require("src.import.RomExtractorGen3").new( + rom, + { id = version }, + Json.decode(read(root .. "/tools/rom_manifest_" .. version .. ".json")) + ) + local audio = dofile(cacheRoot .. "/" .. version .. "/data/generated/audio.lua") + audio.gen3.file, audio.programPrefix = "owned-imported-native-bgm.bin", nil + love.filesystem.write(audio.gen3.file, image) + extractor._songs, extractor._audio, extractor._constants = audio.songs, audio, {} + extractor:extractFanfares() + constants = assert(extractor._constants.gen3Fanfares) +end +local rows = {} +for _, case in ipairs({ "native-fade", "native-wait", "deferred-default", "stop-bgm" }) do + local h = dofile(root .. "/tests/native_bgm/streams.lua").install(root, backend, version, f) + h.start({ { "playbgm", 0, 0 }, { "end" } }) + local resolve = native and Audio.resolveSong or require("src.core.Gen3SongIds").resolve + local ids = { resolve("MUS_LEVEL_UP"), resolve("MUS_OBTAIN_ITEM") } + if native then + Audio._pack, Audio._cache, Audio._ready = pack, cache, true + Audio._fanfareSd, Audio._fanfareSrc = {}, {} + else + local audio = dofile(cacheRoot .. "/" .. version .. "/data/generated/audio.lua") + audio.gen3.file, audio.programPrefix = "owned-imported-native-bgm.bin", nil + love.filesystem.write(audio.gen3.file, image) + f.data.audio, f.data.constants.gen3Fanfares = audio, constants + require("src.core.M4ASynth").invalidate() + end + h.clear() + h.start({ { "playbgm", h.ids[case == "deferred-default" and 2 or 1], 0 }, { "end" } }) + h.update() + local script + if case == "native-fade" then + script = { { "fadeoutbgm", 4 }, { "setvar", 0x4001, 1 }, { "end" } } + else + script = { { "playfanfare", ids[1] } } + if case == "deferred-default" then + script[#script + 1] = { "fadedefaultbgm" } + end + if case == "stop-bgm" then + script[#script + 1] = { "playbgm", 0, 0 } + end + script[#script + 1] = { "waitfanfare" } + script[#script + 1] = { "setvar", 0x4001, 1 } + script[#script + 1] = { "end" } + end + local runner = h.start(script) + if case ~= "native-fade" then + assert(h.soundData.staticInputs > 0, "actual-import cold fanfare must admit userdata PCM") + end + local frames, completed = {}, nil + for tick = 0, 85 do + h.tick(tick) + if tick > 0 then + if native then + runner:tick() + else + runner:update() + end + end + local state = h.state() + state.fanfare = h.fanfareState(ids) + state.tick, state.running, state.value = tick, runner:isRunning(), h.value(runner) + if state.value == 1 then + completed = completed or tick + end + frames[#frames + 1] = state + h.update() + end + assert(completed ~= nil and not runner:isRunning(), "imported native script must complete") + rows[#rows + 1] = { + case = case, + completed = completed, + frames = frames, + trace = h.trace(), + coldFanfareSource = case ~= "native-fade", + } + h.close() +end +f.close() +love.filesystem.remove("owned-imported-native-bgm.bin") +print("IMPORTED_NATIVE_BGM_STREAMS " .. require("src.link.Json").encode(rows)) diff --git a/tools/enemy-action-contract-check/imported-native-cry-render.lua b/tools/enemy-action-contract-check/imported-native-cry-render.lua new file mode 100644 index 000000000..ba2b0b3c8 --- /dev/null +++ b/tools/enemy-action-contract-check/imported-native-cry-render.lua @@ -0,0 +1,74 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local backend, version, cache, romPath = assert(arg[1]), assert(arg[2]), assert(arg[3]), assert(arg[4]) +local native = backend == "gen1" +local audio = assert(dofile(cache .. "/" .. version .. "/data/generated/audio.lua")) +local file = assert(io.open(cache .. "/" .. version .. "/" .. audio.gen3.file, "rb")) +local image = file:read("*a") +file:close() +local romFile = assert(io.open(romPath, "rb")) +local rom = romFile:read("*a") +romFile:close() +assert( + image == rom:sub(audio.gen3.base + 1, audio.gen3.base + #image), + "actual imported M4A bytes match source ROM" +) +local function uint(offset, bytes) + local value = 0 + for i = 0, bytes - 1 do + value = value + assert(rom:byte(offset + i + 1)) * 256 ^ i + end + return value +end +local mounted = "owned-real-cry-" .. version .. ".bin" +love.filesystem.write(mounted, image) +audio.gen3.file, audio.programPrefix = mounted, nil +local rows = {} +for _, index in ipairs({ 0, 3, 273, 387 }) do + local wave = uint(audio.gen3Cries.table + index * 12 + 4, 4) - 0x08000000 + local kind, count, frequency = uint(wave, 2), uint(wave + 12, 4), uint(wave + 4, 4) + assert(kind == 0 or kind == 1) + local bytes = kind == 1 and math.ceil(count / 64) * 33 or count + local encoded = rom:sub(wave + 17, wave + 16 + bytes) + for _, mode in ipairs({ 0, 1, 4, 6, 9, 12 }) do + local sound, info, controls + if native then + local sample = require("src.core.game3.m4a_sample") + local pcm = kind == 1 and sample.decodeGfdpcm(encoded, count) or encoded + controls = sample.cryParams(mode, nil, version == "emerald" and { [6] = { volume = 70 } } or nil) + sound, info = sample.renderCry(pcm, sample.waveRate(frequency), controls, { outRate = 44100 }) + else + sound, info, controls = require("src.core.M4ASynth").renderNativeCry( + { audio = audio }, + index, + { version = version, mode = mode } + ) + end + assert(sound and info and controls) + local samples = {} + for i = 0, sound:getSampleCount() - 1 do + local values = {} + for c = 1, sound:getChannelCount() do + values[c] = sound:getSample(i, c) + end + samples[#samples + 1] = values + end + rows[#rows + 1] = { + version = version, + index = index, + mode = mode, + wave = wave, + inputType = kind, + inputSamples = count, + frequency = frequency, + controls = controls, + info = info, + rate = sound:getSampleRate(), + channels = sound:getChannelCount(), + depth = sound:getBitDepth(), + samples = samples, + } + end +end +love.filesystem.remove(mounted) +print("REAL_NATIVE_CRY_PCM " .. require("src.link.Json").encode(rows)) diff --git a/tools/enemy-action-contract-check/imported-native-cry-smoke.lua b/tools/enemy-action-contract-check/imported-native-cry-smoke.lua new file mode 100644 index 000000000..ade487866 --- /dev/null +++ b/tools/enemy-action-contract-check/imported-native-cry-smoke.lua @@ -0,0 +1,144 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local hostRoot, backend, version, cache, romPath = unpack(arg) +local native = backend == "gen1" +local function read(path) + local f = assert(io.open(path, "rb")) + local bytes = f:read("*a") + f:close() + return bytes +end +local rom = read(romPath) +local function uint(at, size) + local n = 0 + for i = 0, size - 1 do + n = n + assert(rom:byte(at + i + 1)) * 256 ^ i + end + return n +end +local original = assert(dofile(cache .. "/" .. version .. "/data/generated/audio.lua")) +local constants = assert(dofile(cache .. "/" .. version .. "/data/generated/constants.lua")) +local image = read(cache .. "/" .. version .. "/" .. original.gen3.file) +assert( + image == rom:sub(original.gen3.base + 1, original.gen3.base + #image), + "imported M4A image equals owned ROM slice" +) +local map = version == "firered" and 0x2539D4 or 0x31F61C +local cryIds, decoded, metadata, sampleMeta = {}, {}, {}, {} +for n, species in ipairs({ 4, 277, 411 }) do + local index = species <= 251 and species - 1 or uint(map + (species - 277) * 2, 2) + local wave = uint(original.gen3Cries.table + index * 12 + 4, 4) - 0x08000000 + local kind, count, freq = uint(wave, 2), uint(wave + 12, 4), uint(wave + 4, 4) + assert(kind == 0 or kind == 1) + local size = kind == 1 and math.ceil(count / 64) * 33 or count + local encoded = rom:sub(wave + 17, wave + 16 + size) + if native then + decoded[n] = kind == 1 and require("src.core.game3.m4a_sample").decodeGfdpcm(encoded, count) or encoded + assert(#decoded[n] == count) + end + cryIds[species], sampleMeta[index] = index, { sampleId = n } + metadata[n] = { offset = native and #(table.concat(decoded)) - #decoded[n] or 0, size = count, freq = freq } +end +local f = dofile(hostRoot .. "/tests/enemy_actions/assist.lua").fixture(hostRoot, backend, version) +local runtime, oldSession +if native then + runtime = require("src.core.game3.runtime") + oldSession, runtime.session = runtime.session, { version = version } +end +local rows = {} +for _, species in ipairs({ 4, 277, 411 }) do + for _, mode in ipairs({ 0, 6, 9 }) do + local hardware = dofile(hostRoot .. "/tests/native_cry/fixture.lua").install(native, f) + if native then + require("src.core.game3.audio")._pack = { + index = { cries = sampleMeta, cryIds = cryIds }, + samplesBin = table.concat(decoded), + samples = metadata, + } + else + local audio = assert(dofile(cache .. "/" .. version .. "/data/generated/audio.lua")) + local mounted = "owned-imported-cry-image.bin" + love.filesystem.write(mounted, image) + audio.gen3.file, audio.programPrefix = mounted, nil + f.data.audio, f.data.constants = audio, constants + require("src.core.M4ASynth").invalidate() + end + local commands = {} + local function add(name, a, b) + commands[#commands + 1] = native and { op = name, a, b } or { name, a, b } + end + add("setvar", 0x8004, species) + add("setvar", 0x8005, mode) + add("playmoncry", 0x8004, 0x8005) + add("waitmoncry") + add("setvar", 0x4001, 1) + add("end") + hardware.tick(0) + local runner, value + if native then + runner = require("src.core.game3.scripting.vm").new({ scripts = { OWNED_IMPORTED_CRY = commands } }) + assert(runner:start("OWNED_IMPORTED_CRY")) + value = function() + return require("src.core.game3.scripting.flags").getVar(runner.store, runner.ctx, 0x4001) + end + else + local Cmd = require("src.script.Gen3Commands") + Cmd.setVar(f.game.save, 0x4001, 0) + local lowered = assert(require("src.script.Gen3ScriptVM").compile({ + map_scripts = { + source = "RomExtractorGen3", + scripts = { OWNED_IMPORTED_CRY = commands }, + }, + }, "OWNED_IMPORTED_CRY")) + runner = require("src.script.ScriptRunner").new(f.game, {}) + runner:run(lowered, { source = { modId = "portable_held_item", strict = true } }) + value = function() + return Cmd.getVar(f.game.save, 0x4001) + end + end + local frames, completed = {}, nil + for tick = 0, 220 do + hardware.tick(tick) + if tick > 0 then + if native then + runner:tick() + else + runner:update() + end + end + local state = hardware.snapshot() + state.tick, state.running, state.value, state.sources = + tick, runner:isRunning(), value(), hardware.active() + frames[#frames + 1] = state + if value() == 1 then + completed = completed or tick + end + hardware.update() + end + assert( + completed and completed > 0 and not runner:isRunning(), + "actual imported cry finishes before script return" + ) + local plays = hardware.trace() + assert( + #plays == 1 and plays[1].samples and #plays[1].samples > 64, + "actual imported cry renders audible PCM" + ) + rows[#rows + 1] = { + version = version, + species = species, + mode = mode, + index = cryIds[species], + completed = completed, + frames = frames, + plays = plays, + } + hardware.close() + love.filesystem.remove("owned-imported-cry-image.bin") + end +end +if native then + runtime.session = oldSession +end +f.close() +print("IMPORTED_NATIVE_CRY_COMMANDS " .. require("src.link.Json").encode(rows)) diff --git a/tools/enemy-action-contract-check/imported-native-fanfare-metadata.lua b/tools/enemy-action-contract-check/imported-native-fanfare-metadata.lua new file mode 100644 index 000000000..b416cd7a5 --- /dev/null +++ b/tools/enemy-action-contract-check/imported-native-fanfare-metadata.lua @@ -0,0 +1,61 @@ +-- Actual import stage and native fanfare timers, checked against private ROM inputs. +-- Arguments: existing cache root, owned FireRed ROM path, owned Emerald ROM path. +package.path = "./?.lua;./?/init.lua;" .. package.path +require("tests.modkit") +local V = require("src.core.GameVersion") +local Extractor = require("src.import.RomExtractorGen3") +local Json = require("src.link.Json") +local function read(path) + local file = assert(io.open(path, "rb")) + local bytes = file:read("*a") + file:close() + return bytes +end +local rows = {} +for _, version in ipairs({ "firered", "emerald" }) do + V.set(version) + local rom = read(assert(arg[version == "firered" and 2 or 3])) + local base = arg[1] .. "/" .. version + local audio = assert(dofile(base .. "/data/generated/audio.lua")) + local constants = assert(dofile(base .. "/data/generated/constants.lua")) + local image = read(base .. "/" .. audio.gen3.file) + assert(image == rom:sub(audio.gen3.base + 1, audio.gen3.base + #image)) + audio.gen3.file, audio.programPrefix = "owned-imported-bgm-image.bin", nil + love.filesystem.write(audio.gen3.file, image) + local manifest = Json.decode(read("tools/rom_manifest_" .. version .. ".json")) + local extractor = Extractor.new(rom, { id = version }, manifest) + local present = constants.gen3Fanfares ~= nil + constants.gen3Fanfares = nil + extractor._songs, extractor._audio, extractor._constants = audio.songs, audio, constants + extractor:extractFanfares() + local metadata = constants.gen3Fanfares + local row = { version = version, cachedMetadata = present, freshMetadata = metadata ~= nil } + if metadata then + row.count, row.table, row.agreed = metadata.count, metadata.table, metadata.agreed + local expected, checked = {}, {} + for i = 0, metadata.count - 1 do + expected[extractor.rom:u16(metadata.table + i * 4)] = extractor.rom:u16(metadata.table + i * 4 + 2) + end + local Fanfare = require("src.core.Gen3Fanfare") + for name, record in pairs(metadata.fanfares) do + assert(expected[record.index] == record.frames, "fresh metadata differs from the ROM timer table") + expected[record.index] = nil + local info = assert(Fanfare.info({ audio = audio, constants = constants }, record.index)) + assert(info.id == record.index and info.frames == record.frames) + assert(info.player == (tonumber(audio.songs[record.song].player) or 2)) + assert(Fanfare.isSong({ audio = audio, constants = constants }, record.index)) + checked[#checked + 1] = { name = name, id = info.id, frames = info.frames, player = info.player } + end + assert(next(expected) == nil and #checked == metadata.count) + table.sort(checked, function(a, b) + return a.id < b.id + end) + row.checked = checked + end + assert(metadata, "current importer did not produce native fanfare metadata") + rows[#rows + 1] = row + print("IMPORTED_NATIVE_FANFARE_STAGE " .. Json.encode(row)) + love.filesystem.remove(audio.gen3.file) + require("src.core.M4ASynth").invalidate() +end +print("IMPORTED_NATIVE_FANFARE_STAGES " .. Json.encode(rows)) diff --git a/tools/enemy-action-contract-check/imported-native-sound-smoke.lua b/tools/enemy-action-contract-check/imported-native-sound-smoke.lua new file mode 100644 index 000000000..4d772473e --- /dev/null +++ b/tools/enemy-action-contract-check/imported-native-sound-smoke.lua @@ -0,0 +1,103 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local Sound, V = require("src.core.Sound"), require("src.core.GameVersion") +local cache = assert(arg[1], "pass existing cache root") +local oldAudio, sources = love.audio, {} +local Source = {} +Source.__index = Source +function Source:play() + self.playing = true +end +function Source:stop() + self.playing = false +end +function Source:isPlaying() + return self.playing or false +end +function Source:setVolume(v) + self.volume = v +end +function Source:setLooping() end +love.audio = { + newSource = function(input) + assert(type(input) == "table" and input.getSampleCount, "real M4A PCM must reach source construction") + assert( + input:getSampleCount() >= 64 + and input:getSampleRate() == 44100 + and (input:getChannelCount() == 1 or input:getChannelCount() == 2) + ) + local s = setmetatable({ input = input }, Source) + sources[#sources + 1] = s + return s + end, +} +local rows = {} +for _, version in ipairs({ "firered", "emerald" }) do + V.set(version) + Sound.invalidate() + require("src.core.M4ASynth").invalidate() + sources = {} + local audio = assert(dofile(cache .. "/" .. version .. "/data/generated/audio.lua")) + local path = cache .. "/" .. version .. "/" .. audio.gen3.file + local f = assert(io.open(path, "rb")) + local blob = f:read("*a") + f:close() + assert(#blob == audio.gen3.size) + local mounted = "owned-real-audio-" .. version .. ".bin" + love.filesystem.write(mounted, blob) + audio.gen3.file, audio.programPrefix = mounted, nil + local data = { audio = audio } + local seen = {} + local function active() + local out = {} + for _, s in ipairs(sources) do + if s.playing then + out[#out + 1] = assert(seen[s]) + end + end + table.sort(out) + return out + end + local function play(id, pan, mono) + local src = assert(Sound.playId(data, id, { pan = pan, mono = mono }), version .. " native source " .. id) + seen[src] = id + local song = assert(audio.songs[("SONG_%03X"):format(id)]) + rows[#rows + 1] = { + version = version, + id = id, + player = song.player, + active = active(), + samples = src.input:getSampleCount(), + channels = src.input:getChannelCount(), + } + return src + end + local first = play(141, -64, false) + local replacement = play(164, 63, false) + assert(not first:isPlaying() and replacement:isPlaying(), "actual same-player source must be replaced") + local ui = play(5, 0, false) + assert(replacement:isPlaying() and ui:isPlaying(), "actual UI player stays independent") + local changed = play(141, 63, true) + assert( + not replacement:isPlaying() and ui:isPlaying() and changed:isPlaying(), + "actual pan/mono variant retains other player" + ) + local specialId = version == "firered" and 74 or 81 + local special = play(specialId, 0, false) + assert( + ui:isPlaying() and changed:isPlaying() and special:isPlaying(), + "actual special player remains independent" + ) + Sound.stopSe(data, 5) + assert( + not ui:isPlaying() and changed:isPlaying() and special:isPlaying(), + "actual stop-by-ID keeps other players" + ) + Sound.stopSe(data) + assert(#active() == 0 and not Sound.isSePlaying(data), "actual native stop-all releases every source") + Sound.invalidate() + require("src.core.M4ASynth").invalidate() + love.filesystem.remove(mounted) +end +love.audio = oldAudio +print("IMPORTED_NATIVE_SOUND_SMOKE " .. require("src.link.Json").encode(rows)) diff --git a/tools/enemy-action-contract-check/imported-native-world-sound-wait-smoke.lua b/tools/enemy-action-contract-check/imported-native-world-sound-wait-smoke.lua new file mode 100644 index 000000000..fad66dfed --- /dev/null +++ b/tools/enemy-action-contract-check/imported-native-world-sound-wait-smoke.lua @@ -0,0 +1,152 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local root, cache = assert(arg[1]), assert(arg[2]) +local Sound, Synth = require("src.core.Sound"), require("src.core.M4ASynth") +local oldAudio = love.audio +local results = {} +for _, version in ipairs({ "firered", "emerald" }) do + local f = dofile(root .. "/tests/enemy_actions/assist.lua").fixture(root, "gen2", version) + Sound.invalidate() + Synth.invalidate() + local audio = assert(dofile(cache .. "/" .. version .. "/data/generated/audio.lua")) + assert(audio.sfx.SONG_08D.m4a == 141, "actual imported native sound label") + local file = assert(io.open(cache .. "/" .. version .. "/" .. audio.gen3.file, "rb")) + local bytes = file:read("*a") + file:close() + assert(#bytes == audio.gen3.size) + local mount = "owned-real-field-audio-" .. version .. ".bin" + love.filesystem.write(mount, bytes) + audio.gen3.file, audio.programPrefix = mount, nil + f.data.audio = audio + local sources = {} + local Source = {} + Source.__index = Source + function Source:play() + self.playing, self.elapsed = true, 0 + end + function Source:stop() + self.playing = false + end + function Source:isPlaying() + return self.playing or false + end + function Source:setVolume(v) + self.volume = v + end + function Source:setLooping() end + function Source:advance(dt) + if self.playing then + self.elapsed = self.elapsed + dt + if self.elapsed >= self.duration then + self.playing = false + end + end + end + love.audio = { + newSource = function(input) + assert(type(input) == "table" and input.getSampleCount, "actual native PCM reaches the speaker") + local source = setmetatable({ + duration = input:getSampleCount() / input:getSampleRate(), + samples = input:getSampleCount(), + rate = input:getSampleRate(), + channels = input:getChannelCount(), + }, Source) + assert(source.samples >= 64 and source.rate == 44100) + sources[#sources + 1] = source + return source + end, + } + local Game, Fixed = require("src.core.Game"), require("src.core.FixedStep") + local stack = require("src.core.StateStack") + stack:init() + local game = setmetatable( + { data = f.data, save = f.game.save, stack = stack, input = require("src.core.Input"), speedOverride = 1 }, + { __index = Game } + ) + game.input:init() + local ow = setmetatable( + { npcs = { {}, {} }, player = { facing = "up" } }, + { __index = require("src.world.OverworldController") } + ) + local runner = require("src.script.ScriptRunner").new(game, ow) + local commands = assert(require("src.script.Gen3ScriptVM").compile({ + map_scripts = { + source = "RomExtractorGen3", + scripts = { + OWNED_IMPORTED_SOUND = { + { "lockall" }, + { "playse", 141 }, + { "waitse" }, + { "setvar", 0x4001, 1 }, + { "releaseall" }, + { "end" }, + }, + }, + }, + }, "OWNED_IMPORTED_SOUND")) + local steps, firstDone, frames, sounds = 0, nil, {}, {} + f.e.api.events:on("sound.played", function(event) + sounds[#sounds + 1] = event + end) + stack:push({ + update = function() + if steps == 0 then + runner:run( + commands, + { source = { modId = "portable_held_item", mapId = "OWNED_IMPORTED_MAP", strict = true } } + ) + else + runner:update() + end + local advanced = require("src.script.Gen3Commands").getVar(game.save, 0x4001) == 1 + firstDone = advanced and (firstDone or steps) or firstDone + frames[#frames + 1] = { + tick = steps, + advanced = advanced, + running = runner:isRunning(), + fieldLocked = ow.gen3Locked or false, + npcFrozen = ow.npcs[1].gen3ScriptFrozen or false, + sourcePlaying = sources[1] and sources[1]:isPlaying() or false, + } + steps = steps + 1 + end, + }) + Fixed:init(function(dt) + game:step(dt) + end) + for _ = 1, 100 do + for _, source in ipairs(sources) do + source:advance(1 / 60) + end + game:update(1 / 60) + end + assert(#sources == 1 and #sounds == 1, "the actual imported script sound plays exactly once") + local expected = math.ceil(sources[1].duration * 60) + assert( + firstDone == expected, + "actual native wait must end with decoded PCM: got " .. tostring(firstDone) .. ", want " .. expected + ) + for _, frame in ipairs(frames) do + if frame.tick < expected then + assert(frame.running and frame.fieldLocked and frame.npcFrozen and not frame.advanced) + else + assert(not frame.running and not frame.fieldLocked and not frame.npcFrozen and frame.advanced) + end + end + assert(runner.ctx.g3Locked == nil and not Sound.isSePlaying(f.data)) + results[#results + 1] = { + version = version, + completed = firstDone, + samples = sources[1].samples, + rate = sources[1].rate, + channels = sources[1].channels, + frames = frames, + sounds = sounds, + } + Sound.invalidate() + Synth.invalidate() + love.filesystem.remove(mount) + f.close() +end +love.audio = oldAudio +print("IMPORTED_NATIVE_WORLD_WAIT_SMOKE " .. require("src.link.Json").encode(results)) diff --git a/tools/enemy-action-contract-check/imported-nature-power-smoke.lua b/tools/enemy-action-contract-check/imported-nature-power-smoke.lua new file mode 100644 index 000000000..e94a44841 --- /dev/null +++ b/tools/enemy-action-contract-check/imported-nature-power-smoke.lua @@ -0,0 +1,176 @@ +-- Existing local imports, controlled battlers and actual public menu submissions. +-- No normal save or generated asset is written; no graphical acceptance is claimed. +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local cache = assert(arg[1], "pass the existing local Gen2Recomp cache root") +local chained = arg[2] == "--sleep-talk" +local children = { [0] = 78, 75, 89, 56, 57, 61, 157, 247, 129, 129 } +local rows = {} +for _, version in ipairs({ "firered", "emerald" }) do + for _, authored in ipairs({ false, true }) do + for terrain = 0, 9 do + for _, itemNumber in ipairs({ 186, 219 }) do + local entry = [=[return function(mod) + local choice + mod.hooks:wrap("battle.enemy_action",function(nextAction,...) + if choice then return choice(nextAction,...)end + return nextAction(...) + end) + mod.exports.choose=function(fn)choice=fn end + mod.exports.api=mod + end]=] + if authored then + entry = entry:gsub( + " local choice", + [=[ assert(mod.content.moves:register("PORTABLE_NATURE",{id="PORTABLE_NATURE",name="PORTABLE_NATURE",type="NORMAL",power=0,accuracy=100,pp=10,effect="NATURE_POWER"})) + local choice]=], + 1 + ) + end + local function configure(data, _, ids) + for _, name in ipairs({ "pokemon", "moves", "items", "constants", "type_chart" }) do + data[name] = assert(dofile(cache .. "/" .. version .. "/data/generated/" .. name .. ".lua")) + end + for name, n in pairs({ TACKLE = 33, STRUGGLE = 165, SCRATCH = 10, MIMIC = 102, FLY = 19 }) do + ids[name] = assert(data.constants.moveOrder[n]) + end + require("src.pokemon.Stats").setNatures(data.constants.natures) + require("src.pokemon.Growth").setTables(data.constants.experienceTables) + end + local f = + require("tests.enemy_actions.fixture").new(".", "gen2", version, 3, "unified", configure, entry) + f.game.save.options.ruleset = "gen3_emerald" + T.link.prepare(f.data) + local b = f.fresh("TACKLE").battle + b:syncSides() + b.terrain = terrain + assert(b.ruleset.baseDamageStyle == "gba") + local function id(n) + return assert(f.data.constants.moveOrder[n]) + end + local parent = authored and "PORTABLE_NATURE" or id(267) + local publicParent = assert(f.e.api.content.moves:get(parent)) + assert(publicParent.effect == "NATURE_POWER") + local child = assert(f.e.api.content.moves:get(id(children[terrain]))) + local selected = chained and id(214) or parent + local publicSelected = assert(f.e.api.content.moves:get(selected)) + local learned = { { id = selected, pp = 10 }, { id = chained and parent or id(33), pp = 10 } } + b.player.mon.moves, b.player.curMoves = learned, learned + b.enemy.mon.moves = { { id = id(33), pp = 10 } } + b.enemy.curMoves = b.enemy.mon.moves + b.player.mon.nickname, b.enemy.mon.nickname = "PLAYER", "FOE" + for _, a in ipairs({ b.player, b.enemy }) do + a.mon.stats.hp = 4000 + a.mon.hp = 4000 + a.abilityOverride = false + end + b.player.mon.hp = 2000 + if chained then + b.player.mon.status, b.player.sleepTurns = "SLP", 4 + end + for key, def in pairs(f.data.items) do + if type(def) == "table" and tonumber(def.index) == itemNumber then + b.player.mon.item = key + break + end + end + assert(b.player.mon.item) + b.player.curStats.speed, b.enemy.curStats.speed = 200, 100 + local used, damage, draws = {}, {}, {} + local beforeFoe + f.e.api.events:on("battle.move_used", function(e) + if e.user.mon.nickname == "PLAYER" then + used[#used + 1] = { + move = e.move.id, + called = e.isCalled or false, + pp = e.user.mon.moves[1].pp, + target = e.target.mon.nickname, + } + else + beforeFoe = b.player.mon.hp + end + end) + f.e.api.events:on("battle.damage_dealt", function(e) + if e.user.mon.nickname == "PLAYER" then + damage[#damage + 1] = e.damage + end + end) + f.e.api.hooks:wrap("battle.crit", function() + return false + end) + b.rng = function(lo, hi) + local value = lo == 1 and hi == 100 and 1 or hi + draws[#draws + 1] = { lo, hi, value } + return value + end + f.e.choose(function() + return { move = "TACKLE" } + end) + local game = b.game + game.input = require("src.core.Input") + game.stack.states = { b } + b.phase, b.queue, b.current, b.frame, b.menuIndex, b.moveIndex = "moveSelect", {}, nil, 0, 1, 1 + local snapshot = assert(f.e.api.battle:snapshot()) + local ok, why = + f.e.api.battle:submit({ kind = "move", slot = 1, id = 1, revision = snapshot.revision }) + assert(ok, why) + for _ = 1, 18000 do + if b.phase == "menu" and b.turnCount == 1 then + break + end + require("src.core.Input").pressed = { a = true } + game.stack:update(1 / 60) + end + assert(b.phase == "menu" and b.turnCount == 1, "actual submitted imported turn must finish") + assert(#used == (chained and 3 or 2) and used[1].move == publicSelected.id and not used[1].called) + local final = used[#used] + assert( + final.move == child.id and final.called, + "actual terrain child uses advertised public identity" + ) + assert(used[1].pp == 10, "selected announcement precedes its PP command") + if chained then + assert( + used[2].move == publicParent.id and used[2].called and used[2].pp == 9 and final.pp == 9, + "native called Nature Power borrows no PP from the outer Sleep Talk" + ) + else + assert(final.pp == 10, "Nature Power child announces before its selected PP command") + end + assert(b.player.lastMove == parent, "inner native caller preserves resulting move history") + if itemNumber == 186 then + assert( + b.player.choicedMove == require("src.battle.Gen3ChoiceBand").identity(b, parent), + "native Choice Band owns the innermost caller" + ) + end + assert(learned[1].pp == 9 and learned[2].pp == 10, "only selected caller slot spends PP") + local healing = itemNumber == 219 and damage[1] and math.max(1, math.floor(damage[1] / 8)) or 0 + assert(beforeFoe == 2000 + healing, "native move-end healing completes before the next actor") + if terrain == 0 then + assert(b.enemy.mon.status == "PAR" and #damage == 0, "actual Stun Spore status handler executes") + else + assert(#damage > 0 and damage[1] > 0, "actual imported damaging child executes") + end + assert( + #f.result.errors == 0 and #f.result.loader.errors == 0, + "public callbacks and registration remain error-free" + ) + rows[#rows + 1] = { + version = version, + authored = authored, + chained = chained, + terrain = terrain, + item = itemNumber, + used = used, + damage = damage, + draws = draws, + pp = learned[1].pp, + beforeFoe = beforeFoe, + } + f.close() + end + end + end +end +print("IMPORTED_NATURE_POWER_SMOKE " .. require("src.link.Json").encode(rows)) diff --git a/tools/enemy-action-contract-check/imported-post-hit-smoke.lua b/tools/enemy-action-contract-check/imported-post-hit-smoke.lua new file mode 100644 index 000000000..9b1d39084 --- /dev/null +++ b/tools/enemy-action-contract-check/imported-post-hit-smoke.lua @@ -0,0 +1,117 @@ +-- CPU-only scenarios using existing local imported definitions and controlled battlers. +-- No cache, normal save, mod assets or graphics are written. +package.path = "./?.lua;./?/init.lua;" .. package.path +local root = assert(arg[1]) +local rows = {} +for _, version in ipairs({ "firered", "emerald" }) do + for _, actor in ipairs({ 0, 1 }) do + for _, number in ipairs({ 33, 3 }) do + for _, item in ipairs({ 187, 219 }) do + for _, override in ipairs({ false, true }) do + local f = require("tests.enemy_actions.fixture").new( + ".", + "gen2", + version, + 3, + "unified", + function(data, _, ids) + for _, name in ipairs({ "pokemon", "moves", "items", "constants", "type_chart" }) do + data[name] = assert(dofile(root .. "/" .. version .. "/data/generated/" .. name .. ".lua")) + end + for name, n in pairs({ TACKLE = 33, STRUGGLE = 165, SCRATCH = 10, MIMIC = 102, FLY = 19 }) do + ids[name] = assert(data.constants.moveOrder[n]) + end + require("src.pokemon.Stats").setNatures(data.constants.natures) + require("src.pokemon.Growth").setTables(data.constants.experienceTables) + end + ) + f.game.save.options.ruleset = "gen3_emerald" + local b = f.fresh("TACKLE").battle + b:syncSides() + b.ruleset.enemyUnlimitedPP = false + local user = actor == 0 and b.player or b.enemy + local target = actor == 0 and b.enemy or b.player + for i, a in ipairs({ b.player, b.enemy }) do + a.mon.stats.hp = 4000 + a.mon.hp = 4000 + a.mon.nickname = "P" .. (i - 1) + a.mon.item = nil + a.abilityOverride = false + end + user.mon.hp = 2000 + local key + for id, def in pairs(f.data.items) do + if type(def) == "table" and def.index == item then + key = id + break + end + end + user.mon.item = assert(key) + local move = assert(f.data.constants.moveOrder[number]) + local def = assert(f.data.moves[move]) + assert(math.floor(def.flags / 32) % 2 == 1) + user.mon.moves = { { id = move, pp = 10 } } + user.curMoves = user.mon.moves + local reads, draws, damage = {}, {}, {} + f.e.api.hooks:wrap("held_item.trigger", function(nextRead, c) + reads[#reads + 1] = { + holder = c.mon.nickname, + item = c.item, + trigger = c.trigger, + effect = c.effect, + parameter = c.parameter, + } + if override and c.trigger == "flinch" and c.mon.nickname == user.mon.nickname then + if item == 187 then + return "HELD_FLINCH", 256 + else + return "HELD_SHELL_BELL", 0 + end + end + return nextRead(c) + end) + f.e.api.hooks:wrap("battle.crit", function() + return false + end) + f.e.api.events:on("battle.damage_dealt", function(e) + damage[#damage + 1] = e.damage + end) + b.rng = function(lo, hi) + local v = lo == 0 and hi == 99 and 0 or lo == 1 and hi == 100 and 1 or hi + draws[#draws + 1] = { lo, hi, v } + return v + end + b:executeAction(user, target, user.curMoves[1]) + assert(#damage > 0 and damage[1] > 0) + if item == 219 then + assert( + user.mon.hp == 2000 + math.max(1, math.floor(damage[1] / (override and 1 or 8))), + "first-damage Shell Bell share" + ) + else + assert( + (target.flinched or false) == (override or actor == 0), + "native canonical player parameter" + ) + end + assert(user.curMoves[1].pp == 9 and b._gbaMoveFrame == nil) + assert(#f.result.loader.errors == 0, table.concat(f.result.loader.errors, "\n")) + assert(#f.result.errors == 0, "no swallowed public callback errors") + rows[#rows + 1] = { + version = version, + actor = actor, + move = number, + item = item, + override = override, + hp = user.mon.hp, + damage = damage, + reads = reads, + draws = draws, + } + f.close() + end + end + end + end +end +print("IMPORTED_POST_HIT_SMOKE " .. require("src.link.Json").encode(rows)) diff --git a/tools/enemy-action-contract-check/imported-sleep-talk-smoke.lua b/tools/enemy-action-contract-check/imported-sleep-talk-smoke.lua new file mode 100644 index 000000000..def653df2 --- /dev/null +++ b/tools/enemy-action-contract-check/imported-sleep-talk-smoke.lua @@ -0,0 +1,168 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local cacheRoot = assert(arg[1], "pass the local Gen2Recomp cache root") +local choiceBand = arg[2] == "--choice-band" +local rows = {} +for _, version in ipairs({ "firered", "emerald" }) do + for _, mode in ipairs({ "repeat", "zero_pp", "empty", "forbidden", "early_wake", "awake" }) do + local function configure(data, _, ids) + local root = cacheRoot .. "/" .. version .. "/data/generated/" + for _, name in ipairs({ "pokemon", "moves", "items", "constants", "type_chart" }) do + data[name] = assert(dofile(root .. name .. ".lua")) + end + for name, n in pairs({ TACKLE = 33, STRUGGLE = 165, SCRATCH = 10, MIMIC = 102, FLY = 19 }) do + ids[name] = assert(data.constants.moveOrder[n]) + end + require("src.pokemon.Stats").setNatures(data.constants.natures) + require("src.pokemon.Growth").setTables(data.constants.experienceTables) + end + local f = require("tests.enemy_actions.fixture").new(".", "gen2", version, 3, "unified", configure) + T.link.prepare(f.data) + f.game.save.options.ruleset = "gen3_emerald" + local b = f.fresh("TACKLE", { enemy = true }).battle + assert(b.ruleset.baseDamageStyle == "gba", "imported scenario requires the actual GBA ruleset") + b:syncSides() + local function id(n) + return assert(f.data.constants.moveOrder[n]) + end + local caller = id(214) + assert(f.data.moves[caller].gen3Effect == 97 and f.data.moves[caller].power == 0) + local nums = { 214, 33, 10 } + if mode == "empty" then + nums = { 214, 118, 119, 274 } + end + if mode == "forbidden" then + nums = { 214, 264, 253, 19 } + end + local moves = {} + for _, n in ipairs(nums) do + moves[#moves + 1] = { id = id(n), pp = f.data.moves[id(n)].pp } + end + b.player.mon.moves, b.player.curMoves = moves, moves + b.enemy.mon.moves = { { id = id(33), pp = f.data.moves[id(33)].pp } } + b.enemy.curMoves = b.enemy.mon.moves + b.player.mon.nickname, b.enemy.mon.nickname = "PLAYER", "FOE" + if choiceBand then + local item + for key, def in pairs(f.data.items) do + if type(def) == "table" and tonumber(def.index) == 186 then + item = key + break + end + end + b.player.mon.item = assert(item, "imported physical Choice Band must exist") + end + b.player.mon.status, b.player.sleepTurns = "SLP", 4 + if mode == "awake" then + b.player.mon.status, b.player.sleepTurns = nil, nil + end + if mode == "early_wake" then + b.player.abilityOverride, b.player.sleepTurns = "EARLY_BIRD", 2 + end + if mode == "zero_pp" then + for i = 2, #moves do + moves[i].pp = 0 + end + end + b.player.curStats.speed, b.enemy.curStats.speed = 200, 100 + local used, draws = {}, {} + local off = f.e.api.events:on("battle.move_used", function(e) + if e.user.mon.nickname == "PLAYER" then + used[#used + 1] = { move = e.move.id, target = e.target.mon.nickname, called = e.isCalled or false } + end + end) + f.e.api.hooks:wrap("battle.crit", function() + return false + end) + b.rng = function(lo, hi) + local v = lo == 1 and 1 or hi + draws[#draws + 1] = { lo = lo, hi = hi, value = v } + return v + end + f.e.choose(function() + return { move = "TACKLE" } + end) + local game = b.game + game.input = require("src.core.Input") + game.stack.states = { b } + b.phase, b.queue, b.current, b.frame, b.menuIndex, b.moveIndex = "moveSelect", {}, nil, 0, 1, 1 + local serial = 0 + local function submit(action) + serial = serial + 1 + action.id, action.revision = serial, assert(f.e.api.battle:snapshot()).revision + local ok, why = f.e.api.battle:submit(action) + assert(ok, why) + end + local turns = mode == "repeat" and 4 or 1 + for turn = 1, turns do + if choiceBand and turn == 4 then + b.player.mon.item = nil + end + if turn > 1 then + submit({ kind = "menu", choice = "fight" }) + end + submit({ kind = "move", slot = turn == 4 and 2 or 1 }) + for _ = 1, 18000 do + if b.phase == "menu" and b.turnCount == turn then + break + end + require("src.core.Input").pressed = { a = true } + game.stack:update(1 / 60) + end + assert(b.phase == "menu" and b.turnCount == turn, "public imported turn must finish") + end + local children = 0 + assert(#f.result.loader.errors == 0, table.concat(f.result.loader.errors, "\n")) + for _, e in ipairs(used) do + if e.move == "SLEEP_TALK" then + assert(e.target == "FOE", "native DEPENDS caller uses its resolved foe target") + end + if e.called then + children = children + 1 + assert(e.target == "FOE" and e.move == "TACKLE") + end + end + assert( + children == (mode == "repeat" and 3 or mode == "zero_pp" and 1 or 0), + "wrong native called execution" + ) + assert(moves[1].pp == f.data.moves[caller].pp - (mode == "repeat" and 3 or 1), "only caller PP is spent") + if mode == "repeat" then + assert(b.player.mon.status == nil, "wake allows the independently selected ordinary move") + assert( + moves[2].pp == f.data.moves[id(33)].pp - 1, + "called Tackle PP stays intact until independently selected" + ) + end + if mode == "zero_pp" then + assert(moves[2].pp == 0 and moves[3].pp == 0) + end + if mode == "early_wake" then + assert(b.player.mon.status == nil and b.player.sleepTurns == nil) + end + assert(b._gbaMoveFrame == nil, "nested dispatcher restores its frame") + if choiceBand then + assert( + b.player.choicedMove == ((mode == "repeat" or mode == "zero_pp") and 214 or nil), + "actual imported caller binds only after its completed move-end command" + ) + end + rows[#rows + 1] = { + version = version, + mode = mode, + passed = true, + turns = turns, + used = used, + draws = draws, + callerPP = moves[1].pp, + childPP = moves[2].pp, + status = b.player.mon.status, + sleep = b.player.sleepTurns, + choiceBand = choiceBand or nil, + choice = b.player.choicedMove, + } + off() + f.close() + end +end +print("IMPORTED_SLEEP_TALK_SMOKE " .. require("src.link.Json").encode(rows)) diff --git a/tools/enemy-action-contract-check/imported-substitute-art.lua b/tools/enemy-action-contract-check/imported-substitute-art.lua new file mode 100644 index 000000000..3556022b0 --- /dev/null +++ b/tools/enemy-action-contract-check/imported-substitute-art.lua @@ -0,0 +1,37 @@ +-- CPU-only targeted import into an explicit QA directory; normal caches stay untouched. +package.path = "./?.lua;./?/init.lua;" .. package.path +require("tests.modkit") +local manifestPath, romPath, destination = assert(arg[1]), assert(arg[2]), assert(arg[3]) +local function read(path) + local file = assert(io.open(path, "rb")) + local value = file:read("*a") + file:close() + return value +end +local Json = require("src.link.Json") +local manifest = assert(Json.decode(read(manifestPath))) +local bytes = read(romPath) +assert(bytes:sub(0xAD, 0xB0) == manifest.romHeader.gameCode, "wrong cartridge header") +love.image = { newImageData = dofile("tests/substitute_art/fixture.lua").image } +local extractor = require("src.import.RomExtractorGen3").new(bytes, manifest.version, manifest) +local images, written = {}, {} +extractor.saveImage = function(_, image, path) + images[path] = image +end +extractor.write = function(_, name, value) + written[name] = value +end +extractor:extractSubstituteArt() +local record = + assert(written.constants and written.constants.gen3SubstituteArt, "native stage must import art") +local paths = {} +for _, side in ipairs({ "front", "back" }) do + local image = assert(images["battle/substitute_" .. side .. ".png"]) + assert(image:getWidth() == 64 and image:getHeight() == 64) + local target = destination .. "/" .. manifest.version .. "-" .. side .. ".rgba" + local file = assert(io.open(target, "wb")) + file:write(image:rgba8()) + file:close() + paths[side] = target +end +print("SUBSTITUTE_ART " .. Json.encode({ version = manifest.version, art = record, raw = paths })) diff --git a/tools/enemy-action-contract-check/imported-triple-kick-smoke.lua b/tools/enemy-action-contract-check/imported-triple-kick-smoke.lua new file mode 100644 index 000000000..583468708 --- /dev/null +++ b/tools/enemy-action-contract-check/imported-triple-kick-smoke.lua @@ -0,0 +1,111 @@ +-- Optional CPU-only check against locally imported cartridges; distributes no assets. +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local cacheRoot = assert(arg[1], "pass the Gen2Recomp import-cache root") +local rows = {} +for _, version in ipairs({ "firered", "emerald" }) do + for _, mode in ipairs({ "ordinary", "miss1", "miss2", "miss3", "sub_break", "endure" }) do + local function configure(data, _, ids) + local root = cacheRoot .. "/" .. version .. "/data/generated/" + for _, name in ipairs({ "pokemon", "moves", "items", "constants", "type_chart" }) do + data[name] = assert(dofile(root .. name .. ".lua")) + end + for name, number in pairs({ TACKLE = 33, STRUGGLE = 165, SCRATCH = 10, MIMIC = 102, FLY = 19 }) do + ids[name] = assert(data.constants.moveOrder[number]) + end + require("src.pokemon.Stats").setNatures(data.constants.natures) + require("src.pokemon.Growth").setTables(data.constants.experienceTables) + end + local f = require("tests.enemy_actions.fixture").new(".", "gen2", version, 3, "unified", configure) + T.link.prepare(f.data) + local b = f.fresh("TACKLE", { enemy = true }).battle + b:syncSides() + local id = assert(f.data.constants.moveOrder[167]) + local def = f.data.moves[id] + assert(def.index == 167 and def.gen3Effect == 104 and def.power == 10 and def.accuracy == 90) + b.player.mon.moves = { { id = id, pp = def.pp } } + b.player.curMoves = b.player.mon.moves + local reply = mode == "endure" and "ENDURE" or "TACKLE" + local replyId = mode == "endure" and assert(f.data.constants.moveOrder[203]) or f.ids.TACKLE + b.enemy.mon.moves = { { id = replyId, pp = f.data.moves[replyId].pp } } + b.enemy.curMoves = b.enemy.mon.moves + b.player.curStats.speed, b.enemy.curStats.speed = 200, 100 + if mode == "sub_break" then + b.enemy.substituteHP = 1 + end + if mode == "endure" then + b.enemy.mon.hp = 1 + b.player.curStats.speed, b.enemy.curStats.speed = 100, 200 + end + local checks, hits, draws = 0, {}, {} + local missAt = tonumber(mode:match("miss(%d)")) + local tripleAccuracy = false + f.e.api.hooks:wrap("battle.accuracy", function(nextAccuracy, ctx) + tripleAccuracy = ctx.move.id == "TRIPLE_KICK" + if tripleAccuracy then + checks = checks + 1 + end + local out = nextAccuracy(ctx) + tripleAccuracy = false + return out + end) + f.e.api.hooks:wrap("battle.crit", function() + return false + end) + local off = f.e.api.events:on("battle.damage_dealt", function(e) + if e.move.id == "TRIPLE_KICK" then + hits[#hits + 1] = { damage = e.damage, substitute = e.substitute or false } + end + end) + b.rng = function(lo, hi) + local value = lo == 1 and hi == 100 and 1 or hi + if tripleAccuracy and checks == missAt then + value = 100 + end + draws[#draws + 1] = { lo = lo, hi = hi, value = value } + return value + end + f.e.choose(function() + return { move = reply } + end) + local game = b.game + game.input = require("src.core.Input") + game.stack.states = { b } + b.phase, b.queue, b.current, b.frame, b.menuIndex, b.moveIndex = "moveSelect", {}, nil, 0, 1, 1 + local snapshot = assert(f.e.api.battle:snapshot()) + assert(f.e.api.battle:submit({ id = 1, revision = snapshot.revision, kind = "move", slot = 1 })) + for _ = 1, 18000 do + if b.phase == "menu" and b.turnCount == 1 then + break + end + require("src.core.Input").pressed = { a = true } + game.stack:update(1 / 60) + end + assert(b.phase == "menu" and b.turnCount == 1, "public imported turn must finish") + local expected = missAt and missAt - 1 or mode == "endure" and 1 or 3 + assert(#hits == expected, mode .. ": wrong reached kick count") + assert(checks == (missAt or expected), mode .. ": wrong per-hit accuracy count") + assert(b.player.curMoves[1].pp == def.pp - 1, "one learned PP per submitted attack") + if mode == "ordinary" then + assert(hits[1].damage < hits[2].damage and hits[2].damage < hits[3].damage) + end + if mode == "sub_break" then + assert(hits[1].substitute and not hits[2].substitute and b.enemy.mon.hp < 400) + end + if mode == "endure" then + assert(b.enemy.mon.hp == 1) + end + rows[#rows + 1] = { + version = version, + mode = mode, + passed = true, + checks = checks, + hits = hits, + pp = b.player.curMoves[1].pp, + draws = draws, + } + off() + f.close() + end +end +print("IMPORTED_TRIPLE_KICK_SMOKE " .. require("src.link.Json").encode(rows)) diff --git a/tools/enemy-action-contract-check/native-audio-session-smoke.lua b/tools/enemy-action-contract-check/native-audio-session-smoke.lua new file mode 100644 index 000000000..599a0a38c --- /dev/null +++ b/tools/enemy-action-contract-check/native-audio-session-smoke.lua @@ -0,0 +1,77 @@ +-- Owned audio-service teardown/re-entry, not the complete game/mod lifecycle. +-- Arguments: host checkout, backend, version. Native endSession and host reload +-- stay unchanged. Complete old queue inventories intentionally retain allocation differences. +package.path = "./?.lua;./?/init.lua;" .. package.path +require("tests.modkit") +local root, backend, version = unpack(arg) +local native = backend == "gen1" +local f = dofile(root .. "/tests/enemy_actions/assist.lua").fixture(root, backend, version) +local rows = {} +local function step(runner) + if native then + runner:tick() + else + runner:update() + end +end +local function snapshot(h, ids, runner) + local state = h.state().native + state.physicalSong = nil + return { state = state, fanfare = h.fanfareState(ids), running = runner:isRunning() } +end +for _, shared in ipairs({ false, true }) do + for _, fanfare in ipairs({ false, true }) do + local h = dofile(root .. "/tests/native_bgm/streams.lua").install(root, backend, version, f) + if shared then + h.override("file") + end + local ids = h.fanfares() + local script = { { "playbgm", h.ids[2], 1 }, { "savebgm", h.ids[3] } } + if fanfare then + script[#script + 1] = { "playfanfare", ids[1] } + script[#script + 1] = { "waitfanfare" } + end + script[#script + 1] = { "end" } + local runner = h.start(script) + h.update() + local frames = { snapshot(h, ids, runner) } + if native then + require("src.core.game3.audio").endSession() + else + require("src.core.Music").reload() + end + step(runner) + frames[#frames + 1] = snapshot(h, ids, runner) + assert(not runner:isRunning(), "old VM must finish at the ended audio session") + h.close() + local fresh = dofile(root .. "/tests/native_bgm/streams.lua").install(root, backend, version, f) + local freshIds = fresh.fanfares() + local nextRunner = fresh.start({ { "playfanfare", freshIds[2] }, { "waitfanfare" }, { "end" } }) + local function observeFresh(tick) + local frame = snapshot(fresh, freshIds, nextRunner) + frame.tick, frame.oldSources, frame.oldQueues = tick, {}, {} + for _, voice in ipairs(h.state().native.voices) do + frame.oldSources[#frame.oldSources + 1] = voice.playing + assert(voice.playing == false, "old static Source cannot resume in the new session") + end + for _, voice in ipairs(h.state().queues) do + frame.oldQueues[#frame.oldQueues + 1] = voice.playing + assert(voice.playing == false, "old native queue cannot resume in the new session") + end + return frame + end + frames[#frames + 1] = observeFresh(0) + assert(not runner:isRunning(), "fresh fanfare cannot revive old VM") + for tick = 1, 6 do + fresh.update() + step(nextRunner) + frames[#frames + 1] = observeFresh(tick) + end + assert(not nextRunner:isRunning(), "fresh session wait must complete") + rows[#rows + 1] = + { case = (shared and "shared" or "native") .. (fanfare and "-fanfare" or "-bgm"), frames = frames } + fresh.close() + end +end +f.close() +print("NATIVE_AUDIO_SESSION_OBSERVATIONS " .. require("src.link.Json").encode(rows)) diff --git a/tools/enemy-action-contract-check/native-bgm-stream-smoke.lua b/tools/enemy-action-contract-check/native-bgm-stream-smoke.lua new file mode 100644 index 000000000..6c2110ba4 --- /dev/null +++ b/tools/enemy-action-contract-check/native-bgm-stream-smoke.lua @@ -0,0 +1,49 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +require("tests.modkit") +local root, backend, version = unpack(arg) +local f = dofile(root .. "/tests/enemy_actions/assist.lua").fixture(root, backend, version) +local cases = { + { name = "startup", ticks = 3 }, + { name = "finite-carrier", ticks = 3, finite = true }, + { name = "outgoing", ticks = 80, command = "fadeoutbgm" }, + { name = "incoming", ticks = 80, command = "fadeinbgm" }, + { name = "restart-mid-out", ticks = 80, command = "fadeoutbgm", replaceAt = 3 }, + { name = "default", ticks = 144, default = true }, + { name = "file-override-default", ticks = 144, default = true, override = "file" }, + { name = "chip-override-default", ticks = 144, default = true, override = "chip" }, + { name = "option-outgoing", ticks = 80, command = "fadeoutbgm", options = true }, +} +local rows = {} +for _, case in ipairs(cases) do + local h = dofile(root .. "/tests/native_bgm/streams.lua").install(root, backend, version, f, case) + if case.override then + h.override(case.override) + end + local script = {} + if case.default then + script[#script + 1] = { "playbgm", h.ids[2], 1 } + script[#script + 1] = { "fadedefaultbgm" } + elseif case.command then + script[#script + 1] = { case.command, 4 } + end + script[#script + 1] = { "end" } + local runner = h.start(script) + local frames = {} + for tick = 0, case.ticks do + h.tick(tick) + if tick == case.replaceAt then + runner = h.start({ { "playbgm", h.ids[1], 0 }, { "end" } }) + end + if case.options and (tick == 2 or tick == 4 or tick == 6) then + h.options(tick == 2 and 4 or tick == 4 and 0 or 7) + end + local state = h.state() + state.tick, state.running = tick, runner:isRunning() + frames[#frames + 1] = state + h.update() + end + rows[#rows + 1] = { case = case.name, frames = frames, trace = h.trace() } + h.close() +end +f.close() +print("NATIVE_BGM_STREAMS " .. require("src.link.Json").encode(rows)) diff --git a/tools/enemy-action-contract-check/native-cry-bgm-smoke.lua b/tools/enemy-action-contract-check/native-cry-bgm-smoke.lua new file mode 100644 index 000000000..52b457e8c --- /dev/null +++ b/tools/enemy-action-contract-check/native-cry-bgm-smoke.lua @@ -0,0 +1,103 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +require("tests.modkit") +local root, backend, version = unpack(arg) +local native = backend == "gen1" +local f = dofile(root .. "/tests/enemy_actions/assist.lua").fixture(root, backend, version) +local runtime, oldSession +if native then + runtime = require("src.core.game3.runtime") + oldSession, runtime.session = runtime.session, { version = version } +end +local rows = {} +for _, case in ipairs({ + { name = "default" }, + { name = "mode-one", options = { mode = 1 } }, + { name = "mode-six", options = { mode = 6 } }, + { name = "no-duck", options = { noDuck = true } }, + { name = "release", options = { release = true } }, + { name = "stop", stop = 3 }, + { name = "overlap", overlap = true }, + { name = "option-change", change = true }, +}) do + local h = dofile(root .. "/tests/native_cry/fixture.lua").install(native, f) + local audio = native and require("src.core.game3.audio") + local Music = require("src.core.Music") + local bgm, originalBuilder = nil, love.audio.newSource + love.audio.newSource = function(input, ...) + local made = originalBuilder(input, ...) + if input == "owned-cry-bgm.wav" then + bgm = made + end + return made + end + local oldSource, oldLevel, oldFadeIn, oldFadeOut + if native then + oldSource, oldLevel, oldFadeIn, oldFadeOut = + audio._bgmSource, audio._bgmVolume, audio._fadeIn, audio._fadeOut + audio._fadeIn, audio._fadeOut = nil, nil + bgm = love.audio.newSource("owned-cry-bgm.wav", "stream") + audio._bgmSource = bgm + audio.applyEngineOptions({ musicVol = 7, sfxVol = 7 }) + bgm:play() + else + Music.stop() + Music.setVolumeLevel(7) + f.data.audio.songs = { OWNED_CRY_BGM = { file = "owned-cry-bgm.wav" } } + Music.play(f.data, "OWNED_CRY_BGM", true) + assert(Music.current() == "OWNED_CRY_BGM") + end + assert(bgm and bgm:isPlaying()) + local initial, scale, frames = bgm.volume, 1, {} + for tick = 0, 30 do + h.tick(tick) + if tick == 0 then + h.play(4, case.options or {}) + end + if tick == case.stop then + h.stop() + end + if tick == 2 and case.overlap then + h.play(277, { mode = 6 }) + end + if case.change and (tick == 2 or tick == 4 or tick == 6) then + local level = tick == 2 and 4 or tick == 4 and 0 or 7 + scale = level / 7 + if native then + audio.applyEngineOptions({ musicVol = level, sfxVol = 7 }) + else + Music.setVolumeLevel(level) + end + end + local state = h.snapshot() + -- Native sample-stream and shared music have different baseline gains. + -- Retain both raw values and assert only this admitted attenuation factor. + assert( + math.abs(bgm.volume - initial * scale * state.mix.level) < 1e-12, + "actual BGM source must receive the current native cry attenuation" + ) + frames[#frames + 1] = { + tick = tick, + rawVolume = bgm.volume, + baseGain = initial, + optionScale = scale, + level = state.mix.level, + playing = bgm:isPlaying(), + } + h.update() + end + rows[#rows + 1] = { case = case.name, frames = frames } + love.audio.newSource = originalBuilder + if native then + bgm:stop() + audio._bgmSource, audio._bgmVolume, audio._fadeIn, audio._fadeOut = + oldSource, oldLevel, oldFadeIn, oldFadeOut + else + Music.stop() + end + h.close() +end +if native then + runtime.session = oldSession +end +f.close() +print("NATIVE_CRY_BGM_SOURCES " .. require("src.link.Json").encode(rows)) diff --git a/tools/enemy-action-contract-check/native-fanfare-stream-smoke.lua b/tools/enemy-action-contract-check/native-fanfare-stream-smoke.lua new file mode 100644 index 000000000..c9bb953dc --- /dev/null +++ b/tools/enemy-action-contract-check/native-fanfare-stream-smoke.lua @@ -0,0 +1,73 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +require("tests.modkit") +local root, backend, version = unpack(arg) +local native = backend == "gen1" +local f = dofile(root .. "/tests/enemy_actions/assist.lua").fixture(root, backend, version) +local rows = {} +for _, case in ipairs({ "native-wait", "deferred-default", "replacement", "repeat", "new-bgm", "stop-bgm" }) do + local h = dofile(root .. "/tests/native_bgm/streams.lua").install(root, backend, version, f) + local ids = h.fanfares() + assert(h.soundData.staticInputs == 0, "fanfare setup must not prewarm a static source") + if native then + assert(next(require("src.core.game3.audio")._fanfareSd) == nil, "native SoundData cache starts cold") + end + h.clear() + h.update() + local script = {} + if case == "deferred-default" then + script[#script + 1] = { "playbgm", h.ids[2], 0 } + end + script[#script + 1] = { "playfanfare", ids[1] } + if case == "deferred-default" then + script[#script + 1] = { "fadedefaultbgm" } + end + script[#script + 1] = { "waitfanfare" } + script[#script + 1] = { "setvar", 0x4001, 1 } + script[#script + 1] = { "end" } + local runner = h.start(script) + assert(h.soundData.staticInputs > 0, "public cold fanfare must create a source from userdata PCM") + local frames, completed = {}, nil + for tick = 0, 12 do + h.tick(tick) + if tick == 2 then + local action + if case == "replacement" then + action = { "playfanfare", ids[2] } + elseif case == "repeat" then + action = { "playfanfare", ids[1] } + elseif case == "new-bgm" then + action = { "playbgm", h.ids[2], 0 } + elseif case == "stop-bgm" then + action = { "playbgm", 0, 0 } + end + if action then + h.start({ action, { "end" } }) + end + end + if tick > 0 then + if native then + runner:tick() + else + runner:update() + end + end + local state = h.state() + state.fanfare = h.fanfareState(ids) + state.tick, state.running, state.value = tick, runner:isRunning(), h.value(runner) + if state.value == 1 then + completed = completed or tick + end + frames[#frames + 1] = state + h.update() + end + rows[#rows + 1] = { + case = case, + completed = completed, + frames = frames, + trace = h.trace(), + coldFanfareSource = true, + } + h.close() +end +f.close() +print("NATIVE_FANFARE_STREAMS " .. require("src.link.Json").encode(rows)) diff --git a/tools/enemy-action-contract-check/native-game-audio-session-smoke.lua b/tools/enemy-action-contract-check/native-game-audio-session-smoke.lua new file mode 100644 index 000000000..48c65cd4e --- /dev/null +++ b/tools/enemy-action-contract-check/native-game-audio-session-smoke.lua @@ -0,0 +1,193 @@ +-- Development probe: real game/mounted-session teardown with owned initialized +-- game state. No Game:load, ROM boot, GPU, device, or normal filesystem writes. +package.path = "./?.lua;./?/init.lua;" .. package.path +require("tests.modkit") +local root, backend, version, variant = unpack(arg) +local native = backend == "gen1" +local shared = variant == "shared" +-- CPU-only OS service for the actual launcher constructor. All filesystem +-- operations remain on tests.modkit's in-memory LOVE filesystem. +love.system = love.system + or { + getOS = function() + return "Linux" + end, + getProcessorCount = function() + return 1 + end, + } +local f = dofile(root .. "/tests/enemy_actions/assist.lua").fixture(root, backend, version) +local h = dofile(root .. "/tests/native_bgm/streams.lua").install(root, backend, version, f) +if shared then + h.override("file") +end +local ids, calls = h.fanfares(), {} +for _, name in ipairs({ "music.started", "music.stopped" }) do + f.e.api.events:on(name, function(event) + calls[#calls + 1] = { name = name, song = event.song } + end) +end +require("src.mods.Runtime").emit("music.started", { song = "OWNED_BEFORE_EXIT" }) +assert(#calls == 1, "old public listener is live before teardown") +local runner = h.start({ + { "playbgm", h.ids[2], 1 }, + { "savebgm", h.ids[3] }, + { "playfanfare", ids[1] }, + { "waitfanfare" }, + { "end" }, +}) +h.update() +assert(runner:isRunning(), "old fanfare VM must be waiting before real session exit") +local teardown = {} +if native then + local Game = dofile("src/core/Game3.lua") + local game = Game.new() + game.data, game.mods, game.session = f.data, f.result.loader, require("src.core.game3.runtime").session + local lifecycle = require("src.core.SessionLifecycle") + lifecycle.endGameSession(game) + assert( + game.data == nil and game.mods == nil and game.session == nil, + "actual Game3:reset must complete, including its final holder release" + ) + teardown.gameReset = true + lifecycle.endMountedSession(version) + teardown.mountedSessionEnded = true +else + -- Load the actual main chunk without love.load: it registers the real + -- launcher return closure. Supply its current Game through the closure's + -- existing upvalue; teardown and launcher construction remain production. + dofile("main.lua") + local shell = require("src.core.HostShell") + local hook + for index = 1, 20 do + local name, value = debug.getupvalue(shell.toLauncher, index) + if not name then + break + end + if name == "launcherHook" then + hook = value + break + end + end + assert(type(hook) == "function", "actual main launcher hook registered") + local found = false + for index = 1, 30 do + local name = debug.getupvalue(hook, index) + if not name then + break + end + if name == "Game" then + debug.setupvalue(hook, index, f.game) + found = true + end + end + assert(found, "main owns the initialized old Game") + local Game = dofile("src/core/Game.lua") + Game.returnToLauncher(f.game) + for index = 1, 30 do + local name, value = debug.getupvalue(hook, index) + if not name then + break + end + if name == "Game" then + assert(value == nil, "main releases old Game") + end + if name == "Importer" then + assert( + type(value) == "table" and value.launcher == true, + "actual production launcher construction completed" + ) + teardown.launcherConstructed = true + end + end + assert(teardown.launcherConstructed) + teardown.gameReleased = true +end +if native then + runner:tick() +else + runner:update() +end +assert(not runner:isRunning(), "real session exit finishes the old audio VM") +local Runtime = require("src.mods.Runtime") +local oldCalls = #calls +Runtime.emit("music.started", { song = "OWNED_AFTER_EXIT" }) +Runtime.emit("music.stopped", { song = "OWNED_AFTER_EXIT" }) +assert(#calls == oldCalls, "old public mod listeners must be detached before fresh entry") +local function oldInventory() + local state = h.state() + for _, voice in ipairs(state.native.voices) do + assert(voice.playing == false, "old static Source remains stopped") + end + for _, queue in ipairs(state.queues) do + assert(queue.playing == false, "old native queue remains stopped") + end + -- h.state().native also reads the process's current controller, which may + -- already belong to the fresh session. Only these hardware lists retain + -- the actual old Source objects across entry. + return { staticSources = state.native.voices, queues = state.queues } +end +local frames = { { phase = "after-exit", old = oldInventory(), controller = h.state().native } } +-- main.bootGame invalidates the asset bus before loading the next Game. +-- Verify this production entry stage before fixture close/install can reset +-- any music state and conceal a saved/map-song leak. +require("src.render.Assets").invalidate() +local entryState = h.state().native +assert( + entryState.savedSong == nil and entryState.mapSong == nil, + "the production cache-entry stage releases previous map/saved music" +) +frames[#frames + 1] = { phase = "before-entry", old = oldInventory(), controller = entryState } +-- Observe source retirement before fixture restoration can stop anything. +-- Fresh entry loads the same public API-3 consumer through a new real Loader. +h.close() +f.close() +local fresh = dofile(root .. "/tests/enemy_actions/assist.lua").fixture(root, backend, version) +local nextAudio = dofile(root .. "/tests/native_bgm/streams.lua").install(root, backend, version, fresh) +local freshCalls = 0 +fresh.e.api.events:on("music.started", function() + freshCalls = freshCalls + 1 +end) +local nextIds = nextAudio.fanfares() +local nextRunner = nextAudio.start({ { "playfanfare", nextIds[2] }, { "waitfanfare" }, { "end" } }) +for tick = 0, 6 do + if tick > 0 then + nextAudio.update() + if native then + nextRunner:tick() + else + nextRunner:update() + end + end + Runtime.emit("music.started", { song = "OWNED_FRESH_ENTRY" }) + assert(freshCalls == tick + 1, "fresh public mod listener is installed and runs exactly once") + assert(#calls == oldCalls, "new mod session cannot revive old listeners") + if native then + runner:tick() + else + runner:update() + end + assert(not runner:isRunning(), "new session cannot revive the old VM") + assert(nextAudio.state().native.savedSong == nil, "previous session's saved song cannot leak") + frames[#frames + 1] = { + phase = "fresh", + tick = tick, + old = oldInventory(), + current = nextAudio.state(), + fanfare = nextAudio.fanfareState(nextIds), + running = nextRunner:isRunning(), + } +end +assert(not nextRunner:isRunning(), "fresh session fanfare wait completes") +nextAudio.close() +fresh.close() +print("NATIVE_GAME_AUDIO_SESSION " .. require("src.link.Json").encode({ + backend = backend, + version = version, + variant = variant, + teardown = teardown, + frames = frames, + oldCallbacks = calls, + oldCallbacksAfterExit = oldCalls, + freshCallbacks = freshCalls, +})) diff --git a/tools/enemy-action-contract-check/native-protocol-audit.lua b/tools/enemy-action-contract-check/native-protocol-audit.lua new file mode 100644 index 000000000..77ebc1935 --- /dev/null +++ b/tools/enemy-action-contract-check/native-protocol-audit.lua @@ -0,0 +1,30 @@ +-- Independently derive the new surface from a clean frozen parent serializer. +-- Argument: frozen parent src/link/Fingerprint.lua; never patch that checkout. +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local V = require("src.core.GameVersion") +local file = assert(io.open(assert(arg[1]), "rb")) +local source = file:read("*a") +file:close() +local parent = assert(loadstring(source, "@frozen-parent-fingerprint"))() +local data, rows = T.fixtures.fresh(), {} +for _, version in ipairs({ "red", "firered", "gold", "emerald", "diamond", "black" }) do + V.set(version) + local before = parent.surface(data, {}) + local after, count = before:gsub("%[native_gba_commands%]19", "[native_gba_commands]20") + assert(count == (V.isGen3() and 1 or 0), "only the frozen GBA revision token may change") + local current = require("src.link.Fingerprint") + assert(current.surface(data, {}) == after, "current serializer differs beyond the derived revision token") + assert( + current.compute(data, {}) == parent.digest(after), + "current digest disagrees with the parent algorithm" + ) + assert(current.nativePartyRevision() == (V.isGen3() and 1 or nil), "party codec revision stays independent") + rows[#rows + 1] = { + version = version, + previous = parent.digest(before), + current = parent.digest(after), + tokenChanges = count, + } +end +print("FROZEN_NATIVE_PROTOCOL_AUDIT " .. require("src.link.Json").encode(rows)) diff --git a/tools/enemy-action-contract-check/native-sound-wait-clock-oracle.lua b/tools/enemy-action-contract-check/native-sound-wait-clock-oracle.lua new file mode 100644 index 000000000..be8b292e7 --- /dev/null +++ b/tools/enemy-action-contract-check/native-sound-wait-clock-oracle.lua @@ -0,0 +1,79 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local root = assert(arg[1]) +local rows = {} +for _, version in ipairs({ "firered", "emerald" }) do + local f = dofile(root .. "/tests/enemy_actions/assist.lua").fixture(root, "gen1", version) + local runtime = require("src.core.game3.runtime") + local savedSession = runtime.session + runtime.session = { version = version } + assert(require("src.core.game3.profile").forSession(nil).id == version) + local audio = require("src.core.game3.audio") + local savedWait, savedSuspend = audio._waitSe, audio._suspended + audio._suspended = false + local hardware = dofile(root .. "/tests/native_sound/fixture.lua").install(root, true, f) + for _, case in ipairs({ + { name = "logic-only", audioEvery = 0, count = 0, expected = 181 }, + { name = "normal-audio-after-logic", audioEvery = 1, count = 1, expected = 180 }, + { name = "two-audio-ticks-per-logic", audioEvery = 1, count = 2, expected = 90 }, + { name = "one-audio-tick-per-two-logic", audioEvery = 2, count = 1, expected = 181 }, + { name = "latched-silence-before-replacement", audioEvery = 1, count = 1, expected = 2, latch = true }, + }) do + hardware.clear() + audio._waitSe = {} + hardware.tick(0) + audio.playSe(141, { pan = -64 }) + local vm = require("src.core.game3.scripting.vm").new({ + scripts = { OWNED_WAIT = { { op = "waitse" }, { op = "setvar", 0x4001, 1 }, { op = "end" } } }, + }) + assert(vm:start("OWNED_WAIT")) + local frames, completed = {}, nil + for tick = 0, 183 do + hardware.tick(tick) + if tick > 0 then + vm:tick() + end + local advanced = require("src.core.game3.scripting.flags").getVar(vm.store, vm.ctx, 0x4001) == 1 + frames[#frames + 1] = { + tick = tick, + advanced = advanced, + running = vm:isRunning(), + active = hardware.active(), + queries = hardware.queries(), + pendingCallbacks = #(audio._waitSe or {}), + } + if advanced then + completed = tick + break + end + if case.latch and tick == 1 then + audio.stopSe() + end + if case.audioEvery > 0 and tick % case.audioEvery == 0 then + for _ = 1, case.count do + audio.update(1 / 60) + end + end + if case.latch and tick == 1 then + audio.playSe(164, { pan = 63 }) + end + end + assert(completed == case.expected, case.name .. " native completion was " .. tostring(completed)) + if case.latch then + assert(audio.isSePlaying(164), "latched wait releases despite the replacement's active voice") + end + rows[#rows + 1] = { + version = version, + case = case.name, + completed = completed, + frames = frames, + sounds = hardware.trace(), + } + end + hardware.clear() + hardware.close() + audio._waitSe, audio._suspended = savedWait, savedSuspend + runtime.session = savedSession + f.close() +end +print("NATIVE_SOUND_WAIT_CLOCK_ORACLE " .. require("src.link.Json").encode(rows)) diff --git a/tools/enemy-action-contract-check/worker.lua b/tools/enemy-action-contract-check/worker.lua new file mode 100644 index 000000000..6636a2faa --- /dev/null +++ b/tools/enemy-action-contract-check/worker.lua @@ -0,0 +1,158 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local root, backend, version, api, mode = unpack(arg) +local T = require("tests.modkit") +local rows +if mode == "native_bgm_options" then + rows = dofile(root .. "/tests/enemy_actions/native_bgm_options.lua").run(root, backend, version) +elseif mode == "native_bgm_hooks" then + rows = dofile(root .. "/tests/enemy_actions/native_bgm_hooks.lua").run(root, backend, version) +elseif mode == "native_bgm_devices" then + rows = dofile(root .. "/tests/enemy_actions/native_bgm_devices.lua").run(root, backend, version) +elseif mode == "native_fanfare_commands" then + rows = dofile(root .. "/tests/enemy_actions/native_fanfare_commands.lua").run(root, backend, version) +elseif mode == "native_bgm_policy" then + rows = dofile(root .. "/tests/enemy_actions/native_bgm_policy.lua").run(root, backend, version) +elseif mode == "native_bgm_commands" then + rows = dofile(root .. "/tests/enemy_actions/native_bgm_commands.lua").run(root, backend, version) +elseif mode == "native_bgm_symbols" then + rows = dofile(root .. "/tests/enemy_actions/native_bgm_symbols.lua").run(root, backend, version) +elseif mode == "native_cry_lifecycle" then + rows = dofile(root .. "/tests/enemy_actions/native_cry_lifecycle.lua").run(root, backend, version) +elseif mode == "native_cry_commands" then + rows = dofile(root .. "/tests/enemy_actions/native_cry_commands.lua").run(root, backend, version) +elseif mode == "native_cry_pcm" then + rows = dofile(root .. "/tests/enemy_actions/native_cry_pcm.lua").run(root, backend, version) +elseif mode == "native_sound_script_waits" then + rows = dofile(root .. "/tests/enemy_actions/native_sound_script_waits.lua").run(root, backend, version) +elseif mode == "native_sound_lifecycle" then + rows = dofile(root .. "/tests/enemy_actions/native_sound_lifecycle.lua").run(root, backend, version) +elseif mode == "substitute_fade" then + rows = dofile(root .. "/tests/enemy_actions/substitute_fade.lua").run(root, backend, version) +elseif mode == "battle_effect_presentation" then + rows = dofile(root .. "/tests/enemy_actions/battle_effect_presentation.lua").run(root, backend, version) +elseif mode == "native_sound_pan" then + rows = dofile(root .. "/tests/enemy_actions/native_sound_pan.lua").run(root, backend, version) +elseif mode == "substitute_multi_hit" then + rows = + dofile(root .. "/tests/enemy_actions/multi_hit.lua").run(root, backend, version, { substitute = true }) +elseif mode == "substitute_presentation" then + rows = dofile(root .. "/tests/enemy_actions/substitute_presentation.lua").run(root, backend, version) +elseif mode == "mirror_move_content" then + rows = dofile(root .. "/tests/enemy_actions/mirror_move.lua").content(root, backend, version) +elseif mode == "mirror_move_chained" then + rows = dofile(root .. "/tests/enemy_actions/mirror_move.lua").chained(root, backend, version) +elseif mode == "mirror_move" then + rows = dofile(root .. "/tests/enemy_actions/mirror_move.lua").run(root, backend, version) +elseif mode == "assist_held" then + rows = dofile(root .. "/tests/enemy_actions/assist.lua").held(root, backend, version) +elseif mode == "assist_failed_callers" then + rows = dofile(root .. "/tests/enemy_actions/assist.lua").failedCallers(root, backend, version) +elseif mode == "assist_content" then + rows = dofile(root .. "/tests/enemy_actions/assist.lua").content(root, backend, version) +elseif mode == "assist" then + rows = dofile(root .. "/tests/enemy_actions/assist.lua").run(root, backend, version) +elseif mode == "nature_power_chained" then + rows = dofile(root .. "/tests/enemy_actions/nature_power.lua").chained(root, backend, version) +elseif mode == "nature_power_content" then + rows = dofile(root .. "/tests/enemy_actions/nature_power.lua").content(root, backend, version) +elseif mode == "nature_power" then + rows = dofile(root .. "/tests/enemy_actions/nature_power.lua").run(root, backend, version) +elseif mode == "post_hit_items_execution" then + rows = dofile(root .. "/tests/enemy_actions/post_hit_items.lua").execution(root, backend, version) +elseif mode == "post_hit_items_command" then + rows = dofile(root .. "/tests/enemy_actions/post_hit_items.lua").command(root, backend, version) +elseif mode == "move_used_pp" then + rows = dofile(root .. "/tests/enemy_actions/move_used_pp.lua").run(root, backend, version) +elseif mode == "choice_band_call_failure" then + rows = dofile(root .. "/tests/enemy_actions/choice_call_failure.lua").run(root, backend, version) +elseif mode == "choice_band_baton" then + rows = + dofile(root .. "/tests/enemy_actions/baton_pass.lua").run(root, backend, version, { choiceBand = true }) +elseif mode == "choice_band_content" then + rows = dofile(root .. "/tests/enemy_actions/choice_content.lua").run(root, backend, version) +elseif mode == "choice_band_calls" then + rows = + dofile(root .. "/tests/enemy_actions/sleep_talk.lua").run(root, backend, version, { choiceBand = true }) +elseif mode == "choice_band_wild" then + rows = dofile(root .. "/tests/enemy_actions/choice_wild.lua").run(root, backend, version) +elseif mode == "choice_band_planning" then + rows = dofile(root .. "/tests/enemy_actions/choice_planning.lua").run(root, backend, version) +elseif mode == "choice_band_selection" then + rows = dofile(root .. "/tests/enemy_actions/choice_selection.lua").run(root, backend, version) +elseif mode == "choice_band_command" then + rows = dofile(root .. "/tests/enemy_actions/choice_band.lua").run(root, backend, version) +elseif mode == "sleep_talk_content" then + rows = dofile(root .. "/tests/enemy_actions/sleep_talk.lua").content(root, backend, version) +elseif mode == "sleep_talk" then + rows = dofile(root .. "/tests/enemy_actions/sleep_talk.lua").run(root, backend, version) +elseif mode == "triple_kick" then + rows = dofile(root .. "/tests/enemy_actions/triple_kick.lua").run(root, backend, version) +elseif mode == "rage" then + rows = dofile(root .. "/tests/enemy_actions/rage.lua").run(root, backend, version) +elseif mode == "multi_hit" then + rows = dofile(root .. "/tests/enemy_actions/multi_hit.lua").run(root, backend, version) +elseif mode == "disable" then + rows = dofile(root .. "/tests/enemy_actions/disable.lua").run(root, backend, version) +elseif mode == "encore" then + rows = dofile(root .. "/tests/enemy_actions/encore.lua").run(root, backend, version) +elseif mode == "baton_pass" then + rows = dofile(root .. "/tests/enemy_actions/baton_pass.lua").run(root, backend, version) +elseif mode == "lock_on" then + rows = dofile(root .. "/tests/enemy_actions/lock_on.lua").run(root, backend, version) +elseif mode == "party_codec_hooks" then + rows = dofile(root .. "/tests/enemy_actions/party_codec_hooks.lua").run(root, backend, version) +elseif mode == "party_codec" then + rows = dofile(root .. "/tests/enemy_actions/party_codec.lua").run(root, backend, version) +elseif mode == "secondary" or mode == "secondary_execution" then + rows = dofile(root .. "/tests/enemy_actions/secondary.lua").run( + root, + backend, + version, + mode == "secondary_execution" + ) +elseif mode == "accuracy_execution" then + rows = dofile(root .. "/tests/enemy_actions/accuracy.lua").run(root, backend, version, true) +elseif mode == "accuracy" then + rows = dofile(root .. "/tests/enemy_actions/accuracy.lua").run(root, backend, version) +elseif mode == "constrained_party" then + rows = dofile(root .. "/tests/enemy_actions/constrained_party.lua").run(root, backend, version) +elseif mode == "critical_context" then + rows = dofile(root .. "/tests/enemy_actions/critical_kinds.lua").run(root, backend, version) +elseif mode == "beat_up_side_party" then + rows = dofile(root .. "/tests/enemy_actions/beat_up.lua").run( + root, + backend, + version, + false, + { sideParty = true } + ) +elseif mode == "beat_up_adjustment" then + rows = dofile(root .. "/tests/enemy_actions/beat_up.lua").run( + root, + backend, + version, + false, + { adjustment = true } + ) +elseif mode == "beat_up_content" then + rows = dofile(root .. "/tests/enemy_actions/beat_up.lua").run(root, backend, version, true) +elseif mode == "beat_up" then + rows = dofile(root .. "/tests/enemy_actions/beat_up.lua").run(root, backend, version) +elseif mode == "spit_up" then + rows = dofile(root .. "/tests/enemy_actions/spit_up.lua").run(root, backend, version) +elseif mode == "helping_hand" then + rows = dofile(root .. "/tests/enemy_actions/helping_hand.lua").run(root, backend, version) +elseif mode == "follow_me" then + rows = dofile(root .. "/tests/enemy_actions/follow_me.lua").run(root, backend, version) +elseif mode == "targets" then + rows = dofile(root .. "/tests/enemy_actions/targets.lua").run(root, backend, version) +elseif mode == "records" then + rows = dofile(root .. "/tests/enemy_actions/records.lua").run(root, backend, version) +elseif mode == "arguments" then + rows = dofile(root .. "/tests/enemy_actions/arguments.lua").run(root, backend, version) +else + rows = + dofile(root .. "/tests/enemy_actions/cases.lua").run(root, backend, version, tonumber(api), "unified") +end +print("ENEMY_ACTION_OBSERVATIONS " .. require("src.link.Json").encode(rows)) +T.finish("enemy action contract") diff --git a/tools/gba-damage-base-contract-check/check.py b/tools/gba-damage-base-contract-check/check.py new file mode 100644 index 000000000..107efa6c0 --- /dev/null +++ b/tools/gba-damage-base-contract-check/check.py @@ -0,0 +1,56 @@ +#!/usr/bin/env python3 +"""Compare ordinary GBA damage through both actual engine calculators, without ROMs.""" +import argparse +import json +import os +from pathlib import Path +import subprocess + +REFERENCE = "535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91" + + +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", type=Path, required=True) + 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() + source, host = args.gen1.resolve(), args.gen2.resolve() + if git(source, "rev-parse", "HEAD") != REFERENCE or git(source, "status", "--porcelain"): + parser.error("reference must be clean at " + REFERENCE) + runs, differences = [], [] + for version in ["firered", "emerald"]: + observations = [] + for backend, engine in [("gen1", source), ("gen2", host)]: + run = subprocess.run([args.lua, str(host / "tests/gba_damage_base/worker.lua"), str(host), + backend, version], cwd=engine, capture_output=True, text=True, timeout=30) + prefix = "GBA_DAMAGE_BASE " + lines = [line.removeprefix(prefix) for line in run.stdout.splitlines() if line.startswith(prefix)] + if run.returncode or len(lines) != 1: + raise RuntimeError(f"{backend}/{version}: {run.stdout}{run.stderr}") + value = json.loads(lines[0]) + observations.append(value) + runs.append({"backend": backend, "version": version, "observations": value}) + if len(observations[0]) != len(observations[1]): + differences.append({"version": version, "error": "observation count"}) + for index, (left, right) in enumerate(zip(*observations)): + if left != right: + differences.append({"version": version, "row": index, "reference": left, "host": right}) + print("PASS" if observations[0] == observations[1] else "FAIL", version) + assert not git(source, "status", "--porcelain"), "reference changed" + report = {"reference_ref": REFERENCE, "host_ref": git(host, "rev-parse", "HEAD"), + "host_dirty": bool(git(host, "status", "--porcelain")), "runs": runs, + "differences": differences} + if args.report: + args.report.write_text(json.dumps(report, indent=2) + "\n") + if differences: + raise SystemExit(f"{len(differences)} differing ordinary GBA damage observations") + + +if __name__ == "__main__": + main() diff --git a/tools/gba_substitute_art.py b/tools/gba_substitute_art.py new file mode 100644 index 000000000..5dfd6c835 --- /dev/null +++ b/tools/gba_substitute_art.py @@ -0,0 +1,47 @@ +"""Locate the native Substitute loader's literal pool and validate its art.""" +import struct + + +def discover(rom, decompress): + data = rom.d + # The loader copies 0x200 words to OBJ VRAM through DMA3. Its literal + # pool also names the shared mon graphics buffer and all three LZ blobs. + anchor = struct.pack(" +#include +#include +#include +typedef uint8_t u8; +typedef uint16_t u16; +typedef uint32_t u32; +typedef u8 bool8; +#define STAT_SPEED 0 +#define ABILITY_SWIFT_SWIM 33 +#define ABILITY_CHLOROPHYLL 34 +#define B_WEATHER_RAIN 7 +#define B_WEATHER_SUN 0x60 +#define ITEM_ENIGMA_BERRY 175 +#define HOLD_EFFECT_MACHO_BRACE 24 +#define HOLD_EFFECT_QUICK_CLAW 26 +#define STATUS1_PARALYSIS 64 +#define BATTLE_TYPE_LINK 1 +#define BATTLE_TYPE_RECORDED_LINK 2 +#define BATTLE_TYPE_FRONTIER 4 +#define B_SIDE_PLAYER 0 +#define FLAG_BADGE03_GET 1 +#define B_ACTION_USE_MOVE 0 +#define MOVE_NONE 0 +#define MOVE_STRUGGLE 5 +static struct { u16 speed, item, hp, moves[4]; u8 ability, statStages[8]; u32 status1; } gBattleMons[4]; +static struct { u8 holdEffect, holdEffectParam; } gEnigmaBerries[4], items[256]; +static struct { int8_t priority; } gBattleMoves[6]; +static struct { u8 noValidMoves; } gProtectStructs[4]; +static struct { u8 chosenMovePositions[4]; } battleStruct, *gBattleStruct = &battleStruct; +static u8 gChosenActionByBattler[4], gBattlersCount, badge; +static u16 gBattleWeather, gRandomTurnNumber, tie; +static u32 gBattleTypeFlags, oracleSpeed1, oracleSpeed2; +static unsigned draws; +static const u8 gStatStageRatios[13][2] = { + {10,40},{10,35},{10,30},{10,25},{10,20},{10,15},{10,10}, + {15,10},{20,10},{25,10},{30,10},{35,10},{40,10} +}; +static u8 weatherActive(void) { + for (u8 i=0; i={{game_version}} <{{next_major}}.0.0", + "api": {{api}}, + "entry": "main.lua",{{games_line}} + "profile": "{{profile}}",{{engine_range_line}} "category": "GAMEPLAY", "priority": 100, "dependencies": [], @@ -378,7 +377,7 @@ def check_github_field(manifest): "MK001", "error", str(err), "manifest.json")) return findings, notes -MAIN_CONTENT = """-- {{id}}: a content-profile mod (api 2). +MAIN_CONTENT = """-- {{id}}: a content-profile mod (api {{api}}). -- The 10-minute loop: edit, save, F5 in a POKEPORT_DEV=1 game, repeat. return function(mod) -- patch, not override: every field you do not name keeps its base value @@ -391,7 +390,16 @@ def check_github_field(manifest): end """ -MAIN_OVERHAUL = """-- {{id}}: an overhaul-profile mod (api 2). +MAIN_PORTABLE = """-- {{id}}: portable API {{api}} entry for Gen1Recomp and Gen2Recomped. +return function(mod) + if mod.content.moves:has("POUND") then + local ok, reason = mod.content.moves:patch("POUND", { power = 45 }) + if not ok then mod.log:warn("%s", reason) end + end +end +""" + +MAIN_OVERHAUL = """-- {{id}}: an overhaul-profile mod (api {{api}}). return function(mod) mod.options:define({ { key = "difficulty", label = "DIFFICULTY", kind = "choice", @@ -411,7 +419,7 @@ def check_github_field(manifest): end """ -MAIN_TC = """-- {{id}}: a total-conversion-profile mod (api 2). +MAIN_TC = """-- {{id}}: a total-conversion-profile mod (api {{api}}). return function(mod) -- the new game itself: spawn, names, money (field.boot, D11) -- mod.content.field:patch("boot", { @@ -442,7 +450,7 @@ def check_github_field(manifest): README_TEMPLATE = """# {{name}} -A `{{profile}}` mod for the LOVE2D Pokemon Red engine (mod api 2). +A `{{profile}}` mod for {{target_engine}} (mod api {{api}}). ## Layout @@ -489,6 +497,10 @@ def cmd_scaffold(args, repo): "{{game_version}}": engine, "{{next_major}}": str(next_major), "{{extra}}": extra, "{{github_line}}": github_line, + "{{api}}": str(args.api), + "{{games_line}}": '\n "games": ["gen1", "gen2", "gen3"],' if args.api == 3 else "", + "{{engine_range_line}}": "" if args.api == 3 else f'\n "game_version": ">={engine} <{next_major}.0.0",', + "{{target_engine}}": "Gen1Recomp and Gen2Recomped" if args.api == 3 else "the LOVE2D Pokemon Red engine", "{{experimental}}": "true" if getattr(args, "experimental", False) else "false", } @@ -504,6 +516,8 @@ def emit(rel, template): main = {"content": MAIN_CONTENT, "overhaul": MAIN_OVERHAUL, "total_conversion": MAIN_TC}[profile] + if args.api == 3: + main = MAIN_PORTABLE layout_extra = "" if profile == "total_conversion": layout_extra = "- `transforms.lua` - asset transforms over the player's cache\n" @@ -519,7 +533,7 @@ def emit(rel, template): emit("transforms.lua", TRANSFORMS_TEMPLATE) if not args.quiet: - print(f"created {dest} ({profile} profile, api 2)") + print(f"created {dest} ({profile} profile, api {args.api})") print(f"next: python3 tools/modkit.py validate {args.id}") return 0 @@ -1964,7 +1978,7 @@ def main(argv): p.add_argument("id") p.add_argument("--profile", default="content", choices=["content", "overhaul", "total_conversion"]) - p.add_argument("--api", type=int, default=2) + p.add_argument("--api", type=int, choices=(1, 2, 3), default=2) p.add_argument("--github", default="", help="optional owner/repo (enables launcher auto-update)") p.add_argument("--experimental", action="store_true", diff --git a/tools/move-effect-contract/export_reference.lua b/tools/move-effect-contract/export_reference.lua new file mode 100644 index 000000000..47f75c4a6 --- /dev/null +++ b/tools/move-effect-contract/export_reference.lua @@ -0,0 +1,44 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local E = require("src.mods.UnifiedEffects") +local M1 = require("src.battle.g3u.EffectMap1") +local M2 = require("src.battle.g3u.EffectMap2") +local N = require("src.core.game3.battle.effect_ids") +local ids = {} +for _, t in ipairs({ M1.EFFECTS, M1.UNSUPPORTED, M2.EFFECTS, M2.UNSUPPORTED, N }) do + for k in pairs(t) do + ids[k] = true + end +end +local out = { bindings = {}, read3 = {}, read2 = {}, variants = {}, reserved = {} } +for id in pairs(ids) do + out.reserved[id] = true +end +for id, row in pairs(M1.EFFECTS) do + out.variants[id] = { semantic = row.effect, chance = row.chance } +end +for id in pairs(ids) do + local row = {} + for gen = 1, 3 do + local native, chance = E.write(id, gen) + if native then + local semantic = gen == 3 and native + or gen == 2 and M2.EFFECTS[native] and M2.EFFECTS[native].effect + or nil + if gen == 2 and native == "EFFECT_BURN" then + semantic = N.WILL_O_WISP + end + row[tostring(gen)] = + { native = native, chance = type(chance) == "number" and chance or nil, semantic = semantic } + end + end + if next(row) then + out.bindings[id] = row + end +end +for i = 0, 255 do + out.read3[tostring(i)] = E.read(i, 3, {}) +end +for id in pairs(M2.EFFECTS) do + out.read2[id] = E.read(id, 2, {}) +end +print(require("src.link.Json").encode(out)) diff --git a/tools/move-effect-contract/generate.py b/tools/move-effect-contract/generate.py new file mode 100644 index 000000000..cb53f7e34 --- /dev/null +++ b/tools/move-effect-contract/generate.py @@ -0,0 +1,75 @@ +#!/usr/bin/env python3 +"""Project the pinned reference effect contract onto host native handler names.""" +import argparse +import hashlib +import json +from pathlib import Path +import re +import subprocess + +PIN = "79ea62f45fb2ea8f65d081a109431e4eb1bbdb36" +ROOT = Path(__file__).resolve().parents[2] + + +def lua(v): + if v is None: + return "nil" + if v is True: + return "true" + if v is False: + return "false" + if isinstance(v, (int, float)): + return str(v) + if isinstance(v, str): + return json.dumps(v, ensure_ascii=False) + if isinstance(v, list): + return "{" + ",".join(lua(x) for x in v) + "}" + return "{" + ",".join("[" + lua(k) + "]=" + lua(x) for k, x in sorted(v.items())) + "}" + + +def main(): + p = argparse.ArgumentParser(description=__doc__) + p.add_argument("--gen1", type=Path, required=True) + p.add_argument("--lua", required=True) + p.add_argument("--check", action="store_true") + a = p.parse_args() + ref = a.gen1.resolve() + def git(*args): + return subprocess.check_output(["git", "-C", str(ref), *args], text=True).strip() + if git("rev-parse", "HEAD") != PIN or git("status", "--porcelain", "--untracked-files=normal"): + p.error("reference must be clean at " + PIN) + raw = subprocess.check_output([a.lua, str(Path(__file__).with_name("export_reference.lua"))], cwd=ref) + data = json.loads(raw) + src = ROOT / "src/import/RomExtractorGen3.lua" + block = src.read_text().split("local GEN3_MOVE_EFFECTS = {", 1)[1].split("\n}", 1)[0] + data["native3"] = {n: re.findall(r'"([A-Z0-9_]+)"', row) + for n, row in re.findall(r'\[(\d+)\]\s*=\s*\{([^}]+)\}', block)} + data.pop("read2") + files = ["src/mods/UnifiedEffects.lua", "src/battle/g3u/EffectMap1.lua", + "src/battle/g3u/EffectMap2.lua", "src/core/game3/battle/effect_ids.lua"] + data["reference"] = {"commit": PIN, "sha256": { + f: hashlib.sha256((ref / f).read_bytes()).hexdigest() for f in files}} + data["hostMapSha256"] = hashlib.sha256(block.encode()).hexdigest() + lines = ["-- Generated by tools/move-effect-contract/generate.py; do not hand-edit.", "return {"] + for group, rows in sorted(data.items()): + lines.append(" [" + lua(group) + "] = {") + for key, value in sorted(rows.items() if isinstance(rows, dict) else []): + lines.append(" [" + lua(key) + "] = " + lua(value) + ",") + if not isinstance(rows, dict): + lines[-1] = " [" + lua(group) + "] = " + lua(rows) + "," + else: + lines.append(" },") + lines.append("}") + text = "\n".join(lines) + "\n" + target = ROOT / "src/mods/MoveEffectContract.lua" + if a.check: + if target.read_text() != text: + raise SystemExit("effect contract differs; regenerate") + print("PASS pinned effect bindings and host native-name map") + else: + target.write_text(text) + print("Wrote", target) + + +if __name__ == "__main__": + main() diff --git a/tools/pokemon-roll-contract-check/README.md b/tools/pokemon-roll-contract-check/README.md new file mode 100644 index 000000000..5baf53e24 --- /dev/null +++ b/tools/pokemon-roll-contract-check/README.md @@ -0,0 +1,28 @@ +# Shared native Pokemon identity contracts + +Use a clean Gen1Recomp checkout pinned at +`535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91`: + +```sh +python3 tools/pokemon-roll-contract-check/check.py --gen1 /path/to/gen1 --api 3 --format unified --report /tmp/identity.json +``` + +Run API 1/native, API 2/native, API 2/unified and API 3/unified. Each mode +starts 12 isolated workers across Red, Gold, Silver, Crystal, FireRed and +Emerald. Reference API 2 remains native; host API-2/unified is compared with +that native behavior. API 3 is the common unified callback boundary. + +The unchanged public consumer exercises actual Gen-2 Mon.new, identity +refresh and breeding queries and GBA Party.giveMon/Pokemon predicates. +The host uses imported-style species keys. No producer is replaced with a +Runtime.call stub. Cases cover overrides, delegation, nil/truthy/invalid +returns, DV/PID steering and replacement, IV isolation, threshold scope and +fallback timing, failure before/after next, entry rollback, native forced +values, cached birth gender, retirement and query RNG/identity preservation. + +The Gold worker additionally records all 65,536 native ratio/attack/speed +outcomes as an exact ordered string. Both engines' strings must match; +counts/checksum alone do not decide parity. Red verifies unavailable hooks. +The tests are ROM-free. Imported Gold/FireRed/Emerald UI/forced-wild tests +are separate native driver checks; no full playthrough or pixel equality is +implied. diff --git a/tools/pokemon-roll-contract-check/check.py b/tools/pokemon-roll-contract-check/check.py new file mode 100644 index 000000000..adec515f4 --- /dev/null +++ b/tools/pokemon-roll-contract-check/check.py @@ -0,0 +1,52 @@ +#!/usr/bin/env python3 +"""Compare unchanged Gen-2/3 shiny and gender hooks through native identity producers.""" +import argparse +import json +import os +from pathlib import Path +import subprocess + +GEN1 = "535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91" + + +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 = [] + for version in ["red", "gold", "silver", "crystal", "firered", "emerald"]: + observations = [] + for backend, engine in [("gen1", gen1), ("gen2", gen2)]: + run = subprocess.run([a.lua, str(gen2 / "tests/pokemon_rolls/worker.lua"), str(gen2), + backend, version, str(a.api), a.format], cwd=engine, + text=True, capture_output=True, timeout=30) + lines = [line[len("POKEMON_ROLL_CONTRACT "):] for line in run.stdout.splitlines() + if line.startswith("POKEMON_ROLL_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}) + assert observations[0] == observations[1], f"Pokemon identity roll mismatch: {version}" + print(f"PASS {version}: {len(observations[0])} matching native Pokemon identity roll observations") + assert not git(gen1, "status", "--porcelain"), "reference changed" + if a.report: + a.report.write_text(json.dumps({"gen1_ref": GEN1, "gen2_ref": git(gen2, "rev-parse", "HEAD"), + "api": a.api, "format": a.format, "gen2_worktree_dirty": bool(git(gen2, "status", "--porcelain")), + "results": results}, indent=2) + "\n") + + +if __name__ == "__main__": + main() diff --git a/tools/rom_manifest_emerald.json b/tools/rom_manifest_emerald.json index a790baac4..c5a6c914d 100644 --- a/tools/rom_manifest_emerald.json +++ b/tools/rom_manifest_emerald.json @@ -2761,6 +2761,9 @@ "gSpecials": 1948260, "gSpecials table": 1948260, "gSpeciesNames": 3245512, + "gSubstituteDollBackGfx": 12767520, + "gSubstituteDollFrontGfx": 12766944, + "gSubstituteDollPal": 12766908, "gSpeciesToNationalPokedexNum": 3267714, "gStdScripts": 1950368, "gTMHMLearnsets": 3270808, diff --git a/tools/rom_manifest_firered.json b/tools/rom_manifest_firered.json index 5cb450f25..179d8c85e 100644 --- a/tools/rom_manifest_firered.json +++ b/tools/rom_manifest_firered.json @@ -883,6 +883,9 @@ "gSpecials": 1441120, "gSpecials table": 1441120, "gSpeciesNames": 2383584, + "gSubstituteDollBackGfx": 13816564, + "gSubstituteDollFrontGfx": 13815988, + "gSubstituteDollPal": 13815952, "gSpeciesToNationalPokedexNum": 2433006, "gStdScripts": 1442896, "gTMHMLearnsets": 2436040,