diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b323a133..8602d822 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,7 +1,8 @@ name: Release -# Builds the macOS, Windows, and Linux desktop apps plus an Android APK -# on the self-hosted Mac runner, and publishes them as a GitHub Release. +# Builds the macOS, Windows, and Linux desktop apps, an Android APK, and the +# Anbernic RG34XXSP (Stock OS 64-bit MOD / PortMaster) port on the self-hosted +# Mac runner, and publishes them as a GitHub Release. # # Versioning: # - First ever release is 0.1.0. @@ -169,6 +170,13 @@ jobs: set -euo pipefail scripts/build_android.sh --version "${{ steps.ver.outputs.version }}" + - name: Build Anbernic RG34XXSP port + run: | + set -euo pipefail + # Self-contained aarch64 PortMaster-style pack; pulls the LÖVE 11.5 + # runtime from PortMaster-GUI, so it needs no signing/notarization. + ./build-rg34xxsp.sh --version "${{ steps.ver.outputs.version }}" + - name: Notarize & staple macOS app run: | set -euo pipefail @@ -218,6 +226,12 @@ jobs: [ -n "$apk" ] || { echo "::error::no Android APK found under dist/android/debug"; exit 1; } cp "$apk" "$outdir/gen1recomp-${v}-android.apk" + # Anbernic handheld port (suffix names the CFW it targets, so a + # future RG35XX/other-CFW pack can ship alongside it). + rg34="dist/rg34xxsp/gen1recomp-rg34xxsp-stockos64-mod.zip" + [ -f "$rg34" ] || { echo "::error::$rg34 not found (expected from ./build-rg34xxsp.sh)"; exit 1; } + cp "$rg34" "$outdir/gen1recomp-${v}-rg34xxsp-stockos64-mod.zip" + # Platform-independent update payload, built alongside the desktop # apps above (same game.love that gets fused into each of them). love_file=".bazinga/work/game.love" @@ -283,6 +297,7 @@ jobs: "dist/release/gen1recomp-${v}-windows.zip" \ "dist/release/gen1recomp-${v}-linux.zip" \ "dist/release/gen1recomp-${v}-android.apk" \ + "dist/release/gen1recomp-${v}-rg34xxsp-stockos64-mod.zip" \ "dist/release/gen1recomp-${v}.love" \ "dist/release/sha256sums.txt" diff --git a/README.md b/README.md index a88c01f4..f7d101c5 100644 --- a/README.md +++ b/README.md @@ -31,40 +31,6 @@ The packaged app contains neither a ROM nor pre-extracted game data. Music, sound effects, and cries are synthesized while the game runs from compact audio channel programs copied out of the verified ROM. -## Anbernic RG34XXSP (Stock OS 64-bit MOD) - -Download `gen1recomp-*-rg34xxsp-stockos64-mod.zip` from -[Releases](https://github.com/bryanthaboi/gen1recomp/releases). This build -targets **Stock OS 64-bit MOD** on the RG34XXSP with PortMaster installed -(TF1). - -1. Unzip the release on your computer. You get `Gen1recomp.sh` and a - `gen1recomp/` folder. -2. Copy **both** onto the SD card under **`Roms/PORTS/`** so the layout is: - - ``` - Roms/PORTS/Gen1recomp.sh - Roms/PORTS/gen1recomp/ - ``` - - On the device that path is `/mnt/mmc/Roms/PORTS/`. Keep the launcher and - the `gen1recomp/` folder as siblings — do not nest the `.sh` inside the - folder. -3. Put your legal US Red and/or Blue `.gb` files inside the game folder: - - ``` - Roms/PORTS/gen1recomp/lovegame/ - ``` - - Example: `Roms/PORTS/gen1recomp/lovegame/Pokemon - Red Version.gb` -4. Eject the card, boot the handheld, open **Ports → Gen1recomp**. -5. On the launcher, move the cursor with the D-pad or left stick, press **A** - to click. Choose the Red or Blue tab, then **Choose ROM** — with no file - picker on stock OS, that scans `lovegame/` for the `.gb` you dropped in. - -After import, saves and the ROM-derived cache stay next to the game on the -SD card (`portable.txt`). - ## Controls @@ -167,7 +133,12 @@ even on a different computer, as long as the same folder comes along. already written to either location is touched automatically, so copy files over yourself if you want to carry existing progress across the switch. +## Handhelds +A PortMaster-style port for the **Anbernic RG34XXSP** on Stock OS 64-bit MOD +ships with every release as `gen1recomp-*-rg34xxsp-stockos64-mod.zip`. +Install steps, controls, and troubleshooting live in +[docs/anbernic-rg34xxsp.md](docs/anbernic-rg34xxsp.md). ## Modding diff --git a/build-rg34xxsp.sh b/build-rg34xxsp.sh index efa9db73..44dbac4c 100755 --- a/build-rg34xxsp.sh +++ b/build-rg34xxsp.sh @@ -202,6 +202,11 @@ export LD_LIBRARY_PATH="$GAMEDIR/libs.aarch64:${LD_LIBRARY_PATH:-}" export SDL_GAMECONTROLLERCONFIG="${sdl_controllerconfig:-}" # Mali / H700: prefer GLES where available export LOVE_GRAPHICS_USE_OPENGLES="${LOVE_GRAPHICS_USE_OPENGLES:-1}" +# Same GPU class as a phone, but getOS() here says "Linux", so the Android +# gate (issue #136) would not fire on its own: refuse GBC FX explicitly. +# Hides the OPTIONS row, pins the level to OFF, and heals a level already +# persisted in options.lua. +export POKEPORT_GBCFX="${POKEPORT_GBCFX:-0}" $ESUDO chmod a+x ./bin/love.aarch64 2>/dev/null || chmod a+x ./bin/love.aarch64 $ESUDO chmod 666 /dev/uinput 2>/dev/null || true @@ -292,6 +297,13 @@ Native LÖVE 11.5 port of gen1recomp for Anbernic RG34XXSP on In-game controls use the normal PortMaster / SDL pad map (rebind under OPTIONS → CONTROLS). +### Display options + +GBC FX is disabled on this device (the H700's Mali GPU compiles that present +pass and then shows a black frame), so the OPTIONS row is hidden. COLORS, +TILT, ZOOM, VOID FILL and MAX FPS all work. To try it anyway, launch with +`POKEPORT_GBCFX=1`. + ### First run Stock OS has no zenity file picker. Put the `.gb` in `lovegame/`, then press diff --git a/docs/anbernic-rg34xxsp.md b/docs/anbernic-rg34xxsp.md new file mode 100644 index 00000000..4081f4bc --- /dev/null +++ b/docs/anbernic-rg34xxsp.md @@ -0,0 +1,91 @@ +# Anbernic RG34XXSP (Stock OS 64-bit MOD) + +Download `gen1recomp-*-rg34xxsp-stockos64-mod.zip` from +[Releases](https://github.com/bryanthaboi/gen1recomp/releases). This build +targets **Stock OS 64-bit MOD** on the RG34XXSP with PortMaster installed +(TF1). + +## Install + +1. Unzip the release on your computer. You get `Gen1recomp.sh` and a + `gen1recomp/` folder. +2. Copy **both** onto the SD card under **`Roms/PORTS/`** so the layout is: + + ``` + Roms/PORTS/Gen1recomp.sh + Roms/PORTS/gen1recomp/ + ``` + + On the device that path is `/mnt/mmc/Roms/PORTS/`. Keep the launcher and + the `gen1recomp/` folder as siblings — do not nest the `.sh` inside the + folder. +3. Put your legal US Red and/or Blue `.gb` files inside the game folder: + + ``` + Roms/PORTS/gen1recomp/lovegame/ + ``` + + Example: `Roms/PORTS/gen1recomp/lovegame/Pokemon - Red Version.gb` +4. Eject the card, boot the handheld, open **Ports → Gen1recomp**. +5. On the launcher, move the cursor with the D-pad or left stick, press **A** + to click. Choose the Red or Blue tab, then **Choose ROM** — with no file + picker on stock OS, that scans `lovegame/` for the `.gb` you dropped in. + +The port ships with `portable.txt` already in place, so after import the +saves and ROM-derived cache stay next to the game on the SD card. See +[Portable Mode](../README.md#portable-mode) for what that means. + +Only the canonical 1 MiB US carts import: + +- Red: `ea9bcae617fdf159b045185467ae58b2e4a48b9a` +- Blue: `d7037c83e1ae5b39bde3c30787637ba1d4c48ce2` + +## Launcher controls + +| Input | Action | +| ------------------ | ------------------ | +| D-pad / left stick | Move cursor | +| A | Click | +| L1 / R1 | Switch tabs | +| Right stick | Scroll lists | +| Start / Select | Play or Choose ROM | + +In-game controls use the normal PortMaster / SDL pad map, rebindable under +**OPTIONS → CONTROLS**. + +## Notes + +**GBC FX is off on this device.** The launcher exports `POKEPORT_GBCFX=0`, +which hides the GBC FX row from OPTIONS, pins the level to OFF, and clears a +level carried over in an `options.lua` from another machine. The H700's Mali +GPU is in the same class as the phone GPUs that compile that present pass and +then show a black frame (issue #136), and `love.system.getOS()` reports +`"Linux"` here, so the Android gate would not have caught it. Every other +display option — COLORS, TILT, ZOOM, VOID FILL, MAX FPS — works normally. If +your device turns out to handle the pass, launch with `POKEPORT_GBCFX=1` to +put the row back. + +The pack bundles the LÖVE 11.5 aarch64 runtime from +[PortMaster](https://portmaster.games/), so the device does not need a +separate `love_11.5` runtime download on first launch. The launcher resolves +paths relative to its own directory rather than PortMaster's `$directory`, +because stock Anbernic firmware runs ports out of `roms/PORTS/` with its own +casing and mount points. + +If a launch fails, `Roms/PORTS/gen1recomp/log.txt` holds the output of the +last run. + +## Building the port + +Release runs build this automatically (see `.github/workflows/release.yml`). +To build it by hand: + +```sh +./build-rg34xxsp.sh --version 0.1.0 # -> dist/rg34xxsp/gen1recomp-rg34xxsp-stockos64-mod.zip +``` + +`install-rg34xxsp.sh` copies that pack straight onto a mounted SD card for +local testing. + +Stock OS 64-bit MOD comes from +[cbepx-me](https://github.com/cbepx-me/Anbernic-H700-RG-xx-StockOS-Modification). diff --git a/docs/new-features.md b/docs/new-features.md index e07b9805..abcd5da4 100644 --- a/docs/new-features.md +++ b/docs/new-features.md @@ -129,6 +129,14 @@ It runs as a final present pass after world + UI composite in ([github.com/mattakins/Pixel_Transparency](https://github.com/mattakins/Pixel_Transparency)). Default OFF; persisted as `save.options.gbcfx`. +Mobile GPUs often compile the pass but present a black frame, so Android and +iOS hide the row entirely, pin the level to OFF, and rewrite a level already +persisted in `options.lua` (issue #136). `POKEPORT_GBCFX` overrides that +decision either way, same tri-state as `POKEPORT_TOUCH`: `=0` refuses the +effect, `=1` forces it available. The Anbernic handheld pack exports `0` from +its launcher because the device reports `"Linux"` while its GPU is in the +phone class (see [Anbernic RG34XXSP](anbernic-rg34xxsp.md)). + ## Peer-to-peer link play (lua-enet) Trades and link battles connect two copies of the game directly over @@ -143,6 +151,30 @@ tradeoff vs. the relay). Headless tests drive the protocol over an in-memory loopback (`Net.loopbackPair`); under LÖVE the same test file also exercises real UDP pairing. +## Fair play in link and online matches + +A link session is decided by the battle and nothing else, so for its +duration: + +- **Game speed is pinned to normal.** The GAME SPEED option and + `POKEPORT_SPEED` are ignored from the moment LINK PLAY opens until it + closes, and apply again after. Fast-forward otherwise runs one peer's + queue faster than the peer it is locked to and drains a tournament shot + clock faster than the opponent racing it. +- **Online play runs vanilla.** Picking ONLINE MATCH or TOURNAMENT with + mods enabled offers to switch them all off and relaunch (mods merge at + boot, so a restart is the only way). The restart is confirmed, not + silent. They stay listed as disabled, ready to switch back on. +- **Only a meaningful split ends a match.** The per-turn state signature + both peers exchange is split three ways: `actives` and `bench` carry + species, HP, status, stat stages, PP and the rest of the party, and a + divergence there ends the match as a draw. `volatile` carries per-turn + flags both sides recompute anyway - a divergence there is logged and + reported to mods, and play continues. + +The relay logs which component diverged on which turn, so a desync report +names something specific. + ## Custom boot text The boot sequence replaces the Nintendo / GAME FREAK identifiers with diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index 55910f23..ad0e5e2d 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -1240,6 +1240,31 @@ local function clearTrapping(battler) battler.trapDamage = nil end +-- core.asm:297-300: both sides' FLINCHED bits are cleared as a turn's move +-- selection opens, but the clear is skipped for a mon that must recharge or +-- is locked into Rage (core.asm:293-295 -- the Hyper Beam flinch-recharge +-- glitch). +-- +-- A method rather than three lines inside the menu branch, because two +-- other places need the identical rule at the identical point in the turn +-- and both got it wrong by not having it: +-- +-- * guarded PER BATTLER, not off self.player alone. This runs on +-- whichever machine is looking at its own menu, and in a lockstep link +-- battle "self.player" is the host's mon on one peer and the guest's on +-- the other, so one shared guard let one peer clear both flags while +-- the other cleared neither -- a bogus desync draw in a winnable match. +-- * a tournament spectator never enters the menu phase at all (it has no +-- decision to make), so nothing cleared a flinch in its replay: the +-- flag survived into the next turn, ate a move the real players saw +-- land, and from there the replay was watching a different battle. +-- LinkBattle.newSpectator calls this at the head of every turn. +function BattleState:clearTurnFlinches() + for _, b in ipairs({ self.player, self.enemy }) do + if b and not (b.mustRecharge or b.rageMove) then b.flinched = false end + end +end + -- Actions that skip DisplayBattleMenu entirely (core.asm:300-310): -- recharge, Rage, thrash, charge. Bide / trapping / being held do NOT -- skip the menu -- the player can still item/switch (and must press @@ -1260,11 +1285,17 @@ function BattleState:fightLockedAction(battler) end if battler.bideTurns then return { special = "bide" } end -- held while the OPPONENT's trapping bit is set (live mirror so a - -- trap ended early by paralysis/faint frees the victim immediately) + -- trap ended early by paralysis/faint frees the victim immediately). + -- Read, not written: executeAction refreshes battler.boundTurns from the + -- same expression when the action actually runs, and that site runs on + -- both peers of a link battle. Storing it here instead wrote a hashed + -- field on whichever machine happened to open its own FIGHT menu, which + -- left the two peers holding boundTurns=0 against nil for the same + -- battler and ended the match as a desync over a mirror of a mirror. local opp = battler.isPlayer and self.enemy or self.player - battler.boundTurns = opp and opp.trappingTurns - and math.max(1, opp.trappingTurns) or nil - if battler.boundTurns then + local bound = opp and opp.trappingTurns + and math.max(1, opp.trappingTurns) or nil + if bound then return { special = "bound" } end return nil @@ -1301,9 +1332,23 @@ function BattleState:swapMoves(i, j) require("src.core.Sound").play(self.data, "Swap") end -function BattleState:update(dt) +-- One frame of the presentational clock: the BGP flash sequences, the +-- per-battler pic slide/hide programs, the send-out grow-in, the intro +-- slide and the screen-shake programs all advance in updateFx and nowhere +-- else. It lives behind its own entry point because a caller that has to +-- skip the rest of update() for a frame must still tick this, and a link +-- battle does exactly that on two hot paths -- waiting on the peer's action, +-- and draining a resolved lockstep turn. Skipping it there froze whatever +-- was mid-flight: a flash stuck on its inverted BGP step repainted the whole +-- UI in inverted shades, and a pic part-way through a slide-off or a grow-in +-- simply stayed gone -- for as long as the opponent took to choose. +function BattleState:tickFx() self.frame = self.frame + 1 self:updateFx() +end + +function BattleState:update(dt) + self:tickFx() local input = self.game.input -- safety net: HP/status changed outside a queued drain (level-up heals, @@ -1386,13 +1431,7 @@ function BattleState:update(dt) end return end - -- core.asm:297-300: both sides' FLINCHED bits are cleared during - -- move selection, but the clear is skipped while the player must - -- recharge or is locked into Rage (core.asm:293-295 -- the Hyper - -- Beam flinch-recharge glitch) - if not (self.player.mustRecharge or self.player.rageMove) then - self.player.flinched, self.enemy.flinched = false, false - end + self:clearTurnFlinches() -- only recharge/Rage/thrash/charge skip DisplayBattleMenu; trapping -- victims (and wrappers) still get FIGHT/PKMN/ITEM/RUN (core.asm:312) local locked = self:menuLockedAction(self.player) diff --git a/src/core/Game.lua b/src/core/Game.lua index 1bad3b9f..5e102d44 100644 --- a/src/core/Game.lua +++ b/src/core/Game.lua @@ -191,6 +191,15 @@ end -- or screenshot run does not depend on whatever the player last chose. function Game:logicSpeed() local GameSpeed = require("src.core.GameSpeed") + -- Link play is always 1X on both machines, and this wins over every other + -- source including POKEPORT_SPEED. Fast-forward multiplies the logic + -- clock, so a peer at 10X burned a tournament shot clock ten times faster + -- than the opponent it is racing, and drove its own animation/message + -- queue at a different rate than the peer it is locked to. Nothing about + -- a match should depend on what either player set this to. + if self.linkSession or (self.linkNet and not self.linkNet.closed) then + return 1 + end if self.speedOverride then return GameSpeed.clamp(self.speedOverride) end local opts = self.save and self.save.options return GameSpeed.clamp(opts and opts.speed or GameSpeed.DEFAULT) diff --git a/src/link/LinkBattle.lua b/src/link/LinkBattle.lua index 4567a6fb..c51f57c5 100644 --- a/src/link/LinkBattle.lua +++ b/src/link/LinkBattle.lua @@ -112,6 +112,19 @@ local function scalar(v) return tostring(v) end +-- A volatile slot is "off" three interchangeable ways -- absent, false, or +-- a counter sitting at 0 -- because every reader tests `if b.key then` or +-- `key > 0`. They have to hash the same, or a match ends over a difference +-- that does not exist. They routinely disagree: the menu-phase flinch +-- clear (BattleState:update) and the FIGHT-branch boundTurns mirror +-- (fightLockedAction) are written by whichever machine is sitting at ITS +-- OWN menu, and in a link battle that is a different battler on each side, +-- so one peer holds flinched=false / boundTurns=0 where the other still +-- holds nil with two identical simulations underneath. +local function off(v) + return v == nil or v == false or v == 0 +end + local function stageStr(b) local out = {} for i, stat in ipairs(STAGES) do @@ -131,7 +144,7 @@ end local function volStr(b) local out = {} for _, key in ipairs(VOLATILE) do - if b[key] ~= nil then + if not off(b[key]) then out[#out + 1] = key .. "=" .. scalar(b[key]) end end @@ -168,6 +181,17 @@ end local PARTS = { "actives", "volatile", "bench" } +-- Which components are allowed to end a match. `actives` and `bench` carry +-- what decides one -- species, HP, status, stat stages, PP, the rest of the +-- party -- so a divergence there is a real split between the two +-- simulations and stays a draw. `volatile` is per-turn bookkeeping that +-- both sides recompute from the authoritative state every turn (see `off` +-- above): it can disagree for a turn without either side being wrong, and +-- when it does mean something real it lands in `actives` as damage or +-- status within a turn or two, where it is caught. Ending a match on it +-- alone cost players games they were winning, over nothing. +local FATAL_PART = { actives = true, bench = true } + -- opts: { myParty = packed, theirParty = packed, theirName, role = -- "host"/"guest", seed, verdict, strict }. Returns nil plus a reason when -- the handshake says the two link surfaces don't match: a lockstep @@ -315,12 +339,23 @@ function LinkBattle.new(game, net, opts) Logger.warn("link: desync turn %s component=%s (%s vs %s)", tostring(turn), component, tostring(localH), tostring(remoteH)) Runtime.emit("link.desync", { turn = turn, component = component, - localHash = localH, remoteHash = remoteH }) + localHash = localH, remoteHash = remoteH, + fatal = true }) endAsDraw(s, Strings( "Link desync!\n%s differs.\fAre both games\nrunning the same\nmods?", component)) end + -- a non-fatal component split: both sides log it and carry on, so the + -- match is decided by the battle rather than by bookkeeping + local function noteDrift(s, turn, component, localH, remoteH) + Logger.warn("link: %s drift on turn %s (%s vs %s) -- match continues", + component, tostring(turn), tostring(localH), tostring(remoteH)) + Runtime.emit("link.desync", { turn = turn, component = component, + localHash = localH, remoteHash = remoteH, + fatal = false }) + end + -- a verified turn stays recorded: consuming it here left a finished -- battle holding 0-1 entries, so the whole-battle sweep the link suite -- runs over localHashes had nothing left to compare @@ -333,8 +368,11 @@ function LinkBattle.new(game, net, opts) if mine and theirs then for _, component in ipairs(PARTS) do if mine[component] ~= theirs[component] then - reportDesync(s, turn, component, mine[component], theirs[component]) - return + if FATAL_PART[component] then + reportDesync(s, turn, component, mine[component], theirs[component]) + return + end + noteDrift(s, turn, component, mine[component], theirs[component]) end end end @@ -565,10 +603,19 @@ function LinkBattle.new(game, net, opts) if net.closed and not s.linkEnded and not s.result then endAsDraw(s) end + -- Both early returns below skip baseUpdate, which is where the + -- presentational clock normally advances -- so tick it here, in the same + -- order baseUpdate would (fx, then the queue). Without this an + -- animation caught mid-flight froze for the whole wait: a flash stopped + -- on its inverted BGP step and repainted the UI in inverted shades, and + -- a pic part-way through a slide or grow-in stayed off screen, which is + -- the "the screen went inverted" / "a Pokemon just vanished" pair. if s.phase == "waitRemote" then + s:tickFx() return -- the other side is still choosing end if s.phase == "messages" and s.afterQueue == "linkNext" then + s:tickFx() if not s:updateQueue() then s.afterQueue = "menu" s.phase = "menu" @@ -742,6 +789,13 @@ function LinkBattle.newSpectator(game, net, opts) end s:act(function() + -- the two real players cleared their flinch flags when their move + -- menu opened; a spectator has no menu, so it does it here instead, + -- at the same point in the turn (see BattleState:clearTurnFlinches). + -- Without this a flinch survived into the next turn and ate a move + -- that landed in the real match, and the replay -- sharing the RNG + -- stream -- was watching a different battle from that point on. + s:clearTurnFlinches() local hostAction = hostMsg.kind ~= "switch" and hostMsg.kind ~= "run" and decodeWireAction(s, hostMsg, s.player) or nil local guestAction = guestMsg.kind ~= "switch" and guestMsg.kind ~= "run" @@ -837,7 +891,11 @@ function LinkBattle.newSpectator(game, net, opts) if net.closed and not s.linkEnded and not s.result then endSpectate(s) end + -- same as the real-participant loop: every path that skips baseUpdate + -- still has to advance the presentational clock, and a spectator sits in + -- waitBoth between every single turn if s.phase == "messages" and s.afterQueue == "linkNext" then + s:tickFx() if not s:updateQueue() then s.afterQueue = "waitBoth" s.phase = "waitBoth" @@ -845,6 +903,7 @@ function LinkBattle.newSpectator(game, net, opts) return end if s.phase == "menu" or s.phase == "waitBoth" then + s:tickFx() return -- frozen between resolved turns; never a real decision here end baseUpdate(s, dt) diff --git a/src/link/LinkState.lua b/src/link/LinkState.lua index a744dd09..016a4802 100644 --- a/src/link/LinkState.lua +++ b/src/link/LinkState.lua @@ -73,6 +73,9 @@ end function LinkState.new(game) local self = setmetatable({}, LinkState) self.game = game + -- a link session runs at 1X on both machines whatever either player set + -- GAME SPEED to (see Game:logicSpeed); cleared in exitWith + game.linkSession = true self.stage = "menu" self.index = 1 self.addr = ipDigits(Net.lanIP()) @@ -98,6 +101,7 @@ end function LinkState:exitWith(message, reason) DiscordPresence.setJoinCode(nil) + self.game.linkSession = nil -- back to the player's own GAME SPEED Runtime.emit("link.ended", { reason = reason or (message and "error" or "bye") }) if self.net then self.net:close() end self.game.stack:pop() @@ -106,6 +110,46 @@ function LinkState:exitWith(message, reason) end end +-- Online play meets strangers, so it requires vanilla on both ends +-- (Handshake.onlineAllowed). Mods merge into the shared Data registries at +-- boot and there is no unmerge, so switching them off has to go through a +-- relaunch -- but the player should not have to go find the mod manager and +-- work out which mods count. This turns every enabled mod off, records +-- them so the mod manager can put them back, and relaunches. The restart +-- is confirmed rather than silent: it drops unsaved progress. +function LinkState:offerVanillaRestart() + local game = self.game + local loader = game.mods + local mods = Handshake.mods(game) + local names = {} + for i, mod in ipairs(mods) do + if i > 2 then break end + names[#names + 1] = tostring(mod.id):upper():sub(1, 12) + end + local list = table.concat(names, ", ") + if #mods > #names then list = list .. (" +%d"):format(#mods - #names) end + local text = Strings( + "Online play runs\nvanilla for both\nplayers.\fTurn off %s\nand restart?", list) + self.game.linkSession = nil + Runtime.emit("link.ended", { reason = "error" }) + if self.net then self.net:close() end + game.stack:pop() + game.stack:push(TextBox.new(game, text, nil, { choice = function(yes) + if not yes then return end + -- setEnabled persists the toggle itself (Loader:_saveState), so the + -- relaunch comes up vanilla and the mod manager lists them as disabled + -- for the player to switch back on afterwards + for _, mod in ipairs(mods) do + if loader and loader.setEnabled then loader:setEnabled(mod.id, false) end + end + if game.restartWithMods then + game:restartWithMods() + elseif love.event and love.event.quit then + love.event.quit("restart") + end + end })) +end + -- ------------------------------------------------------------------- -- handshake v2 (D8): both peers announce engine version, api version and -- a fingerprint of their link surface, and the verdict comes from the two @@ -202,7 +246,7 @@ function LinkState:update(dt) self.index = 1 elseif self.index == 2 or self.index == 3 then if not Handshake.onlineAllowed(self.game) then - self:exitWith(Strings("Online play needs\nno mods enabled.\fDisable them in\nSTART > MODS.")) + self:offerVanillaRestart() return end if self.index == 2 then @@ -509,6 +553,7 @@ function LinkState:updateTrade(input) if self.game.writeSave then self.game:writeSave() end local name = received.nickname or self.game.data.pokemon[received.species].name Runtime.emit("link.ended", { reason = "done" }) + self.game.linkSession = nil -- this path pops without exitWith self.net:close() self.game.stack:pop() local game = self.game diff --git a/src/link/Tournament.lua b/src/link/Tournament.lua index 47019e4b..233cf4e9 100644 --- a/src/link/Tournament.lua +++ b/src/link/Tournament.lua @@ -91,6 +91,11 @@ function Tournament.new(game) self.settingsIndex = 1 self.roster = {} self.spectatorRoster = {} + -- everything from here to exitWith runs at 1X regardless of the GAME + -- SPEED option (see Game:logicSpeed): a tournament's shot clock counts + -- down on the logic step, so fast-forward would hand one player less + -- real time to choose than the opponent they are racing + game.linkSession = true Sound.startLoop(game.data, MUSIC) return self end @@ -113,6 +118,7 @@ end function Tournament:exitWith(message) DiscordPresence.setJoinCode(nil) + self.game.linkSession = nil -- back to the player's own GAME SPEED Sound.stopLoop(MUSIC) Runtime.emit("link.ended", { reason = message and "error" or "bye" }) if self.net then self.net:close() end diff --git a/src/render/GBCFX.lua b/src/render/GBCFX.lua index 0a05db7f..c2788a2a 100644 --- a/src/render/GBCFX.lua +++ b/src/render/GBCFX.lua @@ -25,7 +25,16 @@ local shader -- false = unavailable (headless / no shader support) -- Mobile GPUs often compile this pass but present a black frame, and the -- level persists in options.lua -- soft-bricking the APK until a manual -- edit (issue #136). Desktop is unchanged; Android/iOS refuse the effect. +-- +-- POKEPORT_GBCFX overrides the platform default either way ("1" force on, +-- "0" force off), same tri-state as POKEPORT_TOUCH. Handheld packs whose +-- GPU is in the same class as a phone's but whose getOS() says "Linux" set +-- it to 0 in their launcher (build-rg34xxsp.sh); it also lets a desktop +-- checkout exercise the unsupported path without stubbing love.system. function GBCFX.isSupported() + local env = os.getenv("POKEPORT_GBCFX") + if env == "1" then return true end + if env == "0" then return false end if not love or not love.system or not love.system.getOS then return true end local osName = love.system.getOS() return osName ~= "Android" and osName ~= "iOS" diff --git a/src/render/TileRenderer.lua b/src/render/TileRenderer.lua index e10bff02..2970a68a 100644 --- a/src/render/TileRenderer.lua +++ b/src/render/TileRenderer.lua @@ -475,6 +475,13 @@ function TileRenderer.new(map, data) -- match the atlas's static tiles instead of showing raw grayscale gbcCtx = { tilesetId = map.tileset.id, mapId = map.id, key = "#gbc:" .. map.id, groupColors = PaletteFX.worldGroupColors(data, map.tileset.id, map.id, nil) } + -- ...and feeds the color-0-keyed single tiles the feet overdraw needs + -- (see getKeyedTile): same source image and palette groups, so keep the + -- context rather than re-deriving it per draw. + gbcCtx.imagePath = map.tileset.image + gbcCtx.perRow = map.tileset.tilesPerRow + self.gbcCtx = gbcCtx + self.gbcKeyed = {} end end -- a full-color atlas colors everything it paints, ring and border fill @@ -672,17 +679,48 @@ local function getColor0KeyShader() return color0KeyShader or nil end --- draw a cell's bottom tile row without touching the shader (the caller --- owns it). drawCellBottom wraps this with the color-0 key; tilt mode's --- upright pass wraps it with a color-0-keyed palette shader instead --- (PaletteFX.keyedShader) so the feet patch is colorized like the ground. +-- RED++ (COLORS=ADVANCED): the color-0 key, BAKED instead of tested for. +local function getKeyedTile(self, tile) + local ctx = self.gbcCtx + local cached = self.gbcKeyed[tile] + if cached ~= nil then return cached or nil end + local img = false + if ctx.groupColors and love.image and love.image.newImageData then + local group = PaletteFX.worldGroupAt(ctx.tilesetId, ctx.mapId, tile) + local colors = group and ctx.groupColors[group + 1] + local src = Assets.imageData(ctx.imagePath) + local ox = (tile % ctx.perRow) * 8 + local oy = math.floor(tile / ctx.perRow) * 8 + local out = love.image.newImageData(8, 8) + for py = 0, 7 do + for px = 0, 7 do + local r, g, b, a = src:getPixel(ox + px, oy + py) + -- read shade 0 off the RAW sheet, on recolorSample's own cutoff, so + -- the keyed pixels are exactly the ones the shader path keys + local shade0 = r > 0.83 + r, g, b, a = recolorSample(r, g, b, a, colors) + out:setPixel(px, py, r, g, b, shade0 and 0 or a) + end + end + img = love.graphics.newImage(out) + end + self.gbcKeyed[tile] = img + return img or nil +end + function TileRenderer:drawCellBottomRaw(cx, cy, camX, camY) local ty = cy * 2 + 1 for i = 0, 1 do local tx = cx * 2 + i - local quad = self.quads[self.map:tileAt(tx, ty)] - if quad then - love.graphics.draw(self.image, quad, tx * 8 - camX, ty * 8 - camY) + local tile = self.map:tileAt(tx, ty) + local keyed = tile and self.gbcCtx and getKeyedTile(self, tile) + if keyed then + love.graphics.draw(keyed, tx * 8 - camX, ty * 8 - camY) + else + local quad = self.quads[tile] + if quad then + love.graphics.draw(self.image, quad, tx * 8 - camX, ty * 8 - camY) + end end end end @@ -690,7 +728,9 @@ end -- redraw a cell's bottom tile row (tall grass hides the lower half of -- sprites standing in it, like the GB sprite-priority trick) function TileRenderer:drawCellBottom(cx, cy, camX, camY) - local shader = getColor0KeyShader() + -- the RED++ path is pre-keyed; the white test would be a no-op there at + -- best, and a false hit on some other group's near-white color 0 at worst + local shader = not self.gbcCtx and getColor0KeyShader() or nil if shader then love.graphics.setShader(shader) end self:drawCellBottomRaw(cx, cy, camX, camY) if shader then love.graphics.setShader() end @@ -712,13 +752,6 @@ function TileRenderer:markCellBottomRedraw(cx, cy, camX, camY, colors) end end --- Window cover for the static tile layer. Refill the reusable window batch --- (and the per-entry animated batches) only when the camera has scrolled past --- what they already cover; a small margin keeps small scrolls free. Cost --- scales with the view, never the map -- crossing a seam or warping in builds --- nothing. The beyond-body area (what the old 3-block ring drew) is painted --- by :drawBorderFill, whose world-aligned border-block tiling is identical --- there, so only body tiles are gathered here. local WINDOW_MARGIN = 8 -- tiles of slack kept around the view between refills function TileRenderer:ensureWindow(camX, camY, vw, vh) @@ -873,6 +906,12 @@ end -- atlas -- is unique to this map (gbcAtlasCache is keyed by map id). function TileRenderer:release() self:releaseBatches() + if self.gbcKeyed then + -- baked per instance, shared with nobody (see getKeyedTile) + for _, img in pairs(self.gbcKeyed) do safeRelease(img) end + self.gbcKeyed = nil + self.gbcCtx = nil + end if self.gbcAtlas and self.image then local key = self.map.tileset.image .. "#gbc:" .. self.map.id if gbcAtlasCache[key] == self.image then gbcAtlasCache[key] = nil end diff --git a/tests/drivers/grass_overlay_bug150_test.lua b/tests/drivers/grass_overlay_bug150_test.lua index aec213ce..8c54bf93 100644 --- a/tests/drivers/grass_overlay_bug150_test.lua +++ b/tests/drivers/grass_overlay_bug150_test.lua @@ -26,8 +26,20 @@ -- * the ROUTE terrain palette still carries BOTH grass green (173,230,90) and -- light-blue (165,214,255), so the grass field keeps its green+blue dither. -- --- Screenshots (SHOT_DIR): grass_bug150_sgb.png (the reported view) and --- grass_bug150_ogred.png (OG RED reference -- green character, red terrain). +-- The SECOND half of #150 is the feet overdraw itself. Tall grass hides the +-- lower half of whoever stands in it (the GB OBJ-priority trick: an OBJ shows +-- through BG colour 0 and hides under colours 1-3), which the port reproduces +-- by redrawing the cell's bottom tile row over the sprite with shade 0 keyed +-- to alpha. Every mode but ADVANCED still has DMG white sitting in shade 0 at +-- draw time, so a white test finds it; ADVANCED bakes the real per-tile GBC +-- palette into the atlas first (TileRenderer.getGbcAtlas), which turns the +-- grass tile's shade 0 into its palette's light green -- the white test then +-- never fires and the patch paints an opaque block over the player. Hence the +-- gap-pixel gate below, run in BOTH modes so the two can't drift apart again. +-- +-- Screenshots (SHOT_DIR): grass_bug150_sgb.png (the reported view), +-- grass_bug150_advanced.png (the ADVANCED view) and grass_bug150_ogred.png +-- (OG RED reference -- green character, red terrain). -- -- Run: POKEPORT_DRIVER=tests/drivers/grass_overlay_bug150_test.lua \ -- POKEPORT_IDENTITY=bug150 POKEPORT_TOUCH=0 love . @@ -49,6 +61,31 @@ return function(game) return false end + -- How many of the 16x8 feet-overdraw pixels let the thing underneath show + -- through, measured the way the screen does it: render the cell's bottom + -- tile row over an opaque magenta field and count the magenta survivors. + -- Mode-agnostic on purpose -- it asks what reached the framebuffer, not + -- which of the two keying paths (shader or baked alpha) produced it. + local function grassGapPixels(ow) + local p = ow.player + local canvas = love.graphics.newCanvas(16, 8) + love.graphics.setCanvas(canvas) + love.graphics.clear(1, 0, 1, 1) + love.graphics.setColor(1, 1, 1, 1) + -- camera placed so the cell's bottom tile row lands at the canvas origin + ow.map.renderer:drawCellBottom(p.cellX, p.cellY, p.cellX * 16, p.cellY * 16 + 8) + love.graphics.setCanvas() + local id = canvas:newImageData() + local n = 0 + for y = 0, 7 do + for x = 0, 15 do + local r, g, b = id:getPixel(x, y) + if r > 0.9 and g < 0.1 and b > 0.9 then n = n + 1 end + end + end + return n + end + -- a party + starter flag so the overworld is fully usable game.save.flags.EVENT_GOT_STARTER = true local Pokemon = require("src.pokemon.Pokemon") @@ -70,6 +107,13 @@ return function(game) U.wait(40) -- let the grass/flower tile animation cycle U.shot(game, DIR .. "/grass_bug150_sgb.png") + -- the feet overdraw is see-through in SGB (the mode the reporter compared + -- ADVANCED against), so this side of the gate holds before AND after + local sgbGaps = grassGapPixels(ow) + check(sgbGaps > 0, + "SGB grass feet overdraw shows the sprite through its gaps (" + .. sgbGaps .. "/128 px)") + -- === render-decision gate: fails before the fix, passes after ========= check(PaletteFX.usesSpriteObp("gbc") == false, "SGB owns no object palette (it cannot colour an OBJ apart from the BG)") @@ -100,7 +144,47 @@ return function(game) check(hasColor(terrain, 165, 214, 255), "ROUTE terrain palette still contains light-blue (grass keeps its blue dither)") + -- === ADVANCED (RED++): the same overdraw, over a baked true-colour atlas == + -- setMode drops every cached Map/TileRenderer and reloads the visible one, + -- so re-read the state's map before touching its renderer. + game.save.options.colors = "redpp" + PaletteFX.setMode("redpp") + U.wait(20) + ow = game.overworld + check(ow.map.renderer.gbcAtlas ~= nil, + "ADVANCED baked the per-tile GBC atlas for ROUTE_1") + local advGaps = grassGapPixels(ow) + check(advGaps > 0, + "ADVANCED grass feet overdraw shows the sprite through its gaps (" + .. advGaps .. "/128 px)") + -- the gaps must be the SAME pixels the other modes key -- a baked-in green + -- shade 0 is what #150 saw, so the count has to match SGB's exactly + check(advGaps == sgbGaps, + "ADVANCED keys the same shade-0 pixels SGB does (" .. advGaps + .. " vs " .. sgbGaps .. ")") + U.shot(game, DIR .. "/grass_bug150_advanced.png") + + -- ROUTE_1 is the OVERWORLD tileset; the other two tilesets that own a grass + -- tile (FOREST $20, PLATEAU $45) file it under the very same pack group 2, + -- so all three baked the same green over shade 0 and all three broke + -- together. One tileset passing proves nothing about the other two. + for _, spot in ipairs({ { "VIRIDIAN_FOREST", 6, 6, "FOREST" }, + { "ROUTE_23", 10, 44, "PLATEAU" } }) do + local id, cx, cy, tsId = spot[1], spot[2], spot[3], spot[4] + U.teleport(game, id, cx, cy, "down") + U.wait(10) + ow = game.overworld + check(ow.map.tileset.id == tsId + and ow.map:isGrassCell(ow.player.cellX, ow.player.cellY), + id .. ": player stands on " .. tsId .. " tall grass") + check(ow.map.renderer.gbcAtlas ~= nil, id .. ": ADVANCED baked its atlas") + local gaps = grassGapPixels(ow) + check(gaps > 0, id .. ": ADVANCED grass feet overdraw shows the sprite " + .. "through its gaps (" .. gaps .. "/128 px)") + end + -- OG RED reference for the human diff (green character over red terrain) + U.teleport(game, "ROUTE_1", 10, 6, "down") game.save.options.colors = "ogred" PaletteFX.setMode("ogred") U.wait(20) diff --git a/tests/drivers/online_match_host.lua b/tests/drivers/online_match_host.lua new file mode 100644 index 00000000..8e9be6de --- /dev/null +++ b/tests/drivers/online_match_host.lua @@ -0,0 +1,144 @@ +-- Driver: play a real online match, hosting side. +-- +-- Pair with tests/drivers/online_match_join.lua in a second instance: +-- +-- POKEPORT_IDENTITY=pokehost ONLINE_CODE_FILE=/tmp/poke_code.txt \ +-- POKEPORT_DRIVER=tests/drivers/online_match_host.lua love . +-- POKEPORT_IDENTITY=pokeguest ONLINE_CODE_FILE=/tmp/poke_code.txt \ +-- POKEPORT_DRIVER=tests/drivers/online_match_join.lua love . +-- +-- Two real windows, the real relay (POKEPORT_RELAY_ADDR to point elsewhere), +-- the real LinkState menus and the real lockstep battle -- the loopback +-- suites can't see anything the transport, the two separate processes or +-- the two save identities contribute. The host writes its room code to +-- ONLINE_CODE_FILE for the joiner to pick up, since on a real screen that +-- code is read aloud to a friend. +-- +-- Prints ONLINE_MATCH_HOST: lines; the wrapper script greps them. + +return function(game) + local U = dofile("tests/drivers/util.lua") + local Pokemon = require("src.pokemon.Pokemon") + local LinkState = require("src.link.LinkState") + local Runtime = require("src.mods.Runtime") + local DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + local CODE_FILE = os.getenv("ONLINE_CODE_FILE") or "/tmp/poke_code.txt" + local TAG = "ONLINE_MATCH_HOST:" + + local function log(...) U.log(TAG, ...) end + + -- a desync is the whole point of the exercise, so make it loud + local desyncs = {} + -- wrap emit rather than subscribing: with no mods loaded Runtime.events is + -- the null sink, and a vanilla online run is exactly the case under test + local realEmit = Runtime.emit + Runtime.emit = function(name, p) + if name == "link.desync" and p then + desyncs[#desyncs + 1] = p + log(("DESYNC turn=%s component=%s fatal=%s"):format( + tostring(p.turn), tostring(p.component), tostring(p.fatal))) + end + return realEmit(name, p) + end + + game.save.player.name = "HOST" + game.save.party = { + Pokemon.new(game.data, "CHARIZARD", 50), + Pokemon.new(game.data, "SNORLAX", 50), + Pokemon.new(game.data, "ALAKAZAM", 50), + } + U.teleport(game, "PALLET_TOWN", 10, 8, "down") + U.wait(30) + + local link = LinkState.new(game) + game.stack:push(link) + U.wait(10) + + -- the GAME SPEED forcing under test: a link session pins the logic clock + -- to 1X no matter what the option or POKEPORT_SPEED says + game.save.options.speed = 10 + U.wait(2) + log("logicSpeed with GAME SPEED=10 during link:", game:logicSpeed()) + + -- top menu: LAN / ONLINE MATCH / TOURNAMENT + U.tap(game, "down"); U.wait(3) + U.tap(game, "a"); U.wait(5) -- ONLINE MATCH + if link.stage ~= "onlineMenu" then + log("FAIL expected onlineMenu, at", tostring(link.stage)) + -- a mods-enabled build lands on the vanilla-restart prompt instead + U.wait(60) + U.shot(game, DIR .. "/online_host_blocked.png") + return + end + U.tap(game, "a"); U.wait(10) -- HOST ONLINE + + -- wait for the relay to hand back a room code + local code + for _ = 1, 900 do + if link.net and link.net.code then code = link.net.code break end + U.wait(1) + end + if not code then + log("FAIL no room code from the relay:", tostring(link.net and link.net.error)) + U.shot(game, DIR .. "/online_host_nocode.png") + return + end + log("hosting code", code) + U.shot(game, DIR .. "/online_host_1_code.png") + local f = io.open(CODE_FILE, "w") + if f then f:write(code) f:close() end + + -- wait for the joiner + for _ = 1, 1800 do + if link.stage == "modeSelect" then break end + U.wait(1) + end + if link.stage ~= "modeSelect" then + log("FAIL nobody joined (stage " .. tostring(link.stage) .. ")") + return + end + log("paired with", tostring(link.peerName)) + U.shot(game, DIR .. "/online_host_2_paired.png") + + U.tap(game, "down"); U.wait(3) -- TRADE / BATTLE -> BATTLE + U.tap(game, "a"); U.wait(10) + -- host owns the level rule; ANY is the default row + for _ = 1, 600 do + if link.stage == "battleOptions" then break end + U.wait(1) + end + if link.stage == "battleOptions" then + U.shot(game, DIR .. "/online_host_3_options.png") + U.tap(game, "a"); U.wait(5) + end + + -- the battle: mash A, exactly like a player who just wants it over with + local battle + for _ = 1, 1800 do + local top = game.stack:top() + if top and top.kind == "link" then battle = top break end + U.wait(1) + end + if not battle then + log("FAIL battle never started (stage " .. tostring(link.stage) .. ")") + U.shot(game, DIR .. "/online_host_nobattle.png") + return + end + log("battle started vs", tostring(battle.opponentName)) + U.shot(game, DIR .. "/online_host_4_battle.png") + + local shots = 0 + for i = 1, 200000 do + if battle.result then break end + U.tap(game, "a") + if i % 900 == 0 and shots < 3 then + shots = shots + 1 + U.shot(game, DIR .. ("/online_host_5_turn%d.png"):format(shots)) + end + end + U.wait(60) + U.shot(game, DIR .. "/online_host_6_result.png") + log("result", tostring(battle.result), "turns", tostring(battle.turnCount)) + log("desyncs", #desyncs) + log("DONE") +end diff --git a/tests/drivers/online_match_join.lua b/tests/drivers/online_match_join.lua new file mode 100644 index 00000000..48f8545d --- /dev/null +++ b/tests/drivers/online_match_join.lua @@ -0,0 +1,129 @@ +-- Driver: play a real online match, joining side. +-- See tests/drivers/online_match_host.lua for how to run the pair. + +return function(game) + local U = dofile("tests/drivers/util.lua") + local Pokemon = require("src.pokemon.Pokemon") + local LinkState = require("src.link.LinkState") + local CodeEntry = require("src.link.CodeEntry") + local Runtime = require("src.mods.Runtime") + local DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + local CODE_FILE = os.getenv("ONLINE_CODE_FILE") or "/tmp/poke_code.txt" + local TAG = "ONLINE_MATCH_JOIN:" + + local function log(...) U.log(TAG, ...) end + + local desyncs = {} + -- wrap emit rather than subscribing: with no mods loaded Runtime.events is + -- the null sink, and a vanilla online run is exactly the case under test + local realEmit = Runtime.emit + Runtime.emit = function(name, p) + if name == "link.desync" and p then + desyncs[#desyncs + 1] = p + log(("DESYNC turn=%s component=%s fatal=%s"):format( + tostring(p.turn), tostring(p.component), tostring(p.fatal))) + end + return realEmit(name, p) + end + + game.save.player.name = "GUEST" + game.save.party = { + Pokemon.new(game.data, "BLASTOISE", 50), + Pokemon.new(game.data, "GENGAR", 50), + Pokemon.new(game.data, "DRAGONITE", 50), + } + U.teleport(game, "PALLET_TOWN", 10, 8, "down") + U.wait(30) + + -- the host writes its room code out once the relay assigns one + local code + for _ = 1, 3600 do + local f = io.open(CODE_FILE, "r") + if f then + local text = (f:read("*a") or ""):gsub("%s+", "") + f:close() + if #text == CodeEntry.LENGTH then code = text break end + end + U.wait(1) + end + if not code then + log("FAIL host never published a code") + return + end + log("joining code", code) + + local link = LinkState.new(game) + game.stack:push(link) + U.wait(10) + game.save.options.speed = 20 + U.wait(2) + log("logicSpeed with GAME SPEED=20 during link:", game:logicSpeed()) + + log("before nav: stage", tostring(link.stage), "index", tostring(link.index), + "top", tostring(game.stack:top() == link), "mods", #require("src.link.Handshake").mods(game)) + U.tap(game, "down"); U.wait(3) + log("after down: stage", tostring(link.stage), "index", tostring(link.index)) + U.tap(game, "a"); U.wait(5) -- ONLINE MATCH + log("after a: stage", tostring(link.stage), "index", tostring(link.index)) + if link.stage ~= "onlineMenu" then + log("FAIL expected onlineMenu, at", tostring(link.stage)) + U.wait(60) + U.shot(game, DIR .. "/online_join_blocked.png") + return + end + U.tap(game, "down"); U.wait(3) + U.tap(game, "a"); U.wait(5) -- JOIN ONLINE -> code entry + if link.stage ~= "codeEntry" then + log("FAIL expected codeEntry, at", tostring(link.stage)) + return + end + -- set the six slots straight rather than scrubbing each one with UP + -- presses; the scrub interaction has its own coverage (online_play_test) + for i = 1, CodeEntry.LENGTH do + local idx = CodeEntry.CHARSET:find(code:sub(i, i), 1, true) + link.codeEntry.chars[i] = idx or 1 + end + U.wait(2) + U.shot(game, DIR .. "/online_join_1_code.png") + U.tap(game, "a"); U.wait(10) + + for _ = 1, 1800 do + if link.stage == "waitMode" or link.stage == "battleWait" then break end + if link.net and link.net.error then + log("FAIL join error:", tostring(link.net.error)) + return + end + U.wait(1) + end + log("connected, stage", tostring(link.stage)) + U.shot(game, DIR .. "/online_join_2_connected.png") + + local battle + for _ = 1, 1800 do + local top = game.stack:top() + if top and top.kind == "link" then battle = top break end + U.wait(1) + end + if not battle then + log("FAIL battle never started (stage " .. tostring(link.stage) .. ")") + U.shot(game, DIR .. "/online_join_nobattle.png") + return + end + log("battle started vs", tostring(battle.opponentName)) + U.shot(game, DIR .. "/online_join_3_battle.png") + + local shots = 0 + for i = 1, 200000 do + if battle.result then break end + U.tap(game, "a") + if i % 900 == 0 and shots < 3 then + shots = shots + 1 + U.shot(game, DIR .. ("/online_join_4_turn%d.png"):format(shots)) + end + end + U.wait(60) + U.shot(game, DIR .. "/online_join_5_result.png") + log("result", tostring(battle.result), "turns", tostring(battle.turnCount)) + log("desyncs", #desyncs) + log("DONE") +end diff --git a/tests/link_desync_fuzz.lua b/tests/link_desync_fuzz.lua new file mode 100644 index 00000000..a1bcd603 --- /dev/null +++ b/tests/link_desync_fuzz.lua @@ -0,0 +1,260 @@ +-- Lockstep desync fuzz: two full LinkBattle simulations over a loopback, +-- driven the way a player drives them, checked turn by turn. +-- +-- The suite in run_link_tests.lua battles one Charizard against one +-- Blastoise with the A button held, which reaches exactly one code path: +-- move slot 1, no switch, no faint, no status, no locked action. Every +-- desync players actually hit lived outside it. This walks the rest: +-- random multi-mon parties with random movesets, random move choices, +-- switches, faints and replacements, and -- the part that matters -- two +-- sides that are NOT identical clients. Three things differ between two +-- real players and never differ inside one test process: +-- +-- options animations on/off, text speed, battle style change how long a +-- side spends in its message queue +-- speed the GAME SPEED option multiplies the logic clock, so one side +-- takes several fixed steps per frame while the other takes one +-- lag the relay is not instantaneous, so a peer's action lands while +-- this side is somewhere in the middle of its own turn +-- +-- None of those may change the outcome: a lockstep battle is decided by the +-- two actions and the shared RNG stream, nothing else. What they DID change +-- was when each machine wrote per-turn bookkeeping flags, and the state hash +-- read `false`/`0` against `nil` as a divergence and ended winnable matches +-- as draws (LinkBattle's `off`, BattleState's per-battler flinch clear, and +-- fightLockedAction no longer storing its boundTurns mirror). +-- +-- Self-contained; run directly or via run_link_tests.lua: +-- luajit tests/link_desync_fuzz.lua [runs] [firstSeed] + +package.path = "./?.lua;./?/init.lua;" .. package.path +love = love or require("tests.love_stub") + +local Data = require("src.core.Data") +if not Data.pokemon then Data:load() end +local Pokemon = require("src.pokemon.Pokemon") +local Protocol = require("src.link.Protocol") +local Net = require("src.link.Net") +local Json = require("src.link.Json") +local Input = require("src.core.Input") +local LinkBattle = require("src.link.LinkBattle") +Input:init() +require("src.render.Font").load(Data) + +-- one PRNG per run so a failure replays from its seed alone +local function makeRandom(seed) + local s = seed % 2147483647 + if s <= 0 then s = s + 2147483646 end + return function(a, b) + s = (s * 16807) % 2147483647 + if a == nil then return s / 2147483647 end + if b == nil then a, b = 1, a end + return a + (s % (b - a + 1)) + end +end + +local SPECIES = {} +for id in pairs(Data.pokemon) do SPECIES[#SPECIES + 1] = id end +table.sort(SPECIES) -- pairs order is not stable; the seed has to be the only input +local MOVES = {} +for id, m in pairs(Data.moves) do + if id ~= "STRUGGLE" and m.pp and m.pp > 0 then MOVES[#MOVES + 1] = id end +end +table.sort(MOVES) + +-- a loopback pair that holds each message for `delay` pumps before it lands +-- in the peer's inbox, so one side is mid-queue when the other's action +-- arrives (Net.loopbackPair on its own delivers instantly, which is the one +-- thing the real relay never does) +local function laggyPair(delayA, delayB) + local a, b = Net.loopbackPair() + a.wire, b.wire = {}, {} + a.delay, b.delay = delayA or 0, delayB or 0 + local function send(self, msg) + if self.closed then return end + local decoded = Json.decode(Json.encode(msg)) -- same round trip as the wire + if decoded then table.insert(self.wire, { msg = decoded, at = self.delay }) end + end + local function update(self) + for i = #self.wire, 1, -1 do + local row = self.wire[i] + row.at = row.at - 1 + if row.at <= 0 then + table.remove(self.wire, i) + if not self.peerEnd.closed then table.insert(self.peerEnd.inbox, row.msg) end + end + end + end + a.send, b.send = send, send + a.update, b.update = update, update + return a, b +end + +local function makeFakeGame(name, options) + local save = require("src.core.SaveData").newGame() + save.player.name = name + for k, v in pairs(options or {}) do save.options[k] = v end + local stack = { list = {} } + function stack:push(s, ...) + table.insert(self.list, s) + if s.enter then s:enter(...) end + end + function stack:pop() table.remove(self.list) end + function stack:top() return self.list[#self.list] end + function stack:update(dt) + local t = self:top() + if t and t.update then t:update(dt) end + end + return { data = Data, input = Input, stack = stack, save = save } +end + +-- random movesets, not level-up ones: status, trapping, thrash, bide, +-- Hyper Beam and Rage are where the locked-action paths live +local function randomParty(rnd, size) + local party = {} + for _ = 1, size do + local mon = Pokemon.new(Data, SPECIES[rnd(1, #SPECIES)], rnd(5, 60)) + mon.moves = {} + for _ = 1, rnd(1, 4) do + local id = MOVES[rnd(1, #MOVES)] + table.insert(mon.moves, { id = id, pp = Data.moves[id].pp }) + end + party[#party + 1] = mon + end + return party +end + +local PARTS = { "actives", "volatile", "bench" } +local function firstMismatch(a, b) + local turns = {} + for t in pairs(a.localParts) do turns[t] = true end + for t in pairs(b.localParts) do turns[t] = true end + local ordered = {} + for t in pairs(turns) do ordered[#ordered + 1] = t end + table.sort(ordered) + for _, t in ipairs(ordered) do + local mine, theirs = a.localParts[t], b.localParts[t] + if mine and theirs then + for _, part in ipairs(PARTS) do + if mine[part] ~= theirs[part] then return t, part end + end + end + end +end + +-- Returns nil when the run agreed, or a description of how it split. +local function runOne(seed) + local rnd = makeRandom(seed) + -- the two sides are deliberately different clients + local optsA = { animations = false, textSpeed = 1, battleStyle = "SET" } + local optsB = { animations = true, textSpeed = 3, battleStyle = "SHIFT" } + local stepsA, stepsB = rnd(1, 4), 1 -- A fast-forwards, B does not + local lagA, lagB = rnd(0, 8), rnd(0, 8) + + local gameA, gameB = makeFakeGame("RED", optsA), makeFakeGame("BLUE", optsB) + gameA.save.party = randomParty(rnd, rnd(1, 4)) + gameB.save.party = randomParty(rnd, rnd(1, 4)) + + local netA, netB = laggyPair(lagA, lagB) + local battleSeed = rnd(1, 2 ^ 30) + local battleA = LinkBattle.newHost(gameA, netA, { + myParty = Protocol.packParty(gameA.save.party), + theirParty = Protocol.packParty(gameB.save.party), + theirName = "BLUE", seed = battleSeed }) + local battleB = LinkBattle.newGuest(gameB, netB, { + myParty = Protocol.packParty(gameB.save.party), + theirParty = Protocol.packParty(gameA.save.party), + theirName = "RED", seed = battleSeed }) + if not battleA or not battleB then return nil, 0 end + + local resA, resB + battleA.onFinish = function(r) resA = r end + battleB.onFinish = function(r) resB = r end + gameA.stack:push(battleA) + gameB.stack:push(battleB) + + local sides = { + { bt = battleA, game = gameA, party = battleA.playerParty, steps = stepsA }, + { bt = battleB, game = gameB, party = battleB.playerParty, steps = stepsB }, + } + + -- Only the cursor is steered; A (held below) does the committing, so the + -- battle is reached through DisplayBattleMenu exactly as a player reaches + -- it and every locked action (recharge / thrash / rage / bide / trapping / + -- bound / Struggle) fires from its own code path rather than being + -- injected. A switch waits for a second menu frame for the same reason: + -- the real PKMN branch sits behind the menu-entry bookkeeping. + local function drive(side) + local bt = side.bt + if bt.result then return end + if bt.phase ~= "menu" then side.menuFrames = 0 end + if bt.phase == "moveSelect" then + local usable = {} + for i, mv in ipairs(bt.player.curMoves) do + if (mv.pp or 0) > 0 and bt.player.disabledSlot ~= i then usable[#usable + 1] = i end + end + if #usable > 0 then bt.moveIndex = usable[rnd(1, #usable)] end + bt.menuIndex = 1 + elseif bt.phase == "menu" then + bt.menuIndex = 1 -- FIGHT + side.menuFrames = (side.menuFrames or 0) + 1 + if side.menuFrames >= 2 and not bt:menuLockedAction(bt.player) + and rnd(1, 100) <= 10 then + for _ = 1, 4 do + local mon = side.party[rnd(1, #side.party)] + if mon.hp > 0 and mon ~= bt.player.mon then + bt:resolveSwitch(mon) + side.menuFrames = 0 + return + end + end + end + end + end + + local guard = 0 + while (resA == nil or resB == nil) and guard < 60000 do + guard = guard + 1 + Input.pressed = { a = true } + for _, side in ipairs(sides) do + for _ = 1, side.steps do + drive(side) + side.game.stack:update(1 / 60) + end + end + local turn, part = firstMismatch(battleA, battleB) + if turn then + return ("seed %d: turn %d %s split (lag %d/%d, steps %d/%d)"):format( + seed, turn, part, lagA, lagB, stepsA, stepsB), battleA.turnCount or 0 + end + end + -- a battle still running at the guard is a stalemate (two mons that cannot + -- KO each other), not a split; only a finished one can be checked mirrored + if guard < 60000 + and (battleA.player.mon.hp ~= battleB.enemy.mon.hp + or battleA.enemy.mon.hp ~= battleB.player.mon.hp) then + return ("seed %d: final HP not mirrored (%d/%d vs %d/%d)"):format( + seed, battleA.player.mon.hp, battleA.enemy.mon.hp, + battleB.enemy.mon.hp, battleB.player.mon.hp), battleA.turnCount or 0 + end + return nil, battleA.turnCount or 0 +end + +local RUNS = tonumber(arg and arg[1]) or 40 +local FIRST = tonumber(arg and arg[2]) or 1 + +local failures, turns = 0, 0 +for seed = FIRST, FIRST + RUNS - 1 do + local ok, why, t = pcall(runOne, seed) + turns = turns + (t or 0) + if not ok then + failures = failures + 1 + print("FAIL link desync fuzz seed " .. seed .. ": " .. tostring(why)) + elseif why then + failures = failures + 1 + print("FAIL link desync fuzz " .. why) + end +end +print(("link desync fuzz: %d runs, %d turns, %d failures"):format(RUNS, turns, failures)) +assert(failures == 0, failures .. " lockstep run(s) diverged") +return true diff --git a/tests/link_tournament16.lua b/tests/link_tournament16.lua new file mode 100644 index 00000000..7a893cb4 --- /dev/null +++ b/tests/link_tournament16.lua @@ -0,0 +1,304 @@ +-- A full 16-player tournament, played out. +-- +-- Sixteen distinct identities, six random Pokemon each, everyone mashing A, +-- through every match of every round until one champion is left: 8 + 4 + 2 + +-- 1 = 15 real lockstep battles, each with the tournament match shape +-- (turnLimit shot clock, keepNetOpen, forceLevel) plus a live spectator +-- rebuilding the same match from the relay's fan-out. +-- +-- What this is actually checking, beyond "it finishes": +-- * 15 consecutive lockstep matches agree turn by turn, so a desync is +-- not something that only shows up deep into a bracket +-- * every match produces one winner and one loser -- never a double-win, +-- a double-loss, or a draw the server has to coin-flip (relay.js +-- resolves conflicting results with crypto.randomInt, so a draw here is +-- a real player's tournament decided by a coin toss) +-- * the spectator's reconstruction matches the two players' own views, +-- which is the tournament-only client path +-- * the shot clock does not fire on its own during ordinary play +-- +-- Bracket pairing/advancement itself is the relay's job and is covered +-- server-side (pokeserver test/tournament16.js); this owns the client half. +-- +-- luajit tests/link_tournament16.lua [seed] + +package.path = "./?.lua;./?/init.lua;" .. package.path +love = love or require("tests.love_stub") + +local Data = require("src.core.Data") +if not Data.pokemon then Data:load() end +local Pokemon = require("src.pokemon.Pokemon") +local Protocol = require("src.link.Protocol") +local Net = require("src.link.Net") +local Input = require("src.core.Input") +local LinkBattle = require("src.link.LinkBattle") +Input:init() +require("src.render.Font").load(Data) + +local PLAYERS = 16 +local PARTY_SIZE = 6 +local TURN_LIMIT = 6 -- one of relay.js's VALID_TURN_LIMITS + +local failures = 0 +local function check(cond, msg) + if cond then + print("ok " .. msg) + else + failures = failures + 1 + print("FAIL " .. msg) + end +end + +local function makeRandom(seed) + local s = seed % 2147483647 + if s <= 0 then s = s + 2147483646 end + return function(a, b) + s = (s * 16807) % 2147483647 + if a == nil then return s / 2147483647 end + if b == nil then a, b = 1, a end + return a + (s % (b - a + 1)) + end +end + +local SPECIES = {} +for id in pairs(Data.pokemon) do SPECIES[#SPECIES + 1] = id end +table.sort(SPECIES) +local MOVES = {} +for id, m in pairs(Data.moves) do + if id ~= "STRUGGLE" and m.pp and m.pp > 0 then MOVES[#MOVES + 1] = id end +end +table.sort(MOVES) + +local function makeFakeGame(name) + local save = require("src.core.SaveData").newGame() + save.player.name = name + local stack = { list = {} } + function stack:push(s, ...) + table.insert(self.list, s) + if s.enter then s:enter(...) end + end + function stack:pop() table.remove(self.list) end + function stack:top() return self.list[#self.list] end + function stack:update(dt) + local t = self:top() + if t and t.update then t:update(dt) end + end + return { data = Data, input = Input, stack = stack, save = save } +end + +-- six random species with random movesets, the tournament's required size +local function randomParty(rnd) + local party = {} + for _ = 1, PARTY_SIZE do + local mon = Pokemon.new(Data, SPECIES[rnd(1, #SPECIES)], rnd(20, 60)) + mon.moves = {} + for _ = 1, rnd(1, 4) do + local id = MOVES[rnd(1, #MOVES)] + table.insert(mon.moves, { id = id, pp = Data.moves[id].pp }) + end + party[#party + 1] = mon + end + return party +end + +-- a three-way loopback: the two players relay to each other, and every line +-- is also fanned out to the spectator tagged with the side that sent it, +-- which is the `spectate` envelope relay.js wraps tournament traffic in +local function matchNets() + local a, b = Net.loopbackPair() + local spec = Net.new() + spec.paired, spec.mode = true, "loopback" + local function send(self, msg) + if self.closed then return end + local Json = require("src.link.Json") + local decoded = Json.decode(Json.encode(msg)) + if not decoded then return end + if not self.peerEnd.closed then table.insert(self.peerEnd.inbox, decoded) end + if not spec.closed then + table.insert(spec.inbox, { type = "spectate", side = self.matchSide, + msg = Json.decode(Json.encode(msg)) }) + end + end + a.matchSide, b.matchSide = "host", "guest" + a.send, b.send = send, send + return a, b, spec +end + +local PARTS = { "actives", "volatile", "bench" } +local function splitTurn(a, b) + for turn, mine in pairs(a.localParts) do + local theirs = b.localParts[turn] + if theirs then + for _, part in ipairs(PARTS) do + if mine[part] ~= theirs[part] then return turn, part end + end + end + end +end + +-- Plays one bracket match to a finish. Returns the winner/loser entries, or +-- nil plus why it could not be decided. +local function playMatch(hostEntry, guestEntry, rnd, label) + local netH, netG, netS = matchNets() + local packedH = Protocol.packParty(hostEntry.game.save.party) + local packedG = Protocol.packParty(guestEntry.game.save.party) + local seed = rnd(1, 2 ^ 30) + local opts = { turnLimit = TURN_LIMIT, keepNetOpen = true, + verdict = "full", strict = true } + + local bH = LinkBattle.newHost(hostEntry.game, netH, { + myParty = packedH, theirParty = packedG, theirName = guestEntry.name, + seed = seed, turnLimit = opts.turnLimit, keepNetOpen = true, + verdict = opts.verdict, strict = opts.strict }) + local bG = LinkBattle.newGuest(guestEntry.game, netG, { + myParty = packedG, theirParty = packedH, theirName = hostEntry.name, + seed = seed, turnLimit = opts.turnLimit, keepNetOpen = true, + verdict = opts.verdict, strict = opts.strict }) + -- one of the fourteen players not in this match, watching it live (it has + -- a party of its own like any eliminated entrant would -- the spectator + -- never battles with it, but newWild builds its scaffold from one) + local specEntry = makeFakeGame("WATCHER") + specEntry.save.party = randomParty(rnd) + local bS = LinkBattle.newSpectator(specEntry, netS, { + hostParty = packedH, guestParty = packedG, + hostName = hostEntry.name, guestName = guestEntry.name, + seed = seed, verdict = opts.verdict, strict = opts.strict }) + if not (bH and bG and bS) then return nil, label .. ": a side refused to build" end + + local resH, resG + bH.onFinish = function(r) resH = r end + bG.onFinish = function(r) resG = r end + hostEntry.game.stack:push(bH) + guestEntry.game.stack:push(bG) + specEntry.stack:push(bS) + + local sides = { + { bt = bH, game = hostEntry.game, party = bH.playerParty }, + { bt = bG, game = guestEntry.game, party = bG.playerParty }, + } + -- everyone mashes A: the cursor is only steered onto a legal move row, + -- and occasionally onto a switch, so all six mons get used + local function drive(side) + local bt = side.bt + if bt.result then return end + if bt.phase ~= "menu" then side.menuFrames = 0 end + if bt.phase == "moveSelect" then + local usable = {} + for i, mv in ipairs(bt.player.curMoves) do + if (mv.pp or 0) > 0 and bt.player.disabledSlot ~= i then usable[#usable + 1] = i end + end + if #usable > 0 then bt.moveIndex = usable[rnd(1, #usable)] end + bt.menuIndex = 1 + elseif bt.phase == "menu" then + bt.menuIndex = 1 + side.menuFrames = (side.menuFrames or 0) + 1 + if side.menuFrames >= 2 and not bt:menuLockedAction(bt.player) + and rnd(1, 100) <= 8 then + for _ = 1, 4 do + local mon = side.party[rnd(1, #side.party)] + if mon.hp > 0 and mon ~= bt.player.mon then + bt:resolveSwitch(mon) + side.menuFrames = 0 + return + end + end + end + end + end + + local guard = 0 + while (resH == nil or resG == nil) and guard < 200000 do + guard = guard + 1 + Input.pressed = { a = true } + for _, side in ipairs(sides) do + drive(side) + side.game.stack:update(1 / 60) + end + specEntry.stack:update(1 / 60) + local turn, part = splitTurn(bH, bG) + if turn then + return nil, ("%s: turn %d %s split"):format(label, turn, part) + end + end + if resH == nil or resG == nil then + return nil, ("%s: unfinished after %d frames (turn %s)"):format( + label, guard, tostring(bH.turnCount)) + end + -- the two views of the same match have to be opposite verdicts; a draw + -- means the server picks the winner with a coin flip + if not ((resH == "win" and resG == "lose") or (resH == "lose" and resG == "win")) then + return nil, ("%s: not a decisive result (%s / %s)"):format(label, resH, resG) + end + -- The spectator is a replay, so it is legitimately behind the two players + -- when they finish -- it still has their last turns queued. Let it drain + -- before comparing, or the check reads a mid-match frame. + local settle = 0 + while settle < 20000 and not bS.finished do + settle = settle + 1 + local before = bS.turnCount + specEntry.stack:update(1 / 60) + if bS.result and bS.turnCount == before and #bS.queue == 0 then break end + end + -- the spectator rebuilt the same battle from the relay copy + local specOk = bS.player.mon.hp == bH.player.mon.hp + and bS.enemy.mon.hp == bG.player.mon.hp + if resH == "win" then + return hostEntry, guestEntry, specOk + end + return guestEntry, hostEntry, specOk +end + +local seed = tonumber(arg and arg[1]) or 20260728 +local rnd = makeRandom(seed) + +local entrants = {} +for i = 1, PLAYERS do + local name = ("PLAYER%02d"):format(i) + local game = makeFakeGame(name) + game.save.party = randomParty(rnd) + game.save.player.id = rnd(0, 65535) -- distinct trainer identities + entrants[i] = { name = name, game = game } +end +check(#entrants == PLAYERS, "16 distinct entrants registered") +local sizesOk = true +for _, e in ipairs(entrants) do + if #e.game.save.party ~= PARTY_SIZE then sizesOk = false end +end +check(sizesOk, "every entrant brings a full party of " .. PARTY_SIZE) + +-- 16 is a power of two, so no byes: 8 + 4 + 2 + 1 real matches +local alive = entrants +local round, matchesPlayed, specOkCount = 0, 0, 0 +while #alive > 1 and failures == 0 do + round = round + 1 + local survivors = {} + for i = 1, #alive, 2 do + local label = ("round %d match %d"):format(round, (i + 1) / 2) + local winner, loser, specOk = playMatch(alive[i], alive[i + 1], rnd, label) + if not winner then + check(false, tostring(loser)) + break + end + matchesPlayed = matchesPlayed + 1 + if specOk then specOkCount = specOkCount + 1 end + survivors[#survivors + 1] = winner + end + if failures > 0 then break end + check(#survivors == #alive / 2, + ("round %d halved the field (%d -> %d)"):format(round, #alive, #survivors)) + alive = survivors +end + +check(matchesPlayed == PLAYERS - 1, + ("every bracket match was played (%d of %d)"):format(matchesPlayed, PLAYERS - 1)) +check(round == 4, ("the bracket ran 4 rounds (ran %d)"):format(round)) +check(#alive == 1, "exactly one champion is left") +if alive[1] then print(" champion: " .. alive[1].name) end +check(specOkCount == matchesPlayed, + ("every match's spectator view matched the players' (%d of %d)"):format( + specOkCount, matchesPlayed)) + +print(("\ntournament16: %s"):format( + failures == 0 and "PASSED" or (failures .. " FAILURES"))) +assert(failures == 0, failures .. " tournament failure(s)") +return true diff --git a/tests/parity_gbcfx.lua b/tests/parity_gbcfx.lua index 996c20ab..891008e6 100644 --- a/tests/parity_gbcfx.lua +++ b/tests/parity_gbcfx.lua @@ -121,5 +121,51 @@ end check(not hasGbc, "Options menu omits GBC FX on Android") love.system = prevSystem +-- POKEPORT_GBCFX overrides the platform default both ways. Handheld packs +-- (build-rg34xxsp.sh) export 0 because their getOS() says "Linux" while the +-- GPU is phone class; 1 is the escape hatch if a device turns out to cope. +local prevEnv = os.getenv("POKEPORT_GBCFX") +local realGetenv = os.getenv +local stubEnv +os.getenv = function(name) + if name == "POKEPORT_GBCFX" then return stubEnv end + return realGetenv(name) +end + +stubEnv = "0" +check(not GBCFX.isSupported(), "POKEPORT_GBCFX=0 refuses GBC FX on desktop") +GBCFX.setLevel(3) +eq(GBCFX.level, 0, "setLevel forces OFF under POKEPORT_GBCFX=0") +local handheldOpts = { gbcfx = 4 } +check(GBCFX.applyOptions(handheldOpts) == true, + "applyOptions reports a cleared level under POKEPORT_GBCFX=0") +eq(handheldOpts.gbcfx, 0, "applyOptions heals a persisted level on a handheld") +check(not GBCFX.active(), "active() is false under POKEPORT_GBCFX=0") + +local omHandheld = OptionsMenu.new({ + data = { rulesets = {}, constants = {} }, + save = { options = {} }, + stack = { pop = function() end }, + input = { wasPressed = function() return false end }, + modStatus = { available = {} }, +}) +hasGbc = false +for _, row in ipairs(omHandheld.rows) do + if row.id == "gbcfx" then hasGbc = true end +end +check(not hasGbc, "Options menu omits GBC FX under POKEPORT_GBCFX=0") + +-- "1" wins over the Android gate, so the override is a real two-way door. +stubEnv = "1" +love.system = { getOS = function() return "Android" end } +check(GBCFX.isSupported(), "POKEPORT_GBCFX=1 forces GBC FX on despite Android") +love.system = prevSystem + +stubEnv = nil +check(GBCFX.isSupported(), "unset POKEPORT_GBCFX falls back to the OS check") +os.getenv = realGetenv +eq(os.getenv("POKEPORT_GBCFX"), prevEnv, "os.getenv restored") +GBCFX.setLevel(0) + -- === summary === S.finish() diff --git a/tests/run_link_tests.lua b/tests/run_link_tests.lua index 0c9af60c..2b1921b3 100644 --- a/tests/run_link_tests.lua +++ b/tests/run_link_tests.lua @@ -600,6 +600,51 @@ eq(battleSpec.player.mon.hp, battleE.player.mon.hp, eq(battleSpec.enemy.mon.hp, battleF.player.mon.hp, "spectator's guest-side HP matches the guest's own view") +-- ---------------------------------------------------------------- fx clock +-- A link battle skips BattleState:update on two hot paths -- waiting for the +-- peer's action, and draining a resolved turn -- and the presentational +-- clock (BGP flash sequences, pic slide/hide programs, the send-out grow-in) +-- only advances inside it. Frozen, a flash stopped on its inverted BGP step +-- and repainted the UI in inverted shades, and a pic caught mid-slide stayed +-- off screen, both for as long as the opponent took to choose. These assert +-- the clock keeps running on every path a link battle can sit in. +local fxGame = makeFakeGame("CHARIZARD") +local fxNetA = select(1, Net.loopbackPair()) +local fxBattle = LinkBattle.newHost(fxGame, fxNetA, { + myParty = Protocol.packParty(fxGame.save.party), + theirParty = Protocol.packParty(makeFakeGame("BLASTOISE").save.party), + theirName = "BLUE", seed = 99 }) +fxGame.stack:push(fxBattle) + +local function fxAdvances(phase, afterQueue) + fxBattle.phase = phase + fxBattle.afterQueue = afterQueue + -- a three-step flash sequence, exactly as an SE_* flash row starts one + fxBattle.fx = fxBattle.fx or {} + fxBattle.fx.bgpSeq = { steps = { { map = {}, frames = 2 }, + { map = {}, frames = 2 }, + { map = {}, frames = 2 } }, + idx = 1, left = 2 } + local startFrame = fxBattle.frame + for _ = 1, 4 do fxBattle:update(1 / 60) end + local seq = fxBattle.fx.bgpSeq + return fxBattle.frame > startFrame and (seq == nil or seq.idx > 1) +end + +check(fxAdvances("waitRemote", nil), + "the fx clock keeps running while waiting for the peer's action") +check(fxAdvances("messages", "linkNext"), + "the fx clock keeps running while a resolved turn drains") + +-- ---------------------------------------------------------------- desync fuzz +-- The lockstep battle above holds A through one Charizard/Blastoise duel, +-- which is one path. This walks the rest -- random parties, switches, +-- faints, locked actions -- with the two sides deliberately configured as +-- DIFFERENT clients (options, game speed, relay lag), which is the shape +-- every desync players reported actually had. +local fuzzOk, fuzzErr = pcall(dofile, "tests/link_desync_fuzz.lua") +check(fuzzOk, "lockstep desync fuzz" .. (fuzzOk and "" or (": " .. tostring(fuzzErr)))) + -- ---------------------------------------------------------------- mod link compat -- Self-contained like the tests/mod_*.lua suites: own bootstrap and -- assert-based checks, so it lands here as a single pass/fail line.