Compare commits

..

9 Commits

Author SHA1 Message Date
bryanthaboi f1950348d4 Merge pull request #344 from bryanthaboi/dev
bazinga linux
2026-07-28 15:39:59 -04:00
bryanthaboi 5603477787 Merge pull request #343 from GFlorio/appimage-fixes
Fix ROM picker + Auto Updater in the Linux Appimage release
2026-07-28 15:36:54 -04:00
bryanthaboi 0ca1defb66 Merge pull request #341 from bryanthaboi/dev
fix internet, fix the mf grass in advanced
2026-07-28 15:16:09 -04:00
bryanthaboi 370ddea0b8 fix the mf grass 2026-07-28 15:14:48 -04:00
Gabriel Florio 6a9e77cde0 Fix app crashing on self-restart in the AppImage release 2026-07-28 16:02:20 -03:00
bryanthaboi a03f69926e bazinga 2026-07-28 15:01:59 -04:00
bryanthaboi 04ece522d7 anbernic in build and disable gbcfx on it 2026-07-28 14:04:49 -04:00
Gabriel Florio 4943d3e5f4 Unset LD_LIBRARY_PATH when calling system tools from AppImage 2026-07-28 14:42:45 -03:00
bryanthaboi 5a48a61f2e anbernic included in next release 2026-07-28 12:49:04 -04:00
22 changed files with 1470 additions and 80 deletions
+17 -2
View File
@@ -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"
+5 -34
View File
@@ -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
+12
View File
@@ -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
+91
View File
@@ -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).
+32
View File
@@ -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
+51 -12
View File
@@ -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)
+10 -3
View File
@@ -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)
@@ -391,9 +400,7 @@ end
-- LÖVE process ensures scripts, registries, and assets are all rebuilt from
-- the newly selected mod state.
function Game:restartWithMods()
if love.event and love.event.quit then
love.event.quit("restart")
end
require("src.core.HostShell").restart()
end
function Game:keyreleased(key)
+51
View File
@@ -0,0 +1,51 @@
-- Helpers for calling host tools (curl, zenity/kdialog, ...).
local HostShell = {}
-- Our AppRun exports LD_LIBRARY_PATH="$APPDIR/lib:..." so every subprocess we
-- spawn tries to link against the libraries we're shipping instead of the
-- system ones. We want to unset the var so that any system tools can find
-- their proper libraries. Only needed when running in an AppImage.
function HostShell.envPrefix()
if os.getenv("APPIMAGE") then
return "env -u LD_LIBRARY_PATH "
end
return ""
end
-- Wraps io.popen with the AppImage env fix applied and lua errors swallowed
function HostShell.popen(command, mode)
local ok, pipe = pcall(io.popen, HostShell.envPrefix() .. command, mode or "r")
if not ok or not pipe then return nil end
return pipe
end
-- Restart the whole app. The obvious love.event.quit("restart") re-runs LÖVE's
-- boot in-process, which calls love.filesystem.init a second time -- and inside
-- an AppImage physfs is already initialized, so that second init throws
-- ("Failed to initialize filesystem: already initialized") and the relaunch
-- crashes. So on an AppImage we relaunch the executable; the fresh process's
-- Boot step mounts any downloaded update exactly as a manual relaunch would.
-- On every other platform the in-process restart works, so keep it.
function HostShell.restart()
if not (love and love.event and love.event.quit) then return end
local appimage = os.getenv("APPIMAGE")
if not appimage then
love.event.quit("restart")
return
end
-- We have to restart the process with this cursed execv call to prevent the
-- PID from changing, which might cause SteamOS and other Linux launchers to
-- think the app has crashed.
local ffi = require("ffi")
pcall(ffi.cdef, [[
int execv(const char *path, char *const argv[]);
int unsetenv(const char *name);
]])
ffi.C.unsetenv("LD_LIBRARY_PATH")
local argv = ffi.new("const char *[2]", appimage, nil)
ffi.C.execv(appimage, ffi.cast("char *const *", argv))
end
return HostShell
+3 -2
View File
@@ -1,5 +1,6 @@
local GameVersion = require("src.core.GameVersion")
local Strings = require("src.core.Strings")
local HostShell = require("src.core.HostShell")
local RomImporter = {}
RomImporter.__index = RomImporter
@@ -285,7 +286,7 @@ end
local function commandOutput(command)
releasePointerGrab()
local pipe = io.popen(command, "r")
local pipe = HostShell.popen(command)
if not pipe then return nil end
local result = pipe:read("*a")
pipe:close()
@@ -1859,7 +1860,7 @@ function RomImporter:mousepressed(x, y, button)
if action == "download" and self.Check then
pcall(self.Check.download)
elseif action == "restart" then
love.event.quit("restart")
HostShell.restart()
elseif action == "openurl" and self.Check then
love.system.openURL(self.Check.releaseUrl())
end
+63 -4
View File
@@ -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)
+46 -1
View File
@@ -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
+6
View File
@@ -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
+9
View File
@@ -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"
+54 -15
View File
@@ -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
+6 -5
View File
@@ -35,6 +35,7 @@ local Json = loadModule("src/link/Json.lua")
local Check = loadModule("src/update/Check.lua")
local Version = loadModule("src/core/Version.lua")
local Semver = loadModule("src/update/Semver.lua")
local HostShell = loadModule("src/core/HostShell.lua")
-- Boot's top-level require("src.update.Semver") cannot resolve in this thread
-- (no src.* searcher), which would leave Boot nil and the minShell gate
-- permanently permissive. Seed the loaded table first so it resolves.
@@ -76,8 +77,8 @@ local function curlCapture(url)
.. "-H " .. shq("User-Agent: gen1recomp-updater") .. " "
.. "-H " .. shq("Accept: application/vnd.github+json") .. " "
.. shq(url)
local ok, pipe = pcall(io.popen, cmd)
if not ok or not pipe then return nil end
local pipe = HostShell.popen(cmd)
if not pipe then return nil end
local out = pipe:read("*a")
pipe:close()
if not out or out == "" then return nil end
@@ -85,8 +86,8 @@ local function curlCapture(url)
end
local function haveCurl()
local ok, pipe = pcall(io.popen, "curl --version")
if not ok or not pipe then return false end
local pipe = HostShell.popen("curl --version")
if not pipe then return false end
local out = pipe:read("*a")
pipe:close()
return out ~= nil and out:find("curl", 1, true) ~= nil
@@ -239,7 +240,7 @@ local function launchDownload(url, partAbs, doneAbs)
os.execute('start "" /b ' .. shq(saveDir .. "/" .. batRel))
else
-- ( ... ) & backgrounds the whole group so os.execute returns at once
os.execute("( curl -fsSL --connect-timeout 15 --max-time 900 -o "
os.execute("( " .. HostShell.envPrefix() .. "curl -fsSL --connect-timeout 15 --max-time 900 -o "
.. shq(partAbs) .. " " .. shq(url)
.. " ; touch " .. shq(doneAbs) .. " ) >/dev/null 2>&1 &")
end
+86 -2
View File
@@ -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)
+144
View File
@@ -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
+129
View File
@@ -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
+260
View File
@@ -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
+304
View File
@@ -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
+46
View File
@@ -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()
+45
View File
@@ -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.