mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-13 08:51:07 +02:00
Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f1950348d4 | |||
| 5603477787 | |||
| 0ca1defb66 | |||
| 370ddea0b8 | |||
| 6a9e77cde0 | |||
| a03f69926e | |||
| 04ece522d7 | |||
| 4943d3e5f4 | |||
| 5a48a61f2e |
@@ -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"
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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).
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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()
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user