From cba47b04e85bb712ea951c1019f5529ceb66ff7f Mon Sep 17 00:00:00 2001 From: bryanthaboi Date: Sat, 29 Aug 2026 09:00:17 -0400 Subject: [PATCH] CLOSES #1928, CLOSES #1929, CLOSES #1930, CLOSES #1931, CLOSES #1933, CLOSES #1934, CLOSES #1935, CLOSES #1936 --- .github/workflows/release.yml | 25 +- scripts/build_android.sh | 61 +++- src/battle/gen2/BattleMusic.lua | 16 +- src/core/Sound.lua | 28 ++ src/core/gen2/Save.lua | 30 +- src/render/TextBox.lua | 3 +- src/script/gen2/Specials.lua | 8 +- src/script/gen2/Vm.lua | 5 +- src/ui/ShaderFXScreen.lua | 32 +- src/ui/gen2/BoxMenu.lua | 21 +- src/ui/gen2/BuenaPassword.lua | 16 +- src/ui/gen2/CenterPcMenu.lua | 4 +- src/ui/gen2/ItemPcMenu.lua | 19 +- src/ui/gen2/PackMenu.lua | 39 ++- src/ui/gen2/PcMenu.lua | 23 +- src/ui/gen2/PrizeMenu.lua | 24 +- src/world/gen2/Map.lua | 11 + src/world/gen2/Permissions.lua | 21 ++ src/world/gen2/World.lua | 33 +- .../caught_ball_palette_bug1896_test.lua | 4 + tests/drivers/crystal_boulder_lip_bug1936.lua | 57 ++++ .../drivers/crystal_roamer_music_bug1928.lua | 89 ++++++ tests/drivers/crystal_seer_bug1929.lua | 84 +++++ tests/drivers/gold_badge_jingle_bug1930.lua | 95 ++++++ tests/drivers/gold_battle_items.lua | 7 + tests/drivers/gold_battle_pack_probe.lua | 41 ++- tests/drivers/gold_bug1556_battle.lua | 4 + tests/drivers/gold_pc_sfx_bug1931.lua | 27 ++ tests/engine/buena_prize_box_bug1935.lua | 73 +++++ tests/engine/evolution_hold_b_bug968_test.lua | 1 + tests/engine/gen2_badge_jingle_bug1930.lua | 163 ++++++++++ ...en2_badge_sfx_after_waitbutton_bug1934.lua | 157 ++++++++++ .../engine/gen2_boulder_side_wall_bug1936.lua | 148 +++++++++ tests/engine/gen2_pc_sfx_bug1931.lua | 288 ++++++++++++++++++ .../gen2_roamer_battle_music_bug1928.lua | 49 +++ .../gen2_seer_caught_location_bug1929.lua | 149 +++++++++ tests/engine/shaderfx_no_bridge_ui.lua | 100 ++++++ tests/gen2_battle_pack_test.lua | 125 +++++++- tests/gen2_battle_test.lua | 17 ++ tests/gen2_crystal_extras_test.lua | 15 + tests/gen2_save_test.lua | 3 +- tests/gen2_world_test.lua | 8 + tests/shaderfx_bridge_packaging_test.lua | 17 +- 43 files changed, 2049 insertions(+), 91 deletions(-) create mode 100644 tests/drivers/crystal_boulder_lip_bug1936.lua create mode 100644 tests/drivers/crystal_roamer_music_bug1928.lua create mode 100644 tests/drivers/crystal_seer_bug1929.lua create mode 100644 tests/drivers/gold_badge_jingle_bug1930.lua create mode 100644 tests/drivers/gold_pc_sfx_bug1931.lua create mode 100644 tests/engine/buena_prize_box_bug1935.lua create mode 100644 tests/engine/gen2_badge_jingle_bug1930.lua create mode 100644 tests/engine/gen2_badge_sfx_after_waitbutton_bug1934.lua create mode 100644 tests/engine/gen2_boulder_side_wall_bug1936.lua create mode 100644 tests/engine/gen2_pc_sfx_bug1931.lua create mode 100644 tests/engine/gen2_roamer_battle_music_bug1928.lua create mode 100644 tests/engine/gen2_seer_caught_location_bug1929.lua create mode 100644 tests/engine/shaderfx_no_bridge_ui.lua diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e6390c1d..c6630c26 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -355,6 +355,11 @@ jobs: runs-on: ubuntu-24.04-arm lib: liblibrashader_bridge.so glibc_triple: aarch64-unknown-linux-gnu.2.17 + # Built here rather than on the packaging runner so the APK's bridge + # does not depend on that machine's local Rust/NDK toolchain (#1932). + - plat: android + runs-on: ubuntu-24.04 + lib: liblibrashader_bridge.so runs-on: ${{ matrix.runs-on }} steps: - uses: actions/checkout@v7 @@ -402,12 +407,26 @@ jobs: cargo zigbuild --release --target "$triple" cp "target/${triple%%.*}/release/$LIB" "$GITHUB_WORKSPACE/$out/$LIB" ;; + android) + rustup target add aarch64-linux-android armv7-linux-androideabi + cargo install cargo-ndk --locked + ndk="${ANDROID_NDK_LATEST_HOME:-${ANDROID_NDK_HOME:-}}" + [ -d "$ndk" ] || { echo "::error::no Android NDK on this runner"; exit 1; } + export ANDROID_NDK_HOME="$ndk" + export CARGO_PROFILE_RELEASE_STRIP=symbols + cargo ndk -t arm64-v8a -t armeabi-v7a \ + -o "$GITHUB_WORKSPACE/$out" build --release + for abi in arm64-v8a armeabi-v7a; do + test -f "$GITHUB_WORKSPACE/$out/$abi/$LIB" \ + || { echo "::error::no Android bridge for $abi"; exit 1; } + done + ;; esac - name: Upload the bridge uses: actions/upload-artifact@v7 with: name: shaderfx-bridge-${{ matrix.plat }} - path: dist/native/${{ matrix.plat }}/${{ matrix.lib }} + path: dist/native/${{ matrix.plat }} if-no-files-found: error retention-days: 1 @@ -450,7 +469,7 @@ jobs: for f in "$d"/*; do mv "$f" "dist/native/$plat/"; done rmdir "$d" done - for plat in mac win-x64 linux-x64 linux-arm64; do + for plat in mac win-x64 linux-x64 linux-arm64 android; do ls "dist/native/$plat" >/dev/null \ || { echo "::error::no ShaderFX bridge staged for $plat"; exit 1; } done @@ -541,6 +560,8 @@ jobs: - name: Build Android env: + SHADERFX_BRIDGE_REQUIRED: "1" + SHADERFX_BRIDGE_ANDROID_DIR: ${{ github.workspace }}/dist/native/android GEN1RECOMP_ANDROID_KEYSTORE: ${{ runner.temp }}/gen1recomp-android-release.keystore GEN1RECOMP_ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_RELEASE_KEYSTORE_PASSWORD }} GEN1RECOMP_ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_RELEASE_KEY_ALIAS }} diff --git a/scripts/build_android.sh b/scripts/build_android.sh index 4cbfd153..dbe4ab4b 100755 --- a/scripts/build_android.sh +++ b/scripts/build_android.sh @@ -490,6 +490,34 @@ shader_bridge_staged_count() { printf '%s' "$count" } +shader_bridge_abi_count() { + local abi count=0 + for abi in $SHADER_BRIDGE_ABIS; do count=$((count + 1)); done + printf '%s' "$count" +} + +shader_bridge_absent() { + if [ "${SHADERFX_BRIDGE_REQUIRED:-}" = "1" ]; then + fail "$SHADER_BRIDGE_LIB: $1 + Stage prebuilt ABIs with SHADERFX_BRIDGE_ANDROID_DIR=/path/to//$SHADER_BRIDGE_LIB + or install the cross toolchain: cargo install cargo-ndk" + fi + warn "$SHADER_BRIDGE_LIB: $1; this build can run converted presets but not CONVERT new ones" +} + +shader_bridge_verify_apk() { + local apk="$1" abi missing="" apk_entries + apk_entries="$(unzip -Z1 "$apk")" + for abi in $SHADER_BRIDGE_ABIS; do + grep -qxF "lib/$abi/$SHADER_BRIDGE_LIB" <<< "$apk_entries" || missing="$missing $abi" + done + [ -z "$missing" ] && return 0 + if [ "${SHADERFX_BRIDGE_REQUIRED:-}" = "1" ]; then + fail "$(basename "$apk") is missing lib//$SHADER_BRIDGE_LIB for:$missing" + fi + warn "$(basename "$apk") has no $SHADER_BRIDGE_LIB for:$missing (SHADER FX cannot CONVERT there)" +} + bundle_shader_bridge_android() { local jni="$ANDROID_DIR/app/src/main/jniLibs" local crate="$ROOT/tools/shaderfx-bridge" @@ -509,19 +537,24 @@ bundle_shader_bridge_android() { warn "SHADERFX_BRIDGE_ANDROID_DIR has no $abi/$SHADER_BRIDGE_LIB" fi done - if [ "$(shader_bridge_staged_count "$jni")" -gt 0 ]; then + local staged + staged="$(shader_bridge_staged_count "$jni")" + if [ "$staged" -gt 0 ]; then + if [ "$staged" -lt "$(shader_bridge_abi_count)" ]; then + shader_bridge_absent "SHADERFX_BRIDGE_ANDROID_DIR staged $staged of $(shader_bridge_abi_count) ABIs" + fi say "bundled $SHADER_BRIDGE_LIB for SHADER FX preset conversion (prebuilt)" return fi fi if [ ! -f "$crate/Cargo.toml" ]; then - warn "$SHADER_BRIDGE_LIB not found: this build can run converted presets but not CONVERT new ones (tools/shaderfx-bridge is missing)" + shader_bridge_absent "tools/shaderfx-bridge is missing" return fi if ! command -v cargo >/dev/null 2>&1 || ! cargo ndk --version >/dev/null 2>&1; then - warn "$SHADER_BRIDGE_LIB not found: this build can run converted presets but not CONVERT new ones (set SHADERFX_BRIDGE_ANDROID_DIR or run 'cargo install cargo-ndk')" + shader_bridge_absent "cargo-ndk is not installed" return fi @@ -530,7 +563,7 @@ bundle_shader_bridge_android() { ndk="${ANDROID_SDK_ROOT:-${ANDROID_HOME:-$HOME/Library/Android/sdk}}/ndk/$NDK_VERSION" fi if [ ! -d "$ndk" ]; then - warn "$SHADER_BRIDGE_LIB not built: NDK $NDK_VERSION not found (set ANDROID_NDK_HOME)" + shader_bridge_absent "NDK $NDK_VERSION not found (set ANDROID_NDK_HOME)" return fi @@ -547,8 +580,11 @@ bundle_shader_bridge_android() { if [ -n "$missing" ]; then warn "$SHADER_BRIDGE_LIB: skipping$missing. Run: rustup target add$missing" fi + if [ -n "$missing" ] && [ "${SHADERFX_BRIDGE_REQUIRED:-}" = "1" ]; then + shader_bridge_absent "no Rust target for$missing" + fi if [ -z "$buildable" ]; then - warn "$SHADER_BRIDGE_LIB not built: this build can run converted presets but not CONVERT new ones (no Android Rust targets installed)" + shader_bridge_absent "no Android Rust targets installed" return fi @@ -566,14 +602,19 @@ bundle_shader_bridge_android() { export CARGO_PROFILE_RELEASE_STRIP="symbols" cargo ndk "${args[@]}" -o "$jni" build --release ); then - warn "$SHADER_BRIDGE_LIB failed to cross-compile: this build can run converted presets but not CONVERT new ones" + shader_bridge_absent "cargo-ndk cross-compile failed" return fi - if [ "$(shader_bridge_staged_count "$jni")" -gt 0 ]; then + local built + built="$(shader_bridge_staged_count "$jni")" + if [ "$built" -gt 0 ]; then + if [ "$built" -lt "$(shader_bridge_abi_count)" ]; then + shader_bridge_absent "cargo-ndk staged $built of $(shader_bridge_abi_count) ABIs" + fi say "bundled $SHADER_BRIDGE_LIB for SHADER FX preset conversion" else - warn "$SHADER_BRIDGE_LIB not found after cargo-ndk: this build can run converted presets but not CONVERT new ones" + shader_bridge_absent "nothing staged after cargo-ndk" fi } @@ -676,6 +717,10 @@ run_gradle() { mkdir -p "$dist_dir" find "$out_dir" -name '*.apk' -exec cp {} "$dist_dir/" \; say "copied to $dist_dir/" + local apk + for apk in "$dist_dir"/*.apk; do + if [ -f "$apk" ]; then shader_bridge_verify_apk "$apk"; fi + done else warn "gradle finished but no APK dir at $out_dir, check gradle logs above" fi diff --git a/src/battle/gen2/BattleMusic.lua b/src/battle/gen2/BattleMusic.lua index 72bf94d2..8be90751 100644 --- a/src/battle/gen2/BattleMusic.lua +++ b/src/battle/gen2/BattleMusic.lua @@ -61,17 +61,21 @@ end -- against the MEMBER id inside the class. BattleMusic.RIVAL2_CHAMPION_MEMBER = "RIVAL2_2_CHIKORITA" --- opts: --- class trainer class id ("FALKNER"), or nil for a wild battle --- member trainer member id ("RIVAL2_1_TOTODILE") -- only RIVAL2 reads it --- members the class's member list, to order `member` against --- landmark the map's landmark index, for RegionCheck --- daytime "MORN" | "DAY" | "NITE" | "DARK" +-- ../pokecrystal/constants/battle_constants.asm:96 +BattleMusic.BATTLETYPE_ROAMING = 5 +BattleMusic.BATTLETYPE_SUICUNE = 12 + function BattleMusic.battleSong(opts) opts = opts or {} local class = opts.class local kanto = BattleMusic.isKanto(opts.landmark) + -- ../pokecrystal/engine/battle/start_battle.asm:60-66 + if opts.crystal and (opts.battleType == BattleMusic.BATTLETYPE_SUICUNE + or opts.battleType == BattleMusic.BATTLETYPE_ROAMING) then + return "Music_SuicuneBattle" + end + if not class then if kanto then return "Music_KantoWildBattle" end -- Only NITE has its own wild theme; DARK (an unlit cave) is a palette diff --git a/src/core/Sound.lua b/src/core/Sound.lua index efb59f2f..8bce4c01 100644 --- a/src/core/Sound.lua +++ b/src/core/Sound.lua @@ -363,6 +363,34 @@ function Sound.sfxBusy() return true end +-- home/audio.asm:225 +function Sound.sfxRemaining() + if not curSfx then return 0 end + local ok, playing = pcall(curSfx.src.isPlaying, curSfx.src) + if not (ok and playing) then + curSfx = nil + return 0 + end + local okd, dur = pcall(curSfx.src.getDuration, curSfx.src) + local okt, pos = pcall(curSfx.src.tell, curSfx.src) + if not (okd and okt) then return nil end + if type(dur) ~= "number" or type(pos) ~= "number" then return nil end + return math.max(0, dur - pos) +end + +-- home/joypad.asm:292 +function Sound.playPress(data) + local src = Sound.play(data, "Press_AB") + if src and curSfx and curSfx.src == src then curSfx.press = true end + return src +end + +function Sound.dropPressSfx() + if not (curSfx and curSfx.press) then return end + pcall(curSfx.src.stop, curSfx.src) + curSfx = nil +end + -- WaitSFX (home/audio.asm), the drain above GiveItemScript's `specialsound` -- (engine/overworld/scripting.asm:445), so ch5-ch8 are free for it (#1483). function Sound.waitSfxDone() diff --git a/src/core/gen2/Save.lua b/src/core/gen2/Save.lua index 37c8ad61..8554c700 100644 --- a/src/core/gen2/Save.lua +++ b/src/core/gen2/Save.lua @@ -79,7 +79,11 @@ local Save = {} -- that already has savings banked therefore starts buying from the -- bottom of the list, which is what a cartridge whose owner had -- saved that much would also do. -Save.FORMAT = 7 +-- 7 -> 8 Crystal caught data: a mon carrying a caught level but no landmark +-- is zeroed, so the Seer takes ReadCaughtData's `.error` arm +-- (../pokecrystal/engine/events/poke_seer.asm:103-104) instead of +-- GetCaughtLocation's "Unknown". #1929 +Save.FORMAT = 8 Save.MAX_MONEY = 999999 Save.MAX_COINS = 9999 @@ -649,6 +653,30 @@ Save.MIGRATIONS = { save.mom.triggerBalance = MomShopping.MOM_MONEY end end, + -- ../pokecrystal/engine/events/poke_seer.asm:103-104 + [7] = function(save) + local Mon = require("src.battle.gen2.Mon") + if not Mon.hasCaughtData(save.version) then return end + local function clear(mon) + if type(mon) ~= "table" or mon.isEgg then return end + if (tonumber(mon.caughtLocation) or 0) ~= 0 then return end + mon.caughtTime, mon.caughtLevel = 0, 0 + mon.caughtLocation, mon.caughtByGender = 0, "boy" + end + for _, mon in ipairs(save.party or {}) do clear(mon) end + for _, box in pairs(save.boxes or {}) do + if type(box) == "table" then + for _, mon in ipairs(box) do clear(mon) end + end + end + local dc = save.dayCare + if type(dc) == "table" then + for _, which in ipairs({ "man", "lady" }) do + local side = dc[which] + if type(side) == "table" then clear(side.mon) end + end + end + end, } function Save.migrate(save) diff --git a/src/render/TextBox.lua b/src/render/TextBox.lua index aeda01ee..111fb122 100644 --- a/src/render/TextBox.lua +++ b/src/render/TextBox.lua @@ -474,7 +474,8 @@ function TextBox:update(dt) end if self:sfxHeld() then return end if input:wasPressed("a") or input:wasPressed("b") then - require("src.core.Sound").play(self.game.data, "Press_AB") + -- home/joypad.asm:292 + require("src.core.Sound").playPress(self.game.data) self.game.stack:pop() if self.onDone then self.onDone() end end diff --git a/src/script/gen2/Specials.lua b/src/script/gen2/Specials.lua index f786932a..25c3e6ea 100644 --- a/src/script/gen2/Specials.lua +++ b/src/script/gen2/Specials.lua @@ -1382,9 +1382,13 @@ H.GiveShuckle = function(vm) end mon.ot = Specials.MANIA_OT mon.otId = Specials.MANIA_OT_ID - list[#list + 1] = mon - -- TryAddMonToParty's .registerpokedex (engine/pokemon/move_mon.asm:188-196) #1719 local record = save(vm) + list[#list + 1] = mon + -- ../pokecrystal/engine/events/shuckle.asm:18-19 + if Mon.hasCaughtData(record and record.version) then + Mon.setGiftCaughtData(mon, "unknown") + end + -- TryAddMonToParty's .registerpokedex (engine/pokemon/move_mon.asm:188-196) #1719 if record then record.pokedex = record.pokedex or { seen = {}, caught = {} } record.pokedex.seen[mon.species] = true diff --git a/src/script/gen2/Vm.lua b/src/script/gen2/Vm.lua index 5451785b..0e81d855 100644 --- a/src/script/gen2/Vm.lua +++ b/src/script/gen2/Vm.lua @@ -1948,6 +1948,7 @@ function Vm.new(scripts, text, events, hooks) playMusicFn = hooks.playMusic, specialSoundFn = hooks.specialSound, waitSfxFn = hooks.waitSfx, + waitSfxCapFn = hooks.waitSfxCap, -- StartAutoInput, by script pointer (`autoinput`) and by stream name -- (CatchTutorial), plus StopAutoInput. See src/core/gen2/AutoInput.lua. autoInputFn = hooks.autoInput, @@ -2681,8 +2682,10 @@ function Vm:resume(resumeValue) self:resume() end) elseif req and req.kind == "waitsfx" then + -- home/audio.asm:225 self.waitSfx = true - self.waitSfxLeft = 180 -- safety cap (~3s) if a source never ends + local cap = self.waitSfxCapFn and self.waitSfxCapFn() + self.waitSfxLeft = math.max(cap or 180, 1) elseif req and req.kind == "battle" then if self.startBattleFn then self.startBattleFn(req.trainer, req.wild, function(outcome) diff --git a/src/ui/ShaderFXScreen.lua b/src/ui/ShaderFXScreen.lua index 9a4eb3c9..8187089e 100644 --- a/src/ui/ShaderFXScreen.lua +++ b/src/ui/ShaderFXScreen.lua @@ -18,25 +18,28 @@ end -- One row's display state from its entry.converted, shared by new() and -- onChoose() so a successful convert updates a row exactly as building it did. -local function applyRowState(item) +local function applyRowState(item, canConvert) if not item.entry then return end -- NOT `cond and a or b`: that idiom breaks when `a` is itself nil/false. if item.entry.converted then item.muted = false item.right = nil - else + elseif canConvert then item.muted = true item.right = Strings("CONVERT") + else + item.muted = true + item.right = Strings("UPDATE") end end -- OFF, every real preset, then the permanent DOWNLOAD SHADERS action row. -local function buildItems(active) +local function buildItems(active, canConvert) local items = { { label = label(nil), entry = nil } } local selected = 1 for _, entry in ipairs(ShaderFX.list()) do local item = { label = label(entry), entry = entry } - applyRowState(item) + applyRowState(item, canConvert) items[#items + 1] = item if active and active.name == entry.name then selected = #items end end @@ -50,7 +53,8 @@ function ShaderFXScreen.new(game, slot) slot = slot or "main" local optKey = ShaderFX.OPTION_KEY[slot] local title = (slot == "secondary") and "SHADER FX 2" or "SHADER FX" - local items, selected = buildItems(ShaderFX.activeEntry(slot)) + local canConvert = ShaderFX.canConvert() + local items, selected = buildItems(ShaderFX.activeEntry(slot), canConvert) local self = setmetatable(ListMenu.new(game, title, items, { -- 7 rows leaves the title line and the one-line footer hint free. @@ -64,7 +68,7 @@ function ShaderFXScreen.new(game, slot) -- Rebuilds from ShaderFX.list() (e.g. after an install adds presets), keeping -- the cursor on the same row index (clamped) rather than resetting to OFF. local function refresh() - local newItems = buildItems(ShaderFX.activeEntry(slot)) + local newItems = buildItems(ShaderFX.activeEntry(slot), canConvert) self.items = newItems self.index = math.max(1, math.min(self.index, #newItems)) end @@ -113,12 +117,16 @@ function ShaderFXScreen.new(game, slot) -- A on an unconverted preset converts it in place instead of activating; -- the screen stays open either way, since a convert is not a selection. if item.entry and not item.entry.converted then + if not canConvert then + self.footer = Strings("Reinstall the app") + return + end local ok, err = ShaderFX.convert(item.entry) if not ok then require("src.core.Logger").error("ShaderFXScreen: convert failed for %s: %s", item.entry.name, tostring(err)) end - applyRowState(item) + applyRowState(item, canConvert) if not ok then item.right = Strings("FAILED") end return end @@ -130,10 +138,12 @@ function ShaderFXScreen.new(game, slot) else -- isConverted() is existence-only with no staleness check, so an explicit -- selection always reconverts. Human-paced, CPU-only, never per frame. - local convOk, convErr = ShaderFX.convert(item.entry) - if not convOk then - require("src.core.Logger").error("ShaderFXScreen: reconvert failed for %s: %s", - item.entry.name, tostring(convErr)) + if canConvert then + local convOk, convErr = ShaderFX.convert(item.entry) + if not convOk then + require("src.core.Logger").error("ShaderFXScreen: reconvert failed for %s: %s", + item.entry.name, tostring(convErr)) + end end local overrides = opts and opts.shaderfxParams and opts.shaderfxParams[item.entry.name] local ok = ShaderFX.activate(slot, item.entry, overrides) diff --git a/src/ui/gen2/BoxMenu.lua b/src/ui/gen2/BoxMenu.lua index 9847ca13..d2da8094 100644 --- a/src/ui/gen2/BoxMenu.lua +++ b/src/ui/gen2/BoxMenu.lua @@ -289,7 +289,7 @@ function BoxMenu:beginMove() -- list comes back, which is the same place the player ends up. self.phase = nil -- engine/pokemon/bills_pc.asm:1607 - self:playSfx("Sfx_Wrong") + self:playRefusalSfx("Sfx_Wrong") self.message = reason return end @@ -318,7 +318,7 @@ function BoxMenu:doWithdraw() local ok, result = Boxes.withdraw(self.save, self.boxIndex, self.index) if not ok then -- engine/pokemon/bills_pc.asm:1845 - self:playSfx("Sfx_Wrong") + self:playRefusalSfx("Sfx_Wrong") self.message = result return end @@ -334,7 +334,7 @@ function BoxMenu:doDeposit() local ok, result = Boxes.deposit(self.save, self.index, self.boxIndex) if not ok then -- engine/pokemon/bills_pc.asm:1790 - self:playSfx("Sfx_Wrong") + self:playRefusalSfx("Sfx_Wrong") self.message = result return end @@ -485,8 +485,11 @@ function BoxMenu:update(_dt) self.submenuIndex = self.submenuIndex < #submenu and self.submenuIndex + 1 or 1 elseif input:wasPressed("a") then + -- home/menu.asm:345 + self:playSfx("Sfx_ReadText2") self:chooseSubmenu() elseif input:wasPressed("b") then + self:playSfx("Sfx_ReadText2") self.phase = nil end return @@ -512,7 +515,7 @@ function BoxMenu:update(_dt) -- .no_space: `dec [hl]` puts the jumptable back on .PrepInsertCursor, -- so the refusal leaves the cursor exactly where it was. -- engine/pokemon/bills_pc.asm:1567 - self:playSfx("Sfx_Wrong") + self:playRefusalSfx("Sfx_Wrong") self.message = reason else self:insertMon() @@ -557,6 +560,12 @@ function BoxMenu:playSfx(name) if sfx and sfx[Sound.resolve(data, name)] then Sound.play(data, name) end end +-- engine/pokemon/bills_pc.asm:1608 +function BoxMenu:playRefusalSfx(name) + Sound.waitSfxDone() + self:playSfx(name) +end + -- PlayMonCry: `call GetCryIndex / jr c, .done` (home/pokemon.asm:113-114) function BoxMenu:playMonCry(mon) local data = self.game and self.game.data @@ -579,7 +588,7 @@ function BoxMenu:askRelease() if not allowed then self.phase = nil -- engine/pokemon/bills_pc.asm:1607 - self:playSfx("Sfx_Wrong") + self:playRefusalSfx("Sfx_Wrong") self.message = refusal return end @@ -588,7 +597,7 @@ function BoxMenu:askRelease() -- even asked over an egg (engine/pokemon/bills_pc.asm:186-187 and :427-428). if mon.isEgg then self.message = NO_RELEASING_EGGS - self:playSfx("Sfx_Wrong") + self:playRefusalSfx("Sfx_Wrong") return end local game = self.game diff --git a/src/ui/gen2/BuenaPassword.lua b/src/ui/gen2/BuenaPassword.lua index c60adc9f..781c9004 100644 --- a/src/ui/gen2/BuenaPassword.lua +++ b/src/ui/gen2/BuenaPassword.lua @@ -4,10 +4,9 @@ -- .MenuHeader is `menu_coords 0, 0, 10, 7` with the right edge -- moved to left + the category's points byte + 2 (:9-12), and -- whose .PasswordIndices answer is zero based (:44-49). --- prize :64 BuenaPrize -- Buena_PlacePrizeMenuBox (:219), --- Buena_PrizeMenu's scrolling list (:249-261) and --- PrintBlueCardBalance's "Points" box (:210). 0 is a B press --- (:236-245). +-- prize ../pokecrystal/engine/events/buena.asm:64 BuenaPrize +-- (:249, ../pokecrystal/home/scrolling_menu.asm:25-41 +-- ../pokecrystal/home/menu.asm:311 -- -- Neither is opaque: engine/events/buena.asm:71-83 prints the prize question -- BEFORE the list goes up over it, and the password menu stands on the map. @@ -25,9 +24,11 @@ BuenaPassword.isOpaque = false local WORD_X, WORD_Y, WORD_SPACING = 2, 2, 2 local WORD_BOX_H = 8 --- engine/events/buena.asm:219 and :249, as menu_coords lays them down. -local FIELD_X, FIELD_Y, FIELD_W, FIELD_H = 0, 0, 18, 12 +-- ../pokecrystal/engine/events/buena.asm:249 local LIST_BOX_X, LIST_BOX_Y, LIST_BOX_W, LIST_BOX_H = 1, 1, 16, 9 +-- (../pokecrystal/home/scrolling_menu.asm:25-41 +local FRAME_X, FRAME_Y = LIST_BOX_X - 1, LIST_BOX_Y - 1 +local FRAME_W, FRAME_H = LIST_BOX_W + 2, LIST_BOX_H + 2 local LIST_X, LIST_Y, LIST_SPACING = 2, 2, 2 -- ScrollingMenu_CallFunctions1and2's `add hl, de` on -- wMenuData_ScrollingMenuWidth (engine/menus/scrolling_menu.asm:424-431). @@ -134,8 +135,7 @@ function BuenaPassword:drawPasswordPanel() end function BuenaPassword:drawPrizePanel() - Chrome.box(FIELD_X, FIELD_Y, FIELD_W, FIELD_H) - Chrome.box(LIST_BOX_X, LIST_BOX_Y, LIST_BOX_W, LIST_BOX_H) + Chrome.box(FRAME_X, FRAME_Y, FRAME_W, FRAME_H) for row = 1, VISIBLE_ROWS do local i = row + self.scroll local prize = self.prizes[i] diff --git a/src/ui/gen2/CenterPcMenu.lua b/src/ui/gen2/CenterPcMenu.lua index 3007170b..cddae6ee 100644 --- a/src/ui/gen2/CenterPcMenu.lua +++ b/src/ui/gen2/CenterPcMenu.lua @@ -235,10 +235,12 @@ function CenterPcMenu:update(_dt) if input:wasPressed("up") or input:wasPressed("down") then c.choice = c.choice == 1 and 2 or 1 elseif input:wasPressed("b") then - -- YesNoBox's B is NO. + -- home/menu.asm:345 + self:playSfx("Sfx_ReadText2") self.confirm = nil if c.onNo then c.onNo() end elseif input:wasPressed("a") then + self:playSfx("Sfx_ReadText2") self.confirm = nil if c.choice == 1 then if c.onYes then c.onYes() end diff --git a/src/ui/gen2/ItemPcMenu.lua b/src/ui/gen2/ItemPcMenu.lua index 9498f11a..40fd5788 100644 --- a/src/ui/gen2/ItemPcMenu.lua +++ b/src/ui/gen2/ItemPcMenu.lua @@ -114,7 +114,7 @@ function ItemPcMenu.new(game, opts) self.confirm = nil if self.house then -- _PlayersHousePC: PC_PlayBootSound, then PlayersPCTurnOnText. - self:playSfx("Sfx_BootPc") + self:playPcSfx("Sfx_BootPc") self:say({ { "{PLAYER} turned on", "the PC." } }) end return self @@ -128,6 +128,12 @@ function ItemPcMenu:playSfx(name) end end +-- engine/events/pokecenter_pc.asm:200 +function ItemPcMenu:playPcSfx(name) + Sound.waitSfxDone() + self:playSfx(name) +end + function ItemPcMenu:playerName() local player = self.save and self.save.player return (player and player.name) or "GOLD" @@ -144,7 +150,7 @@ function ItemPcMenu:close() -- _PlayersHousePC plays PC_PlayShutdownSound only on the unchanged arm; -- `.changed_deco_tiles` leaves for the map reload without it. if self.house and not self.changedDecorations then - self:playSfx("Sfx_ShutDownPc") + self:playPcSfx("Sfx_ShutDownPc") end if self.onClose then self.onClose(self.changedDecorations) end end @@ -451,9 +457,12 @@ function ItemPcMenu:update(_dt) if input:wasPressed("up") or input:wasPressed("down") then c.choice = c.choice == 1 and 2 or 1 elseif input:wasPressed("b") then + -- home/menu.asm:345 + self:playSfx("Sfx_ReadText2") self.confirm = nil if c.onNo then c.onNo() end elseif input:wasPressed("a") then + self:playSfx("Sfx_ReadText2") self.confirm = nil if c.choice == 1 then if c.onYes then c.onYes() end @@ -483,8 +492,11 @@ function ItemPcMenu:update(_dt) or 1 self:ensureVisible() elseif input:wasPressed("b") then + -- engine/menus/scrolling_menu.asm:24 + self:playSfx("Sfx_ReadText2") self.phase = "menu" elseif input:wasPressed("a") then + self:playSfx("Sfx_ReadText2") if self.phase == "withdraw" then self:chooseWithdraw() else @@ -499,9 +511,12 @@ function ItemPcMenu:update(_dt) elseif input:wasPressed("down") then self.index = self.index < #self.entries and self.index + 1 or 1 elseif input:wasPressed("a") then + -- home/menu.asm:476 + self:playSfx("Sfx_ReadText2") self:choose() elseif input:wasPressed("b") then -- DoNthMenu's carry is `.turn_off`. + self:playSfx("Sfx_ReadText2") self:close() end end diff --git a/src/ui/gen2/PackMenu.lua b/src/ui/gen2/PackMenu.lua index bfa18eed..07eb4e8d 100644 --- a/src/ui/gen2/PackMenu.lua +++ b/src/ui/gen2/PackMenu.lua @@ -12,6 +12,7 @@ local Bag = require("src.inventory.Bag") local Chrome = require("src.ui.gen2.Chrome") +local GameVersion = require("src.core.GameVersion") local Gen2Save = require("src.core.gen2.Save") local PackGfx = require("src.ui.gen2.PackGfx") local Screens = require("src.ui.Screens") @@ -164,6 +165,8 @@ function PackMenu.new(game, opts) -- a rod or the ITEMFINDER on the way past (src/ui/gen2/HeldItemMenu.lua). self.give = opts.give and true or false self.battle = opts.battle and true or false + -- engine/items/pack.asm:1068 TutorialPack + self.tutorial = opts.tutorial and true or false self.cursorStore = cursorStore(game) -- engine/menus/scrolling_menu.asm:6 self.hold = MenuRepeat.new(MenuRepeat.GEN2_DELAY, MenuRepeat.GEN2_RATE) @@ -469,28 +472,20 @@ end -- ------------------------------------------------------------- the submenu --- Whether A on a row opens the item submenu. Three packs on the cart skip it --- and hand their row straight back, and all three are here: -- -- DepositSellPack (pack.asm:931) -- the mart's SELL, the item PC's DEPOSIT -- and HeldItemMenu's GIVE. Its jumptable is four ScrollingMenus and -- nothing else, which is why `give` and the empty-world callers answer -- their chooser directly. -- TutorialPack (pack.asm:1068) -- the DUDE's pack, same shape. --- BattlePack (pack.asm:627) -- this one DOES have a submenu on the cart --- (ItemSubmenu, USE / QUIT or QUIT alone), but it can neither toss, give --- nor register, so the row it would add over this port's direct dispatch --- is a second A press on the way to the same item effect. The field --- PACK is the one this bug is about; see src/ui/gen2/BattleState.lua for --- the battle side. -- --- The test is the world rather than a flag because that is what already tells --- a field PACK from a chooser here: MartMenu:enterSell and +-- engine/items/pack.asm:627 BattlePack -- ItemPcMenu:enterDeposit both pass `world = {}` precisely so no field effect -- can fire, and Game2's START-menu PACK passes the real overworld. function PackMenu:hasSubmenu() if self.give then return false end - if self:inBattle() then return false end + if self.tutorial then return false end + if self:inBattle() then return true end local world = self.world return (world and world.useFieldItem) and true or false end @@ -501,6 +496,13 @@ end -- gate and ItemPcMenu:cantToss take. function PackMenu:submenuRows(itemId) local def = self.items and self.items[itemId] + if self:inBattle() then + -- engine/items/pack.asm:783 ItemSubmenu, :745 .TMHMPocketMenu + local usable = not (def and def.battleMenu == "ITEMMENU_NOUSE") + if self:pocket().id == "TM_HM" then usable = false end + if usable then return { "use", "quit" } end + return { "quit" } + end local canToss = not (def and def.canToss == false) local canSelect = def ~= nil and def.canSelect == true local usable = not (def and def.fieldMenu == "ITEMMENU_NOUSE") @@ -1071,16 +1073,25 @@ end -- header is the one exception, reaching one row further down (TEXTBOX_Y), so -- the bottom is 12 there and 11 otherwise; either way the first label sits one -- row inside (STATICMENU_NO_TOP_SPACING) with the cursor a column left of it. +-- ../pokecrystal/engine/items/pack.asm:810, :826 open at column 13; +-- ../pokegold/engine/items/pack.asm:810, :826 open at column 0. +function PackMenu:submenuColumn() + if not self:inBattle() then return 0 end + local version = (self.save and self.save.version) or GameVersion.get() + return GameVersion.engine(version) == "crystal" and 13 or 0 +end + function PackMenu:drawSubmenu() local menu = self.submenu local count = #menu.rows + local x = self:submenuColumn() local bottom = count >= 5 and 12 or 11 local top = bottom - count * 2 - Chrome.box(0, top, 7, bottom - top + 1) + Chrome.box(x, top, 7, bottom - top + 1) for i, id in ipairs(menu.rows) do local ty = top + 1 + (i - 1) * 2 - if i == menu.index then Chrome.cursorThrough(1, ty, Chrome.DEFAULT_BOX_PALETTE) end - Chrome.printThrough(SUBMENU_LABEL[id] or id, 2, ty, Chrome.DEFAULT_BOX_PALETTE) + if i == menu.index then Chrome.cursorThrough(x + 1, ty, Chrome.DEFAULT_BOX_PALETTE) end + Chrome.printThrough(SUBMENU_LABEL[id] or id, x + 2, ty, Chrome.DEFAULT_BOX_PALETTE) end end diff --git a/src/ui/gen2/PcMenu.lua b/src/ui/gen2/PcMenu.lua index 05717c55..e15f15b4 100644 --- a/src/ui/gen2/PcMenu.lua +++ b/src/ui/gen2/PcMenu.lua @@ -27,6 +27,7 @@ local Runtime = require("src.mods.Runtime") local Save = require("src.core.gen2.Save") local SaveMenu = require("src.ui.gen2.SaveMenu") local Screens = require("src.ui.Screens") +local Sound = require("src.core.Sound") local Strings = require("src.core.Strings") -- _PCMonHoldingMailText (data/text/common_2.asm), the refusal @@ -171,6 +172,13 @@ function PcMenu:notice(pages) self.messageCloses = false end +-- home/menu.asm:746 +function PcMenu:playSfx(name) + local data = self.game and self.game.data + local sfx = data and data.audio and data.audio.sfx + if sfx and sfx[Sound.resolve(data, name)] then Sound.play(data, name) end +end + function PcMenu:close() if self.onClose then self.onClose(self.changedDecorations) end end @@ -211,7 +219,11 @@ function PcMenu:writeChangeBox() Boxes.setCurrent(self.save, self.changeBox) local ok = self.writer(self.save) self.saved = ok and true or false - if ok then SaveMenu.playSaveSfx(self.game, SaveMenu.SFX_SAVE) end + -- engine/menus/save.asm:266 + if ok then + Sound.waitSfxDone() + SaveMenu.playSaveSfx(self.game, SaveMenu.SFX_SAVE) + end end function PcMenu:savePrompt() @@ -257,9 +269,12 @@ function PcMenu:updateChangeBox() if input:wasPressed("up") or input:wasPressed("down") then self.saveChoice = self.saveChoice == 1 and 2 or 1 elseif input:wasPressed("a") then + -- home/menu.asm:345 + self:playSfx("Sfx_ReadText2") self:acceptChangeBox() elseif input:wasPressed("b") then -- B out of a yes/no is NO (InterpretTwoOptionMenu returns carry). + self:playSfx("Sfx_ReadText2") self:refuseChangeBox() end end @@ -351,12 +366,15 @@ function PcMenu:update(_dt) elseif input:wasPressed("down") then self.pickIndex = self.pickIndex < total and self.pickIndex + 1 or 1 elseif input:wasPressed("a") then + -- engine/menus/scrolling_menu.asm:24 + self:playSfx("Sfx_ReadText2") if self.pickIndex == (self.save.currentBox or 1) then self.picking = false else self:beginChangeBox(self.pickIndex) end elseif input:wasPressed("b") then + self:playSfx("Sfx_ReadText2") self.picking = false end return @@ -367,8 +385,11 @@ function PcMenu:update(_dt) elseif input:wasPressed("down") then self.index = self.index < #self.entries and self.index + 1 or 1 elseif input:wasPressed("a") then + -- home/menu.asm:476 + self:playSfx("Sfx_ReadText2") self:choose() elseif input:wasPressed("b") then + self:playSfx("Sfx_ReadText2") self:close() end end diff --git a/src/ui/gen2/PrizeMenu.lua b/src/ui/gen2/PrizeMenu.lua index 168b4473..15d6e241 100644 --- a/src/ui/gen2/PrizeMenu.lua +++ b/src/ui/gen2/PrizeMenu.lua @@ -252,7 +252,7 @@ end -- The transaction itself, once the player has said yes. Item counters do the -- giveitem BEFORE the takecoins, so a bag that turns out to be full costs -- nothing; that ordering is what makes "room" a possible answer here too. -function PrizeMenu.buy(save, counter, prize, data) +function PrizeMenu.buy(save, counter, prize, data, where) if counter.kind == "coins" then local reason = PrizeMenu.check(save, counter, prize, data) if reason ~= "ok" then return reason end @@ -269,6 +269,15 @@ function PrizeMenu.buy(save, counter, prize, data) save.party = save.party or {} -- The prize is a `givepoke`, so it takes AddPartyMon's stamp (move_mon.asm:143-149). Mon.stampOT(save, mon) + -- ../pokecrystal/engine/pokemon/move_mon.asm:1761-1773 + local Catching = require("src.battle.gen2.Catching") + local stamp = { version = save.version, save = save, data = data } + if type(where) == "table" then + for key, value in pairs(where) do + if stamp[key] == nil then stamp[key] = value end + end + end + Catching.stampCaughtData(mon, stamp) save.party[#save.party + 1] = mon -- `special GameCornerPrizeMonCheckDex` right before the givepoke: the prize -- mon is registered as seen and caught even though it never appeared in a @@ -305,13 +314,13 @@ function PrizeMenu:drawsWidescreen() return true end -- opts: save, counter (a COUNTERS key or a table), texts (a TEXTS key), -- version ("gold"/"silver"), data, hasCoinCase (defaults to the bag), --- onClose() function PrizeMenu.new(game, opts) opts = opts or {} local self = setmetatable({}, PrizeMenu) self.game = game self.save = opts.save or (game and game.save) self.data = opts.data or (game and game.data) + self.where = opts.where self.onClose = opts.onClose local counter = opts.counter or "CELADON_TM" self.counter = type(counter) == "table" and counter @@ -435,8 +444,17 @@ function PrizeMenu:choose() function() self:cancel() end) end +-- ../pokecrystal/engine/pokemon/caught_data.asm:177-193 +function PrizeMenu:caughtWhere() + if self.where then return self.where end + local world = self.game and self.game.world + if not (world and world.caughtDataOpts) then return nil end + return world:caughtDataOpts() +end + function PrizeMenu:complete(prize) - local reason = PrizeMenu.buy(self.save, self.counter, prize, self.data) + local reason = PrizeMenu.buy(self.save, self.counter, prize, self.data, + self:caughtWhere()) if reason ~= "ok" then self:refuse(reason) return diff --git a/src/world/gen2/Map.lua b/src/world/gen2/Map.lua index 2986afb7..514e73e3 100644 --- a/src/world/gen2/Map.lua +++ b/src/world/gen2/Map.lua @@ -276,6 +276,17 @@ function Map:stepPermitted(cx, cy, dir) function(x, y) return self:cellCollision(x, y) end, cx, cy, dir) end +-- CanObjectMoveInDirection, engine/overworld/npc_movement.asm:1 +-- GetCoordTileCollision's .nope, ../pokegold/home/map.asm:2095 +function Map:objectStepPermitted(cx, cy, dir) + local d = DELTA[dir] + if not d then return false end + local tx, ty = cx + d[1], cy + d[2] + if not self:inBounds(tx, ty) then return false end + return Permissions.objectStepPermitted( + self:cellCollision(cx, cy), self:cellCollision(tx, ty), dir) +end + function Map:connection(dir) return self.connections[dir] end diff --git a/src/world/gen2/Permissions.lua b/src/world/gen2/Permissions.lua index 2f731cd8..cfbe66f5 100644 --- a/src/world/gen2/Permissions.lua +++ b/src/world/gen2/Permissions.lua @@ -313,6 +313,27 @@ function Permissions.stepPermitted(collOf, cx, cy, dir) return true end +-- WillObjectBumpIntoTile .dir_masks, engine/overworld/npc_movement.asm:116 +local OPPOSITE = { up = "down", down = "up", left = "right", right = "left" } + +function Permissions.entryBlocks(coll) + local row = Permissions.sideBlocks(coll) + if not row then return nil end + local out = {} + for d in pairs(row) do out[OPPOSITE[d]] = true end + return out +end + +-- WillObjectBumpIntoWater, engine/overworld/npc_movement.asm:60 +function Permissions.objectStepPermitted(fromColl, toColl, dir) + local leave = Permissions.sideBlocks(fromColl) + if leave and leave[dir] then return false end + if not Permissions.isLand(toColl) then return false end + local enter = Permissions.entryBlocks(toColl) + if enter and enter[dir] then return false end + return true +end + function Permissions.isWarpCollision(coll) if coll == nil or coll < 0 then return false end -- COLL_PIT / COLL_PIT_68 plus high-nybble $7 (CheckWarpCollision / diff --git a/src/world/gen2/World.lua b/src/world/gen2/World.lua index ef8874cf..f5feb90e 100644 --- a/src/world/gen2/World.lua +++ b/src/world/gen2/World.lua @@ -145,6 +145,8 @@ for id, state in pairs(PLAYER_STATE_BY_ID) do PLAYER_STATE_ID[state] = id end local BATTLETYPE = { CANLOSE = 1, + -- CheckEncounterRoamMon, ../pokecrystal/engine/overworld/wildmons.asm:561 + ROAMING = 5, FORCESHINY = 7, FORCEITEM = 10, } @@ -1118,6 +1120,8 @@ function World:load() self:playCry(speciesIndex) end, playSound = function(sfxId) + -- home/audio.asm:180 + require("src.core.Sound").dropPressSfx() self:playSfx(sfxId) end, playMusic = function(musicId) @@ -1139,6 +1143,22 @@ function World:load() local ok, playing = pcall(src.isPlaying, src) return not (ok and playing) end, + waitSfxCap = function() + local left = require("src.core.Sound").sfxRemaining() + local src = self.lastSfx + local okp, playing = false, false + if src then okp, playing = pcall(src.isPlaying, src) end + if okp and playing then + local okd, dur = pcall(src.getDuration, src) + local okt, pos = pcall(src.tell, src) + if not (okd and okt) then return nil end + if type(dur) ~= "number" or type(pos) ~= "number" then return nil end + local rest = math.max(0, dur - pos) + if left == nil or rest > left then left = rest end + end + if left == nil then return nil end + return math.ceil(left * 60) + 30 + end, readVar = function(varId) return self:readVar(varId) end, @@ -2391,6 +2411,7 @@ end function World:warpSound() local p = self.player if not (self.map and p) then return end + Sound.dropPressSfx() local coll = self.map:cellCollision(p.cellX, p.cellY) local id = SFX.EXIT_BUILDING if coll == COLL.DOOR then @@ -5826,7 +5847,9 @@ function World:tryPushBoulder(dir, cx, cy) end local d = Map.DELTA[dir] local tx, ty = cx + d[1], cy + d[2] - if not self.map:isWalkable(tx, ty) then return false end + -- CanObjectMoveInDirection, engine/overworld/npc_movement.asm:1 + -- is MovementFunction_Strength's .ok2, engine/overworld/map_objects.asm:686 + if not self.map:objectStepPermitted(cx, cy, dir) then return false end for _, e in ipairs(self.entities or {}) do if e ~= npc and e.cellX == tx and e.cellY == ty then return false end end @@ -6370,8 +6393,10 @@ end -- opponent belongs to, the member inside it (only RIVAL2 reads that), the -- map's landmark for RegionCheck, and the clock. function World:battleMusicContext(opts) + local GameVersion = require("src.core.GameVersion") local members = self.constants and self.constants.trainerClassMembers local trainer = opts and opts.trainer + local save = self.game and self.game.save return { class = trainer and trainer.classId, member = trainer and trainer.memberId, @@ -6381,6 +6406,12 @@ function World:battleMusicContext(opts) -- PlayBattleMusic reads wTimeOfDay (engine/battle/start_battle.asm:24), -- not the map's pinned palette set. daytime = self.tod, + -- ../pokecrystal/engine/battle/start_battle.asm:60-66 + -- wBattleType write at ../pokecrystal/engine/overworld/wildmons.asm:561 + battleType = (opts and opts.battleType) + or (opts and opts.roaming and BATTLETYPE.ROAMING) or nil, + crystal = GameVersion.engine((save and save.version) + or GameVersion.get()) == "crystal", } end diff --git a/tests/drivers/caught_ball_palette_bug1896_test.lua b/tests/drivers/caught_ball_palette_bug1896_test.lua index 8c0dad31..69f876fa 100644 --- a/tests/drivers/caught_ball_palette_bug1896_test.lua +++ b/tests/drivers/caught_ball_palette_bug1896_test.lua @@ -105,6 +105,10 @@ return function(game) U.wait(3) end ok("the cursor reached it", pack.index == ballRow, pack.index) + -- ItemSubmenu (engine/items/pack.asm:783 + U.tap(game, "a") + U.wait(4) + ok("the ball opened ItemSubmenu", pack.submenu ~= nil, pack.submenu) U.tap(game, "a") U.wait(6) diff --git a/tests/drivers/crystal_boulder_lip_bug1936.lua b/tests/drivers/crystal_boulder_lip_bug1936.lua new file mode 100644 index 00000000..3b9c9f2f --- /dev/null +++ b/tests/drivers/crystal_boulder_lip_bug1936.lua @@ -0,0 +1,57 @@ +local U = require("tests.drivers.util") +local World = require("src.world.gen2.World") + +-- ../pokecrystal/maps/MountMortarB1F.asm:150 +local MAP = "MOUNT_MORTAR_B1F" +local START_X, START_Y = 9, 9 +local LIP_Y = 12 + +return function(game) + local fails = 0 + + local function say(line) print("[boulder1936] " .. line) end + local function ok(cond, line) + if not cond then fails = fails + 1 end + say((cond and "PASS " or "FAIL ") .. line) + end + + U.wait(60) + local world = game.world + if not (world and world.map) then + say("FAIL the crystal world did not boot") + love.event.quit(1) + return + end + + world:warpToMapId(MAP, START_X, START_Y, "down") + U.wait(60) + ok(world.map and world.map.id == MAP, "arrived at " .. MAP) + + world.strengthActive = true + world.noWildEncounters = true + + local boulder + for _, npc in ipairs(world.npcs or {}) do + if World.isStrengthBoulder(npc) then boulder = npc end + end + ok(boulder ~= nil, "the Strength boulder spawned") + if not boulder then love.event.quit(1) return end + + local lowest = boulder.cellY + for _ = 1, 12 do + U.hold(game, "down", 24) + U.wait(24) + if boulder.cellY > lowest then lowest = boulder.cellY end + end + + say(("boulder rests at (%d,%d); player at (%d,%d)"):format( + boulder.cellX, boulder.cellY, world.player.cellX, world.player.cellY)) + ok(lowest < LIP_Y, "the boulder never crossed the $b2 lip at y=" .. LIP_Y) + ok(boulder.cellY == 11, "it stops on the cell above the lip") + ok(world.player.cellY < boulder.cellY, "and the player is bumping into it") + + U.shot(game, "/tmp/pokeport-shots/boulder1936.png") + + say(fails == 0 and "ALL PASS" or (fails .. " FAILURES")) + love.event.quit(fails == 0 and 0 or 1) +end diff --git a/tests/drivers/crystal_roamer_music_bug1928.lua b/tests/drivers/crystal_roamer_music_bug1928.lua new file mode 100644 index 00000000..3b75895d --- /dev/null +++ b/tests/drivers/crystal_roamer_music_bug1928.lua @@ -0,0 +1,89 @@ +-- ../pokecrystal/engine/battle/start_battle.asm:60-66 +local U = require("tests.drivers.util") + +local GameVersion = require("src.core.GameVersion") +local Mon = require("src.battle.gen2.Mon") +local Music = require("src.core.Music") +local Roamers = require("src.core.gen2.Roamers") + +-- ../pokecrystal/constants/battle_constants.asm:103 +local BATTLETYPE_SUICUNE = 12 + +return function(game) + local fails = 0 + local function say(line) print("[1928] " .. line) end + local function ok(cond, line) + if not cond then fails = fails + 1 end + say((cond and "PASS " or "FAIL ") .. line) + end + + U.wait(60) + local world = game.world + if not (world and world.map) then + say("FAIL the crystal world did not boot") + love.event.quit(1) + return + end + + local save = game.save + save.party = { Mon.new(game.data, "CYNDAQUIL", 30) } + ok(save.party[1] ~= nil, "the cache built the player's mon") + world:warpToMapId("ROUTE_29", 20, 8, "down") + U.wait(30) + + -- ../pokecrystal/engine/overworld/wildmons.asm:493 + Roamers.init(save, { data = game.data, encounters = world.encounters }) + local beast = Roamers.beginBattle(save, 1, game.data) + ok(beast ~= nil, "slot 1 handed over a beast (" + .. tostring(beast and beast.species) .. ")") + + local function popBattle() + for _ = 1, 240 do + local top = game.stack:top() + if top and top.battle then + game.stack:pop() + U.wait(2) + return + end + U.wait(1) + end + end + + local crystal = GameVersion.engine(save.version or GameVersion.get()) + == "crystal" + local function wildTheme(song) + return song == "Music_JohtoWildBattle" + or song == "Music_JohtoWildBattleNight" + end + local function beastTheme(song) + if crystal then return song == "Music_SuicuneBattle" end + return wildTheme(song) + end + local want = crystal and "Suicune's theme" or "the ordinary wild theme" + + local wild = Mon.new(game.data, "RATTATA", 4) + world:startBattle({ wild = wild }) + U.wait(5) + ok(wildTheme(Music.current()), + "an ordinary Route 29 wild battle keeps the Johto theme (got " + .. tostring(Music.current()) .. ")") + popBattle() + + world:startBattle({ wild = beast, roaming = 1 }) + U.wait(5) + ok(beastTheme(Music.current()), + "a roaming beast fights to " .. want .. " (got " + .. tostring(Music.current()) .. ")") + popBattle() + + local scripted = Mon.new(game.data, "SUICUNE", 40) + world:startBattle({ wild = scripted, battleType = BATTLETYPE_SUICUNE }) + U.wait(5) + ok(beastTheme(Music.current()), + "and a BATTLETYPE_SUICUNE battle plays " .. want .. " (got " + .. tostring(Music.current()) .. ")") + popBattle() + + say(fails == 0 and "ALL PASS" or (fails .. " FAILURES")) + love.event.quit(fails == 0 and 0 or 1) +end diff --git a/tests/drivers/crystal_seer_bug1929.lua b/tests/drivers/crystal_seer_bug1929.lua new file mode 100644 index 00000000..dac43a89 --- /dev/null +++ b/tests/drivers/crystal_seer_bug1929.lua @@ -0,0 +1,84 @@ +local U = require("tests.drivers.util") + +local Mon = require("src.battle.gen2.Mon") +local Save = require("src.core.gen2.Save") +local Specials = require("src.script.gen2.Specials") + +-- constants/pokemon_constants.asm +-- (../pokecrystal/maps/PokeSeersHouse.asm:29 +local TOTODILE = 158 +local SEER_MAP, SEER_X, SEER_Y = "POKE_SEERS_HOUSE", 2, 4 + +return function(game) + local fails = 0 + local function say(line) print("[1929] " .. line) end + local function ok(cond, line) + if not cond then fails = fails + 1 end + say((cond and "PASS " or "FAIL ") .. line) + end + + U.wait(60) + local world = game.world + if not (world and world.map and world.vm) then + say("FAIL the crystal world did not boot") + love.event.quit(1) + return + end + + local save = game.save + save.party = {} + + world:warpToMapId("ELMS_LAB", 5, 5, "down") + U.wait(30) + local landmark = world:caughtDataOpts().landmark + say("ELMS_LAB landmark=" .. tostring(landmark)) + + world.vm.givePokeFn(TOTODILE, 5, 0, nil) + U.wait(5) + local starter = save.party[1] + ok(starter ~= nil, "the givepoke landed") + if starter then + local b0, b1 = Mon.packCaughtData(starter) + ok(starter.caughtLocation == landmark, + "the starter carries the lab's landmark (got " + .. tostring(starter.caughtLocation) .. ")") + say("starter bytes " .. b0 .. " " .. b1) + end + + Specials.HANDLERS.GiveShuckle(world.vm) + U.wait(5) + local shuckie = save.party[2] + ok(shuckie ~= nil, "SHUCKIE joined") + if shuckie then + local b0, b1 = Mon.packCaughtData(shuckie) + ok(shuckie.caughtLocation == Mon.LANDMARK_GIFT, + "SHUCKIE is LANDMARK_GIFT (got " .. tostring(shuckie.caughtLocation) .. ")") + ok(b0 == 0 and b1 == Mon.LANDMARK_GIFT, + "and its byte pair is 0/" .. Mon.LANDMARK_GIFT + .. " (got " .. b0 .. "/" .. b1 .. ")") + end + + save.party[3] = Mon.new(game.data, "GEODUDE", 30) + if save.party[3] then + save.party[3].caughtLevel = 30 + save.party[3].caughtLocation = nil + Save.MIGRATIONS[7](save) + local b0, b1 = Mon.packCaughtData(save.party[3]) + ok(b0 == 0 and b1 == 0, + "the legacy slot migrates to an empty pair (got " .. b0 .. "/" .. b1 .. ")") + local s0, s1 = Mon.packCaughtData(starter or {}) + ok(s0 ~= 0 or s1 ~= 0, "and the stamped starter survives the migration") + say("starter after migrate " .. s0 .. " " .. s1) + end + + world:warpToMapId(SEER_MAP, SEER_X, SEER_Y, "up") + U.wait(60) + ok(world.map and world.map.def and world.map.def.id == SEER_MAP, + "standing in front of the Seer") + U.shot(game, "/tmp/pokeport-shots/crystal_seer_bug1929.png") + say("press A: slot 1 must read NEW BARK TOWN, slot 2 and 3 " + .. "\"Whaaaat? I can't tell a thing!\"") + + say(fails == 0 and "all claims passed" or (fails .. " claims failed")) + love.event.quit(fails == 0 and 0 or 1) +end diff --git a/tests/drivers/gold_badge_jingle_bug1930.lua b/tests/drivers/gold_badge_jingle_bug1930.lua new file mode 100644 index 00000000..5ceb6e38 --- /dev/null +++ b/tests/drivers/gold_badge_jingle_bug1930.lua @@ -0,0 +1,95 @@ +-- #1930 home/audio.asm:225 +local U = require("tests.drivers.util") + +local Sound = require("src.core.Sound") + +return function(game) + local fails, lines = 0, {} + + local function claim(ok, text) + if not ok then fails = fails + 1 end + lines[#lines + 1] = (ok and "PASS " or "FAIL ") .. text + return ok + end + + local function stop() + for _, line in ipairs(lines) do U.log(line) end + U.log(("%d checks, %d failed"):format(#lines, fails)) + while true do coroutine.yield() end + end + + U.wait(45) + local world = game.world + if not (world and world.map and world.vm) then + U.log("FAIL the gold world never booted, nothing to listen to") + while true do coroutine.yield() end + end + + local audio = game.data.audio or {} + local ids = {} + for i, name in ipairs(audio.sfxOrder or {}) do ids[name] = i - 1 end + claim(ids.Sfx_GetBadge ~= nil and audio.sfx and audio.sfx.Sfx_GetBadge ~= nil, + ("the cache can play Sfx_GetBadge (id %s)"):format( + tostring(ids.Sfx_GetBadge))) + claim((ids.Sfx_ReadText2 or 0) < (ids.Sfx_GetBadge or 0), + "and the box beep outranks it, so the gate is live") + local vol = game.save.options and game.save.options.sfxVol + claim(vol ~= 0, ("SFX VOL is %s"):format(tostring(vol))) + if not ids.Sfx_GetBadge then stop() end + + local probe = Sound.play(game.data, "Sfx_GetBadge") + local length = 0 + if probe then + local ok, dur = pcall(probe.getDuration, probe) + if ok and dur then length = math.ceil(dur * 60) end + end + Sound.waitSfxDone() + U.wait(15) + U.log(("Sfx_GetBadge is %d frames"):format(length)) + claim(length > 180, "which is past the cap the park used to carry") + + world.vm:start({ { op = "playsound", id = ids.Sfx_GetBadge }, + { op = "waitsfx" } }) + local parked = 0 + while world.vm:running() and parked < 900 do + parked = parked + 1 + coroutine.yield() + end + local busy = Sound.sfxBusy() + U.log(("vm released after %d frames; sfx still busy = %s"):format( + parked, tostring(busy))) + claim(parked >= length - 5, + ("the vm stayed parked for the fanfare (%d of %d frames)"):format( + parked, length)) + claim(parked > 180, "which the old flat cap cut short") + claim(not busy, "and the channels were free when it moved on") + Sound.waitSfxDone() + U.wait(30) + + local started = nil + local realPlay = Sound.play + Sound.play = function(d, name) + local src = realPlay(d, name) + if Sound.resolve(d, name) == "Sfx_GetBadge" then started = src ~= nil end + return src + end + local blip = Sound.playPress(game.data) + claim(blip ~= nil, "the box beep sounds, as it does on every dismissed box") + world.vm:start({ { op = "playsound", id = ids.Sfx_GetBadge }, + { op = "waitsfx" } }) + local n = 0 + while world.vm:running() and n < 900 do + n = n + 1 + coroutine.yield() + end + Sound.play = realPlay + claim(started == true, "the script's playsound rang over the retired beep") + Sound.waitSfxDone() + + for _, line in ipairs(lines) do U.log(line) end + U.log(("%d checks, %d failed"):format(#lines, fails)) + U.log("ears-on: beat Falkner and mash A through the badge line; the") + U.log("fanfare must ring in full, and his next box must not cut it short.") + U.log("the controls are yours.") + while true do coroutine.yield() end +end diff --git a/tests/drivers/gold_battle_items.lua b/tests/drivers/gold_battle_items.lua index 92469b53..13d65d4f 100644 --- a/tests/drivers/gold_battle_items.lua +++ b/tests/drivers/gold_battle_items.lua @@ -94,6 +94,10 @@ return function(game) U.tap(game, "down") U.wait(2) end + -- ItemSubmenu (engine/items/pack.asm:783 + U.tap(game, "a") + U.wait(4) + assert(pack.submenu, "the REVIVE did not open ItemSubmenu") U.tap(game, "a") U.wait(4) local party = game.stack:top() @@ -132,6 +136,9 @@ return function(game) end U.tap(game, "a") U.wait(4) + assert(ppPack.submenu, "the ETHER did not open ItemSubmenu") + U.tap(game, "a") + U.wait(4) local pickMon = game.stack:top() assert(pickMon and pickMon.prompt, "the ETHER did not open the party list") U.tap(game, "a") diff --git a/tests/drivers/gold_battle_pack_probe.lua b/tests/drivers/gold_battle_pack_probe.lua index f6ef134e..776fa097 100644 --- a/tests/drivers/gold_battle_pack_probe.lua +++ b/tests/drivers/gold_battle_pack_probe.lua @@ -2,11 +2,8 @@ -- -- POKEPORT_GAME=gold POKEPORT_DRIVER=tests/drivers/gold_battle_pack_probe.lua love . -- --- BattlePack (engine/items/pack.asm) is a different jumptable from the field --- PACK's, and its first four entries are .Oak: a key item picked mid-fight --- prints OakThisIsntTheTimeText inside the pack. Nothing here may reach the --- field jumptable, whose ITEMFINDER arm quits the PACK -- over a battle that --- takes the battle off the stack with it. +-- engine/items/pack.asm:627 +-- ../pokegold/engine/items/pack.asm:810 local U = require("tests.drivers.util") local Mon = require("src.battle.gen2.Mon") @@ -67,17 +64,41 @@ return function(game) print("[driver] key item row 1 " .. tostring(pack.rows[1].id)) tap("a") U.wait(4) - U.shot(game, out .. "/00-battle-pack-oak.png") + U.shot(game, out .. "/00-battle-pack-unusable.png") - assert(pack.message and pack.message[1] == "OAK: {PLAYER}!", - "the ITEMFINDER did not print OakThisIsntTheTimeText") + assert(pack.submenu, "the ITEMFINDER did not open ItemSubmenu") + assert(#pack.submenu.rows == 1 and pack.submenu.rows[1] == "quit", + "the key item was offered more than .UnusableMenuHeader's QUIT") + assert(pack:submenuColumn() == 0, + "Gold drew the battle submenu in column " .. tostring(pack:submenuColumn())) + assert(pack.message == nil, "OakThisIsntTheTimeText printed inside a battle") assert(game.stack:top() == pack, "the pack left the stack") assert(world.battleActive, "battleActive was cleared by a field effect") assert(world.queuedScript == nil, "a field script was queued from a battle") assert(game.save.inventory.ITEMFINDER == 1, "the key item was spent") - -- B clears the message, B again closes the pack, and the battle is still - -- there underneath with its menu. + tap("a") + assert(pack.submenu == nil, "QUIT did not close the submenu") + assert(game.save.inventory.ITEMFINDER == 1, "QUIT spent the key item") + + tap("left") + tap("left") + assert(pack:pocket().id == "ITEM", + "did not reach the ITEMS pocket: " .. tostring(pack:pocket().id)) + for _ = 1, #pack.rows do + if pack.rows[pack.index] and pack.rows[pack.index].id == "POTION" then break end + tap("down") + end + assert(pack.rows[pack.index] and pack.rows[pack.index].id == "POTION", + "never landed on the POTION") + tap("a") + U.wait(4) + U.shot(game, out .. "/01-battle-pack-usable.png") + assert(pack.submenu and #pack.submenu.rows == 2 + and pack.submenu.rows[1] == "use", + "the POTION did not get .UsableMenuHeader's USE / QUIT") + assert(game.stack:top() == pack, "USE ran before it was chosen") + tap("b") tap("b") for _ = 1, 120 do diff --git a/tests/drivers/gold_bug1556_battle.lua b/tests/drivers/gold_bug1556_battle.lua index 77b55e71..585f6d9e 100644 --- a/tests/drivers/gold_bug1556_battle.lua +++ b/tests/drivers/gold_bug1556_battle.lua @@ -104,6 +104,10 @@ return function(game) assert(ballRow, "the battle PACK does not show the MASTER BALL") for _ = 2, ballRow do U.tap(game, "down") U.wait(2) end reset() + -- ItemSubmenu (engine/items/pack.asm:783 + U.tap(game, "a") + U.wait(4) + assert(pack.submenu, "the MASTER BALL did not open ItemSubmenu") U.tap(game, "a") awaitSfx("Sfx_CaughtMon", 400) awaitSfx("Sfx_SlotMachineStart", 300) diff --git a/tests/drivers/gold_pc_sfx_bug1931.lua b/tests/drivers/gold_pc_sfx_bug1931.lua new file mode 100644 index 00000000..a430edb1 --- /dev/null +++ b/tests/drivers/gold_pc_sfx_bug1931.lua @@ -0,0 +1,27 @@ +-- #1931: home/menu.asm:746 +local U = require("tests.drivers.util") + +local Boxes = require("src.core.gen2.Boxes") +local Mon = require("src.battle.gen2.Mon") + +return function(game) + U.wait(45) + assert(game.world and game.world.map, "gold world did not boot") + local save, data = game.save, game.data + + save.party = {} + for _, species in ipairs({ "CYNDAQUIL", "PIDGEY", "SENTRET" }) do + save.party[#save.party + 1] = Mon.new(data, species, 12) + end + local stored = Boxes.box(save, 1) + for i = #stored, 1, -1 do stored[i] = nil end + for i, species in ipairs({ "GEODUDE", "ZUBAT", "RATTATA" }) do + stored[i] = Mon.new(data, species, 10 + i) + end + save.currentBox = 1 + save.pcItems = { POTION = 5, ANTIDOTE = 2 } + + assert(game.world:openPc(), "openPc failed") + U.wait(8) + U.log("done -- the whose-PC menu is open; the controls are yours") +end diff --git a/tests/engine/buena_prize_box_bug1935.lua b/tests/engine/buena_prize_box_bug1935.lua new file mode 100644 index 00000000..c1510a2d --- /dev/null +++ b/tests/engine/buena_prize_box_bug1935.lua @@ -0,0 +1,73 @@ +-- Buena_PlacePrizeMenuBox (#1935): ../pokecrystal/home/scrolling_menu.asm:25-41, +-- ../pokecrystal/engine/events/buena.asm:219 + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local check, eq = T.check, T.eq +love = love or require("tests.love_stub") + +local calls = {} +package.loaded["src.render.Font"] = { + BORDER = { tl = 1, tr = 2, bl = 3, br = 4, h = 5, v = 6 }, + draw = function(text, x, y) calls[#calls + 1] = { "draw", text, x, y } end, + drawCode = function(code, x, y) calls[#calls + 1] = { "code", code, x, y } end, + drawBox = function(tx, ty, tw, th) calls[#calls + 1] = { "box", tx, ty, tw, th } end, + encode = function(text) return { string.byte(tostring(text), 1) or 0 } end, + advanceOf = function() return 8 end, + width = function(text) return #tostring(text) * 8 end, +} +package.loaded["src.ui.gen2.Chrome"] = nil +package.loaded["src.ui.gen2.BuenaPassword"] = nil +local BuenaPassword = require("src.ui.gen2.BuenaPassword") + +local function boxes() + local found = {} + for _, c in ipairs(calls) do + if c[1] == "box" then found[#found + 1] = c end + end + return found +end + +local function drew(text, x, y) + for _, c in ipairs(calls) do + if c[1] == "draw" and c[2] == text and c[3] == x and c[4] == y then + return true + end + end + return false +end + +local prizes = {} +for i = 1, 6 do prizes[i] = { name = "PRIZE " .. i, cost = i } end + +local screen = BuenaPassword.new({}, { mode = "prize", prizes = prizes, balance = 1 }) +screen:draw() + +local drawn = boxes() +eq(#drawn, 2, "prize mode draws the list frame and the Points box, nothing else") + +local frame = drawn[1] +if check(frame ~= nil, "the scrolling list is framed") then + eq(frame[2], 0, "InitScrollingMenu decrements the left coord to 0") + eq(frame[3], 0, "and the top coord to 0") + eq(frame[4], 18, "16 interior columns plus both borders") + eq(frame[5], 11, "9 interior rows plus both borders") +end + +local balance = drawn[2] +if check(balance ~= nil, "PrintBlueCardBalance keeps its own box") then + eq(balance[2], 0, "BlueCardBalanceMenuHeader's menu_coords 0, 11, 11, 13") + eq(balance[3], 11, "row 11") + eq(balance[4], 12, "through column 11") + eq(balance[5], 3, "through row 13") +end + +check(drew("PRIZE 1", 16, 16), "the first prize name sits at (16, 16)") +check(drew("1", 120, 16), "with its cost digit in column 15") +check(drew("\xe2\x96\xbc", 128, 72), + "the ▼ lands inside the frame at (128, 72), not on its border") +check(not drew("\xe2\x96\xb2", 128, 8), + "the ▲ stays off until the list has scrolled") + +T.finish("buena prize box bug 1935") diff --git a/tests/engine/evolution_hold_b_bug968_test.lua b/tests/engine/evolution_hold_b_bug968_test.lua index fc4c5502..2f3a0927 100644 --- a/tests/engine/evolution_hold_b_bug968_test.lua +++ b/tests/engine/evolution_hold_b_bug968_test.lua @@ -10,6 +10,7 @@ love = love or require("tests.love_stub") -- TextBox and EvolutionState both require Sound inside update, so seeding package.loaded["src.core.Sound"] = { play = function() end, + playPress = function() end, playCry = function() end, } diff --git a/tests/engine/gen2_badge_jingle_bug1930.lua b/tests/engine/gen2_badge_jingle_bug1930.lua new file mode 100644 index 00000000..38e9c2ee --- /dev/null +++ b/tests/engine/gen2_badge_jingle_bug1930.lua @@ -0,0 +1,163 @@ +-- home/audio.asm:225 + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local check, eq = T.check, T.eq +love = love or require("tests.love_stub") + +local playing = {} +local Source = {} +Source.__index = Source +function Source:play() playing[self] = true end +function Source:stop() playing[self] = nil end +function Source:isPlaying() return playing[self] == true end +function Source:setVolume(v) self.volume = v end +function Source:setPitch(p) self.pitch = p end +function Source:getDuration() return self.duration end +function Source:tell() return self.pos end + +local Mute = {} +Mute.__index = Mute +function Mute:play() playing[self] = true end +function Mute:stop() playing[self] = nil end +function Mute:isPlaying() return playing[self] == true end +function Mute:setVolume(v) self.volume = v end +function Mute:setPitch(p) self.pitch = p end + +local savedAudio = love.audio +love.audio = { + newSource = function(file) + local meta = file == "sfx/mute.wav" and Mute or Source + return setmetatable({ file = file, duration = 0, pos = 0 }, meta) + end, +} + +local Sound = require("src.core.Sound") +local Vm = require("src.script.gen2.Vm") +local Events = require("src.world.gen2.Events") + +Sound.invalidate() + +local order = {} +for i = 1, 160 do order[i] = ("Sfx_Pad%d"):format(i) end +order[0x9c + 1] = "Sfx_GetBadge" +order[0x9b + 1] = "Sfx_Mute" + +local data = { + audio = { + fanfares = {}, + sfxOrder = order, + sfx = { + Sfx_GetBadge = { file = "sfx/getbadge.wav", generation = 2 }, + Sfx_Mute = { file = "sfx/mute.wav", generation = 2 }, + }, + }, +} + + +eq(Sound.sfxRemaining(), 0, "nothing sounding leaves nothing to wait on") + +do + -- audio/sfx.asm + local badge = Sound.play(data, "Sfx_GetBadge") + check(badge ~= nil, "the fanfare plays") + badge.duration, badge.pos = 4.017, 1.0 + local left = Sound.sfxRemaining() + check(left and math.abs(left - 3.017) < 1e-6, + "sfxRemaining reads the tail off the source") + check(math.ceil(4.017 * 60) > 180, + "and 241 frames is past the old flat cap") + + badge.pos = 4.017 + eq(Sound.sfxRemaining(), 0, "a finished source has no tail") + badge:stop() + eq(Sound.sfxRemaining(), 0, "nor does a stopped one") +end + +do + local mute = Sound.play(data, "Sfx_Mute") + check(mute ~= nil, "a source that cannot be measured still plays") + eq(Sound.sfxRemaining(), nil, "and answers nil, not a guess") + mute:stop() +end + + +local RISINGBADGE = 7 + +local function badgeVm(hooks) + local scripts = { + generation = 2, + ["s:badge"] = { + { op = "playsound", id = 0x9c }, + { op = "waitsfx" }, + { op = "setevent", event = RISINGBADGE }, + { op = "end" }, + }, + } + local events = Events.new() + local vm = Vm.new(scripts, {}, events, hooks) + return vm, events +end + +do + local busyFor = 241 + local ticks = 0 + local vm, events = badgeVm({ + playSound = function() end, + waitSfx = function() return ticks >= busyFor end, + waitSfxCap = function() return 271 end, + }) + check(vm:start("s:badge"), "the badge script starts") + eq(vm.waitSfxLeft, 271, "the park is capped by the sound, not by 180") + + while ticks < 180 do + vm:update() + ticks = ticks + 1 + end + check(not events:get(RISINGBADGE), + "the old 180-frame cap no longer walks out from under the fanfare") + while ticks < busyFor do + vm:update() + ticks = ticks + 1 + end + check(not events:get(RISINGBADGE), "still parked on the last busy frame") + vm:update() + check(events:get(RISINGBADGE), + "and moves on the frame the fanfare ends") +end + +do + local vm = badgeVm({ + playSound = function() end, + waitSfx = function() return false end, + }) + vm:start("s:badge") + eq(vm.waitSfxLeft, 180, "with no cap hook the old guard is the fallback") +end + +do + local vm = badgeVm({ + playSound = function() end, + waitSfx = function() return false end, + waitSfxCap = function() return nil end, + }) + vm:start("s:badge") + eq(vm.waitSfxLeft, 180, "so is a cap nothing could measure") +end + +do + local vm, events = badgeVm({ + playSound = function() end, + waitSfx = function() return false end, + waitSfxCap = function() return 271 end, + }) + vm:start("s:badge") + for _ = 1, 271 do vm:update() end + check(events:get(RISINGBADGE), "a runaway sound does not hang the vm") +end + +Sound.invalidate() +love.audio = savedAudio + +T.finish("gen2 badge jingle bug1930") diff --git a/tests/engine/gen2_badge_sfx_after_waitbutton_bug1934.lua b/tests/engine/gen2_badge_sfx_after_waitbutton_bug1934.lua new file mode 100644 index 00000000..8b59a6e0 --- /dev/null +++ b/tests/engine/gen2_badge_sfx_after_waitbutton_bug1934.lua @@ -0,0 +1,157 @@ +-- home/joypad.asm:292 + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local check, eq = T.check, T.eq +love = love or require("tests.love_stub") + +local playing = {} +local Source = {} +Source.__index = Source +function Source:play() playing[self] = true end +function Source:stop() playing[self] = nil end +function Source:isPlaying() return playing[self] == true end +function Source:setVolume(v) self.volume = v end +function Source:setPitch(p) self.pitch = p end + +local savedAudio = love.audio +love.audio = { + newSource = function(file) return setmetatable({ file = file }, Source) end, +} + +local Sound = require("src.core.Sound") +local Vm = require("src.script.gen2.Vm") +local Events = require("src.world.gen2.Events") + +Sound.invalidate() + +-- constants/sfx_constants.asm:11 +local order = {} +for i = 1, 160 do order[i] = ("Sfx_Pad%d"):format(i) end +order[0x08 + 1] = "Sfx_ReadText2" +order[0x41 + 1] = "Sfx_Tackle" +order[0x6e + 1] = "Sfx_Elevator" +order[0x9c + 1] = "Sfx_GetBadge" + +local data = { + audio = { + fanfares = {}, + sfxOrder = order, + sfx = { + Sfx_ReadText2 = { file = "sfx/readtext2.wav", generation = 2 }, + Sfx_Tackle = { file = "sfx/tackle.wav", generation = 2 }, + Sfx_Elevator = { file = "sfx/elevator.wav", generation = 2 }, + Sfx_GetBadge = { file = "sfx/getbadge.wav", generation = 2 }, + }, + }, + text = {}, +} + +do + local blip = Sound.playPress(data) + check(blip ~= nil, "the box blip sounds") + check(Sound.play(data, "Sfx_GetBadge") == nil, + "PlaySFX's cp e / jr c drops SFX_GET_BADGE under the $08 blip") + + Sound.dropPressSfx() + check(not blip:isPlaying(), "the blip the cart never rang is retired") + local badge = Sound.play(data, "Sfx_GetBadge") + check(badge ~= nil, "and the badge fanfare now plays") + badge:stop() +end + +do + local tackle = Sound.play(data, "Sfx_Tackle") + check(tackle ~= nil, "the tackle sounds") + Sound.dropPressSfx() + check(tackle:isPlaying(), "a script sound is not a press blip") + check(Sound.play(data, "Sfx_Elevator") == nil, + "the elevator rumble is still dropped") + tackle:stop() +end + +do + local gen1 = { audio = { fanfares = {}, sfx = { Press_AB = "sfx/ab.wav" } } } + local src = Sound.playPress(gen1) + check(src ~= nil, "the Gen 1 beep still plays") + Sound.dropPressSfx() + check(src:isPlaying(), "and is left alone: no wCurSFX to retire") + src:stop() +end + +do + local TextBox = require("src.render.TextBox") + local game = { + save = { player = {}, options = { textSpeed = "FAST" } }, + data = data, + } + game.stack = { + states = {}, + push = function(self, s) table.insert(self.states, s) end, + pop = function(self) return table.remove(self.states) end, + top = function(self) return self.states[#self.states] end, + } + game.input = { + queue = {}, + wasPressed = function(self, btn) return self.queue[btn] or false end, + isDown = function(self, btn) return self.queue[btn] or false end, + } + local box = TextBox.new(game, "CLAIR: Here.\nTake this.") + game.stack:push(box) + local frames = 0 + while not box.done and frames < 600 do + box:update(1 / 60) + frames = frames + 1 + end + game.input.queue.a = true + box:update(1 / 60) + eq(#game.stack.states, 0, "the press closed the box") + check(Sound.play(data, "Sfx_GetBadge") == nil, + "its blip holds the channels the way PlaySFX would read them") + Sound.dropPressSfx() + check(Sound.play(data, "Sfx_GetBadge") ~= nil, + "and the script's playsound retires it first") + Sound.waitSfxDone() +end + +-- ../pokecrystal/maps/DragonShrine.asm:159-163 +do + local scripts = { + generation = 2, + ["s:badge"] = { + { op = "writetext", text = "t:badge" }, + { op = "waitbutton" }, + { op = "setflag", flag = 0 }, + { op = "playsound", id = 0x9c }, + { op = "waitsfx" }, + { op = "end" }, + }, + } + local heard = {} + local vm = Vm.new(scripts, { ["t:badge"] = "Take the RISINGBADGE!" }, + Events.new(), { + showText = function(body, onDone) + Sound.playPress(data) + onDone() + end, + setFlag = function() end, + playSound = function(id) + Sound.dropPressSfx() + local name = order[(id or 0) + 1] + heard[#heard + 1] = { id, Sound.play(data, name) ~= nil } + end, + waitSfx = function() return not Sound.sfxBusy() end, + }) + check(vm:start("s:badge"), "the badge script starts") + for _ = 1, 400 do vm:update() end + eq(#heard, 1, "playsound ran once") + eq(heard[1][1], 0x9c, "with SFX_GET_BADGE") + check(heard[1][2], "and the fanfare was not dropped by the box's own blip") + Sound.waitSfxDone() +end + +Sound.invalidate() +love.audio = savedAudio + +T.finish("gen2 badge sfx after waitbutton bug1934") diff --git a/tests/engine/gen2_boulder_side_wall_bug1936.lua b/tests/engine/gen2_boulder_side_wall_bug1936.lua new file mode 100644 index 00000000..66b06078 --- /dev/null +++ b/tests/engine/gen2_boulder_side_wall_bug1936.lua @@ -0,0 +1,148 @@ +-- CanObjectMoveInDirection (engine/overworld/npc_movement.asm:1 + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local check, eq = T.check, T.eq + +love = require("tests.love_stub") + +local Permissions = require("src.world.gen2.Permissions") +local Map = require("src.world.gen2.Map") +local World = require("src.world.gen2.World") +local Runtime = require("src.mods.Runtime") + +local FLOOR = 0x00 +local UP_WALL = 0xb2 +local RIGHT_WALL = 0xb0 +local UP_BUOY = 0xc2 +local WALL = 0x07 + +local function permitted(from, to, dir) + return Permissions.objectStepPermitted(from, to, dir) == true +end + +do + check(not permitted(FLOOR, UP_WALL, "down"), "DOWN onto an UP_WALL is refused") + check(permitted(FLOOR, UP_WALL, "up"), "UP onto one is not") + check(permitted(FLOOR, UP_WALL, "left"), "nor LEFT") + check(permitted(UP_WALL, FLOOR, "down"), "DOWN off an UP_WALL is fine") + check(not permitted(UP_WALL, FLOOR, "up"), "UP off one is CanObjectLeaveTile") + + check(not permitted(FLOOR, RIGHT_WALL, "left"), "LEFT onto a RIGHT_WALL is refused") + check(permitted(FLOOR, RIGHT_WALL, "right"), "RIGHT onto one is not") + check(not permitted(RIGHT_WALL, FLOOR, "right"), "RIGHT off one is refused") + + check(not permitted(FLOOR, UP_BUOY, "down"), "DOWN onto an UP_BUOY is refused") + check(not permitted(FLOOR, UP_BUOY, "up"), "and so is UP") + check(not permitted(UP_BUOY, FLOOR, "up"), "leaving one upward is the mask") + check(permitted(UP_BUOY, FLOOR, "down"), "leaving one downward is not") + + for _, dir in ipairs({ "up", "down", "left", "right" }) do + check(not permitted(FLOOR, WALL, dir), "no direction enters a WALL tile") + end + check(permitted(FLOOR, FLOOR, "down"), "plain floor to plain floor moves") +end + +-- entryBlocks is the leave row's mirror, npc_movement.asm:116 against :142 +do + local MIRROR = { up = "down", down = "up", left = "right", right = "left" } + for _, base in ipairs({ 0xb0, 0xc0 }) do + for coll = base, base + 7 do + local leave = Permissions.sideBlocks(coll) + local enter = Permissions.entryBlocks(coll) + local ok = true + for d in pairs(leave) do if not enter[MIRROR[d]] then ok = false end end + for d in pairs(enter) do if not leave[MIRROR[d]] then ok = false end end + check(ok, ("$%02x's entry row mirrors its exit row"):format(coll)) + end + end + check(Permissions.entryBlocks(FLOOR) == nil, "a plain floor has no entry row") +end + +local MAP_ID = "MOUNT_MORTAR_B1F" + +local function lipMap() + local def = { + id = MAP_ID, width = 1, height = 2, + blocks = { 1, 2 }, warps = {}, connections = {}, + } + local tileset = { + collision = { + { 0xff, 0xff, 0xff, 0xff }, + { FLOOR, FLOOR, FLOOR, FLOOR }, + { UP_WALL, UP_WALL, FLOOR, FLOOR }, + }, + } + return Map.new(def, tileset) +end + +local function boulderWorld() + local map = lipMap() + local world = World.new({ data = {}, save = { player = {} } }) + world.map = map + world.entities = {} + world.strengthActive = true + local boulder = { + def = { index = 0, movement = 0x19 }, cellX = 0, cellY = 0, steps = 0, + } + boulder.scriptStep = function(self, dir) + local d = Map.DELTA[dir] + self.cellX, self.cellY = self.cellX + d[1], self.cellY + d[2] + self.steps = self.steps + 1 + end + world.npcs = { boulder } + world.entities = { boulder } + return world, boulder, map +end + +do + local map = lipMap() + eq(map:cellCollision(0, 0), FLOOR, "the column decodes floor at the top") + eq(map:cellCollision(0, 2), UP_WALL, "the lip sits at y=2") + eq(map:cellCollision(0, 3), FLOOR, "and the lower floor under it") + check(not map:objectStepPermitted(0, 1, "down"), "the lip refuses a step down") + check(map:objectStepPermitted(0, 0, "down"), "the step above it does not") + check(not map:objectStepPermitted(0, 3, "down"), "off-map is GetCoordTileCollision $ff") +end + +do + local world, boulder = boulderWorld() + check(world:tryPushBoulder("down", 0, 0), "the first push moves the boulder") + eq(boulder.cellY, 1, "onto the cell above the lip") + check(not world:tryPushBoulder("down", 0, 1), "the second push is refused") + eq(boulder.cellY, 1, "and the boulder has not moved") + eq(boulder.steps, 1, "one scriptStep in all") +end + +do + local world, boulder = boulderWorld() + boulder.cellX, boulder.cellY = 0, 2 + check(world:tryPushBoulder("down", 0, 2), "DOWN off the lip is still allowed") + eq(boulder.cellY, 3, "onto the lower floor") +end + +do + local seen = {} + local saved = Runtime.events + Runtime.events = { + listeners = { ["world.boulder_moved"] = true }, + emit = function(_, name, payload) seen[name] = payload end, + } + local world, boulder = boulderWorld() + check(world:tryPushBoulder("down", 0, 0), "a floor-to-floor push still moves") + eq(boulder.cellY, 1, "the boulder stepped") + local p = seen["world.boulder_moved"] + check(p ~= nil, "and world.boulder_moved still fires") + eq(p and p.mapId, MAP_ID, "with the map it moved on") + eq(p and p.npcId, 1, "the one-based object id") + eq(p and p.x, 0, "the destination x") + eq(p and p.y, 1, "and the destination y") + + seen = {} + check(not world:tryPushBoulder("down", 0, 1), "the refused push does not move") + check(seen["world.boulder_moved"] == nil, "and emits nothing") + Runtime.events = saved +end + +T.finish("gen2 boulder side wall bug1936") diff --git a/tests/engine/gen2_pc_sfx_bug1931.lua b/tests/engine/gen2_pc_sfx_bug1931.lua new file mode 100644 index 00000000..ff83f4c0 --- /dev/null +++ b/tests/engine/gen2_pc_sfx_bug1931.lua @@ -0,0 +1,288 @@ +-- home/menu.asm:746 + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local eq = T.eq +love = love or require("tests.love_stub") + +local Sound = require("src.core.Sound") +local Save = require("src.core.gen2.Save") +local PcMenu = require("src.ui.gen2.PcMenu") +local ItemPcMenu = require("src.ui.gen2.ItemPcMenu") +local BoxMenu = require("src.ui.gen2.BoxMenu") +local CenterPcMenu = require("src.ui.gen2.CenterPcMenu") + +local played = {} +Sound.play = function(_data, name) played[#played + 1] = name end +Sound.waitSfxDone = function() end + +local SFX = { + Sfx_ReadText2 = true, + Sfx_BootPc = true, + Sfx_ShutDownPc = true, + Sfx_ChoosePcOption = true, + Sfx_Wrong = true, +} + +local function newInput() + local input = { pressed = {} } + function input:press(...) + for _, button in ipairs({ ... }) do self.pressed[button] = true end + end + function input:wasPressed(button) + if self.pressed[button] then + self.pressed[button] = nil + return true + end + return false + end + function input:isDown() return false end + return input +end + +local function newGame(save) + return { + input = newInput(), + save = save, + options = save.options, + data = { items = {}, moves = {}, pokemon = {}, + audio = { sfx = SFX } }, + stack = { push = function() end, pop = function() end }, + } +end + +local function newSave() + local save = Save.newGame() + save.party = { { species = "CYNDAQUIL", level = 5 } } + save.pcItems = {} + return save +end + +local function last() return played[#played] end + + +local function openPc() + local save = newSave() + local game = newGame(save) + local menu = PcMenu.new(game, { save = save, bills = true, + saveExists = false, writer = function() return true end, + onClose = function() end }) + menu.message = nil + played = {} + return menu, game +end + +do + local menu, game = openPc() + menu.index = #menu.entries + game.input:press("a") + menu:update(0) + eq(last(), "Sfx_ReadText2", "the PC's top menu clicks on A") +end + +do + local menu, game = openPc() + game.input:press("b") + menu:update(0) + eq(last(), "Sfx_ReadText2", "and on the B that logs off") +end + +do + local menu, game = openPc() + game.input:press("down") + menu:update(0) + eq(#played, 0, "walking the rows is silent") +end + + +do + local menu, game = openPc() + menu.picking = true + menu.pickIndex = menu.save.currentBox or 1 + game.input:press("a") + menu:update(0) + eq(last(), "Sfx_ReadText2", "CHANGE BOX's picker clicks on A") + eq(menu.picking, false, "and the picker closes on the current box") +end + +do + local menu, game = openPc() + menu.picking = true + menu.pickIndex = 1 + game.input:press("b") + menu:update(0) + eq(last(), "Sfx_ReadText2", "and on the B that backs out") +end + + +do + local menu, game = openPc() + menu.picking = true + menu:beginChangeBox(2) + menu.saveChoice = 2 + game.input:press("a") + menu:update(0) + eq(last(), "Sfx_ReadText2", "the CHANGE BOX yes/no clicks on A") +end + +do + local menu, game = openPc() + menu.picking = true + menu:beginChangeBox(2) + game.input:press("b") + menu:update(0) + eq(last(), "Sfx_ReadText2", "and on B") +end + + +local function openItemPc() + local save = newSave() + local game = newGame(save) + local menu = ItemPcMenu.new(game, { save = save, onClose = function() end }) + menu.message = nil + played = {} + return menu, game +end + +do + local menu, game = openItemPc() + menu.index = #menu.entries + game.input:press("a") + menu:update(0) + eq(last(), "Sfx_ReadText2", "the item PC's menu clicks on A") +end + +do + local menu, game = openItemPc() + game.input:press("b") + menu:update(0) + eq(last(), "Sfx_ReadText2", "and on the B that turns it off") +end + +do + local menu, game = openItemPc() + menu.phase = "withdraw" + menu.rows = {} + menu.listIndex = 1 + game.input:press("a") + menu:update(0) + eq(last(), "Sfx_ReadText2", ".PCItemsMenuData clicks on A") + + menu.phase = "withdraw" + game.input:press("b") + menu:update(0) + eq(last(), "Sfx_ReadText2", "and on the B that quits the list") + eq(menu.phase, "menu", "which drops back to the top menu") +end + +do + local menu, game = openItemPc() + menu.qtyState = { qty = 1, max = 9, onAccept = function() end } + game.input:press("a") + menu:update(0) + eq(#played, 0, "the quantity picker stays silent") +end + +do + local menu, game = openItemPc() + menu.confirm = { choice = 2, onNo = function() end } + game.input:press("a") + menu:update(0) + eq(last(), "Sfx_ReadText2", "the item PC's yes/no clicks on A") + + menu.confirm = { choice = 1, onNo = function() end } + game.input:press("b") + menu:update(0) + eq(last(), "Sfx_ReadText2", "and on B") +end + + +local function openBox() + local save = newSave() + local game = newGame(save) + local menu = BoxMenu.new(game, { save = save, mode = "withdraw", + onClose = function() end }) + played = {} + return menu, game +end + +do + local menu, game = openBox() + menu.phase = "submenu" + menu.submenuIndex = #menu:submenuRows() + game.input:press("a") + menu:update(0) + eq(last(), "Sfx_ReadText2", "the box submenu clicks on A") + eq(menu.phase, nil, "and CANCEL closes it") +end + +do + local menu, game = openBox() + menu.phase = "submenu" + game.input:press("b") + menu:update(0) + eq(last(), "Sfx_ReadText2", "and on B") +end + +do + local menu, game = openBox() + game.input:press("a") + menu:update(0) + eq(#played, 0, "the mon list itself is silent on A") + + game.input:press("b") + menu:update(0) + eq(#played, 0, "and on B") +end + + +local function openCenter() + local save = newSave() + local game = newGame(save) + local menu = CenterPcMenu.new(game, { save = save, items = {}, + onClose = function() end }) + menu.message = nil + played = {} + return menu, game +end + +do + local menu, game = openCenter() + menu.index = 1 + game.input:press("a") + menu:update(0) + eq(#played, 1, "MENU_NO_CLICK_SFX: the whose-PC menu adds no click") + eq(last(), "Sfx_ChoosePcOption", "only PC_PlayChoosePCSound") +end + +do + local menu, game = openCenter() + game.input:press("b") + menu:update(0) + eq(#played, 1, "and none on the B that shuts the PC down") + eq(last(), "Sfx_ShutDownPc", "only PC_PlayShutdownSound") +end + +do + local menu, game = openCenter() + menu.confirm = { choice = 2, onNo = function() end } + game.input:press("a") + menu:update(0) + eq(last(), "Sfx_ReadText2", "OakPCText1's yes/no clicks on A") + + menu.confirm = { choice = 1, onNo = function() end } + game.input:press("b") + menu:update(0) + eq(last(), "Sfx_ReadText2", "and on B") +end + +do + local menu, game = openPc() + game.data.audio.sfx = {} + game.input:press("b") + menu:update(0) + eq(#played, 0, "a cache without the cue simply makes no sound") +end + +T.finish("gen2 pc sfx bug 1931") diff --git a/tests/engine/gen2_roamer_battle_music_bug1928.lua b/tests/engine/gen2_roamer_battle_music_bug1928.lua new file mode 100644 index 00000000..9b3ce3f3 --- /dev/null +++ b/tests/engine/gen2_roamer_battle_music_bug1928.lua @@ -0,0 +1,49 @@ +-- start_battle.asm:60-66 +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local eq = T.eq + +love = love or require("tests.love_stub") + +local BattleMusic = require("src.battle.gen2.BattleMusic") +local World = require("src.world.gen2.World") + +eq(BattleMusic.battleSong({ crystal = true, battleType = 5, landmark = 1, + daytime = "NITE" }), "Music_SuicuneBattle", + "a Crystal roaming battle wins over the night wild theme") +eq(BattleMusic.battleSong({ crystal = true, battleType = 12, landmark = 1 }), + "Music_SuicuneBattle", "and so does the Tin Tower Suicune") +eq(BattleMusic.battleSong({ crystal = true, battleType = 5, class = "FALKNER", + landmark = 1 }), "Music_SuicuneBattle", + "the wBattleType test sits above the wOtherTrainerClass ladder") +eq(BattleMusic.battleSong({ battleType = 5, landmark = 1, daytime = "DAY" }), + "Music_JohtoWildBattle", "Gold and Silver have no such block") +eq(BattleMusic.battleSong({ crystal = true, landmark = 1, daytime = "DAY" }), + "Music_JohtoWildBattle", "an ordinary Crystal wild battle is untouched") + +-- (../pokecrystal/engine/overworld/wildmons.asm:561 +local function context(version, opts) + local world = { + tod = "DAY", + map = { def = { landmark = 1 } }, + game = { save = { version = version } }, + } + return World.battleMusicContext(world, opts) +end + +eq(context("crystal", { roaming = 3 }).battleType, 5, + "a roaming encounter reports BATTLETYPE_ROAMING") +eq(context("crystal", { roaming = 3 }).crystal, true, "...on Crystal") +eq(context("gold", { roaming = 3 }).crystal, false, "...but not on Gold") +eq(context("crystal", {}).battleType, nil, + "an ordinary wild battle reports no battle type") +eq(context("crystal", { battleType = 12 }).battleType, 12, + "the Tin Tower script's own loadvar rides through") + +eq(BattleMusic.battleSong(context("crystal", { roaming = 3 })), + "Music_SuicuneBattle", "a beast fights to Suicune's theme in Crystal") +eq(BattleMusic.battleSong(context("gold", { roaming = 3 })), + "Music_JohtoWildBattle", "and to the ordinary wild theme in Gold") + +T.finish("gen2_roamer_battle_music_bug1928") diff --git a/tests/engine/gen2_seer_caught_location_bug1929.lua b/tests/engine/gen2_seer_caught_location_bug1929.lua new file mode 100644 index 00000000..d823decf --- /dev/null +++ b/tests/engine/gen2_seer_caught_location_bug1929.lua @@ -0,0 +1,149 @@ +-- ../pokecrystal/engine/events/poke_seer.asm:103-104 +-- ../pokecrystal/engine/events/shuckle.asm:18-19 +-- ../pokecrystal/engine/pokemon/move_mon.asm:1761-1773 +package.path = "./?.lua;./?/init.lua;" .. package.path + +love = require("tests.love_stub") + +local T = require("tests.harness") +local Events = require("src.world.gen2.Events") +local Mon = require("src.battle.gen2.Mon") +local PrizeMenu = require("src.ui.gen2.PrizeMenu") +local Save = require("src.core.gen2.Save") +local Specials = require("src.script.gen2.Specials") +local Vm = require("src.script.gen2.Vm") + +-- constants/landmark_constants.asm +local GOLDENROD = 12 + +local DATA = { + constants = { bagSize = 20 }, + items = { BERRY = { name = "BERRY", pocket = "ITEM" } }, + moves = { + CONSTRICT = { name = "CONSTRICT", pp = 35 }, + TELEPORT = { name = "TELEPORT", pp = 20 }, + }, + pokemon = { + growthRates = { MEDIUM_FAST = {} }, + SHUCKLE = { name = "SHUCKLE", index = 213, growthRate = "MEDIUM_FAST", + types = { "BUG", "ROCK" }, + baseStats = { hp = 20, attack = 10, defense = 230, speed = 5, + specialAttack = 10, specialDefense = 230 }, + levelMoves = { { level = 1, move = "CONSTRICT" } } }, + ABRA = { name = "ABRA", index = 63, growthRate = "MEDIUM_FAST", + types = { "PSYCHIC", "PSYCHIC" }, + baseStats = { hp = 25, attack = 20, defense = 15, speed = 90, + specialAttack = 105, specialDefense = 55 }, + levelMoves = { { level = 1, move = "TELEPORT" } } }, + }, +} + +local function newSave(version, coins) + return { + version = version, party = {}, inventory = {}, + player = { name = "KRIS", id = 4242, gender = "female", + coins = coins or 0 }, + pokedex = { seen = {}, caught = {} }, + } +end + + +local function giftVm(record) + return Vm.new({ generation = 2 }, {}, Events.new(), { specials = { + party = function() return record.party end, + save = function() return record end, + data = function() return DATA end, + } }) +end + +do + local record = newSave("crystal") + Specials.HANDLERS.GiveShuckle(giftVm(record)) + local mon = record.party[1] + T.check(mon ~= nil, "SHUCKIE joins the party") + T.eq(mon.caughtLocation, Mon.LANDMARK_GIFT, + "SetGiftPartyMonCaughtData writes LANDMARK_GIFT") + T.eq(mon.caughtLevel, 0, "byte 0 is xor'd, so no caught level") + T.eq(mon.caughtTime, 0, "and no caught time") + T.eq(mon.caughtByGender, "boy", + "CAUGHT_BY_UNKNOWN leaves the gender bit clear") + local byte0, byte1 = Mon.packCaughtData(mon) + T.eq(byte0, 0, "byte 0 of MON_CAUGHTDATA") + T.eq(byte1, Mon.LANDMARK_GIFT, "byte 1 is LANDMARK_GIFT alone") +end + +do + local record = newSave("gold") + Specials.HANDLERS.GiveShuckle(giftVm(record)) + local mon = record.party[1] + T.check(mon ~= nil, "Gold hands the same SHUCKIE over") + T.eq(mon.caughtLocation, nil, "but Gold's struct has no caught data") +end + + +local counter = PrizeMenu.COUNTERS.GOLDENROD_MON +local abra = counter.prizes[1] + +do + local save = newSave("crystal", 9999) + local where = { landmark = GOLDENROD, timeOfDay = 1, + playerGender = "female" } + T.eq(PrizeMenu.buy(save, counter, abra, DATA, where), "ok", + "the prize is handed over") + local mon = save.party[1] + T.eq(mon.caughtLocation, GOLDENROD, + "the givepoke takes the .wildmon arm's SetCaughtData") + T.eq(mon.caughtLevel, abra.level, "at the scripted level") + T.eq(mon.caughtTime, 2, "wTimeOfDay DAY stores as 2") + T.eq(mon.caughtByGender, "girl", "wPlayerGender bit 0 on bit 7") +end + +do + local save = newSave("gold", 9999) + T.eq(PrizeMenu.buy(save, counter, abra, DATA, + { landmark = GOLDENROD, timeOfDay = 1 }), "ok", "Gold buys it too") + T.eq(save.party[1].caughtLocation, nil, "and stamps nothing") +end + + +local function stamped() + return { species = "ABRA", level = 40, caughtLevel = 12, caughtTime = 1, + caughtLocation = 2, caughtByGender = "girl" } +end + +local function halfStamped(level) + return { species = "ABRA", level = level, caughtLevel = level } +end + +do + local file = { + format = 7, version = "crystal", + party = { halfStamped(30), stamped(), + { species = "ABRA", level = 5, isEgg = true, caughtLevel = 5 }, + { species = "ABRA", level = 15, caughtLevel = 15, + caughtLocation = Mon.LANDMARK_GIFT } }, + boxes = { [1] = { halfStamped(9) }, [2] = {} }, + dayCare = { man = { mon = halfStamped(7) }, lady = {} }, + } + Save.migrate(file) + T.eq(file.format, Save.FORMAT, "the file reaches the current format") + + local byte0, byte1 = Mon.packCaughtData(file.party[1]) + T.eq(byte0, 0, "a half-stamped party mon loses byte 0") + T.eq(byte1, 0, "and byte 1 stays clear, so ReadCaughtData takes .error") + T.eq(file.party[2].caughtLevel, 12, "a fully stamped mon keeps its level") + T.eq(file.party[2].caughtLocation, 2, "and its landmark") + T.eq(file.party[3].caughtLevel, 5, "an egg is left alone") + T.eq(file.party[4].caughtLevel, 15, "so is LANDMARK_GIFT") + T.eq(file.boxes[1][1].caughtLevel, 0, "a box mon is cleared too") + T.eq(file.dayCare.man.mon.caughtLevel, 0, "and a Day-Care deposit") +end + +do + local file = { format = 7, version = "gold", party = { halfStamped(30) } } + Save.migrate(file) + T.eq(file.party[1].caughtLevel, 30, + "Gold has no MON_CAUGHTDATA, so the backfill skips it") +end + +T.finish("gen2 seer caught location bug 1929") diff --git a/tests/engine/shaderfx_no_bridge_ui.lua b/tests/engine/shaderfx_no_bridge_ui.lua new file mode 100644 index 00000000..114e05b6 --- /dev/null +++ b/tests/engine/shaderfx_no_bridge_ui.lua @@ -0,0 +1,100 @@ + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local check, eq = T.check, T.eq +love = love or require("tests.love_stub") + +package.loaded["src.render.Font"] = { + BORDER = { tl = 1, tr = 2, bl = 3, br = 4, h = 5, v = 6 }, + draw = function() end, + drawCode = function() end, + drawBox = function() end, + width = function(text) return #tostring(text) * 8 end, + split = function(text) + local spans = {} + for i = 1, #tostring(text) do spans[i] = { from = i, to = i, code = 0 } end + return spans + end, + spansFitting = function(spans, pixels) return math.min(#spans, math.floor(pixels / 8)) end, +} +package.loaded["src.core.Logger"] = { + info = function() end, + error = function() end, + warn = function() end, +} +package.loaded["src.core.Sound"] = { play = function() end } + +local converts = {} +local activated = {} +local fake +fake = { + OPTION_KEY = { main = "shaderfx", secondary = "shaderfx2" }, + canConvert = function() return fake.can end, + bridgeError = function() return fake.can and nil or "librashader bridge not found" end, + list = function() + return { + { name = "bevel.slangp", converted = true }, + { name = "zfast-lcd.slangp", converted = false }, + } + end, + activeEntry = function() return nil end, + convert = function(entry) + converts[#converts + 1] = entry.name + return fake.convertOk, fake.convertOk and nil or "no bridge" + end, + activate = function(_, entry) activated[#activated + 1] = entry.name; return true end, + deactivate = function() end, + downloadPresets = function() return {} end, + downloadStatus = function() return { status = "pending" } end, + installDownloaded = function() return 0 end, +} +package.loaded["src.render.ShaderFX"] = fake + +local ShaderFXScreen = require("src.ui.ShaderFXScreen") + +local function newGame() + return { + input = { wasPressed = function() return false end }, + stack = { top = function() end, pop = function() end }, + save = { options = {} }, + writeOptions = function() end, + } +end + +local function open() + converts, activated = {}, {} + return ShaderFXScreen.new(newGame(), "main") +end + +fake.can, fake.convertOk = false, false +local screen = open() +eq(screen.items[3].right, "UPDATE", "an unconvertible preset says what would fix it") +check(#screen.items[3].right <= #"CONVERT", + "the no-bridge label leaves at least as much room for the name as CONVERT") +eq(screen.items[2].right, nil, "an already-converted preset keeps its bare row") +screen.onChoose(screen.items[3]) +eq(#converts, 0, "A on that row never calls convert") +check(screen.footer and screen.footer:find("Reinstall", 1, true) ~= nil, + "the footer names the fix (got " .. tostring(screen.footer) .. ")") +check(screen.footer:find("\n", 1, true) == nil and #screen.footer <= 18, + "the footer stays one 18-column line, so it cannot paint over list row 7") +eq(screen.items[3].right, "UPDATE", "the row keeps its label instead of reading FAILED") + +screen.onChoose(screen.items[2]) +eq(#converts, 0, "activating a converted preset skips the reconvert with no bridge") +eq(activated[1], "bevel.slangp", "the cached artifact still activates") + +fake.can, fake.convertOk = true, true +screen = open() +eq(screen.items[3].right, "CONVERT", "a build with the bridge still offers CONVERT") +screen.onChoose(screen.items[3]) +eq(converts[1], "zfast-lcd.slangp", "A on that row converts") +eq(screen.footer, "SELECT:EDIT PARAMS", "a convertible build keeps the hint footer") + +fake.convertOk = false +screen = open() +screen.onChoose(screen.items[3]) +eq(screen.items[3].right, "FAILED", "a real convert failure still reads FAILED") + +T.finish("shaderfx no-bridge picker") diff --git a/tests/gen2_battle_pack_test.lua b/tests/gen2_battle_pack_test.lua index 9de5bc3a..9b3e3889 100644 --- a/tests/gen2_battle_pack_test.lua +++ b/tests/gen2_battle_pack_test.lua @@ -197,13 +197,15 @@ local function pressPack(pack, button) end -- ---- the battle PACK never runs a field effect ---------------------------- +-- ItemSubmenu (engine/items/pack.asm:783 do local screen, _, world, save, pushed = newBattleOverWorld({ - POTION = 2, ITEMFINDER = 1, NORMAL_BOX = 1 }) + POTION = 2, ITEMFINDER = 1, NORMAL_BOX = 1, TM_ROCK_SMASH = 1 }) check(runToMenu(screen), "the intro drains to the battle menu") local pack = openPackFromMenu(screen, pushed) eq(getmetatable(pack), PackMenu, "PACK opens the pack") eq(pack:inBattle(), true, "as BattlePack rather than the field Pack") + eq(pack:hasSubmenu(), true, "which has ItemSubmenu on the cart") eq(#pushed, 2, "over the battle, which is still on the stack") -- KEY ITEMS, where the arm that ate the battle lived. @@ -214,34 +216,139 @@ do pack.index = 1 eq(pack.rows[1].id, "ITEMFINDER", "the ITEMFINDER is the first row") pressPack(pack, "a") - eq(pack.message and pack.message[1], "OAK: {PLAYER}!", - "A on it prints OakThisIsntTheTimeText") + check(pack.submenu ~= nil, "A on it opens the submenu") + eq(table.concat(pack.submenu.rows, ","), "quit", + "a battle-NOUSE key item gets .UnusableMenuHeader's lone QUIT") + eq(pack.message, nil, "with no Oak line anywhere near it") eq(#pushed, 2, "the pack is still up") eq(pushed[1], screen, "and the battle is still under it") eq(world.battleActive, true, "the world is still in its battle") eq(world.queuedScript, nil, "no field script was queued") eq(save.inventory.ITEMFINDER, 1, "and the key item is still in the bag") + pressPack(pack, "a") + eq(pack.submenu, nil, "QUIT closes the submenu") + eq(pack.message, nil, "printing nothing") + eq(save.inventory.ITEMFINDER, 1, "and still spending nothing") + -- The trophy boxes are the same nibble: no decoration flag, no box spent. - pack.message = nil pressPack(pack, "left") pressPack(pack, "left") eq(pack:pocket().id, "ITEM", "back in the ITEMS pocket") pack.index = 2 eq(pack.rows[2].id, "NORMAL_BOX", "with the trophy box in it") pressPack(pack, "a") - eq(pack.message and pack.message[1], "OAK: {PLAYER}!", - "a trophy box answers .Oak in battle too") + eq(pack.submenu and table.concat(pack.submenu.rows, ","), "quit", + "a trophy box gets the QUIT-only submenu too") eq(save.inventory.NORMAL_BOX, 1, "the box is not spent") eq(next(world.events.flags or {}), nil, "and no decoration flag was granted") + pressPack(pack, "b") + eq(pack.submenu, nil, "B backs out of it as well") - -- ITEMMENU_PARTY still reaches the battle's own item flow. - pack.message = nil + pressPack(pack, "right") + pressPack(pack, "right") + pressPack(pack, "right") + eq(pack:pocket().id, "TM_HM", "the TM pocket") + pack.index = 1 + eq(pack.rows[1].id, "TM_ROCK_SMASH", "with the TM in it") + pressPack(pack, "a") + eq(pack.submenu and table.concat(pack.submenu.rows, ","), "quit", + "a TM in a fight gets QUIT alone") + eq(table.concat(pack:submenuRows("MYSTERY_THING"), ","), "quit", + "and so does a row with no attributes behind it, `xor a` forcing it") + pressPack(pack, "b") + + pressPack(pack, "left") + pressPack(pack, "left") + pressPack(pack, "left") + eq(pack:pocket().id, "ITEM", "back in the ITEMS pocket") pack.index = 1 eq(pack.rows[1].id, "POTION", "on the POTION") pressPack(pack, "a") + eq(pack.submenu and table.concat(pack.submenu.rows, ","), "use,quit", + "a battle-legal item gets .UsableMenuHeader's USE / QUIT") + eq(pack.submenu.index, 1, "opening on USE (`db 1 ; default option`)") + eq(getmetatable(pushed[#pushed]), PackMenu, "and nothing is used yet") + pressPack(pack, "a") eq(getmetatable(pushed[#pushed]), PartyMenu, - "a battle-legal item opens UseItem_SelectMon instead") + "USE opens UseItem_SelectMon") +end + +-- engine/items/pack.asm:1068 +do + local save = { player = { name = "GOLD" }, + inventory = { POKE_BALL = 1 } } + local game = { data = DATA, save = save, input = Input, options = {}, + stack = { push = function() end, pop = function() end, + top = function() return nil end } } + local thrown + local pack = PackMenu.new(game, { + save = save, items = ITEMS, world = {}, pocket = "BALL", + battle = true, tutorial = true, + onChoose = function(id) thrown = id end, + }) + eq(pack:inBattle(), true, "the DUDE's pack is opened over a battle") + eq(pack:hasSubmenu(), false, "and still has no submenu") + pressPack(pack, "a") + eq(pack.submenu, nil, "so A opens nothing") + eq(thrown, "POKE_BALL", "and throws the ball on the first press") +end + +-- ../pokecrystal/engine/items/pack.asm:810 +-- two headers in ../pokegold/engine/items/pack.asm are menu_coords 0. +do + local Chrome = require("src.ui.gen2.Chrome") + local realBox = Chrome.box + local realCursor = Chrome.cursorThrough + local realPrint = Chrome.printThrough + local boxes = {} + Chrome.box = function(tx, ty, tw, th) boxes[#boxes + 1] = { tx, ty, tw, th } end + Chrome.cursorThrough = function() end + Chrome.printThrough = function() end + + local function battlePack(version) + local save = { player = { name = "GOLD" }, version = version, + inventory = { POTION = 1 } } + local game = { data = DATA, save = save, input = Input, options = {}, + stack = { push = function() end, pop = function() end, + top = function() return nil end } } + return PackMenu.new(game, { save = save, items = ITEMS, world = {}, + pocket = "ITEM", battle = true }) + end + + local function drawnBox(pack) + pressPack(pack, "a") + boxes = {} + pack:drawSubmenu() + return boxes[1] + end + + local gold = battlePack("gold") + eq(gold:submenuColumn(), 0, "Gold's battle submenu is menu_coords 0") + local goldBox = drawnBox(gold) + eq(goldBox and goldBox[1], 0, "and the box it draws starts in column 0") + eq(goldBox and goldBox[3], 7, "SCREEN_WIDTH - 14 wide, both borders in") + eq(goldBox and goldBox[2], 7, ".UsableMenuHeader's row 7") + + local crystal = battlePack("crystal") + eq(crystal:submenuColumn(), 13, "Crystal's is menu_coords 13") + local crystalBox = drawnBox(crystal) + eq(crystalBox and crystalBox[1], 13, "putting the same box on the right") + eq(crystalBox and crystalBox[3], 7, "SCREEN_WIDTH - 1 is the same 7 wide") + + local silver = battlePack("silver") + eq(silver:submenuColumn(), 0, "Silver runs Gold's engine and its column") + + local field = PackMenu.new({ data = DATA, input = Input, options = {}, + save = { player = { name = "GOLD" }, version = "crystal", + inventory = { POTION = 1 } }, + stack = { push = function() end, pop = function() end, + top = function() return nil end } }, { items = ITEMS }) + eq(field:submenuColumn(), 0, "the field pack keeps the column it drew in") + + Chrome.box = realBox + Chrome.cursorThrough = realCursor + Chrome.printThrough = realPrint end -- ---- and the world refuses both arms on its own --------------------------- diff --git a/tests/gen2_battle_test.lua b/tests/gen2_battle_test.lua index 3fb4c794..e086328a 100644 --- a/tests/gen2_battle_test.lua +++ b/tests/gen2_battle_test.lua @@ -2257,6 +2257,23 @@ check("...but from RIVAL2_2 on it is the Champion's", BattleMusic.battleSong({ class = "RIVAL2", member = "RIVAL2_2_CHIKORITA", members = RIVAL2_MEMBERS, landmark = 80 }), "Music_ChampionBattle") +-- ../pokecrystal/engine/battle/start_battle.asm:60-66 +check("a Crystal roaming battle plays Suicune's theme", + BattleMusic.battleSong({ crystal = true, battleType = 5, landmark = 1, + daytime = "NITE" }), "Music_SuicuneBattle") +check("...and so does the Tin Tower Suicune", + BattleMusic.battleSong({ crystal = true, battleType = 12, landmark = 1 }), + "Music_SuicuneBattle") +check("...ahead of even the trainer class", + BattleMusic.battleSong({ crystal = true, battleType = 5, class = "FALKNER", + landmark = 1 }), "Music_SuicuneBattle") +check("Gold's roamers keep the ordinary wild theme", + BattleMusic.battleSong({ battleType = 5, landmark = 1, daytime = "DAY" }), + "Music_JohtoWildBattle") +check("...as does an ordinary Crystal wild battle", + BattleMusic.battleSong({ crystal = true, landmark = 1, daytime = "DAY" }), + "Music_JohtoWildBattle") + check("a wild win plays the wild jingle", BattleMusic.victorySong({}), "Music_WildPokemonVictory") -- PlayVictoryMusic's `.lost` path: no participant left standing means no diff --git a/tests/gen2_crystal_extras_test.lua b/tests/gen2_crystal_extras_test.lua index 23fe2ae9..1edbc556 100644 --- a/tests/gen2_crystal_extras_test.lua +++ b/tests/gen2_crystal_extras_test.lua @@ -498,6 +498,21 @@ do "CAUGHT_EGG_LEVEL prints EGG_LEVEL, not 1") end +-- GetCaughtLocation's .Unknown arm, ../pokecrystal/engine/events/poke_seer.asm +do + local Save = require("src.core.gen2.Save") + local legacy = runSeer(seerMon({ caughtLevel = 30 })) + check(legacy[2]:find("Unknown", 1, true) ~= nil, + "a half-stamped mon reads as Unknown before the migration") + + local file = { format = 7, version = "crystal", + party = { seerMon({ caughtLevel = 30 }) } } + Save.migrate(file) + local pages = runSeer(file.party[1]) + check(pages[2]:find("Whaaaat", 1, true) ~= nil, + "and as _SeerCantTellAThingText after it") +end + -- SeerAdviceTexts (engine/events/poke_seer.asm:357-364), walked in order; -- `sub c` is one byte, so the 255 row catches an underflow. do diff --git a/tests/gen2_save_test.lua b/tests/gen2_save_test.lua index a2a3d5d4..4ebbc859 100644 --- a/tests/gen2_save_test.lua +++ b/tests/gen2_save_test.lua @@ -175,8 +175,7 @@ check("and the player's name", legacy.party[1].ot, "GOLD") -- store, format 4 the MAIL structs (sPartyMail + sMailboxes), format 5 the -- world state (wEventFlags and the wSceneID block) becoming load bearing, -- format 6 wPlayerState, format 7 Mom's shopping pair (wWhichMomItem and --- wMomItemTriggerBalance). -check("format is 7", Save.FORMAT, 7) +check("format is 8", Save.FORMAT, 8) for from = 1, Save.FORMAT - 1 do check("a migration exists for format " .. from, type(Save.MIGRATIONS[from]), "function") diff --git a/tests/gen2_world_test.lua b/tests/gen2_world_test.lua index dfc2741f..b04998f0 100644 --- a/tests/gen2_world_test.lua +++ b/tests/gen2_world_test.lua @@ -164,6 +164,14 @@ local function fakeMap(cells, opts) if not map:inBounds(x, y) then return false end return Permissions.isWalkable(map:cellCollision(x, y)) end, + objectStepPermitted = function(_, cx, cy, dir) + local d = Map.DELTA[dir] + if not d then return false end + local tx, ty = cx + d[1], cy + d[2] + if not map:inBounds(tx, ty) then return false end + return Permissions.objectStepPermitted( + map:cellCollision(cx, cy), map:cellCollision(tx, ty), dir) + end, warpAt = function() return nil end, } return map diff --git a/tests/shaderfx_bridge_packaging_test.lua b/tests/shaderfx_bridge_packaging_test.lua index 43801778..9cc58c02 100644 --- a/tests/shaderfx_bridge_packaging_test.lua +++ b/tests/shaderfx_bridge_packaging_test.lua @@ -26,6 +26,7 @@ local arm64_pack = read("scripts/linux-arm64/build_appimage.sh") local verify = read("scripts/linux-arm64/verify_appimage.sh") local rg34 = read("build-rg34xxsp.sh") local sbc = read("build-linux-arm-sbc.sh") +local android = read("scripts/build_android.sh") for _, call in ipairs({ 'bundle_shader_bridge "$out_app/Contents/MacOS" "liblibrashader_bridge.dylib" mac', @@ -39,16 +40,28 @@ mustContain(build, 'SHADERFX_BRIDGE_REQUIRED', "build.sh hard-fail switch") mustContain(build, '[ "$plat" = "$(shader_bridge_host_plat)" ]', "build.sh host guard") mustContain(release, "shaderfx-bridge:", "release.yml bridge job") -for _, plat in ipairs({ "win-x64", "mac", "linux-x64", "linux-arm64" }) do +for _, plat in ipairs({ "win-x64", "mac", "linux-x64", "linux-arm64", "android" }) do mustContain(release, "plat: " .. plat, "release.yml matrix") end mustContain(release, "lipo -create", "release.yml universal macOS bridge") +mustContain(release, "cargo ndk -t arm64-v8a -t armeabi-v7a", "release.yml Android bridge build") +mustContain(release, "SHADERFX_BRIDGE_ANDROID_DIR", "release.yml stages the Android bridge") local requiredIn = select(2, release:gsub('SHADERFX_BRIDGE_REQUIRED: "1"', "")) -check(requiredIn >= 6, +check(requiredIn >= 7, 'release.yml must set SHADERFX_BRIDGE_REQUIRED on every shipping job, found ' .. requiredIn) mustContain(release, "librashader_bridge.dll", "release.yml Windows zip assertion") +mustContain(android, 'SHADERFX_BRIDGE_REQUIRED', "build_android.sh hard-fail switch") +mustContain(android, 'shader_bridge_absent', "build_android.sh routes every miss to the gate") +mustContain(android, 'lib/$abi/$SHADER_BRIDGE_LIB', "build_android.sh per-ABI APK assertion") +mustContain(android, 'shader_bridge_verify_apk "$apk"', "build_android.sh checks the built APK") +mustContain(android, 'apk_entries="$(unzip -Z1 "$apk")"', "build_android.sh lists the APK once") +check(android:find('unzip -l "$apk" | grep', 1, true) == nil, + "build_android.sh must not pipe unzip into grep -q (pipefail SIGPIPEs unzip, #774)") +check(android:find('warn "$SHADER_BRIDGE_LIB not', 1, true) == nil, + "build_android.sh must not warn-and-continue past a missing bridge") + mustContain(manifest, "# BRIDGE-BEGIN", "flatpak manifest markers") mustContain(manifest, "/app/share/gen1recomp/liblibrashader_bridge.so", "flatpak install path") mustContain(flatpak, "/# BRIDGE-BEGIN/,/# BRIDGE-END/d", "build_flatpak.sh strip")