mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-19 20:20:19 +02:00
Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 74e04cb086 | |||
| 25ec896545 | |||
| 8240205254 | |||
| 55616fc03d | |||
| 675971068e | |||
| cfa8406306 | |||
| 0ea224d5db | |||
| c2e1db0f89 | |||
| 7b1e796c48 | |||
| 4c13770e70 |
@@ -120,7 +120,7 @@ jobs:
|
|||||||
echo "changed=true" >> "$GITHUB_OUTPUT"
|
echo "changed=true" >> "$GITHUB_OUTPUT"
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
if git diff --name-only "$BASE_SHA" "$HEAD_SHA" | grep -Eq '^(scripts/build_switch\.sh$|scripts/switch/|docs/switch-.*\.md$|tests/switch_ci_workflows_test\.lua$|tests/switch_transfer_docs_test\.lua$|\.github/workflows/(ci|release|switch-artifact-comment)\.yml$|src/core/(NxAssetOverlay|Platform|GameVersion)\.lua$|src/import/CacheFs\.lua$|tests/engine/(assets_version_fallback|nx_generated_guard|nx_yellow_boot|switch_diagnostics)_test\.lua$|tests/engine/platform_nx)'; then
|
if git diff --name-only "$BASE_SHA" "$HEAD_SHA" | grep -Eq '^(scripts/build_switch\.sh$|scripts/switch/|docs/switch-.*\.md$|tests/switch_ci_workflows_test\.lua$|tests/switch_transfer_docs_test\.lua$|\.github/workflows/(ci|release|switch-artifact-comment)\.yml$|src/core/(NxAssetOverlay|Platform|GameVersion)\.lua$|src/import/CacheFs\.lua$|tests/engine/(assets_version_fallback|nx_generated_guard|nx_yellow_boot|switch_diagnostics|cache_fs_gold_nx_load)_test\.lua$|tests/engine/platform_nx)'; then
|
||||||
echo "changed=true" >> "$GITHUB_OUTPUT"
|
echo "changed=true" >> "$GITHUB_OUTPUT"
|
||||||
else
|
else
|
||||||
echo "changed=false" >> "$GITHUB_OUTPUT"
|
echo "changed=false" >> "$GITHUB_OUTPUT"
|
||||||
@@ -151,6 +151,9 @@ jobs:
|
|||||||
luajit tests/engine/assets_version_fallback_test.lua
|
luajit tests/engine/assets_version_fallback_test.lua
|
||||||
luajit tests/engine/nx_generated_guard_test.lua
|
luajit tests/engine/nx_generated_guard_test.lua
|
||||||
luajit tests/engine/nx_yellow_boot_test.lua
|
luajit tests/engine/nx_yellow_boot_test.lua
|
||||||
|
luajit tests/engine/cache_fs_gold_nx_load_test.lua
|
||||||
|
luajit tests/engine/cache_fs_blue_mount_test.lua
|
||||||
|
luajit tests/engine/switch_diagnostics_test.lua
|
||||||
|
|
||||||
switch-build:
|
switch-build:
|
||||||
name: Switch fused build
|
name: Switch fused build
|
||||||
|
|||||||
@@ -354,7 +354,8 @@ request with real detail is one that can actually get built.
|
|||||||
- [Save editor](https://github.com/bryanthaboi/gen1recomp/wiki/Guide-Save-Editor)
|
- [Save editor](https://github.com/bryanthaboi/gen1recomp/wiki/Guide-Save-Editor)
|
||||||
— edit party, boxes, items, events, and Pokédex flags outside the game.
|
— edit party, boxes, items, events, and Pokédex flags outside the game.
|
||||||
- `docs/architecture.md` — runtime details;
|
- `docs/architecture.md` — runtime details;
|
||||||
`docs/behavior-porting-notes.md` — formula provenance.
|
`docs/behavior-porting-notes.md` — formula provenance;
|
||||||
|
`docs/link-security.md` — what link play defends against, and what it doesn't.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
# Link play: threat model and what the code actually guarantees
|
||||||
|
|
||||||
|
Link play is the only part of this game that reads bytes written by
|
||||||
|
somebody else. This is what it defends against, what it does not, and
|
||||||
|
where each guarantee lives.
|
||||||
|
|
||||||
|
## The boundary
|
||||||
|
|
||||||
|
Everything a peer or the relay sends arrives as one JSON object per line.
|
||||||
|
There is exactly one place it becomes a message:
|
||||||
|
|
||||||
|
src/link/Net.lua reads bytes, frames lines, decodes JSON
|
||||||
|
src/link/Wire.lua rebuilds each line as a typed message
|
||||||
|
src/link/Session.lua the only path from a transport into a mode
|
||||||
|
|
||||||
|
`Session:update` runs `Wire.sanitize` on every message before anything
|
||||||
|
else sees it. A schema returns a **new** table holding only the fields it
|
||||||
|
names, at the Lua types it names, so the rest of `src/link/` can read
|
||||||
|
`msg.slot`, `msg.parts.actives` or `msg.mons[i].dvs.hp` directly and be
|
||||||
|
right by construction. A message with no schema (a mod's, or a future
|
||||||
|
build's) keeps a bounded, scalar-only copy of its payload instead of
|
||||||
|
being dropped.
|
||||||
|
|
||||||
|
A message that fails its schema is **dropped and logged**, never fatal.
|
||||||
|
Latching a terminal failure would hand a hostile peer a cheaper
|
||||||
|
disconnect than sending nothing at all.
|
||||||
|
|
||||||
|
### Why the bounds are loose
|
||||||
|
|
||||||
|
Wire's numeric bounds are deliberately wider than the game's own clamps in
|
||||||
|
`Protocol.unpackMon`. Both peers run identical clamps over identical
|
||||||
|
packets; a bound that bit an honest value would change one side's copy of
|
||||||
|
a mon and desync the lockstep. Wire's job is types and sizes. Rules are
|
||||||
|
`Protocol`'s job, and it keeps its own clamps for the callers that reach
|
||||||
|
it without a Session (the mod API, `tests/`).
|
||||||
|
|
||||||
|
### Containment behind it
|
||||||
|
|
||||||
|
Assume something still gets through:
|
||||||
|
|
||||||
|
- `Game:step` pcalls the link pump, and pcalls `stack:update` **only
|
||||||
|
while a link session is active**. On a throw, `Game:breakLink` closes
|
||||||
|
the connection, unwinds to the overworld and says "The link was
|
||||||
|
broken." Outside link play the stack is unguarded on purpose: a blanket
|
||||||
|
pcall would swallow real engine bugs and leave the game silently wrong
|
||||||
|
instead of loudly broken.
|
||||||
|
- `Net` caps `rxBuf` at 256KB and its per-frame read at 512KB, so a peer
|
||||||
|
that never sends a newline ends as a clean disconnect.
|
||||||
|
- `Json.decode` refuses documents nested past 64 levels, and takes an
|
||||||
|
optional length cap that the link path passes and the mod-manifest path
|
||||||
|
does not.
|
||||||
|
|
||||||
|
## The relay (`../pokeserver`)
|
||||||
|
|
||||||
|
- A line that is not a JSON **object** with a string `type` is dropped
|
||||||
|
before any handler runs, and `onLine` is wrapped in try/catch.
|
||||||
|
`server.js` installs `uncaughtException`/`unhandledRejection` handlers:
|
||||||
|
one bad packet must never take every live match down with the process.
|
||||||
|
- Line buffers are capped, lines per second are capped, connections per
|
||||||
|
IP and in total are capped, and an unbound connection that never hosts
|
||||||
|
or joins is swept after 30s.
|
||||||
|
- `SERVER_ONLY` is the set of message types the server is the only
|
||||||
|
legitimate author of (`peer_gone`, `bracket_update`, `match_start`,
|
||||||
|
`tournament_over`, `spectate`, ...). A peer that sends one has them
|
||||||
|
dropped rather than forwarded, so a bracket opponent cannot forge a
|
||||||
|
tournament result or fake "your opponent left".
|
||||||
|
- Trainer names are reduced to a printable subset and capped at the same
|
||||||
|
10 characters the game enforces, on the way in, because they are
|
||||||
|
rendered by the dashboard and broadcast to every participant.
|
||||||
|
|
||||||
|
`pokeserver/test/hostile.js` is the regression net for all of that.
|
||||||
|
|
||||||
|
## What is NOT defended
|
||||||
|
|
||||||
|
**Party legality is trust-the-client.** Online play meets strangers, and
|
||||||
|
`Handshake.onlineAllowed` is a Lua function in the same VM the mods load
|
||||||
|
into. It cannot be made tamper-proof in-process, and pretending otherwise
|
||||||
|
would only cost honest mod authors. What lockstep and
|
||||||
|
`Protocol.unpackMon`'s recompute-from-species-data *do* guarantee is that
|
||||||
|
a cheater cannot invent stats, moves, or a shiny: every derived value is
|
||||||
|
rebuilt locally from real species data. They can send a legal party they
|
||||||
|
farmed or edited. That is the honest boundary.
|
||||||
|
|
||||||
|
What the relay does instead is **observe and record**. It already sees
|
||||||
|
every `hello`, so it keeps each connection's self-reported
|
||||||
|
`engineVersion`, `fingerprint` and `linkModified`, compares the two sides
|
||||||
|
of a room or a live tournament match, and logs and surfaces a
|
||||||
|
`modded` / `fingerprint_mismatch` / `version_skew` flag on the dashboard.
|
||||||
|
A patched client can still lie; what it cannot do is lie without the
|
||||||
|
tournament organizer having a record of it.
|
||||||
|
|
||||||
|
Client-side attestation is deliberately not built. This is an
|
||||||
|
open-source Lua game: it would be theater, and it would break honest
|
||||||
|
mods.
|
||||||
|
|
||||||
|
**The relay has no TLS.** Port 7778 is plaintext, so party contents,
|
||||||
|
trades and trainer names are visible to anyone on the network path. There
|
||||||
|
is nothing secret in a Pokemon party, but it is a real property of the
|
||||||
|
system and not an oversight. Fixing it means a TLS terminator in front of
|
||||||
|
the relay and a client that speaks it, which is a version break for every
|
||||||
|
shipped build.
|
||||||
|
|
||||||
|
**The dashboard has no default password.** `DASHBOARD_PASSWORD` is
|
||||||
|
required; with it unset the relay runs and the dashboard simply does not
|
||||||
|
start. It is still Basic Auth over plain HTTP, so it belongs behind an
|
||||||
|
IP restriction or an SSH tunnel (`pokeserver/DEPLOY.md`).
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
luajit tests/link_hostile.lua every message type x every wrong type
|
||||||
|
luajit tests/link_desync_fuzz.lua lockstep fuzz, plus a mutation mode
|
||||||
|
luajit tests/run_link_tests.lua both of the above, plus the rest
|
||||||
|
cd ../pokeserver && npm test relay smoke, 16-player bracket, hostile
|
||||||
|
|
||||||
|
`tests/link_hostile.lua` builds its corpus from a template per message
|
||||||
|
type, replaces each field (and several nested ones) with every wrong Lua
|
||||||
|
type, and drives the survivors through the real trade session, a real
|
||||||
|
lockstep battle, a real spectator battle, and the tournament screen
|
||||||
|
**including its draw** -- because the two nastiest payloads are
|
||||||
|
delayed-fuse ones that crash on render rather than on receipt.
|
||||||
+10
-1
@@ -26,7 +26,7 @@ as-is. Supported keys:
|
|||||||
| `overlayN_overlay` | bezel image |
|
| `overlayN_overlay` | bezel image |
|
||||||
| `overlayN_full_screen` | stretch the page to the window |
|
| `overlayN_full_screen` | stretch the page to the window |
|
||||||
| `overlayN_rect` | page placement, default `0,0,1,1` |
|
| `overlayN_rect` | page placement, default `0,0,1,1` |
|
||||||
| `overlayN_aspect_ratio` | fallback aspect when not full screen |
|
| `overlayN_aspect_ratio` | design aspect; the overlay letterboxes to it even when full screen |
|
||||||
| `overlayN_range_mod`, `overlayN_alpha_mod` | desc defaults |
|
| `overlayN_range_mod`, `overlayN_alpha_mod` | desc defaults |
|
||||||
| `overlayN_viewport` | `x,y,w,h`, the screen cutout |
|
| `overlayN_viewport` | `x,y,w,h`, the screen cutout |
|
||||||
| `overlayN_viewport_fill` | parsed; the engine always fits, see below |
|
| `overlayN_viewport_fill` | parsed; the engine always fits, see below |
|
||||||
@@ -162,6 +162,15 @@ coordinate its art was drawn at. Bind, hitbox shape, hit reach and idle and
|
|||||||
pressed images are per control; the bezel, the pages and the screen cutout are
|
pressed images are per control; the bezel, the pages and the screen cutout are
|
||||||
per page. The cutout is itself a draggable element with a 10:9 lock.
|
per page. The cutout is itself a draggable element with a 10:9 lock.
|
||||||
|
|
||||||
|
Each page can **Lock** to portrait or landscape. With **Match canvas** on
|
||||||
|
(the default), Next page picks a matching mock device and the canvas preset
|
||||||
|
picks a matching page. Turn Match canvas off to look at a portrait page on a
|
||||||
|
landscape device.
|
||||||
|
|
||||||
|
A RetroArch overlay whose pages are already named portrait / landscape
|
||||||
|
(the auto-rotate convention) locks those pages and turns Match canvas on
|
||||||
|
when you open it. You do not have to click Lock first.
|
||||||
|
|
||||||
**Art.** The **Bezel**, **Idle art** and **Pressed art** rows cycle through the
|
**Art.** The **Bezel**, **Idle art** and **Pressed art** rows cycle through the
|
||||||
images already in the skin folder; the **Import** button beside each one opens
|
images already in the skin folder; the **Import** button beside each one opens
|
||||||
the host file picker (`src/core/FilePicker.lua`: osascript, PowerShell,
|
the host file picker (`src/core/FilePicker.lua`: osascript, PowerShell,
|
||||||
|
|||||||
@@ -169,6 +169,7 @@ the NX runtime modules `src/core/NxAssetOverlay.lua`, `src/core/Platform.lua`,
|
|||||||
`tests/engine/assets_version_fallback_test.lua`,
|
`tests/engine/assets_version_fallback_test.lua`,
|
||||||
`tests/engine/nx_generated_guard_test.lua`,
|
`tests/engine/nx_generated_guard_test.lua`,
|
||||||
`tests/engine/nx_yellow_boot_test.lua`,
|
`tests/engine/nx_yellow_boot_test.lua`,
|
||||||
|
`tests/engine/cache_fs_gold_nx_load_test.lua`,
|
||||||
`tests/engine/switch_diagnostics_test.lua`, `tests/engine/platform_nx_*`,
|
`tests/engine/switch_diagnostics_test.lua`, `tests/engine/platform_nx_*`,
|
||||||
or the Switch-related workflow YAML), CI runs:
|
or the Switch-related workflow YAML), CI runs:
|
||||||
|
|
||||||
@@ -179,7 +180,8 @@ or the Switch-related workflow YAML), CI runs:
|
|||||||
`luajit tests/switch_transfer_docs_test.lua`, and the NX engine suites
|
`luajit tests/switch_transfer_docs_test.lua`, and the NX engine suites
|
||||||
headlessly (`luajit tests/engine/assets_version_fallback_test.lua`,
|
headlessly (`luajit tests/engine/assets_version_fallback_test.lua`,
|
||||||
`luajit tests/engine/nx_generated_guard_test.lua`,
|
`luajit tests/engine/nx_generated_guard_test.lua`,
|
||||||
`luajit tests/engine/nx_yellow_boot_test.lua`).
|
`luajit tests/engine/nx_yellow_boot_test.lua`,
|
||||||
|
`luajit tests/engine/cache_fs_gold_nx_load_test.lua`).
|
||||||
2. **Fused NRO build** only on the **main** repository
|
2. **Fused NRO build** only on the **main** repository
|
||||||
(`bryanthaboi/gen1recomp`), on the self-hosted Mac runner
|
(`bryanthaboi/gen1recomp`), on the self-hosted Mac runner
|
||||||
(`scripts/build_switch.sh --fetch --fused`), and only when the workflow
|
(`scripts/build_switch.sh --fetch --fused`), and only when the workflow
|
||||||
|
|||||||
+13
-9
@@ -3,7 +3,7 @@
|
|||||||
Every GitHub Release that includes Switch support ships an SD-ready zip:
|
Every GitHub Release that includes Switch support ships an SD-ready zip:
|
||||||
`gen1recomp-*-switch.zip`. Extract it at the root of your microSD (install
|
`gen1recomp-*-switch.zip`. Extract it at the root of your microSD (install
|
||||||
or update, same steps), launch with **title override**, then import your
|
or update, same steps), launch with **title override**, then import your
|
||||||
own legal `.gb` ROM.
|
own legal `.gb` / `.gbc` ROM.
|
||||||
|
|
||||||
> You need a console that can run Switch homebrew (custom firmware / hbmenu).
|
> You need a console that can run Switch homebrew (custom firmware / hbmenu).
|
||||||
> This project does not help you set that up.
|
> This project does not help you set that up.
|
||||||
@@ -91,13 +91,14 @@ Do **not** launch from the Album applet path for normal play.
|
|||||||
|
|
||||||
This project ships **no** game data. On first launch:
|
This project ships **no** game data. On first launch:
|
||||||
|
|
||||||
1. Put your own legally obtained Pokémon Red, Blue (`.gb`), or Yellow
|
1. Put your own legally obtained Pokémon Red, Blue (`.gb`), Yellow, or
|
||||||
(`.gbc`) dump into `switch/gen1recomp/pokemon-love2d/imports/` (the
|
Gold (`.gbc`) dump into `switch/gen1recomp/pokemon-love2d/imports/` (the
|
||||||
launcher also shows the live save-dir path). All three can sit in the
|
launcher also shows the live save-dir path). All four can sit in the
|
||||||
same folder.
|
same folder.
|
||||||
2. Use **Scan again** on that game's tab (Red / Blue / Yellow). Rescan
|
2. Use **Scan again** on that game's tab (Red / Blue / Yellow / Gold).
|
||||||
matches by ROM SHA-1 for the open tab only. A Red dump never imports
|
Rescan matches by ROM SHA-1 for the open tab only. A Red dump never
|
||||||
from the Yellow tab (and vice versa).
|
imports from the Yellow tab (and vice versa). Gold is Beta in the
|
||||||
|
launcher; a clean US Gold dump is enough to Play.
|
||||||
|
|
||||||
## 5. Import / Export a raw `.sav`
|
## 5. Import / Export a raw `.sav`
|
||||||
|
|
||||||
@@ -109,10 +110,13 @@ SD / FTP, same transfer methods as ROMs. Paths are **per game**:
|
|||||||
| Red | `imports/saves/red/` | `exports/red/` |
|
| Red | `imports/saves/red/` | `exports/red/` |
|
||||||
| Blue | `imports/saves/blue/` | `exports/blue/` |
|
| Blue | `imports/saves/blue/` | `exports/blue/` |
|
||||||
| Yellow | `imports/saves/yellow/` | `exports/yellow/` |
|
| Yellow | `imports/saves/yellow/` | `exports/yellow/` |
|
||||||
|
| Gold | `imports/saves/gold/` | `exports/gold/` |
|
||||||
|
|
||||||
(Under the save dir `pokemon-love2d/`. The zip already creates these folders.)
|
(Under the save dir `pokemon-love2d/`. The zip already creates these folders.
|
||||||
|
Gold cart `.sav` import/export is not supported yet -- the folders exist so
|
||||||
|
MTP browsing matches the other games. Gold progress still saves in-engine.)
|
||||||
|
|
||||||
1. Copy a Gen1 `.sav` (32 KB) into that game's inbox under the save dir
|
1. Copy a Gen 1 `.sav` (32 KB) into that game's inbox under the save dir
|
||||||
([switch-transfer.md](switch-transfer.md)).
|
([switch-transfer.md](switch-transfer.md)).
|
||||||
2. With the game's ROM already imported, open **that game's tab** →
|
2. With the game's ROM already imported, open **that game's tab** →
|
||||||
**SAVE FILES** → **Import save**. Only that folder is scanned.
|
**SAVE FILES** → **Import save**. Only that folder is scanned.
|
||||||
|
|||||||
@@ -22,8 +22,8 @@ Player install (what to download, title override) stays in
|
|||||||
| Loose iteration pair | `sdmc:/switch/gen1recomp/gen1recomp.nro` **and** `game.love` beside it |
|
| Loose iteration pair | `sdmc:/switch/gen1recomp/gen1recomp.nro` **and** `game.love` beside it |
|
||||||
| ROM inbox | LÖVE save dir → `imports/` (launcher shows the live `getSaveDirectory()` path; under MTP often `1: SD Card/<save identity>/imports/`) |
|
| ROM inbox | LÖVE save dir → `imports/` (launcher shows the live `getSaveDirectory()` path; under MTP often `1: SD Card/<save identity>/imports/`) |
|
||||||
| Mod zip inbox | Same save dir → `imports/mods/` then MODS → **Scan again** |
|
| Mod zip inbox | Same save dir → `imports/mods/` then MODS → **Scan again** |
|
||||||
| Save `.sav` inbox | Same save dir → `imports/saves/red\|blue\|yellow/` then that game's SAVE FILES → **Import save** |
|
| Save `.sav` inbox | Same save dir → `imports/saves/red\|blue\|yellow\|gold/` then that game's SAVE FILES → **Import save** (Gold cart `.sav` not supported yet) |
|
||||||
| Save exports | Same save dir → `exports/red\|blue\|yellow/` (pull after **Export save**; MTP / SD / FTP) |
|
| Save exports | Same save dir → `exports/red\|blue\|yellow\|gold/` (pull after **Export save**; Gold cart `.sav` not supported yet) |
|
||||||
| Opt-in diagnostics | Empty `switch-debug.txt` in the save dir → `switch.log` |
|
| Opt-in diagnostics | Empty `switch-debug.txt` in the save dir → `switch.log` |
|
||||||
| Lua error log | `lua-error.log` in the save dir |
|
| Lua error log | `lua-error.log` in the save dir |
|
||||||
|
|
||||||
@@ -54,8 +54,8 @@ macOS, not a Mac-only requirement.
|
|||||||
3. Create `switch/gen1recomp/` if needed; extract the release zip at SD root
|
3. Create `switch/gen1recomp/` if needed; extract the release zip at SD root
|
||||||
(or copy NRO / `game.love` for loose).
|
(or copy NRO / `game.love` for loose).
|
||||||
4. For ROMs/mods/saves, open the save-dir `imports/`, `imports/mods/`,
|
4. For ROMs/mods/saves, open the save-dir `imports/`, `imports/mods/`,
|
||||||
`imports/saves/<red|blue|yellow>/`, or `exports/<red|blue|yellow>/` path the
|
`imports/saves/<red|blue|yellow|gold>/`, or `exports/<red|blue|yellow|gold>/`
|
||||||
launcher prints.
|
path the launcher prints.
|
||||||
5. Wait for the queue; refresh; exit MTP responder; title-override launch.
|
5. Wait for the queue; refresh; exit MTP responder; title-override launch.
|
||||||
|
|
||||||
macOS clients often create AppleDouble sidecars (`._Something.zip`,
|
macOS clients often create AppleDouble sidecars (`._Something.zip`,
|
||||||
|
|||||||
@@ -12,6 +12,20 @@
|
|||||||
"tintColor": "3b5ca8",
|
"tintColor": "3b5ca8",
|
||||||
"category": "games",
|
"category": "games",
|
||||||
"versions": [
|
"versions": [
|
||||||
|
{
|
||||||
|
"version": "0.2.4",
|
||||||
|
"date": "2026-08-18",
|
||||||
|
"size": 13582747,
|
||||||
|
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.4/gen1recomp++-0.2.4-ios.ipa",
|
||||||
|
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #1496 Investigate Security according to https://hdbreaker.github.io/blog/pokemon-gen1recomp-hate-cheat/\n\n## Contributors\n\n- @bryanthaboi"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "0.2.3",
|
||||||
|
"date": "2026-08-18",
|
||||||
|
"size": 13579254,
|
||||||
|
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.3/gen1recomp++-0.2.3-ios.ipa",
|
||||||
|
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #1497 skin studio needs a import file picker babyyyyyy\n\n## Contributors\n\n- @anxiousintrovert\n- @AverageConsumer\n- @bryanthaboi\n- @thibautbus"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"version": "0.2.2",
|
"version": "0.2.2",
|
||||||
"date": "2026-08-18",
|
"date": "2026-08-18",
|
||||||
|
|||||||
@@ -71,15 +71,15 @@ First install or update (same steps):
|
|||||||
your saves, imported ROMs, mods, and options. Re-extracting only
|
your saves, imported ROMs, mods, and options. Re-extracting only
|
||||||
replaces the NRO(s) and these help files.
|
replaces the NRO(s) and these help files.
|
||||||
3. Launch with title override (hold R on HOME, open any title → hbmenu).
|
3. Launch with title override (hold R on HOME, open any title → hbmenu).
|
||||||
4. Copy a legal Pokemon Red/Blue .gb into:
|
4. Copy a legal Pokemon Red/Blue .gb or Yellow/Gold .gbc into:
|
||||||
switch/gen1recomp/pokemon-love2d/imports/
|
switch/gen1recomp/pokemon-love2d/imports/
|
||||||
then use Scan again in the launcher if needed.
|
then use Scan again in the launcher if needed.
|
||||||
|
|
||||||
Inboxes (drop files here via MTP / SD / FTP):
|
Inboxes (drop files here via MTP / SD / FTP):
|
||||||
imports/ — ROM .gb / .gbc
|
imports/ — ROM .gb / .gbc
|
||||||
imports/mods/ — community mod .zip
|
imports/mods/ — community mod .zip
|
||||||
imports/saves/red|blue|yellow/ — raw .sav import
|
imports/saves/red|blue|yellow|gold/ — raw .sav import (Gold cart .sav not yet)
|
||||||
exports/red|blue|yellow/ — pull after Export save
|
exports/red|blue|yellow|gold/ — pull after Export save (Gold not yet)
|
||||||
|
|
||||||
Full guide: https://github.com/bryanthaboi/gen1recomp/blob/main/docs/switch-install.md
|
Full guide: https://github.com/bryanthaboi/gen1recomp/blob/main/docs/switch-install.md
|
||||||
EOF
|
EOF
|
||||||
@@ -92,7 +92,7 @@ write_readme() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
write_readme "$SAVE_ROOT/imports/README.txt" \
|
write_readme "$SAVE_ROOT/imports/README.txt" \
|
||||||
"Put a legal Pokemon Red or Blue .gb / .gbc here, then Scan again in the launcher."
|
"Put a legal Pokemon Red/Blue .gb or Yellow/Gold .gbc here, then Scan again in the launcher."
|
||||||
write_readme "$SAVE_ROOT/imports/mods/README.txt" \
|
write_readme "$SAVE_ROOT/imports/mods/README.txt" \
|
||||||
"Put community mod .zip files here, then MODS → Scan again."
|
"Put community mod .zip files here, then MODS → Scan again."
|
||||||
write_readme "$SAVE_ROOT/imports/saves/red/README.txt" \
|
write_readme "$SAVE_ROOT/imports/saves/red/README.txt" \
|
||||||
@@ -101,12 +101,16 @@ write_readme "$SAVE_ROOT/imports/saves/blue/README.txt" \
|
|||||||
"Put a Blue .sav (32 KB) here, then Blue tab → SAVE FILES → Import save."
|
"Put a Blue .sav (32 KB) here, then Blue tab → SAVE FILES → Import save."
|
||||||
write_readme "$SAVE_ROOT/imports/saves/yellow/README.txt" \
|
write_readme "$SAVE_ROOT/imports/saves/yellow/README.txt" \
|
||||||
"Put a Yellow .sav (32 KB) here, then Yellow tab → SAVE FILES → Import save."
|
"Put a Yellow .sav (32 KB) here, then Yellow tab → SAVE FILES → Import save."
|
||||||
|
write_readme "$SAVE_ROOT/imports/saves/gold/README.txt" \
|
||||||
|
"Gold cart .sav import is not supported yet. Folder reserved so MTP matches the other games."
|
||||||
write_readme "$SAVE_ROOT/exports/red/README.txt" \
|
write_readme "$SAVE_ROOT/exports/red/README.txt" \
|
||||||
"After Export save (Red), copy the .sav out of this folder via MTP / SD / FTP."
|
"After Export save (Red), copy the .sav out of this folder via MTP / SD / FTP."
|
||||||
write_readme "$SAVE_ROOT/exports/blue/README.txt" \
|
write_readme "$SAVE_ROOT/exports/blue/README.txt" \
|
||||||
"After Export save (Blue), copy the .sav out of this folder via MTP / SD / FTP."
|
"After Export save (Blue), copy the .sav out of this folder via MTP / SD / FTP."
|
||||||
write_readme "$SAVE_ROOT/exports/yellow/README.txt" \
|
write_readme "$SAVE_ROOT/exports/yellow/README.txt" \
|
||||||
"After Export save (Yellow), copy the .sav out of this folder via MTP / SD / FTP."
|
"After Export save (Yellow), copy the .sav out of this folder via MTP / SD / FTP."
|
||||||
|
write_readme "$SAVE_ROOT/exports/gold/README.txt" \
|
||||||
|
"Gold cart .sav export is not supported yet. Folder reserved so MTP matches the other games."
|
||||||
|
|
||||||
rm -f "$OUT_ZIP"
|
rm -f "$OUT_ZIP"
|
||||||
(
|
(
|
||||||
@@ -135,9 +139,11 @@ REQUIRED=(
|
|||||||
"switch/gen1recomp/pokemon-love2d/imports/saves/red/README.txt"
|
"switch/gen1recomp/pokemon-love2d/imports/saves/red/README.txt"
|
||||||
"switch/gen1recomp/pokemon-love2d/imports/saves/blue/README.txt"
|
"switch/gen1recomp/pokemon-love2d/imports/saves/blue/README.txt"
|
||||||
"switch/gen1recomp/pokemon-love2d/imports/saves/yellow/README.txt"
|
"switch/gen1recomp/pokemon-love2d/imports/saves/yellow/README.txt"
|
||||||
|
"switch/gen1recomp/pokemon-love2d/imports/saves/gold/README.txt"
|
||||||
"switch/gen1recomp/pokemon-love2d/exports/red/README.txt"
|
"switch/gen1recomp/pokemon-love2d/exports/red/README.txt"
|
||||||
"switch/gen1recomp/pokemon-love2d/exports/blue/README.txt"
|
"switch/gen1recomp/pokemon-love2d/exports/blue/README.txt"
|
||||||
"switch/gen1recomp/pokemon-love2d/exports/yellow/README.txt"
|
"switch/gen1recomp/pokemon-love2d/exports/yellow/README.txt"
|
||||||
|
"switch/gen1recomp/pokemon-love2d/exports/gold/README.txt"
|
||||||
)
|
)
|
||||||
for rel in "${REQUIRED[@]}"; do
|
for rel in "${REQUIRED[@]}"; do
|
||||||
printf '%s\n' "$LISTING" | grep -Fq "$rel" || fail "zip missing $rel"
|
printf '%s\n' "$LISTING" | grep -Fq "$rel" || fail "zip missing $rel"
|
||||||
|
|||||||
@@ -270,9 +270,11 @@ for rel in \
|
|||||||
"switch/gen1recomp/pokemon-love2d/imports/saves/red/README.txt" \
|
"switch/gen1recomp/pokemon-love2d/imports/saves/red/README.txt" \
|
||||||
"switch/gen1recomp/pokemon-love2d/imports/saves/blue/README.txt" \
|
"switch/gen1recomp/pokemon-love2d/imports/saves/blue/README.txt" \
|
||||||
"switch/gen1recomp/pokemon-love2d/imports/saves/yellow/README.txt" \
|
"switch/gen1recomp/pokemon-love2d/imports/saves/yellow/README.txt" \
|
||||||
|
"switch/gen1recomp/pokemon-love2d/imports/saves/gold/README.txt" \
|
||||||
"switch/gen1recomp/pokemon-love2d/exports/red/README.txt" \
|
"switch/gen1recomp/pokemon-love2d/exports/red/README.txt" \
|
||||||
"switch/gen1recomp/pokemon-love2d/exports/blue/README.txt" \
|
"switch/gen1recomp/pokemon-love2d/exports/blue/README.txt" \
|
||||||
"switch/gen1recomp/pokemon-love2d/exports/yellow/README.txt"
|
"switch/gen1recomp/pokemon-love2d/exports/yellow/README.txt" \
|
||||||
|
"switch/gen1recomp/pokemon-love2d/exports/gold/README.txt"
|
||||||
do
|
do
|
||||||
printf '%s\n' "$ZIP_LIST" | grep -Fq "$rel" || PACK_MISSING="${PACK_MISSING} ${rel}"
|
printf '%s\n' "$ZIP_LIST" | grep -Fq "$rel" || PACK_MISSING="${PACK_MISSING} ${rel}"
|
||||||
done
|
done
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ run_tier "T0 switch transfer docs gate" "$LUA" tests/switch_transfer_docs_test.l
|
|||||||
run_tier "T0 NX asset overlay fallback" "$LUA" tests/engine/assets_version_fallback_test.lua
|
run_tier "T0 NX asset overlay fallback" "$LUA" tests/engine/assets_version_fallback_test.lua
|
||||||
run_tier "T0 NX generated-path static guard" "$LUA" tests/engine/nx_generated_guard_test.lua
|
run_tier "T0 NX generated-path static guard" "$LUA" tests/engine/nx_generated_guard_test.lua
|
||||||
run_tier "T0 NX Yellow/Blue boot (dynamic paths)" "$LUA" tests/engine/nx_yellow_boot_test.lua
|
run_tier "T0 NX Yellow/Blue boot (dynamic paths)" "$LUA" tests/engine/nx_yellow_boot_test.lua
|
||||||
|
run_tier "T0 NX Gold cache load (maps.lua prefix)" "$LUA" tests/engine/cache_fs_gold_nx_load_test.lua
|
||||||
run_tier "T0 touch-controls pad cursor" "$LUA" tests/engine/touch_controls_pad_cursor_test.lua
|
run_tier "T0 touch-controls pad cursor" "$LUA" tests/engine/touch_controls_pad_cursor_test.lua
|
||||||
run_tier "T1/T2 engine invariants + parity gates" "$LUA" tests/run_engine.lua
|
run_tier "T1/T2 engine invariants + parity gates" "$LUA" tests/run_engine.lua
|
||||||
run_tier "T4 mod-SDK" "$LUA" tests/run_modkit.lua
|
run_tier "T4 mod-SDK" "$LUA" tests/run_modkit.lua
|
||||||
|
|||||||
@@ -424,11 +424,12 @@ local function renderEffect(data, header, options)
|
|||||||
return love.audio.newSource(sd, "static")
|
return love.audio.newSource(sd, "static")
|
||||||
end
|
end
|
||||||
|
|
||||||
function ChipAudio.newSfx(data, name, pitch, tempo, header)
|
function ChipAudio.newSfx(data, name, pitch, tempo, header, plainFrames)
|
||||||
header = header or data.audio.sfx[name]
|
header = header or data.audio.sfx[name]
|
||||||
return renderEffect(data, header, {
|
return renderEffect(data, header, {
|
||||||
frequencyOffset = pitch or 0,
|
frequencyOffset = pitch or 0,
|
||||||
frameTicks = 0x80 + (tempo or 0x80),
|
frameTicks = 0x80 + (tempo or 0x80),
|
||||||
|
plainFrames = plainFrames,
|
||||||
})
|
})
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
+12
-6
@@ -280,6 +280,7 @@ function Channel.new(engine, spec, options)
|
|||||||
allowLoops = options.allowLoops ~= false,
|
allowLoops = options.allowLoops ~= false,
|
||||||
frequencyOffset = options.frequencyOffset or 0,
|
frequencyOffset = options.frequencyOffset or 0,
|
||||||
frameTicks = options.frameTicks or FRAME_TICKS,
|
frameTicks = options.frameTicks or FRAME_TICKS,
|
||||||
|
plainTicks = (options.plainFrames or 0) * FRAME_TICKS,
|
||||||
speed = 12,
|
speed = 12,
|
||||||
noteLength = 1, -- Gen 2 CHANNEL_NOTE_LENGTH (note_type)
|
noteLength = 1, -- Gen 2 CHANNEL_NOTE_LENGTH (note_type)
|
||||||
durationModifier = 0, -- Gen 2 fractional-frame carry
|
durationModifier = 0, -- Gen 2 fractional-frame carry
|
||||||
@@ -349,8 +350,9 @@ function Channel:frequencyGen2(note, octave)
|
|||||||
return bit.band(register + self.frequencyOffset, 0x7FF)
|
return bit.band(register + self.frequencyOffset, 0x7FF)
|
||||||
end
|
end
|
||||||
|
|
||||||
function Channel:durationTicks(length)
|
function Channel:durationTicks(length, plain)
|
||||||
local tempo = self.sfx and self.frameTicks or self.engine.tempo
|
local tempo = self.sfx and (plain and FRAME_TICKS or self.frameTicks)
|
||||||
|
or self.engine.tempo
|
||||||
local speed = self.sfx and (self.executeMusic and self.speed or 1)
|
local speed = self.sfx and (self.executeMusic and self.speed or 1)
|
||||||
or self.speed
|
or self.speed
|
||||||
return length * speed * tempo
|
return length * speed * tempo
|
||||||
@@ -586,6 +588,9 @@ function Channel:nextEvent()
|
|||||||
local packed = self:byte()
|
local packed = self:byte()
|
||||||
local volume = bit.rshift(packed, 4)
|
local volume = bit.rshift(packed, 4)
|
||||||
local fade = fadeValue(bit.band(packed, 0x0F))
|
local fade = fadeValue(bit.band(packed, 0x0F))
|
||||||
|
-- audio/engine_2.asm:991-1013, :1015-1033, :1077-1096
|
||||||
|
local plain = self.timeTicks < self.plainTicks
|
||||||
|
local offset = plain and 0 or self.frequencyOffset
|
||||||
if self.noise then
|
if self.noise then
|
||||||
-- Audio2_ApplyWavePatternAndFrequency adds wFrequencyModifier to the
|
-- Audio2_ApplyWavePatternAndFrequency adds wFrequencyModifier to the
|
||||||
-- frequency low byte for every channel at or past CHAN5, the noise
|
-- frequency low byte for every channel at or past CHAN5, the noise
|
||||||
@@ -595,12 +600,12 @@ function Channel:nextEvent()
|
|||||||
-- byte that noise does not use for frequency. Dropping it left the
|
-- byte that noise does not use for frequency. Dropping it left the
|
||||||
-- battle hit sounds at their unmodified pitches, where super effective
|
-- battle hit sounds at their unmodified pitches, where super effective
|
||||||
-- reads as the duller of the two (#826).
|
-- reads as the duller of the two (#826).
|
||||||
local parameter = bit.band(self:byte() + self.frequencyOffset, 0xFF)
|
local parameter = bit.band(self:byte() + offset, 0xFF)
|
||||||
return self:noiseEvent(
|
return self:noiseEvent(
|
||||||
self:durationTicks(length), volume, fade, parameter)
|
self:durationTicks(length, plain), volume, fade, parameter)
|
||||||
end
|
end
|
||||||
local register = bit.band(self:word() + self.frequencyOffset, 0x7FF)
|
local register = bit.band(self:word() + offset, 0x7FF)
|
||||||
return self:tone(self:durationTicks(length), register, volume, fade)
|
return self:tone(self:durationTicks(length, plain), register, volume, fade)
|
||||||
elseif command == 0x10 then
|
elseif command == 0x10 then
|
||||||
local packed = self:byte()
|
local packed = self:byte()
|
||||||
self.sweep = {
|
self.sweep = {
|
||||||
@@ -1260,6 +1265,7 @@ function Engine.new(data, header, options)
|
|||||||
allowLoops = options.allowLoops,
|
allowLoops = options.allowLoops,
|
||||||
frequencyOffset = options.frequencyOffset,
|
frequencyOffset = options.frequencyOffset,
|
||||||
frameTicks = frameTicks,
|
frameTicks = frameTicks,
|
||||||
|
plainFrames = options.plainFrames,
|
||||||
})
|
})
|
||||||
end
|
end
|
||||||
return engine
|
return engine
|
||||||
|
|||||||
+35
-2
@@ -222,6 +222,26 @@ function Game:touchSkinHotkey(action, pressed)
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
function Game:breakLink(err)
|
||||||
|
Logger.error("link: torn down after an error\n%s", tostring(err))
|
||||||
|
self.linkSession = nil
|
||||||
|
local net = self.linkNet
|
||||||
|
self.linkNet = nil
|
||||||
|
if net then pcall(net.close, net) end
|
||||||
|
pcall(ModRuntime.emit, "link.ended", { reason = "error" })
|
||||||
|
local stack = self.stack
|
||||||
|
local guard = 0
|
||||||
|
while #stack.states > 1 and stack:top() ~= self.overworld and guard < 64 do
|
||||||
|
guard = guard + 1
|
||||||
|
pcall(stack.pop, stack)
|
||||||
|
end
|
||||||
|
pcall(function()
|
||||||
|
local Strings = require("src.core.Strings")
|
||||||
|
local TextBox = require("src.render.TextBox")
|
||||||
|
stack:push(TextBox.new(self, Strings("The link was\nbroken.")))
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
function Game:step(dt)
|
function Game:step(dt)
|
||||||
-- Tool mods (autoplay, accessibility drivers, input visualizers) act on
|
-- Tool mods (autoplay, accessibility drivers, input visualizers) act on
|
||||||
-- the same fixed-step boundary as a physical controller. Run them before
|
-- the same fixed-step boundary as a physical controller. Run them before
|
||||||
@@ -249,9 +269,22 @@ function Game:step(dt)
|
|||||||
-- stall just because PartyMenu/ChoiceBox/NamingScreen is temporarily
|
-- stall just because PartyMenu/ChoiceBox/NamingScreen is temporarily
|
||||||
-- on top of BattleState (see LinkBattle.new)
|
-- on top of BattleState (see LinkBattle.new)
|
||||||
if self.linkNet and not self.linkNet.closed then
|
if self.linkNet and not self.linkNet.closed then
|
||||||
self.linkNet:update()
|
local ok, err = pcall(self.linkNet.update, self.linkNet)
|
||||||
|
if not ok then
|
||||||
|
self:breakLink(err)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if self.linkSession or self.linkNet then
|
||||||
|
local ok, err = xpcall(function() self.stack:update(dt) end,
|
||||||
|
function(e) return debug.traceback(tostring(e), 2) end)
|
||||||
|
if not ok then
|
||||||
|
self:breakLink(err)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
else
|
||||||
|
self.stack:update(dt)
|
||||||
end
|
end
|
||||||
self.stack:update(dt)
|
|
||||||
-- play time for the trainer card / save screen
|
-- play time for the trainer card / save screen
|
||||||
self.save.playTime = (self.save.playTime or 0) + dt
|
self.save.playTime = (self.save.playTime or 0) + dt
|
||||||
-- Music.update is NOT serviced here: it decrements fade counters and
|
-- Music.update is NOT serviced here: it decrements fade counters and
|
||||||
|
|||||||
+5
-5
@@ -120,11 +120,11 @@ local function visibleBaseState(stack)
|
|||||||
end
|
end
|
||||||
|
|
||||||
local function loadGenerated(path)
|
local function loadGenerated(path)
|
||||||
local chunk = love.filesystem.load(path)
|
-- CacheFs.loadActive, not love.filesystem.load: Gold's cache lives under
|
||||||
if not chunk then return nil end
|
-- gold/ and fused NX often cannot mount that tree onto data/generated/.
|
||||||
local ok, data = pcall(chunk)
|
local CacheFs = require("src.import.CacheFs")
|
||||||
if ok then return data end
|
local data = CacheFs.loadActive(path)
|
||||||
return nil
|
return data
|
||||||
end
|
end
|
||||||
|
|
||||||
-- NewGame (engine/menus/intro_menu.asm) calls OakSpeech, and OakSpeech's first
|
-- NewGame (engine/menus/intro_menu.asm) calls OakSpeech, and OakSpeech's first
|
||||||
|
|||||||
+30
-13
@@ -1,10 +1,11 @@
|
|||||||
-- NX-only asset overlay: fused love-nx cannot reliably mount
|
-- NX-only asset overlay: fused love-nx cannot reliably mount
|
||||||
-- blue|yellow/assets/generated onto the un-prefixed assets/generated, so
|
-- blue|yellow|gold/{assets,data}/generated onto the un-prefixed paths, so
|
||||||
-- instead of teaching every call site about versioned caches, this module
|
-- instead of teaching every call site about versioned caches, this module
|
||||||
-- wraps EVERY read-side love entry point that accepts a filesystem path
|
-- wraps EVERY read-side love entry point that accepts a filesystem path
|
||||||
-- once at boot: any string path under assets/generated/ that does not
|
-- once at boot: any string path under assets/generated/ or data/generated/
|
||||||
-- resolve falls back to the active version's prefixed copy
|
-- prefers the active version's prefixed copy (yellow|blue|gold/...) when
|
||||||
-- (yellow|blue/assets/generated/...). Covering the whole read surface --
|
-- that file exists, so leftover unprefixed Red cache cannot shadow it.
|
||||||
|
-- Covering the whole read surface --
|
||||||
-- not just the loaders we happened to need -- is what keeps future states
|
-- not just the loaders we happened to need -- is what keeps future states
|
||||||
-- and mods inside the fallback without anyone updating this file.
|
-- and mods inside the fallback without anyone updating this file.
|
||||||
--
|
--
|
||||||
@@ -18,27 +19,43 @@
|
|||||||
-- * the chip-audio worker (src/core/chip_worker.lua) is a separate Lua
|
-- * the chip-audio worker (src/core/chip_worker.lua) is a separate Lua
|
||||||
-- state without these wrappers; ChipAudio.slimAudio hands it the prefix
|
-- state without these wrappers; ChipAudio.slimAudio hands it the prefix
|
||||||
-- explicitly as audio.programPrefix.
|
-- explicitly as audio.programPrefix.
|
||||||
-- * data/generated module loads go through CacheFs.readActive, which
|
-- * Gen 1 Data:load and Gold Game2/World go through CacheFs.readActive /
|
||||||
-- already implements the same fallback for require bytes.
|
-- CacheFs.loadActive, which implement the same fallback for Lua bytes.
|
||||||
|
-- data/generated is still rewritten here so any leftover
|
||||||
|
-- love.filesystem.load("data/generated/...") call (the Gold intro /
|
||||||
|
-- naming / maps hole on 0.2.4) stays inside the overlay.
|
||||||
|
|
||||||
local GameVersion = require("src.core.GameVersion")
|
local GameVersion = require("src.core.GameVersion")
|
||||||
|
|
||||||
local GENERATED = "assets/generated/"
|
local GENERATED_PREFIXES = {
|
||||||
|
"assets/generated/",
|
||||||
|
"data/generated/",
|
||||||
|
}
|
||||||
|
|
||||||
local NxAssetOverlay = {}
|
local NxAssetOverlay = {}
|
||||||
|
|
||||||
local originals -- raw love functions, non-nil while installed
|
local originals -- raw love functions, non-nil while installed
|
||||||
|
|
||||||
-- Resolve `path` to the versioned copy when the un-prefixed file is missing
|
-- Resolve `path` to the active version's prefixed copy when that file
|
||||||
-- and the active version (Blue/Yellow) carries it. Returns nil when the
|
-- exists (gold|yellow|blue|red/{assets,data}/generated/...). The versioned
|
||||||
-- caller's path should be used untouched (non-generated path, Red, the real
|
-- tree wins over a leftover un-prefixed file so a pre-#899 Red root cache
|
||||||
-- file exists, or no versioned copy).
|
-- (assets/generated/font.png in the save dir) cannot shadow Gold/Blue/
|
||||||
|
-- Yellow art that shares a name. Returns nil when the caller's path
|
||||||
|
-- should be used untouched (non-generated path, empty prefix, or no
|
||||||
|
-- versioned copy).
|
||||||
local function versioned(path)
|
local function versioned(path)
|
||||||
if type(path) ~= "string" then return nil end
|
if type(path) ~= "string" then return nil end
|
||||||
if path:sub(1, #GENERATED) ~= GENERATED then return nil end
|
local generated = false
|
||||||
|
for i = 1, #GENERATED_PREFIXES do
|
||||||
|
local gen = GENERATED_PREFIXES[i]
|
||||||
|
if path:sub(1, #gen) == gen then
|
||||||
|
generated = true
|
||||||
|
break
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if not generated then return nil end
|
||||||
local prefix = GameVersion.cachePrefix()
|
local prefix = GameVersion.cachePrefix()
|
||||||
if prefix == "" then return nil end
|
if prefix == "" then return nil end
|
||||||
if originals.getInfo(path) then return nil end
|
|
||||||
local candidate = prefix .. path
|
local candidate = prefix .. path
|
||||||
if originals.getInfo(candidate) then return candidate end
|
if originals.getInfo(candidate) then return candidate end
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
+109
-36
@@ -8,6 +8,7 @@
|
|||||||
local Assets = require("src.render.Assets")
|
local Assets = require("src.render.Assets")
|
||||||
local Logger = require("src.core.Logger")
|
local Logger = require("src.core.Logger")
|
||||||
local Runtime = require("src.mods.Runtime")
|
local Runtime = require("src.mods.Runtime")
|
||||||
|
local bit = require("bit")
|
||||||
|
|
||||||
local Sound = {}
|
local Sound = {}
|
||||||
|
|
||||||
@@ -142,10 +143,10 @@ local function newFileSource(def)
|
|||||||
return s
|
return s
|
||||||
end
|
end
|
||||||
|
|
||||||
local function newSfxSource(data, key, def, pitch, tempo)
|
local function newSfxSource(data, key, def, pitch, tempo, plain)
|
||||||
if isChipDef(def) then
|
if isChipDef(def) then
|
||||||
local ok, s = pcall(require("src.core.ChipAudio").newSfx,
|
local ok, s = pcall(require("src.core.ChipAudio").newSfx,
|
||||||
data, key:match("^([^@]+)") or key, pitch, tempo, def)
|
data, key:match("^([^@]+)") or key, pitch, tempo, def, plain)
|
||||||
if not ok then return nil, tostring(s) end
|
if not ok then return nil, tostring(s) end
|
||||||
if not s then return nil, "no source" end
|
if not s then return nil, "no source" end
|
||||||
return s
|
return s
|
||||||
@@ -153,12 +154,12 @@ local function newSfxSource(data, key, def, pitch, tempo)
|
|||||||
return newFileSource(def)
|
return newFileSource(def)
|
||||||
end
|
end
|
||||||
|
|
||||||
local function playPath(data, key, def, pitch, tempo)
|
local function playPath(data, key, def, pitch, tempo, plain)
|
||||||
if not love.audio or not def then return nil end
|
if not love.audio or not def then return nil end
|
||||||
local src = cache[key]
|
local src = cache[key]
|
||||||
if src == false then return nil end -- known bad, already logged
|
if src == false then return nil end -- known bad, already logged
|
||||||
if not src then
|
if not src then
|
||||||
local s, err = newSfxSource(data, key, def, pitch, tempo)
|
local s, err = newSfxSource(data, key, def, pitch, tempo, plain)
|
||||||
if not s then
|
if not s then
|
||||||
cache[key] = false
|
cache[key] = false
|
||||||
reportBadDef("sfx", key, owner(data, "sfx", key), err)
|
reportBadDef("sfx", key, owner(data, "sfx", key), err)
|
||||||
@@ -413,50 +414,109 @@ end
|
|||||||
-- still sounding when the second row starts, so the original never plays
|
-- still sounding when the second row starts, so the original never plays
|
||||||
-- SFX_BATTLE_2A (CHAN5+6+8) at all -- unguarded, its tail is heard running
|
-- SFX_BATTLE_2A (CHAN5+6+8) at all -- unguarded, its tail is heard running
|
||||||
-- past the end of the animation (#844).
|
-- past the end of the animation (#844).
|
||||||
local lastMoveSfx -- { src, rank, engine, channels } of the last row sound
|
local moveSfxChannels = {} -- software channel (5-8) -> { src, address, engine }
|
||||||
|
|
||||||
local function channelsOverlap(a, b)
|
local function sourceAlive(entry)
|
||||||
if not (a and b) then return false end
|
local ok, playing = pcall(entry.src.isPlaying, entry.src)
|
||||||
for _, x in ipairs(a) do
|
return ok and playing
|
||||||
for _, y in ipairs(b) do
|
end
|
||||||
if x == y then return true end
|
|
||||||
end
|
local function pruneMoveSfx()
|
||||||
|
for ch, cur in pairs(moveSfxChannels) do
|
||||||
|
if not sourceAlive(cur) then moveSfxChannels[ch] = nil end
|
||||||
end
|
end
|
||||||
return false
|
|
||||||
end
|
end
|
||||||
|
|
||||||
-- would PlaySound start this def now? Taking a channel over also stops the
|
-- would PlaySound start this def now? Taking a channel over also stops the
|
||||||
-- sound that held it, the way .playChannel resets the channel.
|
-- sound that held it, the way .playChannel resets the channel.
|
||||||
local function sfxChannelGate(data, def)
|
local function sfxChannelGate(data, def)
|
||||||
local cur = lastMoveSfx
|
pruneMoveSfx()
|
||||||
if not cur then return true end
|
|
||||||
local ok, playing = pcall(cur.src.isPlaying, cur.src)
|
|
||||||
if not (ok and playing) then
|
|
||||||
lastMoveSfx = nil
|
|
||||||
return true
|
|
||||||
end
|
|
||||||
-- an unrankable def (file asset, or another engine's bank) has no
|
-- an unrankable def (file asset, or another engine's bank) has no
|
||||||
-- comparable sound id: leave it to the mixer, as before
|
-- comparable sound id: leave it to the mixer, as before
|
||||||
if type(def) ~= "table" or not def.address or def.engine ~= cur.engine then
|
if type(def) ~= "table" or not def.address then return true end
|
||||||
return true
|
|
||||||
end
|
|
||||||
local channels = require("src.core.ChipSynth").effectChannels(data, def)
|
local channels = require("src.core.ChipSynth").effectChannels(data, def)
|
||||||
if not channelsOverlap(channels, cur.channels) then return true end
|
if not channels then return true end
|
||||||
if def.address > cur.rank then return false end
|
local takeover
|
||||||
pcall(cur.src.stop, cur.src)
|
for _, ch in ipairs(channels) do
|
||||||
lastMoveSfx = nil
|
local cur = moveSfxChannels[ch]
|
||||||
return true
|
if cur and cur.engine == def.engine then
|
||||||
|
if def.address > cur.address then return false end
|
||||||
|
takeover = takeover or {}
|
||||||
|
takeover[cur.src] = true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if takeover then
|
||||||
|
for ch, cur in pairs(moveSfxChannels) do
|
||||||
|
if takeover[cur.src] then moveSfxChannels[ch] = nil end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return true, takeover
|
||||||
end
|
end
|
||||||
|
|
||||||
local function noteMoveSfx(data, def, src)
|
local function noteMoveSfx(data, def, src)
|
||||||
if not src or type(def) ~= "table" or not def.address then
|
if not src or type(def) ~= "table" or not def.address then
|
||||||
lastMoveSfx = nil
|
moveSfxChannels = {}
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
lastMoveSfx = {
|
local channels = require("src.core.ChipSynth").effectChannels(data, def)
|
||||||
src = src, rank = def.address, engine = def.engine,
|
if not channels then
|
||||||
channels = require("src.core.ChipSynth").effectChannels(data, def),
|
moveSfxChannels = {}
|
||||||
}
|
return
|
||||||
|
end
|
||||||
|
local entry = { src = src, address = def.address, engine = def.engine }
|
||||||
|
for _, ch in ipairs(channels) do moveSfxChannels[ch] = entry end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- constants/music_constants.asm:4
|
||||||
|
local function sfxHeaderId(def)
|
||||||
|
if type(def) ~= "table" or not def.address then return nil end
|
||||||
|
local rel = def.address - 0x4000
|
||||||
|
if rel <= 0 or rel % 3 ~= 0 then return nil end
|
||||||
|
return rel / 3
|
||||||
|
end
|
||||||
|
|
||||||
|
local function remainingFrames(src)
|
||||||
|
local ok, dur = pcall(src.getDuration, src, "seconds")
|
||||||
|
if not ok or type(dur) ~= "number" then return nil end
|
||||||
|
local pos
|
||||||
|
ok, pos = pcall(src.tell, src, "seconds")
|
||||||
|
if not ok or type(pos) ~= "number" then return nil end
|
||||||
|
return math.max(0, math.ceil((dur - pos) * 60))
|
||||||
|
end
|
||||||
|
|
||||||
|
-- audio/engine_2.asm:1077-1096, :991-1013, :1015-1033
|
||||||
|
local function plainMoveFrames(data, def, channels)
|
||||||
|
local id = sfxHeaderId(def)
|
||||||
|
local sfx = data.audio and data.audio.sfx
|
||||||
|
if not (id and sfx and channels) then return 0 end
|
||||||
|
local first = sfxHeaderId(sfx.Peck) -- constants/music_constants.asm:178
|
||||||
|
local last = sfxHeaderId(sfx.Trainer_Appeared) -- constants/music_constants.asm:228
|
||||||
|
if not (first and last) then return 0 end
|
||||||
|
local claimed = {}
|
||||||
|
for _, ch in ipairs(channels) do claimed[ch] = true end
|
||||||
|
local base = (claimed[5] or claimed[8]) and id or 0
|
||||||
|
local others = {}
|
||||||
|
for _, ch in ipairs({ 5, 8 }) do
|
||||||
|
local cur = (not claimed[ch]) and moveSfxChannels[ch] or nil
|
||||||
|
if cur and cur.engine == def.engine and sourceAlive(cur) then
|
||||||
|
local otherId = sfxHeaderId(cur)
|
||||||
|
local rem = remainingFrames(cur.src)
|
||||||
|
if otherId and rem and rem > 0 then
|
||||||
|
others[#others + 1] = { id = otherId, rem = rem }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
table.sort(others, function(a, b) return a.rem < b.rem end)
|
||||||
|
local plain = 0
|
||||||
|
for drop = 0, #others do
|
||||||
|
local combined = base
|
||||||
|
for i = drop + 1, #others do
|
||||||
|
combined = bit.bor(combined, others[i].id)
|
||||||
|
end
|
||||||
|
if combined >= first and combined <= last then return plain end
|
||||||
|
if drop < #others then plain = others[drop + 1].rem end
|
||||||
|
end
|
||||||
|
return plain
|
||||||
end
|
end
|
||||||
|
|
||||||
function Sound.playMove(data, anim)
|
function Sound.playMove(data, anim)
|
||||||
@@ -466,13 +526,20 @@ function Sound.playMove(data, anim)
|
|||||||
local name = anim.sound
|
local name = anim.sound
|
||||||
local pitch, tempo = anim.pitch or 0, anim.tempo or 0x80
|
local pitch, tempo = anim.pitch or 0, anim.tempo or 0x80
|
||||||
local def = sfx[name]
|
local def = sfx[name]
|
||||||
if not sfxChannelGate(data, def) then return end
|
local allowed, superseded = sfxChannelGate(data, def)
|
||||||
|
if not allowed then return end
|
||||||
local src
|
local src
|
||||||
-- a chip program synthesizes the modified variant on demand; a file def
|
-- a chip program synthesizes the modified variant on demand; a file def
|
||||||
-- can only reach for a pre-rendered one
|
-- can only reach for a pre-rendered one
|
||||||
if isChipDef(def) then
|
if isChipDef(def) then
|
||||||
src = playPath(data, ("%s@%02x%02x"):format(name, pitch, tempo),
|
local plain = 0
|
||||||
def, pitch, tempo)
|
if pitch ~= 0 or tempo ~= 0x80 then
|
||||||
|
local channels = require("src.core.ChipSynth").effectChannels(data, def)
|
||||||
|
plain = plainMoveFrames(data, def, channels)
|
||||||
|
end
|
||||||
|
local key = ("%s@%02x%02x"):format(name, pitch, tempo)
|
||||||
|
if plain > 0 then key = ("%s~%d"):format(key, plain) end
|
||||||
|
src = playPath(data, key, def, pitch, tempo, plain)
|
||||||
else
|
else
|
||||||
local key = ("%s@%02x%02x"):format(name, pitch, tempo)
|
local key = ("%s@%02x%02x"):format(name, pitch, tempo)
|
||||||
if (pitch ~= 0 or tempo ~= 0x80) and sfx[key] then
|
if (pitch ~= 0 or tempo ~= 0x80) and sfx[key] then
|
||||||
@@ -481,6 +548,12 @@ function Sound.playMove(data, anim)
|
|||||||
src = playPath(data, name, def)
|
src = playPath(data, name, def)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
-- audio/engine_2.asm:1537
|
||||||
|
if superseded then
|
||||||
|
for old in pairs(superseded) do
|
||||||
|
if old ~= src then pcall(old.stop, old) end
|
||||||
|
end
|
||||||
|
end
|
||||||
if src then
|
if src then
|
||||||
played("move", name)
|
played("move", name)
|
||||||
noteMoveSfx(data, def, src)
|
noteMoveSfx(data, def, src)
|
||||||
@@ -711,7 +784,7 @@ end
|
|||||||
-- hot reload / jukebox A-B: drop one key's sources (its pitch-tempo
|
-- hot reload / jukebox A-B: drop one key's sources (its pitch-tempo
|
||||||
-- variants included) or all of them, so the next play re-resolves the def
|
-- variants included) or all of them, so the next play re-resolves the def
|
||||||
function Sound.invalidate(name)
|
function Sound.invalidate(name)
|
||||||
lastMoveSfx = nil -- its source is about to be dropped or stopped
|
moveSfxChannels = {} -- their sources are about to be dropped or stopped
|
||||||
-- Same for wCurSFX, and a reloaded table can repoint the id order.
|
-- Same for wCurSFX, and a reloaded table can repoint the id order.
|
||||||
curSfx = nil
|
curSfx = nil
|
||||||
sfxIds = nil
|
sfxIds = nil
|
||||||
|
|||||||
+90
-4
@@ -142,7 +142,11 @@ function TouchSkin.parse(text)
|
|||||||
controls = {},
|
controls = {},
|
||||||
}
|
}
|
||||||
if page.imagePath == "" then page.imagePath = nil end
|
if page.imagePath == "" then page.imagePath = nil end
|
||||||
if not page.aspect or page.aspect <= 0 then
|
-- An explicit aspect_ratio is the overlay's design aspect. RetroArch
|
||||||
|
-- letterboxes to it even when full_screen is set, so range_x/range_y
|
||||||
|
-- that were authored as a circle stay a circle. #1503
|
||||||
|
page.aspectFromCfg = page.aspect ~= nil and page.aspect > 0
|
||||||
|
if not page.aspectFromCfg then
|
||||||
page.aspect = page.name:lower():find("portrait", 1, true)
|
page.aspect = page.name:lower():find("portrait", 1, true)
|
||||||
and PORTRAIT_ASPECT or DEFAULT_ASPECT
|
and PORTRAIT_ASPECT or DEFAULT_ASPECT
|
||||||
end
|
end
|
||||||
@@ -170,6 +174,12 @@ function TouchSkin.parse(text)
|
|||||||
pages[#pages + 1] = page
|
pages[#pages + 1] = page
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- RetroArch auto-rotate: overlay names containing portrait / landscape
|
||||||
|
-- are the lock. Stamp it so the studio does not need a second click. #1503
|
||||||
|
for _, page in ipairs(pages) do
|
||||||
|
if not page.orient then page.orient = TouchSkin.pageOrient(page) end
|
||||||
|
end
|
||||||
|
|
||||||
return { pages = pages }
|
return { pages = pages }
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -255,6 +265,9 @@ function TouchSkin.parseNative(text)
|
|||||||
rangeMod = num(raw.rangeMod, 1),
|
rangeMod = num(raw.rangeMod, 1),
|
||||||
alphaMod = num(raw.alphaMod, 1),
|
alphaMod = num(raw.alphaMod, 1),
|
||||||
aspect = num(raw.aspect, DEFAULT_ASPECT),
|
aspect = num(raw.aspect, DEFAULT_ASPECT),
|
||||||
|
aspectFromCfg = raw.fitAspect == true,
|
||||||
|
orient = (raw.orient == "portrait" or raw.orient == "landscape"
|
||||||
|
or raw.orient == "any") and raw.orient or nil,
|
||||||
rect = { x = 0, y = 0, w = 1, h = 1 },
|
rect = { x = 0, y = 0, w = 1, h = 1 },
|
||||||
controls = {},
|
controls = {},
|
||||||
}
|
}
|
||||||
@@ -287,6 +300,7 @@ function TouchSkin.parseNative(text)
|
|||||||
nextTarget = c.nextTarget,
|
nextTarget = c.nextTarget,
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
if not page.orient then page.orient = TouchSkin.pageOrient(page) end
|
||||||
pages[#pages + 1] = page
|
pages[#pages + 1] = page
|
||||||
end
|
end
|
||||||
return { pages = pages, name = data.name, author = data.author,
|
return { pages = pages, name = data.name, author = data.author,
|
||||||
@@ -309,6 +323,9 @@ function TouchSkin.toNative(skin)
|
|||||||
rangeMod = page.rangeMod,
|
rangeMod = page.rangeMod,
|
||||||
alphaMod = page.alphaMod,
|
alphaMod = page.alphaMod,
|
||||||
aspect = page.aspect,
|
aspect = page.aspect,
|
||||||
|
fitAspect = page.aspectFromCfg or nil,
|
||||||
|
orient = (page.orient == "portrait" or page.orient == "landscape"
|
||||||
|
or page.orient == "any") and page.orient or nil,
|
||||||
controls = {},
|
controls = {},
|
||||||
}
|
}
|
||||||
if page.rect and (page.rect.x ~= 0 or page.rect.y ~= 0
|
if page.rect and (page.rect.x ~= 0 or page.rect.y ~= 0
|
||||||
@@ -704,6 +721,10 @@ end
|
|||||||
|
|
||||||
TouchSkin.active = nil
|
TouchSkin.active = nil
|
||||||
TouchSkin.pageIndex = 1
|
TouchSkin.pageIndex = 1
|
||||||
|
-- RetroArch Auto-Rotate Overlay (1.7.9, default on mobile): a cfg whose
|
||||||
|
-- pages are named portrait / landscape is swapped to match the display.
|
||||||
|
-- The Skin Studio turns this off so PAGE and the canvas preset stay independent.
|
||||||
|
TouchSkin.autoOrient = true
|
||||||
|
|
||||||
TouchSkin.surfaceRect = nil
|
TouchSkin.surfaceRect = nil
|
||||||
|
|
||||||
@@ -732,9 +753,68 @@ function TouchSkin.select(id)
|
|||||||
return TouchSkin.setActive(skin)
|
return TouchSkin.setActive(skin)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
local function displaySize()
|
||||||
|
local r = TouchSkin.surfaceRect
|
||||||
|
if r and r.w and r.h and r.w > 0 and r.h > 0 then return r.w, r.h end
|
||||||
|
if love and love.graphics and love.graphics.getDimensions then
|
||||||
|
return love.graphics.getDimensions()
|
||||||
|
end
|
||||||
|
return 0, 0
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Explicit lock (studio) wins; otherwise the page name, the RetroArch
|
||||||
|
-- auto-rotate convention. "any" means unlocked even if the name says
|
||||||
|
-- portrait or landscape.
|
||||||
|
function TouchSkin.pageOrient(page)
|
||||||
|
if not page then return nil end
|
||||||
|
if page.orient == "any" then return nil end
|
||||||
|
if page.orient == "portrait" or page.orient == "landscape" then
|
||||||
|
return page.orient
|
||||||
|
end
|
||||||
|
local n = tostring(page.name or ""):lower()
|
||||||
|
if n:find("landscape", 1, true) then return "landscape" end
|
||||||
|
if n:find("portrait", 1, true) then return "portrait" end
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
function TouchSkin.hasOrientPair(skin)
|
||||||
|
local saw = {}
|
||||||
|
for _, page in ipairs(skin and skin.pages or {}) do
|
||||||
|
local o = TouchSkin.pageOrient(page)
|
||||||
|
if o then saw[o] = true end
|
||||||
|
end
|
||||||
|
return saw.portrait == true and saw.landscape == true
|
||||||
|
end
|
||||||
|
|
||||||
|
local function findOrientPage(skin, keyword)
|
||||||
|
for i, page in ipairs(skin.pages or {}) do
|
||||||
|
if TouchSkin.pageOrient(page) == keyword then return i end
|
||||||
|
end
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
-- If the current page is the wrong orientation of a portrait/landscape pair,
|
||||||
|
-- jump to the matching one. Pages locked to neither (gb_anim's GameBoy /
|
||||||
|
-- GameBoyColor) are left alone. #1503
|
||||||
|
function TouchSkin.syncOrientation(w, h)
|
||||||
|
if not TouchSkin.autoOrient then return end
|
||||||
|
local skin = TouchSkin.active
|
||||||
|
if not skin or not w or not h or w <= 0 or h <= 0 then return end
|
||||||
|
local want = w > h and "landscape" or "portrait"
|
||||||
|
local unwant = w > h and "portrait" or "landscape"
|
||||||
|
local page = skin.pages[TouchSkin.pageIndex] or skin.pages[1]
|
||||||
|
local current = TouchSkin.pageOrient(page)
|
||||||
|
if current == want then return end
|
||||||
|
if current ~= unwant then return end
|
||||||
|
local idx = findOrientPage(skin, want)
|
||||||
|
if idx then TouchSkin.pageIndex = idx end
|
||||||
|
end
|
||||||
|
|
||||||
function TouchSkin.page()
|
function TouchSkin.page()
|
||||||
local skin = TouchSkin.active
|
local skin = TouchSkin.active
|
||||||
if not skin then return nil end
|
if not skin then return nil end
|
||||||
|
local w, h = displaySize()
|
||||||
|
TouchSkin.syncOrientation(w, h)
|
||||||
return skin.pages[TouchSkin.pageIndex] or skin.pages[1]
|
return skin.pages[TouchSkin.pageIndex] or skin.pages[1]
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -766,7 +846,12 @@ function TouchSkin.pageBox(page, w, h, ox, oy)
|
|||||||
ox, oy = ox or 0, oy or 0
|
ox, oy = ox or 0, oy or 0
|
||||||
if not page then return ox, oy, w, h end
|
if not page then return ox, oy, w, h end
|
||||||
local bx, by, bw, bh = ox, oy, w, h
|
local bx, by, bw, bh = ox, oy, w, h
|
||||||
if not page.fullScreen and h > 0 then
|
-- full_screen means "relative to the window, not the game viewport".
|
||||||
|
-- When the cfg also names an aspect_ratio, that window is then fitted
|
||||||
|
-- to the overlay's design aspect so buttons do not stretch. #1503
|
||||||
|
local fit = ((not page.fullScreen) or page.aspectFromCfg)
|
||||||
|
and page.aspect and page.aspect > 0 and h > 0
|
||||||
|
if fit then
|
||||||
local displayAspect = w / h
|
local displayAspect = w / h
|
||||||
if displayAspect > page.aspect then
|
if displayAspect > page.aspect then
|
||||||
bw = h * page.aspect
|
bw = h * page.aspect
|
||||||
@@ -833,8 +918,9 @@ function TouchSkin.viewport(w, h, ox, oy)
|
|||||||
local page = TouchSkin.page()
|
local page = TouchSkin.page()
|
||||||
if not page or not page.viewport or not TouchSkin.drawable() then return nil end
|
if not page or not page.viewport or not TouchSkin.drawable() then return nil end
|
||||||
local v = page.viewport
|
local v = page.viewport
|
||||||
local x, y = (ox or 0) + v.x * w, (oy or 0) + v.y * h
|
local bx, by, bw, bh = TouchSkin.pageBox(page, w, h, ox, oy)
|
||||||
local vw, vh = v.w * w, v.h * h
|
local x, y = bx + v.x * bw, by + v.y * bh
|
||||||
|
local vw, vh = v.w * bw, v.h * bh
|
||||||
if vw <= 0 or vh <= 0 then return nil end
|
if vw <= 0 or vh <= 0 then return nil end
|
||||||
return x, y, vw, vh, page.viewportFill == true, page.viewportExpand == true
|
return x, y, vw, vh, page.viewportFill == true, page.viewportExpand == true
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -220,6 +220,9 @@ function SwitchDiagnostics.probeAssets(version)
|
|||||||
"assets/generated/tilesets/reds_house.png",
|
"assets/generated/tilesets/reds_house.png",
|
||||||
"assets/generated/sprites/red.png",
|
"assets/generated/sprites/red.png",
|
||||||
"assets/generated/sprites/monster.png",
|
"assets/generated/sprites/monster.png",
|
||||||
|
"data/generated/maps.lua",
|
||||||
|
"data/generated/oak_speech.lua",
|
||||||
|
"data/generated/font.lua",
|
||||||
}
|
}
|
||||||
for _, path in ipairs(samples) do
|
for _, path in ipairs(samples) do
|
||||||
local versioned = prefix ~= "" and (prefix .. path) or path
|
local versioned = prefix ~= "" and (prefix .. path) or path
|
||||||
@@ -230,17 +233,26 @@ function SwitchDiagnostics.probeAssets(version)
|
|||||||
lines[#lines + 1] = "versioned=" .. probeInfo(filesystem, versioned)
|
lines[#lines + 1] = "versioned=" .. probeInfo(filesystem, versioned)
|
||||||
end
|
end
|
||||||
lines[#lines + 1] = "resolve=" .. tostring(resolved)
|
lines[#lines + 1] = "resolve=" .. tostring(resolved)
|
||||||
lines[#lines + 1] = "newImage=" .. probeOpen("image", resolved)
|
if path:sub(-4) == ".lua" then
|
||||||
lines[#lines + 1] = "newImageData=" .. probeOpen("imageData", resolved)
|
local CacheFs = require("src.import.CacheFs")
|
||||||
if prefix ~= "" and resolved ~= versioned then
|
local loaded, err = CacheFs.loadActive(path)
|
||||||
lines[#lines + 1] = "newImage_versioned=" .. probeOpen("image", versioned)
|
lines[#lines + 1] = "loadActive=" .. (loaded ~= nil and "ok"
|
||||||
lines[#lines + 1] = "newImageData_versioned=" .. probeOpen("imageData", versioned)
|
or ("FAIL " .. tostring(err):gsub("%s+", " "):sub(1, 160)))
|
||||||
|
else
|
||||||
|
lines[#lines + 1] = "newImage=" .. probeOpen("image", resolved)
|
||||||
|
lines[#lines + 1] = "newImageData=" .. probeOpen("imageData", resolved)
|
||||||
|
if prefix ~= "" and resolved ~= versioned then
|
||||||
|
lines[#lines + 1] = "newImage_versioned=" .. probeOpen("image", versioned)
|
||||||
|
lines[#lines + 1] = "newImageData_versioned=" .. probeOpen("imageData", versioned)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Shallow listing so we can see if the extract tree exists at all.
|
-- Shallow listing so we can see if the extract tree exists at all.
|
||||||
local roots = { "yellow", "blue", "assets", "yellow/assets/generated",
|
local roots = { "yellow", "blue", "gold", "assets", "yellow/assets/generated",
|
||||||
"yellow/assets/generated/sprites", "blue/assets/generated/sprites" }
|
"yellow/assets/generated/sprites", "blue/assets/generated/sprites",
|
||||||
|
"gold/assets/generated", "gold/assets/generated/sprites",
|
||||||
|
"gold/data/generated" }
|
||||||
for _, dir in ipairs(roots) do
|
for _, dir in ipairs(roots) do
|
||||||
local info = filesystem.getInfo(dir)
|
local info = filesystem.getInfo(dir)
|
||||||
if info and info.type == "directory" and filesystem.getDirectoryItems then
|
if info and info.type == "directory" and filesystem.getDirectoryItems then
|
||||||
|
|||||||
+28
-2
@@ -34,7 +34,7 @@ local SEP = package.config:sub(1, 1)
|
|||||||
|
|
||||||
-- Cache-relative paths are prefixed with this before every read/write, so a
|
-- Cache-relative paths are prefixed with this before every read/write, so a
|
||||||
-- version's import lands under its GameVersion.cachePrefix (red/, blue/,
|
-- version's import lands under its GameVersion.cachePrefix (red/, blue/,
|
||||||
-- yellow/). The launcher sets it per import / per readiness check; it stays
|
-- yellow/, gold/). The launcher sets it per import / per readiness check; it stays
|
||||||
-- "" outside those flows. Runtime *reads* (require / newImage) do NOT go
|
-- "" outside those flows. Runtime *reads* (require / newImage) do NOT go
|
||||||
-- through here -- CacheFs.mountVersion overlays the active version's subtree
|
-- through here -- CacheFs.mountVersion overlays the active version's subtree
|
||||||
-- onto the un-prefixed paths instead.
|
-- onto the un-prefixed paths instead.
|
||||||
@@ -305,7 +305,7 @@ function CacheFs.read(rel)
|
|||||||
end
|
end
|
||||||
|
|
||||||
-- Read cache-relative `rel` for the active GameVersion when PhysFS may hide
|
-- Read cache-relative `rel` for the active GameVersion when PhysFS may hide
|
||||||
-- prefixed Blue/Yellow trees (fused NX mount hole). Same order Data:load
|
-- prefixed Blue/Yellow/Gold trees (fused NX mount hole). Same order Data:load
|
||||||
-- already used: active version prefix with CacheFs.prefix cleared, then
|
-- already used: active version prefix with CacheFs.prefix cleared, then
|
||||||
-- `rel` under the caller's CacheFs.prefix. Returns the bytes or nil.
|
-- `rel` under the caller's CacheFs.prefix. Returns the bytes or nil.
|
||||||
function CacheFs.readActive(rel)
|
function CacheFs.readActive(rel)
|
||||||
@@ -322,6 +322,32 @@ function CacheFs.readActive(rel)
|
|||||||
return nil
|
return nil
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- Load a generated Lua table the way Data:load does: versioned save-dir
|
||||||
|
-- bytes first (gold/data/generated/maps.lua), then the un-prefixed path.
|
||||||
|
-- Game2/World used love.filesystem.load("data/generated/...") which misses
|
||||||
|
-- on fused NX when the gold/ overlay mount fails -- intro art still loads
|
||||||
|
-- via NxAssetOverlay, but oak_speech.lua / font.lua / maps.lua do not.
|
||||||
|
function CacheFs.loadActive(rel)
|
||||||
|
local bytes = CacheFs.readActive(rel)
|
||||||
|
if type(bytes) == "string" then
|
||||||
|
local GameVersion = require("src.core.GameVersion")
|
||||||
|
local loader = loadstring or load
|
||||||
|
local chunk, err = loader(bytes, "@" .. GameVersion.cachePrefix() .. rel)
|
||||||
|
if not chunk then return nil, err end
|
||||||
|
local ok, value = pcall(chunk)
|
||||||
|
if not ok then return nil, value end
|
||||||
|
return value
|
||||||
|
end
|
||||||
|
if love and love.filesystem and love.filesystem.load then
|
||||||
|
local chunk, err = love.filesystem.load(rel)
|
||||||
|
if not chunk then return nil, err end
|
||||||
|
local ok, value = pcall(chunk)
|
||||||
|
if not ok then return nil, value end
|
||||||
|
return value
|
||||||
|
end
|
||||||
|
return nil, "Could not open file " .. rel .. ". Does not exist."
|
||||||
|
end
|
||||||
|
|
||||||
-- does cache-relative `rel` exist as a file?
|
-- does cache-relative `rel` exist as a file?
|
||||||
function CacheFs.exists(rel)
|
function CacheFs.exists(rel)
|
||||||
rel = withPrefix(rel)
|
rel = withPrefix(rel)
|
||||||
|
|||||||
+26
-28
@@ -192,20 +192,28 @@ local PAL = {
|
|||||||
chipInkGold = { 58, 44, 0 }, -- #3a2c00 dark "Y" on the gold chip
|
chipInkGold = { 58, 44, 0 }, -- #3a2c00 dark "Y" on the gold chip
|
||||||
}
|
}
|
||||||
|
|
||||||
|
-- Per-version required cache files. Gold replaces the Gen 1 list entirely
|
||||||
|
-- (VERSION_REQUIRED_FILES_OVERRIDE); Yellow adds a few extra markers.
|
||||||
|
local function requiredFilesFor(version)
|
||||||
|
local override = VERSION_REQUIRED_FILES_OVERRIDE[version]
|
||||||
|
if override then return override, true end
|
||||||
|
return REQUIRED_FILES, false
|
||||||
|
end
|
||||||
|
|
||||||
-- CacheFs.exists checks the game folder directly for a portable install,
|
-- CacheFs.exists checks the game folder directly for a portable install,
|
||||||
-- otherwise the save directory through love.filesystem. It honors
|
-- otherwise the save directory through love.filesystem. It honors
|
||||||
-- CacheFs.prefix, so we point it at the version's cache subtree (red/,
|
-- CacheFs.prefix, so we point it at the version's cache subtree (red/,
|
||||||
-- blue/, yellow/).
|
-- blue/, yellow/, gold/).
|
||||||
local function allRequiredFilesExist(version)
|
local function allRequiredFilesExist(version)
|
||||||
local CacheFs = require("src.import.CacheFs")
|
local CacheFs = require("src.import.CacheFs")
|
||||||
local saved = CacheFs.prefix
|
local saved = CacheFs.prefix
|
||||||
CacheFs.prefix = GameVersion.cachePrefix(version)
|
CacheFs.prefix = GameVersion.cachePrefix(version)
|
||||||
local ok = true
|
local ok = true
|
||||||
local required = VERSION_REQUIRED_FILES_OVERRIDE[version] or REQUIRED_FILES
|
local required, isOverride = requiredFilesFor(version)
|
||||||
for _, path in ipairs(required) do
|
for _, path in ipairs(required) do
|
||||||
if not CacheFs.exists(path) then ok = false; break end
|
if not CacheFs.exists(path) then ok = false; break end
|
||||||
end
|
end
|
||||||
if ok and not VERSION_REQUIRED_FILES_OVERRIDE[version] then
|
if ok and not isOverride then
|
||||||
for _, path in ipairs(VERSION_REQUIRED_FILES[version] or {}) do
|
for _, path in ipairs(VERSION_REQUIRED_FILES[version] or {}) do
|
||||||
if not CacheFs.exists(path) then ok = false; break end
|
if not CacheFs.exists(path) then ok = false; break end
|
||||||
end
|
end
|
||||||
@@ -215,20 +223,23 @@ local function allRequiredFilesExist(version)
|
|||||||
end
|
end
|
||||||
|
|
||||||
-- A developer checkout / Python build leaves generated data in the physfs
|
-- A developer checkout / Python build leaves generated data in the physfs
|
||||||
-- source: Red at the historical root, Blue/Yellow in their versioned trees.
|
-- source: Red at the historical root, Blue/Yellow/Gold in their versioned
|
||||||
-- Imported Red caches still live under red/. Check source paths directly so
|
-- trees. Imported Red caches still live under red/. Check source paths
|
||||||
-- that cache prefix cannot hide Red's source tree, and keep save-dir caches
|
-- directly so that cache prefix cannot hide Red's source tree, and keep
|
||||||
-- from counting as current source data.
|
-- save-dir caches from counting as current source data.
|
||||||
local function sourceTreeHasData(version)
|
local function sourceTreeHasData(version)
|
||||||
if not love.filesystem.getRealDirectory then return false end
|
if not love.filesystem.getRealDirectory then return false end
|
||||||
local prefix = version == "red" and "" or GameVersion.cachePrefix(version)
|
local prefix = version == "red" and "" or GameVersion.cachePrefix(version)
|
||||||
for _, path in ipairs(REQUIRED_FILES) do
|
local required, isOverride = requiredFilesFor(version)
|
||||||
|
for _, path in ipairs(required) do
|
||||||
if love.filesystem.getInfo(prefix .. path, "file") == nil then return false end
|
if love.filesystem.getInfo(prefix .. path, "file") == nil then return false end
|
||||||
end
|
end
|
||||||
for _, path in ipairs(VERSION_REQUIRED_FILES[version] or {}) do
|
if not isOverride then
|
||||||
if love.filesystem.getInfo(prefix .. path, "file") == nil then return false end
|
for _, path in ipairs(VERSION_REQUIRED_FILES[version] or {}) do
|
||||||
|
if love.filesystem.getInfo(prefix .. path, "file") == nil then return false end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
local path = prefix .. REQUIRED_FILES[1]
|
local path = prefix .. required[1]
|
||||||
local real = love.filesystem.getRealDirectory(path)
|
local real = love.filesystem.getRealDirectory(path)
|
||||||
return real == love.filesystem.getSource()
|
return real == love.filesystem.getSource()
|
||||||
end
|
end
|
||||||
@@ -285,7 +296,7 @@ local function purgeSaveDirCache()
|
|||||||
return true
|
return true
|
||||||
end
|
end
|
||||||
-- Purge each version's stale save-directory copy (under its red/ / blue/
|
-- Purge each version's stale save-directory copy (under its red/ / blue/
|
||||||
-- / yellow/ prefix) so it cannot shadow the portable game-folder cache.
|
-- / yellow/ / gold/ prefix) so it cannot shadow the portable game-folder cache.
|
||||||
for _, version in ipairs(GameVersion.ORDER) do
|
for _, version in ipairs(GameVersion.ORDER) do
|
||||||
local prefix = GameVersion.cachePrefix(version)
|
local prefix = GameVersion.cachePrefix(version)
|
||||||
if saveDirHas(prefix .. MARKER_PATH) or saveDirHas(prefix .. REQUIRED_FILES[1]) then
|
if saveDirHas(prefix .. MARKER_PATH) or saveDirHas(prefix .. REQUIRED_FILES[1]) then
|
||||||
@@ -4324,25 +4335,12 @@ function RomImporter:_findRows()
|
|||||||
category = self.findCategory,
|
category = self.findCategory,
|
||||||
})
|
})
|
||||||
if self.modScope then
|
if self.modScope then
|
||||||
|
local ModTargets = require("src.mods.ModTargets")
|
||||||
local gen = GameVersion.generation(self.modScope)
|
local gen = GameVersion.generation(self.modScope)
|
||||||
local kept = {}
|
local kept = {}
|
||||||
for _, entry in ipairs(rows) do
|
for _, entry in ipairs(rows) do
|
||||||
local has1, has2 = false, false
|
local versions = ModTargets.normalize(entry.games)
|
||||||
local function note(s)
|
if #versions == 0 or ModTargets.covers(versions, gen) then
|
||||||
s = tostring(s or ""):lower()
|
|
||||||
if s == "gen1" or s == "gen 1" or s == "red" or s == "blue"
|
|
||||||
or s == "yellow" then
|
|
||||||
has1 = true
|
|
||||||
end
|
|
||||||
if s == "gen2" or s == "gen 2" or s == "gold" then
|
|
||||||
has2 = true
|
|
||||||
end
|
|
||||||
end
|
|
||||||
for _, cat in ipairs(entry.categories or {}) do note(cat) end
|
|
||||||
for _, tag in ipairs(entry.tags or {}) do note(tag) end
|
|
||||||
if (not has1 and not has2)
|
|
||||||
or (gen == 2 and has2)
|
|
||||||
or (gen ~= 2 and has1) then
|
|
||||||
kept[#kept + 1] = entry
|
kept[#kept + 1] = entry
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -141,7 +141,7 @@ function SaveFileIO.exportActiveSlot(version)
|
|||||||
fs.createDirectory("exports")
|
fs.createDirectory("exports")
|
||||||
fs.createDirectory("exports/" .. version)
|
fs.createDirectory("exports/" .. version)
|
||||||
end
|
end
|
||||||
-- Per-game folder so MTP browsing matches inbox layout (red/blue/yellow).
|
-- Per-game folder so MTP browsing matches inbox layout (red/blue/yellow/gold).
|
||||||
local rel = ("exports/%s/gen1recomp-%s-%s.sav"):format(version, version, slotId)
|
local rel = ("exports/%s/gen1recomp-%s-%s.sav"):format(version, version, slotId)
|
||||||
local ok, writeErr = fs.write(rel, bytes)
|
local ok, writeErr = fs.write(rel, bytes)
|
||||||
if not ok then return false, "could not write the export: " .. tostring(writeErr) end
|
if not ok then return false, "could not write the export: " .. tostring(writeErr) end
|
||||||
|
|||||||
@@ -270,7 +270,10 @@ end
|
|||||||
|
|
||||||
local function index(mods)
|
local function index(mods)
|
||||||
local byId = {}
|
local byId = {}
|
||||||
for _, mod in ipairs(mods or {}) do byId[tostring(mod.id)] = mod end
|
if type(mods) ~= "table" then return byId end
|
||||||
|
for _, mod in ipairs(mods) do
|
||||||
|
if type(mod) == "table" then byId[tostring(mod.id)] = mod end
|
||||||
|
end
|
||||||
return byId
|
return byId
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -327,7 +330,8 @@ end
|
|||||||
-- lines for the incompatibility screen: what differs, then what still works
|
-- lines for the incompatibility screen: what differs, then what still works
|
||||||
function Handshake.describe(localHello, remoteHello, verdict, mode)
|
function Handshake.describe(localHello, remoteHello, verdict, mode)
|
||||||
local lines = {}
|
local lines = {}
|
||||||
local peer = (remoteHello and remoteHello.name) or "THEY"
|
local peerName = remoteHello and remoteHello.name
|
||||||
|
local peer = type(peerName) == "string" and peerName or "THEY"
|
||||||
if verdict == "refused" then
|
if verdict == "refused" then
|
||||||
-- checked before the v1 arm for the same reason checkCompat checks it
|
-- checked before the v1 arm for the same reason checkCompat checks it
|
||||||
-- first: a Gen 1 peer meeting a Gen 2 one has no `protocol` to read yet
|
-- first: a Gen 1 peer meeting a Gen 2 one has no `protocol` to read yet
|
||||||
|
|||||||
+13
-5
@@ -4,6 +4,8 @@
|
|||||||
|
|
||||||
local Json = {}
|
local Json = {}
|
||||||
|
|
||||||
|
Json.MAX_DEPTH = 64
|
||||||
|
|
||||||
local function encodeValue(v, out)
|
local function encodeValue(v, out)
|
||||||
local t = type(v)
|
local t = type(v)
|
||||||
if v == nil then
|
if v == nil then
|
||||||
@@ -109,7 +111,9 @@ local function decodeString(s, i)
|
|||||||
error("unterminated string")
|
error("unterminated string")
|
||||||
end
|
end
|
||||||
|
|
||||||
decodeValue = function(s, i)
|
decodeValue = function(s, i, depth)
|
||||||
|
depth = (depth or 0) + 1
|
||||||
|
assert(depth <= Json.MAX_DEPTH, "json nested too deeply")
|
||||||
i = skipWs(s, i)
|
i = skipWs(s, i)
|
||||||
local c = s:sub(i, i)
|
local c = s:sub(i, i)
|
||||||
if c == '"' then
|
if c == '"' then
|
||||||
@@ -124,7 +128,7 @@ decodeValue = function(s, i)
|
|||||||
i = skipWs(s, i)
|
i = skipWs(s, i)
|
||||||
assert(s:sub(i, i) == ":", "expected :")
|
assert(s:sub(i, i) == ":", "expected :")
|
||||||
local val
|
local val
|
||||||
val, i = decodeValue(s, i + 1)
|
val, i = decodeValue(s, i + 1, depth)
|
||||||
obj[key] = val
|
obj[key] = val
|
||||||
i = skipWs(s, i)
|
i = skipWs(s, i)
|
||||||
local d = s:sub(i, i)
|
local d = s:sub(i, i)
|
||||||
@@ -138,7 +142,7 @@ decodeValue = function(s, i)
|
|||||||
if s:sub(i, i) == "]" then return arr, i + 1 end
|
if s:sub(i, i) == "]" then return arr, i + 1 end
|
||||||
while true do
|
while true do
|
||||||
local val
|
local val
|
||||||
val, i = decodeValue(s, i)
|
val, i = decodeValue(s, i, depth)
|
||||||
arr[#arr + 1] = val
|
arr[#arr + 1] = val
|
||||||
i = skipWs(s, i)
|
i = skipWs(s, i)
|
||||||
local d = s:sub(i, i)
|
local d = s:sub(i, i)
|
||||||
@@ -162,9 +166,13 @@ decodeValue = function(s, i)
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
function Json.decode(s)
|
function Json.decode(s, maxLength)
|
||||||
|
if type(s) ~= "string" then return nil, "json input is not a string" end
|
||||||
|
if maxLength and #s > maxLength then
|
||||||
|
return nil, ("json input is %d bytes (max %d)"):format(#s, maxLength)
|
||||||
|
end
|
||||||
local ok, v = pcall(function()
|
local ok, v = pcall(function()
|
||||||
local val = select(1, decodeValue(s, 1))
|
local val = select(1, decodeValue(s, 1, 0))
|
||||||
return val
|
return val
|
||||||
end)
|
end)
|
||||||
if ok then return v end
|
if ok then return v end
|
||||||
|
|||||||
@@ -29,7 +29,9 @@ local LinkBattle = {}
|
|||||||
-- Deterministic Park-Miller PRNG: both sides must roll identical
|
-- Deterministic Park-Miller PRNG: both sides must roll identical
|
||||||
-- streams, so love.math.random can't be used.
|
-- streams, so love.math.random can't be used.
|
||||||
local function makeRng(seed)
|
local function makeRng(seed)
|
||||||
local s = seed % 2147483647
|
local s = tonumber(seed) or 1
|
||||||
|
if s ~= s or s == math.huge or s == -math.huge then s = 1 end
|
||||||
|
s = math.floor(s) % 2147483647
|
||||||
if s <= 0 then s = s + 2147483646 end
|
if s <= 0 then s = s + 2147483646 end
|
||||||
return function(a, b)
|
return function(a, b)
|
||||||
s = (s * 16807) % 2147483647
|
s = (s * 16807) % 2147483647
|
||||||
|
|||||||
+19
-5
@@ -40,6 +40,10 @@ Net.__index = Net
|
|||||||
Net.DEFAULT_PORT = 7777
|
Net.DEFAULT_PORT = 7777
|
||||||
Net.DEFAULT_RELAY_ADDRESS = "147.182.215.255:7778"
|
Net.DEFAULT_RELAY_ADDRESS = "147.182.215.255:7778"
|
||||||
|
|
||||||
|
Net.MAX_LINE = 256 * 1024
|
||||||
|
Net.MAX_RX_PER_FRAME = 512 * 1024
|
||||||
|
Net.ENET_BANDWIDTH = 256 * 1024
|
||||||
|
|
||||||
function Net.available()
|
function Net.available()
|
||||||
return enet ~= nil
|
return enet ~= nil
|
||||||
end
|
end
|
||||||
@@ -115,7 +119,8 @@ function Net:host(port)
|
|||||||
return false
|
return false
|
||||||
end
|
end
|
||||||
port = tonumber(port) or Net.defaultPort()
|
port = tonumber(port) or Net.defaultPort()
|
||||||
local ok, h, err = pcall(enet.host_create, ("*:%d"):format(port), 2, 1)
|
local ok, h, err = pcall(enet.host_create, ("*:%d"):format(port), 2, 1,
|
||||||
|
Net.ENET_BANDWIDTH, Net.ENET_BANDWIDTH)
|
||||||
if not ok or not h then
|
if not ok or not h then
|
||||||
self.error = ("can't open UDP port %d (%s)"):format(
|
self.error = ("can't open UDP port %d (%s)"):format(
|
||||||
port, tostring(ok and err or h))
|
port, tostring(ok and err or h))
|
||||||
@@ -255,7 +260,7 @@ local function handleGenericRelayControl(self, msg)
|
|||||||
end
|
end
|
||||||
|
|
||||||
function Net:handleTCPLine(line)
|
function Net:handleTCPLine(line)
|
||||||
local msg = Json.decode(line)
|
local msg = Json.decode(line, Net.MAX_LINE)
|
||||||
if msg == nil then
|
if msg == nil then
|
||||||
Logger.warn("link: bad relay message %q", line:sub(1, 60))
|
Logger.warn("link: bad relay message %q", line:sub(1, 60))
|
||||||
return
|
return
|
||||||
@@ -277,6 +282,11 @@ function Net:drainLines()
|
|||||||
self.rxBuf = self.rxBuf:sub(nl + 1)
|
self.rxBuf = self.rxBuf:sub(nl + 1)
|
||||||
if #line > 0 then self:handleTCPLine(line) end
|
if #line > 0 then self:handleTCPLine(line) end
|
||||||
end
|
end
|
||||||
|
if #self.rxBuf > Net.MAX_LINE then
|
||||||
|
self.rxBuf = ""
|
||||||
|
self.error = Strings("The other side\nsent bad data.")
|
||||||
|
self.closed = true
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
-- non-blocking pump for the relay TCP backend: flush queued writes, drain
|
-- non-blocking pump for the relay TCP backend: flush queued writes, drain
|
||||||
@@ -300,10 +310,14 @@ function Net:updateTCP()
|
|||||||
return
|
return
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
while true do
|
local budget = Net.MAX_RX_PER_FRAME
|
||||||
|
while budget > 0 do
|
||||||
local data, err, partial = sock:receive(8192)
|
local data, err, partial = sock:receive(8192)
|
||||||
local chunk = data or partial or ""
|
local chunk = data or partial or ""
|
||||||
if #chunk > 0 then self.rxBuf = self.rxBuf .. chunk end
|
if #chunk > 0 then
|
||||||
|
self.rxBuf = self.rxBuf .. chunk
|
||||||
|
budget = budget - #chunk
|
||||||
|
end
|
||||||
if err == "closed" then
|
if err == "closed" then
|
||||||
self.closed = true
|
self.closed = true
|
||||||
break
|
break
|
||||||
@@ -350,7 +364,7 @@ function Net:update()
|
|||||||
for _, msg in ipairs(queued) do self:send(msg) end
|
for _, msg in ipairs(queued) do self:send(msg) end
|
||||||
end
|
end
|
||||||
elseif event.type == "receive" then
|
elseif event.type == "receive" then
|
||||||
local msg = Json.decode(event.data)
|
local msg = Json.decode(event.data, Net.MAX_LINE)
|
||||||
if msg ~= nil then
|
if msg ~= nil then
|
||||||
table.insert(self.inbox, msg)
|
table.insert(self.inbox, msg)
|
||||||
else
|
else
|
||||||
|
|||||||
+89
-42
@@ -17,6 +17,25 @@ local Runtime = require("src.mods.Runtime")
|
|||||||
|
|
||||||
local Protocol = {}
|
local Protocol = {}
|
||||||
|
|
||||||
|
local MAX_WIRE_NAME = 40
|
||||||
|
|
||||||
|
local function num(v, default)
|
||||||
|
local n = tonumber(v)
|
||||||
|
if n == nil or n ~= n or n == math.huge or n == -math.huge then
|
||||||
|
return default
|
||||||
|
end
|
||||||
|
return n
|
||||||
|
end
|
||||||
|
|
||||||
|
local function tbl(v)
|
||||||
|
return type(v) == "table" and v or {}
|
||||||
|
end
|
||||||
|
|
||||||
|
local function text(v)
|
||||||
|
if type(v) ~= "string" then return nil end
|
||||||
|
return v:sub(1, MAX_WIRE_NAME)
|
||||||
|
end
|
||||||
|
|
||||||
Protocol.hello = Handshake.hello
|
Protocol.hello = Handshake.hello
|
||||||
Protocol.checkCompat = Handshake.checkCompat
|
Protocol.checkCompat = Handshake.checkCompat
|
||||||
|
|
||||||
@@ -78,6 +97,10 @@ function Protocol.unpackMon(data, packed, opts)
|
|||||||
local Stats = require("src.pokemon.Stats")
|
local Stats = require("src.pokemon.Stats")
|
||||||
local Growth = require("src.pokemon.Growth")
|
local Growth = require("src.pokemon.Growth")
|
||||||
local strict = opts and opts.strict
|
local strict = opts and opts.strict
|
||||||
|
if type(packed) ~= "table" then
|
||||||
|
if strict then return nil, "unknown POKéMON" end
|
||||||
|
return nil
|
||||||
|
end
|
||||||
-- forceLevel comes from an "auto-level" ruling. The picker's ANY choice
|
-- forceLevel comes from an "auto-level" ruling. The picker's ANY choice
|
||||||
-- ("use each mon's real level", Gen1's only mode) is a string sentinel on
|
-- ("use each mon's real level", Gen1's only mode) is a string sentinel on
|
||||||
-- the LinkState/Tournament side (see levelForWire) that must mean "no
|
-- the LinkState/Tournament side (see levelForWire) that must mean "no
|
||||||
@@ -91,7 +114,7 @@ function Protocol.unpackMon(data, packed, opts)
|
|||||||
if strict then return nil, "unknown POKéMON" end
|
if strict then return nil, "unknown POKéMON" end
|
||||||
return nil
|
return nil
|
||||||
end
|
end
|
||||||
local level = math.max(2, math.min(100, math.floor(packed.level or 5)))
|
local level = math.max(2, math.min(100, math.floor(num(packed.level, 5))))
|
||||||
-- "auto-level" tournaments/matches: every participant's real level is
|
-- "auto-level" tournaments/matches: every participant's real level is
|
||||||
-- ignored and everyone rebuilds at the same fixed level instead, so a
|
-- ignored and everyone rebuilds at the same fixed level instead, so a
|
||||||
-- Lv12 and a Lv100 party can battle on equal footing. Both sides pass
|
-- Lv12 and a Lv100 party can battle on equal footing. Both sides pass
|
||||||
@@ -99,25 +122,27 @@ function Protocol.unpackMon(data, packed, opts)
|
|||||||
if forceLevel then
|
if forceLevel then
|
||||||
level = math.max(2, math.min(100, math.floor(forceLevel)))
|
level = math.max(2, math.min(100, math.floor(forceLevel)))
|
||||||
end
|
end
|
||||||
|
local packedDvs, packedStatExp = tbl(packed.dvs), tbl(packed.statExp)
|
||||||
local dvs = {}
|
local dvs = {}
|
||||||
for _, k in ipairs({ "hp", "attack", "defense", "speed", "special" }) do
|
for _, k in ipairs({ "hp", "attack", "defense", "speed", "special" }) do
|
||||||
dvs[k] = math.max(0, math.min(15, math.floor((packed.dvs or {})[k] or 0)))
|
dvs[k] = math.max(0, math.min(15, math.floor(num(packedDvs[k], 0))))
|
||||||
end
|
end
|
||||||
local statExp = {}
|
local statExp = {}
|
||||||
for _, k in ipairs({ "hp", "attack", "defense", "speed", "special" }) do
|
for _, k in ipairs({ "hp", "attack", "defense", "speed", "special" }) do
|
||||||
statExp[k] = math.max(0, math.min(65535, math.floor((packed.statExp or {})[k] or 0)))
|
statExp[k] = math.max(0, math.min(65535, math.floor(num(packedStatExp[k], 0))))
|
||||||
end
|
end
|
||||||
local stats = Stats.calc(def, level, dvs, statExp)
|
local stats = Stats.calc(def, level, dvs, statExp)
|
||||||
local moves = {}
|
local moves = {}
|
||||||
for _, mv in ipairs(packed.moves or {}) do
|
for _, entry in ipairs(tbl(packed.moves)) do
|
||||||
|
local mv = tbl(entry)
|
||||||
local mdef = data.moves[mv.id]
|
local mdef = data.moves[mv.id]
|
||||||
if mdef and #moves < 4 then
|
if mdef and #moves < 4 then
|
||||||
local ppUps = math.max(0, math.min(3, math.floor(mv.ppUps or 0)))
|
local ppUps = math.max(0, math.min(3, math.floor(num(mv.ppUps, 0))))
|
||||||
local maxPP = mdef.pp + ppUps * math.floor(mdef.pp / 5)
|
local maxPP = mdef.pp + ppUps * math.floor(mdef.pp / 5)
|
||||||
local entry = { id = mv.id,
|
local move = { id = mv.id,
|
||||||
pp = math.max(0, math.min(maxPP, math.floor(mv.pp or 0))) }
|
pp = math.max(0, math.min(maxPP, math.floor(num(mv.pp, 0)))) }
|
||||||
if mv.ppUps ~= nil then entry.ppUps = ppUps end
|
if mv.ppUps ~= nil then move.ppUps = ppUps end
|
||||||
table.insert(moves, entry)
|
table.insert(moves, move)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
if #moves == 0 then
|
if #moves == 0 then
|
||||||
@@ -132,27 +157,29 @@ function Protocol.unpackMon(data, packed, opts)
|
|||||||
-- same as a standardized tournament format would
|
-- same as a standardized tournament format would
|
||||||
local forced = forceLevel
|
local forced = forceLevel
|
||||||
local hp = forced and stats.hp
|
local hp = forced and stats.hp
|
||||||
or math.max(0, math.min(stats.hp, math.floor(packed.hp or stats.hp)))
|
or math.max(0, math.min(stats.hp, math.floor(num(packed.hp, stats.hp))))
|
||||||
local status = forced and nil or packed.status
|
local status = forced and nil or text(packed.status)
|
||||||
-- preserve the sender's original-trainer identity (party_struct MON_OTID +
|
-- preserve the sender's original-trainer identity (party_struct MON_OTID +
|
||||||
-- wPartyMonOT on a real cable), clamped/typed like every other field so a
|
-- wPartyMonOT on a real cable), clamped/typed like every other field so a
|
||||||
-- tampered packet can't inject a bad ID or a huge name. Left nil when the
|
-- tampered packet can't inject a bad ID or a huge name. Left nil when the
|
||||||
-- packet omits them (a v1/old peer) -- no worse than before for that legacy
|
-- packet omits them (a v1/old peer) -- no worse than before for that legacy
|
||||||
-- path, and once ot is set the load-time stampOT backfill (mon.ot or ...)
|
-- path, and once ot is set the load-time stampOT backfill (mon.ot or ...)
|
||||||
-- becomes a no-op so the sender's identity survives save/reload (#215).
|
-- becomes a no-op so the sender's identity survives save/reload (#215).
|
||||||
local otId = packed.otId
|
local packedOtId = num(packed.otId)
|
||||||
and math.max(0, math.min(65535, math.floor(packed.otId))) or nil
|
local otId = packedOtId
|
||||||
|
and math.max(0, math.min(65535, math.floor(packedOtId))) or nil
|
||||||
local ot = type(packed.ot) == "string" and packed.ot:sub(1, 10) or nil
|
local ot = type(packed.ot) == "string" and packed.ot:sub(1, 10) or nil
|
||||||
return {
|
return {
|
||||||
species = packed.species,
|
species = packed.species,
|
||||||
level = level,
|
level = level,
|
||||||
exp = math.max(0, math.floor(packed.exp or Growth.expForLevel(def.growthRate, level))),
|
exp = math.max(0, math.floor(num(packed.exp,
|
||||||
|
Growth.expForLevel(def.growthRate, level)))),
|
||||||
dvs = dvs,
|
dvs = dvs,
|
||||||
statExp = statExp,
|
statExp = statExp,
|
||||||
stats = stats,
|
stats = stats,
|
||||||
hp = hp,
|
hp = hp,
|
||||||
status = status,
|
status = status,
|
||||||
nickname = packed.nickname,
|
nickname = text(packed.nickname),
|
||||||
ot = ot,
|
ot = ot,
|
||||||
otId = otId,
|
otId = otId,
|
||||||
moves = moves,
|
moves = moves,
|
||||||
@@ -246,6 +273,10 @@ end
|
|||||||
function Protocol.unpackMon2(data, packed, opts)
|
function Protocol.unpackMon2(data, packed, opts)
|
||||||
local Mon = require("src.battle.gen2.Mon")
|
local Mon = require("src.battle.gen2.Mon")
|
||||||
local strict = opts and opts.strict
|
local strict = opts and opts.strict
|
||||||
|
if type(packed) ~= "table" then
|
||||||
|
if strict then return nil, "unknown POKéMON" end
|
||||||
|
return nil
|
||||||
|
end
|
||||||
local forceLevel = opts and tonumber(opts.forceLevel) or nil
|
local forceLevel = opts and tonumber(opts.forceLevel) or nil
|
||||||
local def = data and data.pokemon and data.pokemon[packed.species]
|
local def = data and data.pokemon and data.pokemon[packed.species]
|
||||||
if not def then
|
if not def then
|
||||||
@@ -253,31 +284,33 @@ function Protocol.unpackMon2(data, packed, opts)
|
|||||||
return nil
|
return nil
|
||||||
end
|
end
|
||||||
local level = math.max(1, math.min(Mon.MAX_LEVEL,
|
local level = math.max(1, math.min(Mon.MAX_LEVEL,
|
||||||
math.floor(packed.level or 5)))
|
math.floor(num(packed.level, 5))))
|
||||||
if forceLevel then
|
if forceLevel then
|
||||||
level = math.max(1, math.min(Mon.MAX_LEVEL, math.floor(forceLevel)))
|
level = math.max(1, math.min(Mon.MAX_LEVEL, math.floor(forceLevel)))
|
||||||
end
|
end
|
||||||
|
local packedDvs, packedStatExp = tbl(packed.dvs), tbl(packed.statExp)
|
||||||
local dvs = {}
|
local dvs = {}
|
||||||
for _, k in ipairs(GEN2_DVS) do
|
for _, k in ipairs(GEN2_DVS) do
|
||||||
dvs[k] = math.max(0, math.min(Mon.MAX_DV,
|
dvs[k] = math.max(0, math.min(Mon.MAX_DV,
|
||||||
math.floor((packed.dvs or {})[k] or 0)))
|
math.floor(num(packedDvs[k], 0))))
|
||||||
end
|
end
|
||||||
-- derived, never taken from the packet (see GEN2_DVS above)
|
-- derived, never taken from the packet (see GEN2_DVS above)
|
||||||
dvs.hp = Mon.hpDV(dvs)
|
dvs.hp = Mon.hpDV(dvs)
|
||||||
local statExp = {}
|
local statExp = {}
|
||||||
for _, k in ipairs(GEN2_STAT_EXP) do
|
for _, k in ipairs(GEN2_STAT_EXP) do
|
||||||
statExp[k] = math.max(0, math.min(65535,
|
statExp[k] = math.max(0, math.min(65535,
|
||||||
math.floor((packed.statExp or {})[k] or 0)))
|
math.floor(num(packedStatExp[k], 0))))
|
||||||
end
|
end
|
||||||
local stats = Mon.stats(def.baseStats, dvs, level, statExp)
|
local stats = Mon.stats(def.baseStats, dvs, level, statExp)
|
||||||
local moves = {}
|
local moves = {}
|
||||||
for _, mv in ipairs(packed.moves or {}) do
|
for _, packedMove in ipairs(tbl(packed.moves)) do
|
||||||
|
local mv = tbl(packedMove)
|
||||||
local mdef = data.moves and data.moves[mv.id]
|
local mdef = data.moves and data.moves[mv.id]
|
||||||
if mdef and #moves < 4 then
|
if mdef and #moves < 4 then
|
||||||
local ppUps = math.max(0, math.min(3, math.floor(mv.ppUps or 0)))
|
local ppUps = math.max(0, math.min(3, math.floor(num(mv.ppUps, 0))))
|
||||||
local maxPp = (mdef.pp or 0) + ppUps * math.floor((mdef.pp or 0) / 5)
|
local maxPp = (mdef.pp or 0) + ppUps * math.floor((mdef.pp or 0) / 5)
|
||||||
local entry = { id = mv.id, maxPp = maxPp,
|
local entry = { id = mv.id, maxPp = maxPp,
|
||||||
pp = math.max(0, math.min(maxPp, math.floor(mv.pp or 0))) }
|
pp = math.max(0, math.min(maxPp, math.floor(num(mv.pp, 0)))) }
|
||||||
if mv.ppUps ~= nil then entry.ppUps = ppUps end
|
if mv.ppUps ~= nil then entry.ppUps = ppUps end
|
||||||
table.insert(moves, entry)
|
table.insert(moves, entry)
|
||||||
end
|
end
|
||||||
@@ -296,26 +329,27 @@ function Protocol.unpackMon2(data, packed, opts)
|
|||||||
-- an item the peer cannot represent), and the Gen 2 arm of
|
-- an item the peer cannot represent), and the Gen 2 arm of
|
||||||
-- Protocol.eligibleParty is what keeps it from ever reaching here on a
|
-- Protocol.eligibleParty is what keeps it from ever reaching here on a
|
||||||
-- negotiated trade.
|
-- negotiated trade.
|
||||||
local item = packed.item
|
local item = type(packed.item) == "string" and packed.item or nil
|
||||||
if item ~= nil and not (data.items and data.items[item]) then
|
if item ~= nil and not (data.items and data.items[item]) then
|
||||||
if strict then return nil, "unknown item" end
|
if strict then return nil, "unknown item" end
|
||||||
item = nil
|
item = nil
|
||||||
end
|
end
|
||||||
local forced = forceLevel
|
local forced = forceLevel
|
||||||
local hp = forced and stats.hp
|
local hp = forced and stats.hp
|
||||||
or math.max(0, math.min(stats.hp, math.floor(packed.hp or stats.hp)))
|
or math.max(0, math.min(stats.hp, math.floor(num(packed.hp, stats.hp))))
|
||||||
local status = forced and nil or packed.status
|
local status = forced and nil or text(packed.status)
|
||||||
local otId = packed.otId
|
local packedOtId = num(packed.otId)
|
||||||
and math.max(0, math.min(65535, math.floor(packed.otId))) or nil
|
local otId = packedOtId
|
||||||
|
and math.max(0, math.min(65535, math.floor(packedOtId))) or nil
|
||||||
local ot = type(packed.ot) == "string" and packed.ot:sub(1, 10) or nil
|
local ot = type(packed.ot) == "string" and packed.ot:sub(1, 10) or nil
|
||||||
local growth = Mon.growthFor(data, def.growthRate)
|
local growth = Mon.growthFor(data, def.growthRate)
|
||||||
local mon = {
|
local mon = {
|
||||||
species = packed.species,
|
species = packed.species,
|
||||||
name = def.name or packed.species,
|
name = def.name or packed.species,
|
||||||
nickname = packed.nickname,
|
nickname = text(packed.nickname),
|
||||||
level = level,
|
level = level,
|
||||||
experience = math.max(0, math.floor(packed.experience
|
experience = math.max(0, math.floor(num(packed.experience,
|
||||||
or Mon.experienceForLevel(growth, level))),
|
Mon.experienceForLevel(growth, level)))),
|
||||||
dvs = dvs,
|
dvs = dvs,
|
||||||
statExp = statExp,
|
statExp = statExp,
|
||||||
stats = stats,
|
stats = stats,
|
||||||
@@ -328,10 +362,10 @@ function Protocol.unpackMon2(data, packed, opts)
|
|||||||
-- GiveEgg starts a hatched mon at 120 and a caught one at 70; a traded mon
|
-- GiveEgg starts a hatched mon at 120 and a caught one at 70; a traded mon
|
||||||
-- keeps what it arrived with, clamped to the byte the cart stores it in
|
-- keeps what it arrived with, clamped to the byte the cart stores it in
|
||||||
happiness = math.max(0, math.min(255,
|
happiness = math.max(0, math.min(255,
|
||||||
math.floor(packed.happiness or 70))),
|
math.floor(num(packed.happiness, 70)))),
|
||||||
pokerus = math.max(0, math.min(255, math.floor(packed.pokerus or 0))),
|
pokerus = math.max(0, math.min(255, math.floor(num(packed.pokerus, 0)))),
|
||||||
caughtLevel = math.max(1, math.min(Mon.MAX_LEVEL,
|
caughtLevel = math.max(1, math.min(Mon.MAX_LEVEL,
|
||||||
math.floor(packed.caughtLevel or level))),
|
math.floor(num(packed.caughtLevel, level)))),
|
||||||
ot = ot,
|
ot = ot,
|
||||||
otName = ot,
|
otName = ot,
|
||||||
otId = otId,
|
otId = otId,
|
||||||
@@ -339,7 +373,7 @@ function Protocol.unpackMon2(data, packed, opts)
|
|||||||
}
|
}
|
||||||
if packed.isEgg then
|
if packed.isEgg then
|
||||||
mon.isEgg = true
|
mon.isEgg = true
|
||||||
mon.eggSteps = math.max(0, math.floor(packed.eggSteps or 0))
|
mon.eggSteps = math.max(0, math.floor(num(packed.eggSteps, 0)))
|
||||||
end
|
end
|
||||||
-- Derived from the DVs on the RECEIVING side, exactly as they were derived on
|
-- Derived from the DVs on the RECEIVING side, exactly as they were derived on
|
||||||
-- the sending one: shininess, gender and an Unown's letter are all functions
|
-- the sending one: shininess, gender and an Unown's letter are all functions
|
||||||
@@ -401,17 +435,19 @@ end
|
|||||||
-- which slots are in play and a pick can never land on a different mon.
|
-- which slots are in play and a pick can never land on a different mon.
|
||||||
function Protocol.eligibleParty(party, myRecords, theirRecords)
|
function Protocol.eligibleParty(party, myRecords, theirRecords)
|
||||||
local eligible, reasons = {}, {}
|
local eligible, reasons = {}, {}
|
||||||
theirRecords = theirRecords or {}
|
theirRecords = tbl(theirRecords)
|
||||||
local theirSpecies = theirRecords.pokemon or {}
|
myRecords = tbl(myRecords)
|
||||||
local theirMoves = theirRecords.moves or {}
|
local theirSpecies = tbl(theirRecords.pokemon)
|
||||||
local mySpecies = (myRecords or {}).pokemon or {}
|
local theirMoves = tbl(theirRecords.moves)
|
||||||
local myMoves = (myRecords or {}).moves or {}
|
local mySpecies = tbl(myRecords.pokemon)
|
||||||
|
local myMoves = tbl(myRecords.moves)
|
||||||
-- Gen 2 only, and absent on both sides of a Gen 1 trade, which is what keeps
|
-- Gen 2 only, and absent on both sides of a Gen 1 trade, which is what keeps
|
||||||
-- the loop below unchanged for Red: a mon with no `item` never reaches the
|
-- the loop below unchanged for Red: a mon with no `item` never reaches the
|
||||||
-- held-item arm at all.
|
-- held-item arm at all.
|
||||||
local theirHeld = theirRecords.heldItems
|
local theirHeld = theirRecords.heldItems ~= nil
|
||||||
local myHeld = (myRecords or {}).heldItems or {}
|
and tbl(theirRecords.heldItems) or nil
|
||||||
for i, mon in ipairs(party or {}) do
|
local myHeld = tbl(myRecords.heldItems)
|
||||||
|
for i, mon in ipairs(tbl(party)) do
|
||||||
local reason
|
local reason
|
||||||
if not theirSpecies[mon.species] then
|
if not theirSpecies[mon.species] then
|
||||||
reason = "not on the other game"
|
reason = "not on the other game"
|
||||||
@@ -551,7 +587,7 @@ function TradeSession:handle(msg)
|
|||||||
end
|
end
|
||||||
if self.stage == "waitParty" then self.stage = "picking" end
|
if self.stage == "waitParty" then self.stage = "picking" end
|
||||||
elseif msg.type == "pick" then
|
elseif msg.type == "pick" then
|
||||||
self.theirPick = msg.index
|
self.theirPick = num(msg.index)
|
||||||
self:advance()
|
self:advance()
|
||||||
elseif msg.type == "confirm" then
|
elseif msg.type == "confirm" then
|
||||||
self.theirConfirm = msg.ok
|
self.theirConfirm = msg.ok
|
||||||
@@ -583,7 +619,18 @@ function TradeSession:confirm(ok)
|
|||||||
return { type = "confirm", ok = ok }
|
return { type = "confirm", ok = ok }
|
||||||
end
|
end
|
||||||
|
|
||||||
|
function TradeSession:pickResolves()
|
||||||
|
local index = self.theirPick
|
||||||
|
if type(index) ~= "number" then return false end
|
||||||
|
return self.theirParty ~= nil and self.theirParty[index] ~= nil
|
||||||
|
end
|
||||||
|
|
||||||
function TradeSession:advance()
|
function TradeSession:advance()
|
||||||
|
if self.theirPick ~= nil and self.theirParty and not self:pickResolves() then
|
||||||
|
self.stage = "cancelled"
|
||||||
|
self.error = "the other game picked a POKéMON that isn't there"
|
||||||
|
return
|
||||||
|
end
|
||||||
if self.stage == "picking" and self.myPick then
|
if self.stage == "picking" and self.myPick then
|
||||||
self.stage = self.theirPick and "confirming" or "waitPick"
|
self.stage = self.theirPick and "confirming" or "waitPick"
|
||||||
elseif self.stage == "waitPick" and self.theirPick then
|
elseif self.stage == "waitPick" and self.theirPick then
|
||||||
|
|||||||
+12
-9
@@ -1,3 +1,6 @@
|
|||||||
|
local Logger = require("src.core.Logger")
|
||||||
|
local Wire = require("src.link.Wire")
|
||||||
|
|
||||||
local Session = {}
|
local Session = {}
|
||||||
Session.__index = Session
|
Session.__index = Session
|
||||||
|
|
||||||
@@ -23,6 +26,7 @@ function Session.new(transport, options)
|
|||||||
_status = "connecting",
|
_status = "connecting",
|
||||||
_terminal = nil,
|
_terminal = nil,
|
||||||
_transportCloseCalled = false,
|
_transportCloseCalled = false,
|
||||||
|
dropped = 0,
|
||||||
paired = false,
|
paired = false,
|
||||||
closed = false,
|
closed = false,
|
||||||
error = nil,
|
error = nil,
|
||||||
@@ -120,16 +124,15 @@ function Session:update()
|
|||||||
end
|
end
|
||||||
else
|
else
|
||||||
for index = 1, #messages do
|
for index = 1, #messages do
|
||||||
local message = messages[index]
|
local raw = messages[index]
|
||||||
if type(message) ~= "table" or type(message.type) ~= "string" then
|
local ok, message = pcall(Wire.sanitize, raw)
|
||||||
if not failureReason then
|
if ok and message then
|
||||||
failureReason = "protocol_error"
|
self._inbox[#self._inbox + 1] = message
|
||||||
failureDetail = ("message %d must be a table with string type")
|
else
|
||||||
:format(index)
|
self.dropped = (self.dropped or 0) + 1
|
||||||
end
|
local label = type(raw) == "table" and tostring(raw.type) or type(raw)
|
||||||
break
|
Logger.warn("link: dropped malformed message (%s)", label)
|
||||||
end
|
end
|
||||||
self._inbox[#self._inbox + 1] = message
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,344 @@
|
|||||||
|
local Wire = {}
|
||||||
|
|
||||||
|
local MAX_INT = 2147483647
|
||||||
|
local MAX_STRING = 64
|
||||||
|
local MAX_NAME = 40
|
||||||
|
local MAX_LIST = 64
|
||||||
|
local MAX_PARTY = 32
|
||||||
|
local MAX_MOVES = 8
|
||||||
|
local MAX_MODS = 256
|
||||||
|
local MAX_RECORDS = 4096
|
||||||
|
local MAX_EXTRA_DEPTH = 8
|
||||||
|
local MAX_ROUNDS = 16
|
||||||
|
local MAX_MATCHES = 128
|
||||||
|
|
||||||
|
function Wire.num(v, default, min, max)
|
||||||
|
local n = tonumber(v)
|
||||||
|
if n == nil or n ~= n then return default end
|
||||||
|
min = min or -MAX_INT
|
||||||
|
max = max or MAX_INT
|
||||||
|
if n < min then return min end
|
||||||
|
if n > max then return max end
|
||||||
|
return math.floor(n)
|
||||||
|
end
|
||||||
|
|
||||||
|
function Wire.str(v, default, maxLen)
|
||||||
|
if type(v) ~= "string" then return default end
|
||||||
|
maxLen = maxLen or MAX_STRING
|
||||||
|
if #v > maxLen then return v:sub(1, maxLen) end
|
||||||
|
return v
|
||||||
|
end
|
||||||
|
|
||||||
|
function Wire.bool(v, default)
|
||||||
|
if type(v) == "boolean" then return v end
|
||||||
|
return default
|
||||||
|
end
|
||||||
|
|
||||||
|
function Wire.list(v, maxN, fn)
|
||||||
|
local out = {}
|
||||||
|
if type(v) ~= "table" then return out end
|
||||||
|
local n = math.min(#v, maxN or MAX_LIST)
|
||||||
|
for i = 1, n do
|
||||||
|
local entry = fn(v[i])
|
||||||
|
if entry ~= nil then out[#out + 1] = entry end
|
||||||
|
end
|
||||||
|
return out
|
||||||
|
end
|
||||||
|
|
||||||
|
function Wire.records(v)
|
||||||
|
local out = {}
|
||||||
|
if type(v) ~= "table" then return out end
|
||||||
|
local n = 0
|
||||||
|
for k, val in pairs(v) do
|
||||||
|
if type(k) == "string" then
|
||||||
|
out[k] = Wire.str(val, nil, MAX_STRING) or tostring(Wire.num(val, 0))
|
||||||
|
n = n + 1
|
||||||
|
if n >= MAX_RECORDS then break end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return out
|
||||||
|
end
|
||||||
|
|
||||||
|
function Wire.plain(v, depth)
|
||||||
|
if type(v) ~= "table" then return nil end
|
||||||
|
depth = depth or 0
|
||||||
|
if depth > MAX_EXTRA_DEPTH then return nil end
|
||||||
|
local out = {}
|
||||||
|
for k, val in pairs(v) do
|
||||||
|
local kt, vt = type(k), type(val)
|
||||||
|
if kt == "string" or kt == "number" then
|
||||||
|
if vt == "string" then out[k] = Wire.str(val, nil, MAX_STRING)
|
||||||
|
elseif vt == "number" or vt == "boolean" then out[k] = val
|
||||||
|
elseif vt == "table" then out[k] = Wire.plain(val, depth + 1) end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return out
|
||||||
|
end
|
||||||
|
|
||||||
|
local STAT_KEYS = { "hp", "attack", "defense", "speed", "special" }
|
||||||
|
|
||||||
|
local function statMap(v)
|
||||||
|
local out = {}
|
||||||
|
if type(v) ~= "table" then return out end
|
||||||
|
for _, k in ipairs(STAT_KEYS) do
|
||||||
|
out[k] = Wire.num(v[k], nil, 0, 65535)
|
||||||
|
end
|
||||||
|
return out
|
||||||
|
end
|
||||||
|
|
||||||
|
local function move(v)
|
||||||
|
if type(v) ~= "table" then return { id = nil } end
|
||||||
|
return {
|
||||||
|
id = Wire.str(v.id, nil, MAX_STRING),
|
||||||
|
pp = Wire.num(v.pp, nil, 0, 255),
|
||||||
|
ppUps = Wire.num(v.ppUps, nil, 0, 255),
|
||||||
|
maxPp = Wire.num(v.maxPp, nil, 0, 255),
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
local function mon(v)
|
||||||
|
if type(v) ~= "table" then return {} end
|
||||||
|
return {
|
||||||
|
species = Wire.str(v.species, nil, MAX_STRING),
|
||||||
|
level = Wire.num(v.level, nil, 0, 65535),
|
||||||
|
exp = Wire.num(v.exp, nil, 0, MAX_INT),
|
||||||
|
experience = Wire.num(v.experience, nil, 0, MAX_INT),
|
||||||
|
hp = Wire.num(v.hp, nil, 0, 65535),
|
||||||
|
status = Wire.str(v.status, nil, MAX_STRING),
|
||||||
|
nickname = Wire.str(v.nickname, nil, MAX_NAME),
|
||||||
|
dvs = statMap(v.dvs),
|
||||||
|
statExp = statMap(v.statExp),
|
||||||
|
moves = Wire.list(v.moves, MAX_MOVES, move),
|
||||||
|
ot = Wire.str(v.ot, nil, MAX_NAME),
|
||||||
|
otId = Wire.num(v.otId, nil, 0, MAX_INT),
|
||||||
|
item = Wire.str(v.item, nil, MAX_STRING),
|
||||||
|
happiness = Wire.num(v.happiness, nil, 0, 65535),
|
||||||
|
pokerus = Wire.num(v.pokerus, nil, 0, 65535),
|
||||||
|
caughtLevel = Wire.num(v.caughtLevel, nil, 0, 65535),
|
||||||
|
isEgg = Wire.bool(v.isEgg, nil),
|
||||||
|
eggSteps = Wire.num(v.eggSteps, nil, 0, MAX_INT),
|
||||||
|
extra = Wire.plain(v.extra),
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
local function modEntry(v)
|
||||||
|
if type(v) ~= "table" then return nil end
|
||||||
|
return {
|
||||||
|
id = Wire.str(v.id, nil, MAX_NAME),
|
||||||
|
version = Wire.str(v.version, nil, MAX_NAME)
|
||||||
|
or Wire.num(v.version, nil, 0, MAX_INT),
|
||||||
|
affectsLink = Wire.bool(v.affectsLink, nil),
|
||||||
|
language = Wire.bool(v.language, nil),
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
local function name(v)
|
||||||
|
return Wire.str(v, nil, MAX_NAME)
|
||||||
|
end
|
||||||
|
|
||||||
|
local sanitize
|
||||||
|
|
||||||
|
local SCHEMAS = {}
|
||||||
|
|
||||||
|
SCHEMAS.hello = function(m)
|
||||||
|
return {
|
||||||
|
protocol = Wire.num(m.protocol, nil, 0, MAX_INT),
|
||||||
|
name = name(m.name),
|
||||||
|
mode = Wire.str(m.mode, nil, MAX_STRING),
|
||||||
|
engineVersion = Wire.str(m.engineVersion, nil, MAX_STRING),
|
||||||
|
apiVersion = Wire.str(m.apiVersion, nil, MAX_STRING),
|
||||||
|
generation = Wire.num(m.generation, nil, 0, 255),
|
||||||
|
fingerprint = Wire.str(m.fingerprint, nil, MAX_STRING),
|
||||||
|
linkModified = Wire.bool(m.linkModified, nil),
|
||||||
|
mods = Wire.list(m.mods, MAX_MODS, modEntry),
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
SCHEMAS.records = function(m)
|
||||||
|
return {
|
||||||
|
pokemon = Wire.records(m.pokemon),
|
||||||
|
moves = Wire.records(m.moves),
|
||||||
|
heldItems = m.heldItems ~= nil and Wire.records(m.heldItems) or nil,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
SCHEMAS.party = function(m)
|
||||||
|
return {
|
||||||
|
mons = Wire.list(m.mons, MAX_PARTY, mon),
|
||||||
|
seed = Wire.num(m.seed, nil, 0, MAX_INT),
|
||||||
|
forceLevel = Wire.num(m.forceLevel, nil, 0, 65535),
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
SCHEMAS.pick = function(m)
|
||||||
|
return { index = Wire.num(m.index, nil, -MAX_INT, MAX_INT) }
|
||||||
|
end
|
||||||
|
|
||||||
|
SCHEMAS.confirm = function(m)
|
||||||
|
return { ok = Wire.bool(m.ok, false) }
|
||||||
|
end
|
||||||
|
|
||||||
|
SCHEMAS.action = function(m)
|
||||||
|
return {
|
||||||
|
kind = Wire.str(m.kind, "", MAX_STRING),
|
||||||
|
slot = Wire.num(m.slot, nil, 1, MAX_MOVES),
|
||||||
|
index = Wire.num(m.index, nil, 1, MAX_PARTY),
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
SCHEMAS.hash = function(m)
|
||||||
|
local parts
|
||||||
|
if type(m.parts) == "table" then
|
||||||
|
parts = {
|
||||||
|
actives = Wire.str(m.parts.actives, nil, MAX_STRING),
|
||||||
|
volatile = Wire.str(m.parts.volatile, nil, MAX_STRING),
|
||||||
|
bench = Wire.str(m.parts.bench, nil, MAX_STRING),
|
||||||
|
}
|
||||||
|
end
|
||||||
|
return {
|
||||||
|
turn = Wire.num(m.turn, 0, 0, MAX_INT),
|
||||||
|
value = Wire.str(m.value, nil, MAX_STRING),
|
||||||
|
parts = parts,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
SCHEMAS.replace = function(m)
|
||||||
|
return { index = Wire.num(m.index, 1, 1, MAX_PARTY) }
|
||||||
|
end
|
||||||
|
|
||||||
|
SCHEMAS.bye = function() return {} end
|
||||||
|
SCHEMAS.forfeit = function() return {} end
|
||||||
|
|
||||||
|
SCHEMAS.hosted = function(m)
|
||||||
|
return { code = Wire.str(m.code, nil, MAX_NAME) }
|
||||||
|
end
|
||||||
|
SCHEMAS.paired = function() return {} end
|
||||||
|
SCHEMAS.peer_gone = function() return {} end
|
||||||
|
SCHEMAS.join_error = function(m)
|
||||||
|
return { reason = Wire.str(m.reason, "", MAX_STRING) }
|
||||||
|
end
|
||||||
|
|
||||||
|
local function rule(m)
|
||||||
|
return {
|
||||||
|
requiredPartySize = Wire.num(m.requiredPartySize, nil, 0, 255),
|
||||||
|
minLevel = Wire.num(m.minLevel, nil, 0, 65535),
|
||||||
|
maxLevel = Wire.num(m.maxLevel, nil, 0, 65535),
|
||||||
|
turnLimit = Wire.num(m.turnLimit, nil, 0, 65535),
|
||||||
|
forceLevel = Wire.num(m.forceLevel, nil, 0, 65535),
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
SCHEMAS.tournament_hosted = function(m)
|
||||||
|
local out = rule(m)
|
||||||
|
out.code = Wire.str(m.code, nil, MAX_NAME)
|
||||||
|
out.participating = Wire.bool(m.participating, nil)
|
||||||
|
return out
|
||||||
|
end
|
||||||
|
|
||||||
|
SCHEMAS.tournament_host_error = function(m)
|
||||||
|
local out = rule(m)
|
||||||
|
out.reason = Wire.str(m.reason, "", MAX_STRING)
|
||||||
|
return out
|
||||||
|
end
|
||||||
|
|
||||||
|
SCHEMAS.tournament_join_error = SCHEMAS.tournament_host_error
|
||||||
|
|
||||||
|
SCHEMAS.tournament_roster = function(m)
|
||||||
|
local out = rule(m)
|
||||||
|
out.players = Wire.list(m.players, MAX_MATCHES, name)
|
||||||
|
out.spectators = Wire.list(m.spectators, MAX_MATCHES, name)
|
||||||
|
return out
|
||||||
|
end
|
||||||
|
|
||||||
|
local function match(v)
|
||||||
|
if type(v) ~= "table" then return nil end
|
||||||
|
return {
|
||||||
|
a = name(v.a), b = name(v.b), winner = name(v.winner),
|
||||||
|
bye = Wire.bool(v.bye, false),
|
||||||
|
state = Wire.str(v.state, nil, MAX_STRING),
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
local function round(v)
|
||||||
|
if type(v) ~= "table" then return nil end
|
||||||
|
return {
|
||||||
|
round = Wire.num(v.round, 0, 0, MAX_ROUNDS),
|
||||||
|
matches = Wire.list(v.matches, MAX_MATCHES, match),
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
SCHEMAS.bracket_update = function(m)
|
||||||
|
local t = type(m.tournament) == "table" and m.tournament or {}
|
||||||
|
local out = rule(t)
|
||||||
|
out.code = Wire.str(t.code, nil, MAX_NAME)
|
||||||
|
out.status = Wire.str(t.status, nil, MAX_STRING)
|
||||||
|
out.round = Wire.num(t.round, 0, 0, MAX_ROUNDS)
|
||||||
|
out.champion = name(t.champion)
|
||||||
|
out.rounds = Wire.list(t.rounds, MAX_ROUNDS, round)
|
||||||
|
return { tournament = out }
|
||||||
|
end
|
||||||
|
|
||||||
|
SCHEMAS.match_start = function(m)
|
||||||
|
return {
|
||||||
|
opponent = name(m.opponent),
|
||||||
|
round = Wire.num(m.round, 0, 0, MAX_ROUNDS),
|
||||||
|
turnLimit = Wire.num(m.turnLimit, nil, 0, 65535),
|
||||||
|
role = Wire.str(m.role, "", MAX_STRING),
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
SCHEMAS.match_start_spectate = function(m)
|
||||||
|
return {
|
||||||
|
round = Wire.num(m.round, 0, 0, MAX_ROUNDS),
|
||||||
|
playerHost = name(m.playerHost),
|
||||||
|
playerGuest = name(m.playerGuest),
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
SCHEMAS.tournament_bye = function(m)
|
||||||
|
return { round = Wire.num(m.round, 0, 0, MAX_ROUNDS) }
|
||||||
|
end
|
||||||
|
|
||||||
|
SCHEMAS.tournament_over = function(m)
|
||||||
|
return { champion = name(m.champion) }
|
||||||
|
end
|
||||||
|
|
||||||
|
local SPECTATABLE = {
|
||||||
|
action = true, replace = true, bye = true, forfeit = true,
|
||||||
|
hello = true, party = true, hash = true,
|
||||||
|
}
|
||||||
|
|
||||||
|
SCHEMAS.spectate = function(m)
|
||||||
|
if type(m.msg) ~= "table" or not SPECTATABLE[m.msg.type] then return nil end
|
||||||
|
local inner = sanitize(m.msg)
|
||||||
|
if not inner then return nil end
|
||||||
|
return { side = Wire.str(m.side, "", MAX_STRING), msg = inner }
|
||||||
|
end
|
||||||
|
|
||||||
|
Wire.SCHEMAS = SCHEMAS
|
||||||
|
|
||||||
|
local function passthrough(m)
|
||||||
|
local out = Wire.plain(m) or {}
|
||||||
|
out.type = nil
|
||||||
|
return out
|
||||||
|
end
|
||||||
|
|
||||||
|
sanitize = function(msg)
|
||||||
|
if type(msg) ~= "table" then return nil end
|
||||||
|
local kind = msg.type
|
||||||
|
if type(kind) ~= "string" or #kind > MAX_STRING then return nil end
|
||||||
|
local schema = SCHEMAS[kind]
|
||||||
|
local out
|
||||||
|
if schema then
|
||||||
|
out = schema(msg)
|
||||||
|
if not out then return nil end
|
||||||
|
else
|
||||||
|
out = passthrough(msg)
|
||||||
|
end
|
||||||
|
out.type = kind
|
||||||
|
return out
|
||||||
|
end
|
||||||
|
|
||||||
|
Wire.sanitize = sanitize
|
||||||
|
|
||||||
|
return Wire
|
||||||
@@ -160,6 +160,7 @@ local function parseEntry(raw)
|
|||||||
summary = str(raw.summary) or "",
|
summary = str(raw.summary) or "",
|
||||||
categories = strArray(raw.categories),
|
categories = strArray(raw.categories),
|
||||||
tags = strArray(raw.tags),
|
tags = strArray(raw.tags),
|
||||||
|
games = strArray(raw.games),
|
||||||
license = str(raw.license),
|
license = str(raw.license),
|
||||||
repo = str(raw.repo),
|
repo = str(raw.repo),
|
||||||
github = str(raw.github),
|
github = str(raw.github),
|
||||||
|
|||||||
@@ -166,7 +166,8 @@ function EvolutionState:draw()
|
|||||||
if sprite then
|
if sprite then
|
||||||
local x = math.floor((160 - sprite:getWidth()) / 2)
|
local x = math.floor((160 - sprite:getWidth()) / 2)
|
||||||
local y = math.max(8, 64 - sprite:getHeight())
|
local y = math.max(8, 64 - sprite:getHeight())
|
||||||
love.graphics.draw(sprite, x, y)
|
-- engine/movie/evolution.asm:103
|
||||||
|
love.graphics.draw(sprite, x + sprite:getWidth(), y, 0, -1, 1)
|
||||||
if spriteTrueColor then
|
if spriteTrueColor then
|
||||||
require("src.render.PaletteFX").markTrueColor(x, y, sprite:getDimensions())
|
require("src.render.PaletteFX").markTrueColor(x, y, sprite:getDimensions())
|
||||||
end
|
end
|
||||||
|
|||||||
+127
-9
@@ -20,6 +20,17 @@ Studio.CANVASES = {
|
|||||||
lockViewport = { x = 48 / 256, y = 40 / 224, w = 160 / 256, h = 144 / 224 } },
|
lockViewport = { x = 48 / 256, y = 40 / 224, w = 160 / 256, h = 144 / 224 } },
|
||||||
}
|
}
|
||||||
|
|
||||||
|
-- Per-page lock, and whether the canvas preset follows it. Match is on
|
||||||
|
-- by default so a portrait/landscape overlay pair does not need two
|
||||||
|
-- separate clicks to preview the right way up. #1503
|
||||||
|
Studio.matchOrient = true
|
||||||
|
Studio.ORIENT_CYCLE = { "any", "portrait", "landscape" }
|
||||||
|
Studio.ORIENT_LABEL = {
|
||||||
|
any = "Lock: Off",
|
||||||
|
portrait = "Lock: Portrait",
|
||||||
|
landscape = "Lock: Landscape",
|
||||||
|
}
|
||||||
|
|
||||||
local HANDLE = 7
|
local HANDLE = 7
|
||||||
local HANDLES = {
|
local HANDLES = {
|
||||||
{ "nw", 0, 0 }, { "n", 0.5, 0 }, { "ne", 1, 0 },
|
{ "nw", 0, 0 }, { "n", 0.5, 0 }, { "ne", 1, 0 },
|
||||||
@@ -64,9 +75,91 @@ local function syncActive()
|
|||||||
TouchSkin.pageIndex = Studio.pageIndex
|
TouchSkin.pageIndex = Studio.pageIndex
|
||||||
end
|
end
|
||||||
|
|
||||||
function Studio.setCanvas(index)
|
local function canvasOrientation(canvas)
|
||||||
|
canvas = canvas or Studio.canvas()
|
||||||
|
if not canvas then return nil end
|
||||||
|
return canvas.w > canvas.h and "landscape" or "portrait"
|
||||||
|
end
|
||||||
|
|
||||||
|
local function pickCanvasIndex(want)
|
||||||
|
local cur = Studio.canvas()
|
||||||
|
if canvasOrientation(cur) == want then return Studio.canvasIndex end
|
||||||
|
if cur and cur.id then
|
||||||
|
local hint = cur.id:gsub("portrait", want):gsub("landscape", want)
|
||||||
|
for i, c in ipairs(Studio.CANVASES) do
|
||||||
|
if c.id == hint then return i end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
for i, c in ipairs(Studio.CANVASES) do
|
||||||
|
if canvasOrientation(c) == want and not c.lockViewport then return i end
|
||||||
|
end
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
function Studio.applyImportedOrient()
|
||||||
|
if not Studio.skin then return false end
|
||||||
|
if not TouchSkin.hasOrientPair(Studio.skin) then
|
||||||
|
Studio.syncCanvasToPage()
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
-- A RetroArch overlay that already auto-rotates should do the same in
|
||||||
|
-- the studio: lock is on, canvas follows, and the visible page matches
|
||||||
|
-- the mock device. #1503
|
||||||
|
Studio.matchOrient = true
|
||||||
|
Studio.syncPageToCanvas()
|
||||||
|
Studio.syncCanvasToPage()
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
function Studio.syncCanvasToPage()
|
||||||
|
if not Studio.matchOrient then return end
|
||||||
|
local want = TouchSkin.pageOrient(Studio.page())
|
||||||
|
if not want then return end
|
||||||
|
local idx = pickCanvasIndex(want)
|
||||||
|
if idx and idx ~= Studio.canvasIndex then Studio.setCanvas(idx, true) end
|
||||||
|
end
|
||||||
|
|
||||||
|
function Studio.syncPageToCanvas()
|
||||||
|
if not Studio.matchOrient or not Studio.skin then return end
|
||||||
|
local want = canvasOrientation()
|
||||||
|
if TouchSkin.pageOrient(Studio.page()) == want then return end
|
||||||
|
for i, page in ipairs(Studio.skin.pages or {}) do
|
||||||
|
if TouchSkin.pageOrient(page) == want then
|
||||||
|
Studio.pageIndex = i
|
||||||
|
Studio.selected = nil
|
||||||
|
syncActive()
|
||||||
|
return
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
function Studio.cyclePageOrient(dir)
|
||||||
|
local page = Studio.page()
|
||||||
|
if not page then return end
|
||||||
|
local cur = TouchSkin.pageOrient(page) or "any"
|
||||||
|
local idx = 1
|
||||||
|
for i, o in ipairs(Studio.ORIENT_CYCLE) do
|
||||||
|
if o == cur then idx = i break end
|
||||||
|
end
|
||||||
|
local n = #Studio.ORIENT_CYCLE
|
||||||
|
local nxt = Studio.ORIENT_CYCLE[((idx - 1 + (dir or 1)) % n) + 1]
|
||||||
|
page.orient = nxt
|
||||||
|
local name = tostring(page.name or "")
|
||||||
|
if (nxt == "portrait" or nxt == "landscape")
|
||||||
|
and (name == "" or name == "main" or name:match("^page%d+$")) then
|
||||||
|
page.name = nxt
|
||||||
|
end
|
||||||
|
markDirty()
|
||||||
|
Studio.syncCanvasToPage()
|
||||||
|
return nxt
|
||||||
|
end
|
||||||
|
|
||||||
|
function Studio.setCanvas(index, fromSync)
|
||||||
local n = #Studio.CANVASES
|
local n = #Studio.CANVASES
|
||||||
Studio.canvasIndex = ((index - 1) % n) + 1
|
Studio.canvasIndex = ((index - 1) % n) + 1
|
||||||
|
-- Pick the matching page before writing canvas-owned fields onto it,
|
||||||
|
-- so a landscape preset does not stamp a portrait page. #1503
|
||||||
|
if not fromSync then Studio.syncPageToCanvas() end
|
||||||
local canvas = Studio.canvas()
|
local canvas = Studio.canvas()
|
||||||
local page = Studio.page()
|
local page = Studio.page()
|
||||||
if page and canvas.lockViewport then
|
if page and canvas.lockViewport then
|
||||||
@@ -77,7 +170,11 @@ function Studio.setCanvas(index)
|
|||||||
page.viewportFill = false
|
page.viewportFill = false
|
||||||
markDirty()
|
markDirty()
|
||||||
end
|
end
|
||||||
if page then page.aspect = canvas.w / canvas.h end
|
-- A cfg-authored aspect_ratio is the overlay's design aspect; keep it so
|
||||||
|
-- the preview letterboxes like RetroArch instead of stretching (#1503).
|
||||||
|
if page and not page.aspectFromCfg then
|
||||||
|
page.aspect = canvas.w / canvas.h
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
function Studio.load(opts)
|
function Studio.load(opts)
|
||||||
@@ -102,6 +199,10 @@ function Studio.load(opts)
|
|||||||
TouchControls.active = true
|
TouchControls.active = true
|
||||||
TouchControls.enabled = true
|
TouchControls.enabled = true
|
||||||
TouchControls:setPreview(true)
|
TouchControls:setPreview(true)
|
||||||
|
-- Play snaps pages from the window aspect. The studio uses its own
|
||||||
|
-- Match canvas toggle against the mock device instead. #1503
|
||||||
|
TouchSkin.autoOrient = false
|
||||||
|
Studio.matchOrient = true
|
||||||
|
|
||||||
local start = opts.skinId
|
local start = opts.skinId
|
||||||
if not start then
|
if not start then
|
||||||
@@ -130,6 +231,7 @@ function Studio.open(id)
|
|||||||
Studio.dirty = false
|
Studio.dirty = false
|
||||||
Studio.images = TouchSkin.listImages(Studio.skin.root)
|
Studio.images = TouchSkin.listImages(Studio.skin.root)
|
||||||
syncActive()
|
syncActive()
|
||||||
|
Studio.applyImportedOrient()
|
||||||
return true
|
return true
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -137,6 +239,7 @@ function Studio.unload()
|
|||||||
Studio.pendingPlay = false
|
Studio.pendingPlay = false
|
||||||
TouchSkin.setSurface(nil)
|
TouchSkin.setSurface(nil)
|
||||||
TouchSkin.setActive(nil)
|
TouchSkin.setActive(nil)
|
||||||
|
TouchSkin.autoOrient = true
|
||||||
TouchControls:setPreview(false)
|
TouchControls:setPreview(false)
|
||||||
TouchControls:reset()
|
TouchControls:reset()
|
||||||
Studio.skin = nil
|
Studio.skin = nil
|
||||||
@@ -358,6 +461,7 @@ function Studio.addPage()
|
|||||||
Studio.pageIndex = #skin.pages
|
Studio.pageIndex = #skin.pages
|
||||||
Studio.selected = nil
|
Studio.selected = nil
|
||||||
syncActive()
|
syncActive()
|
||||||
|
Studio.syncCanvasToPage()
|
||||||
markDirty()
|
markDirty()
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -432,7 +536,8 @@ end
|
|||||||
local function viewportRect(page, r)
|
local function viewportRect(page, r)
|
||||||
local v = page.viewport
|
local v = page.viewport
|
||||||
if not v then return nil end
|
if not v then return nil end
|
||||||
return r.x + v.x * r.w, r.y + v.y * r.h, v.w * r.w, v.h * r.h
|
local bx, by, bw, bh = TouchSkin.pageBox(page, r.w, r.h, r.x, r.y)
|
||||||
|
return bx + v.x * bw, by + v.y * bh, v.w * bw, v.h * bh
|
||||||
end
|
end
|
||||||
|
|
||||||
local function handleRects(bx, by, bw, bh)
|
local function handleRects(bx, by, bw, bh)
|
||||||
@@ -540,17 +645,19 @@ function Studio.updateDrag(mx, my, r)
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
local px, py, pw, ph = TouchSkin.pageBox(page, r.w, r.h, r.x, r.y)
|
||||||
|
if pw <= 0 or ph <= 0 then return end
|
||||||
if d.kind:find("control") then
|
if d.kind:find("control") then
|
||||||
local ctl = Studio.selectedControl()
|
local ctl = Studio.selectedControl()
|
||||||
if not ctl then return end
|
if not ctl then return end
|
||||||
ctl.x = clamp01(((bx + bw * 0.5) - r.x) / r.w)
|
ctl.x = clamp01(((bx + bw * 0.5) - px) / pw)
|
||||||
ctl.y = clamp01(((by + bh * 0.5) - r.y) / r.h)
|
ctl.y = clamp01(((by + bh * 0.5) - py) / ph)
|
||||||
ctl.rangeX = math.max(0.002, (bw * 0.5) / r.w)
|
ctl.rangeX = math.max(0.002, (bw * 0.5) / pw)
|
||||||
ctl.rangeY = math.max(0.002, (bh * 0.5) / r.h)
|
ctl.rangeY = math.max(0.002, (bh * 0.5) / ph)
|
||||||
else
|
else
|
||||||
page.viewport = {
|
page.viewport = {
|
||||||
x = clamp01((bx - r.x) / r.w), y = clamp01((by - r.y) / r.h),
|
x = clamp01((bx - px) / pw), y = clamp01((by - py) / ph),
|
||||||
w = math.max(0.02, bw / r.w), h = math.max(0.02, bh / r.h),
|
w = math.max(0.02, bw / pw), h = math.max(0.02, bh / ph),
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
markDirty()
|
markDirty()
|
||||||
@@ -665,11 +772,22 @@ local function inspectorBody(x, y, w)
|
|||||||
Studio.pageIndex = (Studio.pageIndex % #Studio.skin.pages) + 1
|
Studio.pageIndex = (Studio.pageIndex % #Studio.skin.pages) + 1
|
||||||
Studio.selected = nil
|
Studio.selected = nil
|
||||||
syncActive()
|
syncActive()
|
||||||
|
Studio.syncCanvasToPage()
|
||||||
end
|
end
|
||||||
if Kit.button(x + half + gap, cy, half, rowH, "Add page", { id = "pageadd" }) then
|
if Kit.button(x + half + gap, cy, half, rowH, "Add page", { id = "pageadd" }) then
|
||||||
Studio.addPage()
|
Studio.addPage()
|
||||||
end
|
end
|
||||||
cy = cy + rowH + gap
|
cy = cy + rowH + gap
|
||||||
|
local lock = TouchSkin.pageOrient(page) or "any"
|
||||||
|
if Kit.button(x, cy, half, rowH, Studio.ORIENT_LABEL[lock] or "Lock: Off",
|
||||||
|
{ id = "orient" }) then
|
||||||
|
Studio.cyclePageOrient(1)
|
||||||
|
end
|
||||||
|
local matchOn = Studio.matchOrient
|
||||||
|
Studio.matchOrient = Kit.checkbox(x + half + gap, cy, half, rowH,
|
||||||
|
Studio.matchOrient, "Match canvas", "matchorient")
|
||||||
|
if Studio.matchOrient and not matchOn then Studio.syncCanvasToPage() end
|
||||||
|
cy = cy + rowH + gap
|
||||||
|
|
||||||
if page then
|
if page then
|
||||||
local bezel = page.imagePath or "(none)"
|
local bezel = page.imagePath or "(none)"
|
||||||
|
|||||||
@@ -445,7 +445,8 @@ function TradeAnim:draw()
|
|||||||
-- mon pic on the BG at hlcoord 7, 2; info box on the window at
|
-- mon pic on the BG at hlcoord 7, 2; info box on the window at
|
||||||
-- hWY $50, so it sits in the bottom half of the screen
|
-- hWY $50, so it sits in the bottom half of the screen
|
||||||
if self.monVisible and self.sentSprite then
|
if self.monVisible and self.sentSprite then
|
||||||
love.graphics.draw(self.sentSprite, 56, 16)
|
-- engine/movie/trade.asm:751
|
||||||
|
love.graphics.draw(self.sentSprite, 56 + self.sentSprite:getWidth(), 16, 0, -1, 1)
|
||||||
if self.sentSpriteTrueColor then
|
if self.sentSpriteTrueColor then
|
||||||
require("src.render.PaletteFX").markTrueColor(
|
require("src.render.PaletteFX").markTrueColor(
|
||||||
56 - self.scx, 16, self.sentSprite:getDimensions())
|
56 - self.scx, 16, self.sentSprite:getDimensions())
|
||||||
@@ -501,7 +502,8 @@ function TradeAnim:draw()
|
|||||||
|
|
||||||
elseif p == "show_enemy" then
|
elseif p == "show_enemy" then
|
||||||
if self.monVisible and self.recvSprite then
|
if self.monVisible and self.recvSprite then
|
||||||
love.graphics.draw(self.recvSprite, 56, 16)
|
-- engine/movie/trade.asm:751
|
||||||
|
love.graphics.draw(self.recvSprite, 56 + self.recvSprite:getWidth(), 16, 0, -1, 1)
|
||||||
if self.recvSpriteTrueColor then
|
if self.recvSpriteTrueColor then
|
||||||
require("src.render.PaletteFX").markTrueColor(
|
require("src.render.PaletteFX").markTrueColor(
|
||||||
56, 16, self.recvSprite:getDimensions())
|
56, 16, self.recvSprite:getDimensions())
|
||||||
|
|||||||
@@ -396,11 +396,10 @@ local function givePokeMon(data, speciesIndex, level, itemIndex)
|
|||||||
end
|
end
|
||||||
|
|
||||||
local function loadGenerated(path)
|
local function loadGenerated(path)
|
||||||
local chunk, err = love.filesystem.load(path)
|
-- Same NX gold/ fallback Game2 uses. World:load is what surfaces
|
||||||
if not chunk then return nil, err end
|
-- "Gold cache incomplete" when maps.lua is invisible at the unprefixed path.
|
||||||
local ok, value = pcall(chunk)
|
local CacheFs = require("src.import.CacheFs")
|
||||||
if not ok then return nil, value end
|
return CacheFs.loadActive(path)
|
||||||
return value
|
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Paste the 9-tile roof sheet over atlas tiles $0a-$12.
|
-- Paste the 9-tile roof sheet over atlas tiles $0a-$12.
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
return function(game)
|
||||||
|
local U = dofile("tests/drivers/util.lua")
|
||||||
|
local DIR = os.getenv("SHOT_DIR") or "/tmp/bug1412"
|
||||||
|
local Pokemon = require("src.pokemon.Pokemon")
|
||||||
|
local Evolution = require("src.pokemon.Evolution")
|
||||||
|
local Screens = require("src.ui.Screens")
|
||||||
|
local TradeAnim = require("src.ui.TradeAnim")
|
||||||
|
local TextBox = require("src.render.TextBox")
|
||||||
|
|
||||||
|
local function check(label, ok)
|
||||||
|
U.log(ok and "PASS" or "FAIL", label)
|
||||||
|
return ok
|
||||||
|
end
|
||||||
|
|
||||||
|
U.log("Issue #1412: mon pics must be mirrored on the evolution and trade")
|
||||||
|
U.log("screens, and only there; battle keeps the raw orientation.")
|
||||||
|
|
||||||
|
U.teleport(game, "ROUTE_1", 5, 5, "down")
|
||||||
|
U.wait(10)
|
||||||
|
check("the overworld fixture is ready", game.overworld ~= nil)
|
||||||
|
|
||||||
|
local mon = Pokemon.new(game.data, "PIKACHU", 20)
|
||||||
|
game.save.party = { mon }
|
||||||
|
-- engine/movie/evolution.asm:103
|
||||||
|
Evolution.evolve(game, mon, "RAICHU", nil, "ITEM")
|
||||||
|
U.wait(12)
|
||||||
|
local top = game.stack:top()
|
||||||
|
check("the evolution screen opened",
|
||||||
|
top and top.screenId == "EvolutionState")
|
||||||
|
U.shot(game, DIR .. "/bug1412_evo_old_pikachu.png")
|
||||||
|
|
||||||
|
for _ = 1, 600 do
|
||||||
|
if top.done then break end
|
||||||
|
U.wait(1)
|
||||||
|
end
|
||||||
|
check("the evolution finished", top.done and not top.canceled)
|
||||||
|
U.wait(2)
|
||||||
|
U.shot(game, DIR .. "/bug1412_evo_new_raichu.png")
|
||||||
|
for _ = 1, 60 do
|
||||||
|
if game.stack:top() == game.overworld then break end
|
||||||
|
U.tap(game, "a")
|
||||||
|
U.wait(4)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- engine/movie/trade.asm:751
|
||||||
|
local sent = Pokemon.new(game.data, "SPEAROW", 10)
|
||||||
|
local recv = Pokemon.new(game.data, "FARFETCHD", 10)
|
||||||
|
recv.nickname = "DUX"
|
||||||
|
recv.traded = true
|
||||||
|
recv.ot = "TRAINER"
|
||||||
|
U.teleport(game, "ROUTE_1", 5, 5, "down")
|
||||||
|
local anim = Screens.push(game, "TradeAnim", {
|
||||||
|
sent = sent, received = recv, enemyName = "TRAINER",
|
||||||
|
})
|
||||||
|
check("the trade cinematic opened", getmetatable(anim) == TradeAnim)
|
||||||
|
for _ = 1, 200 do
|
||||||
|
if anim.sub == "hold" then break end
|
||||||
|
U.wait(1)
|
||||||
|
end
|
||||||
|
check("the sent SPEAROW pic is on screen",
|
||||||
|
anim.phase == "show_player" and anim.sub == "hold" and anim.monVisible)
|
||||||
|
U.shot(game, DIR .. "/bug1412_trade_sent_spearow.png")
|
||||||
|
|
||||||
|
local function topIsText()
|
||||||
|
return getmetatable(game.stack:top()) == TextBox
|
||||||
|
end
|
||||||
|
for _ = 1, 8000 do
|
||||||
|
if anim.phase == "show_enemy" and anim.monVisible then break end
|
||||||
|
if anim.phase == "done" then break end
|
||||||
|
if anim.waitingText or topIsText() then
|
||||||
|
U.wait(1)
|
||||||
|
else
|
||||||
|
anim:update(1 / 60)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
U.wait(2)
|
||||||
|
check("the received FARFETCH'D pic is on screen",
|
||||||
|
anim.phase == "show_enemy" and anim.monVisible)
|
||||||
|
U.shot(game, DIR .. "/bug1412_trade_recv_farfetchd.png")
|
||||||
|
|
||||||
|
U.log("shots in", DIR)
|
||||||
|
U.log("Right: all four pics are mirrored left-right compared to the same")
|
||||||
|
U.log("mon's battle front sprite (compare the hardware capture on #1412:")
|
||||||
|
U.log("PIKACHU's face points the other way than it does in battle).")
|
||||||
|
U.log("Wrong: any of the four matches the battle orientation.")
|
||||||
|
U.log("Battle, pokedex, title, Hall of Fame, League PC, credits, and the")
|
||||||
|
U.log("museum fossils are untouched and must still match the cart.")
|
||||||
|
|
||||||
|
while true do
|
||||||
|
coroutine.yield()
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
-- data/moves/sfx.asm:46, data/moves/animations.asm:448, audio/engine_2.asm:1077
|
||||||
|
return function(game)
|
||||||
|
local U = dofile("tests/drivers/util.lua")
|
||||||
|
local Pokemon = require("src.pokemon.Pokemon")
|
||||||
|
local BattleState = require("src.battle.BattleState")
|
||||||
|
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||||
|
|
||||||
|
local function check(label, ok)
|
||||||
|
U.log(ok and "PASS" or "FAIL", label)
|
||||||
|
return ok
|
||||||
|
end
|
||||||
|
|
||||||
|
local opts = game.save.options or {}
|
||||||
|
check(("sfx volume %d (needs > 0 to hear anything)"):format(opts.sfxVol or 7),
|
||||||
|
(opts.sfxVol or 7) > 0)
|
||||||
|
check("battle animations are on", opts.animations ~= false)
|
||||||
|
|
||||||
|
local mdef = game.data.moves.LEER
|
||||||
|
check("LEER is in the move table", mdef ~= nil)
|
||||||
|
local anim = mdef and mdef.anim
|
||||||
|
check(("LEER maps to %s pitch %s tempo %s (wants Battle_31 255 64)"):format(
|
||||||
|
anim and tostring(anim.sound) or "?",
|
||||||
|
anim and tostring(anim.pitch) or "?",
|
||||||
|
anim and tostring(anim.tempo) or "?"),
|
||||||
|
anim ~= nil and anim.sound == "Battle_31"
|
||||||
|
and anim.pitch == 255 and anim.tempo == 64)
|
||||||
|
|
||||||
|
local lead = Pokemon.new(game.data, "CHARMANDER", 20)
|
||||||
|
lead.moves = { { id = "LEER", pp = 30 } }
|
||||||
|
game.save.party = { lead }
|
||||||
|
U.teleport(game, "ROUTE_1", 5, 5, "down")
|
||||||
|
U.wait(15)
|
||||||
|
local ow = game.overworld
|
||||||
|
check("standing on ROUTE_1", ow.map.id == "ROUTE_1")
|
||||||
|
|
||||||
|
local ChipAudio = require("src.core.ChipAudio")
|
||||||
|
local renders = {}
|
||||||
|
local origNewSfx = ChipAudio.newSfx
|
||||||
|
ChipAudio.newSfx = function(data, name, pitch, tempo, header, plain)
|
||||||
|
renders[#renders + 1] = { name = name, pitch = pitch, tempo = tempo,
|
||||||
|
plain = plain }
|
||||||
|
return origNewSfx(data, name, pitch, tempo, header, plain)
|
||||||
|
end
|
||||||
|
|
||||||
|
local battle = BattleState.newWild(game, "PIDGEY", 8)
|
||||||
|
battle.onFinish = function() end
|
||||||
|
ow:pushBattle(battle)
|
||||||
|
|
||||||
|
local function waitPhase(phase, tries)
|
||||||
|
for _ = 1, tries do
|
||||||
|
if battle.phase == phase then return true end
|
||||||
|
U.tap(game, "a")
|
||||||
|
U.wait(6)
|
||||||
|
end
|
||||||
|
return battle.phase == phase
|
||||||
|
end
|
||||||
|
|
||||||
|
U.log("")
|
||||||
|
U.log("LISTEN: the cart's LEER opens with a short PIERCING high tone")
|
||||||
|
U.log(" (about a third of a second, while the seed-drop noise is still")
|
||||||
|
U.log(" running) and only then falls to the slow low buzz. Before the")
|
||||||
|
U.log(" fix the recomp skipped the piercing part and played the whole")
|
||||||
|
U.log(" sound as the low buzz.")
|
||||||
|
U.log("")
|
||||||
|
|
||||||
|
for round = 1, 3 do
|
||||||
|
check(("round %d: menu is up"):format(round), waitPhase("menu", 150))
|
||||||
|
U.tap(game, "a")
|
||||||
|
check(("round %d: move list is up"):format(round),
|
||||||
|
waitPhase("moveSelect", 12))
|
||||||
|
U.tap(game, "a")
|
||||||
|
U.wait(150)
|
||||||
|
if round == 1 then U.shot(game, DIR .. "/bug1414_leer.png") end
|
||||||
|
end
|
||||||
|
|
||||||
|
ChipAudio.newSfx = origNewSfx
|
||||||
|
local sawPlain, sawSeed = false, false
|
||||||
|
for _, r in ipairs(renders) do
|
||||||
|
if r.name == "Battle_31" and r.pitch == 255 and r.tempo == 64
|
||||||
|
and (r.plain or 0) > 0 then
|
||||||
|
sawPlain = true
|
||||||
|
end
|
||||||
|
if r.name == "Battle_1B" then sawSeed = true end
|
||||||
|
end
|
||||||
|
check("the seed-drop noise (Battle_1B) rendered", sawSeed)
|
||||||
|
check("LEER's Battle_31 rendered with an unmodified opening"
|
||||||
|
.. " while the noise still ran", sawPlain)
|
||||||
|
for _, r in ipairs(renders) do
|
||||||
|
U.log((" rendered %s pitch=%s tempo=%s plainFrames=%s"):format(
|
||||||
|
r.name, tostring(r.pitch), tostring(r.tempo), tostring(r.plain)))
|
||||||
|
end
|
||||||
|
|
||||||
|
U.log("done; the window stays open, pick LEER again to re-listen")
|
||||||
|
end
|
||||||
@@ -97,10 +97,11 @@ local launcher = love.graphics.newImage("assets/launcher/gear.png")
|
|||||||
eq(launcher.path, "assets/launcher/gear.png",
|
eq(launcher.path, "assets/launcher/gear.png",
|
||||||
"overlay leaves non-generated paths alone")
|
"overlay leaves non-generated paths alone")
|
||||||
|
|
||||||
-- The real un-prefixed file wins when it exists
|
-- Leftover un-prefixed Red cache must not shadow the versioned copy
|
||||||
|
-- (pre-#899 assets/generated at the save-dir root).
|
||||||
love.filesystem.write(PNG, "root-png-bytes")
|
love.filesystem.write(PNG, "root-png-bytes")
|
||||||
eq(love.filesystem.read(PNG), "root-png-bytes",
|
eq(love.filesystem.read(PNG), "yellow-png-bytes",
|
||||||
"overlay prefers the real un-prefixed file over the versioned copy")
|
"overlay prefers the versioned copy over leftover unprefixed")
|
||||||
clearPath(PNG)
|
clearPath(PNG)
|
||||||
|
|
||||||
-- Blue gets the same treatment
|
-- Blue gets the same treatment
|
||||||
@@ -110,7 +111,29 @@ clearPath("yellow/" .. PNG)
|
|||||||
eq(love.filesystem.read(PNG), "blue-png-bytes",
|
eq(love.filesystem.read(PNG), "blue-png-bytes",
|
||||||
"overlay maps generated reads to blue/ for Blue")
|
"overlay maps generated reads to blue/ for Blue")
|
||||||
|
|
||||||
-- Red has no prefix: nothing is rewritten
|
-- Leftover unprefixed font (old Red root) must not beat Gold's copy.
|
||||||
|
GameVersion.set("gold")
|
||||||
|
local FONT = "assets/generated/fonts/font.png"
|
||||||
|
love.filesystem.write(FONT, "stale-red-font")
|
||||||
|
love.filesystem.write("gold/" .. FONT, "gold-font")
|
||||||
|
eq(love.filesystem.read(FONT), "gold-font",
|
||||||
|
"Gold versioned font wins over leftover unprefixed Red font")
|
||||||
|
clearPath(FONT)
|
||||||
|
clearPath("gold/" .. FONT)
|
||||||
|
|
||||||
|
-- Gold data/generated: fused NX hides gold/maps.lua at the unprefixed path,
|
||||||
|
-- which is the "Gold cache incomplete / maps.lua Does not exist" crash.
|
||||||
|
local MAPS = "data/generated/maps.lua"
|
||||||
|
love.filesystem.write("gold/" .. MAPS, "return { NEW_BARK_TOWN = true }")
|
||||||
|
local mapsChunk = love.filesystem.load(MAPS)
|
||||||
|
eq(type(mapsChunk) == "function" and mapsChunk().NEW_BARK_TOWN or nil, true,
|
||||||
|
"wrapped filesystem.load resolves gold/data/generated/maps.lua")
|
||||||
|
eq(love.filesystem.read(MAPS), "return { NEW_BARK_TOWN = true }",
|
||||||
|
"wrapped filesystem.read returns gold maps.lua bytes")
|
||||||
|
clearPath("gold/" .. MAPS)
|
||||||
|
|
||||||
|
-- Red has no empty prefix anymore (cachePrefix is red/), but with no
|
||||||
|
-- red/ copy the unprefixed miss stays a miss.
|
||||||
GameVersion.set("red")
|
GameVersion.set("red")
|
||||||
clearPath("blue/" .. PNG)
|
clearPath("blue/" .. PNG)
|
||||||
eq(love.filesystem.read(PNG), nil, "Red keeps the stock miss behavior")
|
eq(love.filesystem.read(PNG), nil, "Red keeps the stock miss behavior")
|
||||||
|
|||||||
@@ -49,5 +49,16 @@ check(CacheFs.mountVersion("yellow") == true, "mountVersion(yellow) returns true
|
|||||||
eq(love.filesystem.read("assets/generated/fonts/font.png"), "yellow-font",
|
eq(love.filesystem.read("assets/generated/fonts/font.png"), "yellow-font",
|
||||||
"Yellow mount exposes fonts/font.png at the unprefixed path")
|
"Yellow mount exposes fonts/font.png at the unprefixed path")
|
||||||
|
|
||||||
|
-- Gold-only: same overlay contract (the Switch intro/maps.lua hole)
|
||||||
|
love.filesystem._mounts = {}
|
||||||
|
GameVersion.set("gold")
|
||||||
|
love.filesystem.write("gold/data/generated/maps.lua", "return { ok = true }")
|
||||||
|
love.filesystem.write("gold/assets/generated/fonts/font.png", "gold-font")
|
||||||
|
check(CacheFs.mountVersion("gold") == true, "mountVersion(gold) returns true")
|
||||||
|
eq(love.filesystem.read("assets/generated/fonts/font.png"), "gold-font",
|
||||||
|
"Gold mount exposes fonts/font.png at the unprefixed path")
|
||||||
|
eq(love.filesystem.read("data/generated/maps.lua"), "return { ok = true }",
|
||||||
|
"Gold mount exposes maps.lua at the unprefixed path")
|
||||||
|
|
||||||
GameVersion.set("red")
|
GameVersion.set("red")
|
||||||
T.finish()
|
T.finish()
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
-- Gold on fused NX: gold/data/generated exists, but the overlay mount
|
||||||
|
-- onto data/generated often fails. Game2/World used to love.filesystem.load
|
||||||
|
-- the unprefixed path and crash with "Gold cache incomplete / maps.lua
|
||||||
|
-- Does not exist" after a textless intro. CacheFs.loadActive must read the
|
||||||
|
-- versioned file without any mount.
|
||||||
|
-- Self-contained: luajit tests/engine/cache_fs_gold_nx_load_test.lua
|
||||||
|
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||||
|
if not _G.love then _G.love = require("tests.love_stub") end
|
||||||
|
|
||||||
|
local T = require("tests.harness")
|
||||||
|
local check = T.check
|
||||||
|
local eq = T.eq
|
||||||
|
|
||||||
|
local CacheFs = require("src.import.CacheFs")
|
||||||
|
local GameVersion = require("src.core.GameVersion")
|
||||||
|
|
||||||
|
local savedVersion = GameVersion.get()
|
||||||
|
local savedPrefix = CacheFs.prefix
|
||||||
|
|
||||||
|
love.filesystem._mounts = {}
|
||||||
|
GameVersion.set("gold")
|
||||||
|
CacheFs.prefix = GameVersion.cachePrefix()
|
||||||
|
|
||||||
|
love.filesystem.write("gold/data/generated/maps.lua",
|
||||||
|
"return { NEW_BARK_TOWN = { id = 1 } }")
|
||||||
|
love.filesystem.write("gold/data/generated/oak_speech.lua",
|
||||||
|
"return { text = { _OakText1 = 'Hello!' } }")
|
||||||
|
love.filesystem.write("gold/data/generated/font.lua",
|
||||||
|
"return { width = 8 }")
|
||||||
|
|
||||||
|
-- Unprefixed path is a miss: the NX mount hole.
|
||||||
|
eq(love.filesystem.read("data/generated/maps.lua"), nil,
|
||||||
|
"unprefixed maps.lua is missing (mount hole)")
|
||||||
|
eq(love.filesystem.load("data/generated/maps.lua"), nil,
|
||||||
|
"love.filesystem.load misses unprefixed maps.lua")
|
||||||
|
|
||||||
|
local maps, mapsErr = CacheFs.loadActive("data/generated/maps.lua")
|
||||||
|
check(maps ~= nil, "loadActive finds gold/data/generated/maps.lua ("
|
||||||
|
.. tostring(mapsErr) .. ")")
|
||||||
|
eq(maps and maps.NEW_BARK_TOWN and maps.NEW_BARK_TOWN.id, 1,
|
||||||
|
"loadActive returns the Gold maps table")
|
||||||
|
|
||||||
|
local oak = CacheFs.loadActive("data/generated/oak_speech.lua")
|
||||||
|
eq(oak and oak.text and oak.text._OakText1, "Hello!",
|
||||||
|
"loadActive returns oak_speech.lua from gold/")
|
||||||
|
|
||||||
|
local font = CacheFs.loadActive("data/generated/font.lua")
|
||||||
|
eq(font and font.width, 8,
|
||||||
|
"loadActive returns font.lua from gold/")
|
||||||
|
|
||||||
|
love.filesystem.remove("gold/data/generated/maps.lua")
|
||||||
|
love.filesystem.remove("gold/data/generated/oak_speech.lua")
|
||||||
|
love.filesystem.remove("gold/data/generated/font.lua")
|
||||||
|
CacheFs.prefix = savedPrefix
|
||||||
|
GameVersion.set(savedVersion)
|
||||||
|
|
||||||
|
T.finish()
|
||||||
@@ -16,5 +16,8 @@ check(CacheFs.read("data/generated/audio.lua") == nil,
|
|||||||
"read is a nil miss headless, not a crash")
|
"read is a nil miss headless, not a crash")
|
||||||
check(CacheFs.readActive("data/generated/audio.lua") == nil,
|
check(CacheFs.readActive("data/generated/audio.lua") == nil,
|
||||||
"readActive is a nil miss headless, not a crash")
|
"readActive is a nil miss headless, not a crash")
|
||||||
|
local loaded, loadErr = CacheFs.loadActive("data/generated/audio.lua")
|
||||||
|
check(loaded == nil,
|
||||||
|
"loadActive is a nil miss headless, not a crash (" .. tostring(loadErr) .. ")")
|
||||||
|
|
||||||
T.finish()
|
T.finish()
|
||||||
|
|||||||
@@ -70,12 +70,12 @@ end
|
|||||||
do
|
do
|
||||||
local host, guest = sessionPair()
|
local host, guest = sessionPair()
|
||||||
guest:send({ type = "before", sequence = 1 })
|
guest:send({ type = "before", sequence = 1 })
|
||||||
guest:send({ type = "hello", sequence = 2 })
|
guest:send({ type = "greeting", sequence = 2 })
|
||||||
guest:send({ type = "after", sequence = 3 })
|
guest:send({ type = "after", sequence = 3 })
|
||||||
guest:send({ type = "hello", sequence = 4 })
|
guest:send({ type = "greeting", sequence = 4 })
|
||||||
host:update()
|
host:update()
|
||||||
|
|
||||||
local hello = host:take("hello")
|
local hello = host:take("greeting")
|
||||||
T.eq(hello.sequence, 2, "take removes the first matching packet")
|
T.eq(hello.sequence, 2, "take removes the first matching packet")
|
||||||
T.eq(host:pollOne().sequence, 1, "pollOne removes only the FIFO head")
|
T.eq(host:pollOne().sequence, 1, "pollOne removes only the FIFO head")
|
||||||
|
|
||||||
@@ -122,14 +122,14 @@ end
|
|||||||
|
|
||||||
do
|
do
|
||||||
local transport = fakeTransport({ onUpdate = function(self)
|
local transport = fakeTransport({ onUpdate = function(self)
|
||||||
self.inbox[#self.inbox + 1] = { type = "bye", final = true }
|
self.inbox[#self.inbox + 1] = { type = "bye" }
|
||||||
self.closed = true
|
self.closed = true
|
||||||
end })
|
end })
|
||||||
local session = Session.new(transport, { role = "host", kind = "link" })
|
local session = Session.new(transport, { role = "host", kind = "link" })
|
||||||
session:update()
|
session:update()
|
||||||
T.eq(session:getStatus(), "draining", "normal close drains its final packet")
|
T.eq(session:getStatus(), "draining", "normal close drains its final packet")
|
||||||
T.eq(session.closed, false, "compatibility closed waits for the FIFO")
|
T.eq(session.closed, false, "compatibility closed waits for the FIFO")
|
||||||
T.eq(session:take("bye").final, true, "final close packet remains observable")
|
T.check(session:take("bye") ~= nil, "final close packet remains observable")
|
||||||
T.eq(session:getStatus(), "closed", "normal drain reaches closed")
|
T.eq(session:getStatus(), "closed", "normal drain reaches closed")
|
||||||
T.eq(transport.closeCount, 1, "transport cleanup runs once")
|
T.eq(transport.closeCount, 1, "transport cleanup runs once")
|
||||||
end
|
end
|
||||||
@@ -182,21 +182,22 @@ do
|
|||||||
} })
|
} })
|
||||||
local session = Session.new(transport, { role = "host", kind = "link" })
|
local session = Session.new(transport, { role = "host", kind = "link" })
|
||||||
session:update()
|
session:update()
|
||||||
local reason = session:getFailure()
|
T.eq(session:getFailure(), nil, "a malformed packet is not a terminal failure")
|
||||||
T.eq(reason, "protocol_error", "malformed packet fails as protocol_error")
|
T.eq(session:getStatus(), "paired", "the session stays usable after a bad packet")
|
||||||
T.eq(session:getStatus(), "draining", "malformed batch drains valid prefix")
|
|
||||||
local messages = session:poll()
|
local messages = session:poll()
|
||||||
T.eq(#messages, 1, "malformed value and untrusted tail are not exposed")
|
T.eq(#messages, 2, "the malformed value is dropped, the rest is delivered")
|
||||||
T.eq(messages[1].sequence, 1, "valid prefix survives malformed packet")
|
T.eq(messages[1].sequence, 1, "packets before the malformed one survive")
|
||||||
T.eq(session:getStatus(), "failed", "protocol drain reaches failed")
|
T.eq(messages[2].sequence, 3, "packets after the malformed one survive")
|
||||||
|
T.eq(session.dropped, 1, "the drop is counted")
|
||||||
end
|
end
|
||||||
|
|
||||||
do
|
do
|
||||||
local transport = fakeTransport({ inbox = { { type = 7 } } })
|
local transport = fakeTransport({ inbox = { { type = 7 } } })
|
||||||
local session = Session.new(transport, { role = "host", kind = "link" })
|
local session = Session.new(transport, { role = "host", kind = "link" })
|
||||||
session:update()
|
session:update()
|
||||||
T.eq(session:getFailure(), "protocol_error",
|
T.eq(session:getFailure(), nil,
|
||||||
"table without string type is a protocol error")
|
"a table without a string type is dropped, not a terminal failure")
|
||||||
|
T.eq(#session:poll(), 0, "...and never reaches the mode")
|
||||||
end
|
end
|
||||||
|
|
||||||
do
|
do
|
||||||
@@ -268,8 +269,9 @@ do
|
|||||||
local receiver = Session.new(receiverNet, { role = "guest", kind = "link" })
|
local receiver = Session.new(receiverNet, { role = "guest", kind = "link" })
|
||||||
senderNet:send(false)
|
senderNet:send(false)
|
||||||
receiver:update()
|
receiver:update()
|
||||||
T.eq(receiver:getFailure(), "protocol_error",
|
T.eq(receiver:getFailure(), nil,
|
||||||
"loopback forwards decoded false to session validation")
|
"a decoded scalar off the loopback is dropped, not fatal")
|
||||||
|
T.eq(#receiver:poll(), 0, "...and never reaches the mode")
|
||||||
end
|
end
|
||||||
|
|
||||||
do
|
do
|
||||||
@@ -284,8 +286,9 @@ do
|
|||||||
}
|
}
|
||||||
local session = Session.new(transport, { role = "guest", kind = "link" })
|
local session = Session.new(transport, { role = "guest", kind = "link" })
|
||||||
session:update()
|
session:update()
|
||||||
T.eq(session:getFailure(), "protocol_error",
|
T.eq(session:getFailure(), nil,
|
||||||
"ENet forwards decoded false to session validation")
|
"a decoded scalar off ENet is dropped, not fatal")
|
||||||
|
T.eq(#session:poll(), 0, "...and never reaches the mode")
|
||||||
end
|
end
|
||||||
|
|
||||||
do
|
do
|
||||||
@@ -294,8 +297,9 @@ do
|
|||||||
T.check(pcall(transport.handleTCPLine, transport, "42"),
|
T.check(pcall(transport.handleTCPLine, transport, "42"),
|
||||||
"TCP control handoff does not index a decoded scalar")
|
"TCP control handoff does not index a decoded scalar")
|
||||||
session:update()
|
session:update()
|
||||||
T.eq(session:getFailure(), "protocol_error",
|
T.eq(session:getFailure(), nil,
|
||||||
"decoded TCP scalar reaches session validation")
|
"a decoded TCP scalar is dropped, not fatal")
|
||||||
|
T.eq(#session:poll(), 0, "...and never reaches the mode")
|
||||||
end
|
end
|
||||||
|
|
||||||
do
|
do
|
||||||
|
|||||||
@@ -66,4 +66,21 @@ check(mainSrc:find("isNX", 1, true) ~= nil
|
|||||||
and mainSrc:find('require("src.core.NxAssetOverlay").install()', 1, true) ~= nil,
|
and mainSrc:find('require("src.core.NxAssetOverlay").install()', 1, true) ~= nil,
|
||||||
"the overlay install stays gated on Platform.isNX()")
|
"the overlay install stays gated on Platform.isNX()")
|
||||||
|
|
||||||
|
-- Gold Game2 / World must compile data/generated via CacheFs.loadActive so
|
||||||
|
-- a fused NX boot does not depend on mounting gold/ onto data/generated.
|
||||||
|
local function readSrc(path)
|
||||||
|
local fh = io.open(path, "r")
|
||||||
|
local body = fh and fh:read("*a") or ""
|
||||||
|
if fh then fh:close() end
|
||||||
|
return body
|
||||||
|
end
|
||||||
|
local game2Src = readSrc("src/core/Game2.lua")
|
||||||
|
check(game2Src:find("loadActive", 1, true) ~= nil,
|
||||||
|
"Game2 compiles generated modules through CacheFs.loadActive")
|
||||||
|
check(game2Src:find("loadActive(path)", 1, true) ~= nil,
|
||||||
|
"Game2.loadGenerated calls loadActive")
|
||||||
|
local worldSrc = readSrc("src/world/gen2/World.lua")
|
||||||
|
check(worldSrc:find("loadActive", 1, true) ~= nil,
|
||||||
|
"World's on-disk fallback also uses CacheFs.loadActive")
|
||||||
|
|
||||||
T.finish()
|
T.finish()
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ package.loaded["src.import.RomImporter"] = nil
|
|||||||
RomImporter = require("src.import.RomImporter")
|
RomImporter = require("src.import.RomImporter")
|
||||||
|
|
||||||
local function clearSavesInbox()
|
local function clearSavesInbox()
|
||||||
for _, ver in ipairs({ "red", "blue", "yellow" }) do
|
for _, ver in ipairs({ "red", "blue", "yellow", "gold" }) do
|
||||||
local dir = "imports/saves/" .. ver
|
local dir = "imports/saves/" .. ver
|
||||||
for _, name in ipairs(love.filesystem.getDirectoryItems(dir) or {}) do
|
for _, name in ipairs(love.filesystem.getDirectoryItems(dir) or {}) do
|
||||||
love.filesystem.remove(dir .. "/" .. name)
|
love.filesystem.remove(dir .. "/" .. name)
|
||||||
@@ -135,6 +135,8 @@ check(createdDirs["imports/saves/blue"] == true,
|
|||||||
"RES-01: ensureSavesInboxDir creates imports/saves/blue/")
|
"RES-01: ensureSavesInboxDir creates imports/saves/blue/")
|
||||||
check(createdDirs["imports/saves/yellow"] == true,
|
check(createdDirs["imports/saves/yellow"] == true,
|
||||||
"RES-01: ensureSavesInboxDir creates imports/saves/yellow/")
|
"RES-01: ensureSavesInboxDir creates imports/saves/yellow/")
|
||||||
|
check(createdDirs["imports/saves/gold"] == true,
|
||||||
|
"RES-01: ensureSavesInboxDir creates imports/saves/gold/")
|
||||||
|
|
||||||
-- NXSAV-02: notice/hint includes save dir + per-game imports/saves/<version>/ MTP path
|
-- NXSAV-02: notice/hint includes save dir + per-game imports/saves/<version>/ MTP path
|
||||||
ri = freshImporter()
|
ri = freshImporter()
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
-- sourceTreeHasData must use each version's required-file list. Gold's
|
||||||
|
-- cache has no Gen 1 trade art / pikachu.png; validating it against
|
||||||
|
-- REQUIRED_FILES made a Gold source tree look incomplete forever.
|
||||||
|
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||||
|
|
||||||
|
local T = require("tests.harness")
|
||||||
|
local check = T.check
|
||||||
|
|
||||||
|
local f = assert(io.open("src/import/RomImporter.lua", "r"))
|
||||||
|
local src = f:read("*a")
|
||||||
|
f:close()
|
||||||
|
|
||||||
|
local start = src:find("local function sourceTreeHasData", 1, true)
|
||||||
|
check(start ~= nil, "sourceTreeHasData is defined")
|
||||||
|
local finish = src:find("\nfunction RomImporter.isReady", start, true)
|
||||||
|
check(finish ~= nil, "sourceTreeHasData ends before isReady")
|
||||||
|
local body = src:sub(start, finish)
|
||||||
|
|
||||||
|
check(body:find("requiredFilesFor", 1, true) ~= nil,
|
||||||
|
"sourceTreeHasData uses requiredFilesFor (Gold override, not Gen 1 only)")
|
||||||
|
check(body:find("ipairs(REQUIRED_FILES)", 1, true) == nil,
|
||||||
|
"sourceTreeHasData does not iterate the Gen 1 REQUIRED_FILES list raw")
|
||||||
|
|
||||||
|
local helperStart = src:find("local function requiredFilesFor", 1, true)
|
||||||
|
check(helperStart ~= nil, "requiredFilesFor helper exists")
|
||||||
|
local helper = src:sub(helperStart, start)
|
||||||
|
check(helper:find("VERSION_REQUIRED_FILES_OVERRIDE", 1, true) ~= nil,
|
||||||
|
"requiredFilesFor consults VERSION_REQUIRED_FILES_OVERRIDE")
|
||||||
|
|
||||||
|
T.finish()
|
||||||
@@ -195,6 +195,55 @@ Studio.addControl()
|
|||||||
eq(#Studio.skin.pages[2].controls, 1, "controls land on the active page")
|
eq(#Studio.skin.pages[2].controls, 1, "controls land on the active page")
|
||||||
eq(#Studio.skin.pages[1].controls, 0, "and not on the other one")
|
eq(#Studio.skin.pages[1].controls, 0, "and not on the other one")
|
||||||
|
|
||||||
|
-- page orientation lock follows the canvas when Match canvas is on (#1503)
|
||||||
|
session()
|
||||||
|
Studio.canvasIndex = 1
|
||||||
|
Studio.matchOrient = true
|
||||||
|
eq(Studio.cyclePageOrient(1), "portrait", "cycle starts at portrait from unlocked")
|
||||||
|
eq(Studio.page().orient, "portrait", "and stores the lock on the page")
|
||||||
|
eq(Studio.page().name, "portrait", "a generic page is renamed so play can auto-rotate")
|
||||||
|
eq(Studio.canvas().id, "phone_portrait", "and the canvas stays portrait")
|
||||||
|
|
||||||
|
Studio.addPage()
|
||||||
|
eq(Studio.cyclePageOrient(1), "portrait", "the new page starts unlocked, first lock is portrait")
|
||||||
|
Studio.cyclePageOrient(1)
|
||||||
|
eq(Studio.page().orient, "landscape", "second cycle is landscape")
|
||||||
|
eq(Studio.canvas().id, "phone_landscape", "Match canvas flips the mock device with the page")
|
||||||
|
|
||||||
|
Studio.pageIndex = 1
|
||||||
|
Studio.syncCanvasToPage()
|
||||||
|
eq(Studio.canvas().id, "phone_portrait", "switching back to the portrait page restores portrait canvas")
|
||||||
|
|
||||||
|
Studio.setCanvas(2)
|
||||||
|
eq(Studio.pageIndex, 2, "picking a landscape canvas selects the landscape page")
|
||||||
|
|
||||||
|
Studio.matchOrient = false
|
||||||
|
Studio.pageIndex = 1
|
||||||
|
Studio.setCanvas(2)
|
||||||
|
eq(Studio.pageIndex, 1, "Match canvas off leaves the page when the device changes")
|
||||||
|
eq(Studio.canvas().id, "phone_landscape", "and still honours the canvas click")
|
||||||
|
|
||||||
|
-- a RetroArch overlay that already auto-rotates locks itself on open (#1503)
|
||||||
|
session()
|
||||||
|
Studio.matchOrient = false
|
||||||
|
Studio.canvasIndex = 2
|
||||||
|
Studio.skin = assert(TouchSkin.parse([[
|
||||||
|
overlays = 2
|
||||||
|
overlay0_name = "portrait"
|
||||||
|
overlay0_full_screen = true
|
||||||
|
overlay0_descs = 1
|
||||||
|
overlay0_desc0 = "a,0.5,0.5,radial,0.05,0.05"
|
||||||
|
overlay1_name = "landscape"
|
||||||
|
overlay1_full_screen = true
|
||||||
|
overlay1_descs = 1
|
||||||
|
overlay1_desc0 = "a,0.5,0.5,radial,0.05,0.05"
|
||||||
|
]]))
|
||||||
|
Studio.pageIndex = 1
|
||||||
|
check(Studio.applyImportedOrient(), "import of a portrait/landscape pair is automatic")
|
||||||
|
check(Studio.matchOrient, "and turns Match canvas on")
|
||||||
|
eq(Studio.page().name, "landscape", "keeping the landscape canvas already on screen")
|
||||||
|
eq(Studio.page().orient, "landscape", "with the page already locked")
|
||||||
|
|
||||||
-- --------------------------------------------------------------- clone
|
-- --------------------------------------------------------------- clone
|
||||||
|
|
||||||
local source = TouchSkin.load("assets/skins/gb_anim", "gb_anim")
|
local source = TouchSkin.load("assets/skins/gb_anim", "gb_anim")
|
||||||
|
|||||||
@@ -89,6 +89,23 @@ check(probe:find("resolve=yellow/assets/generated/fonts/font.png", 1, true) ~= n
|
|||||||
"probe records versioned font path visibility")
|
"probe records versioned font path visibility")
|
||||||
check(not probe:find(string.char(0xEA, 0x9B), 1, true),
|
check(not probe:find(string.char(0xEA, 0x9B), 1, true),
|
||||||
"probe log contains no ROM-like binary")
|
"probe log contains no ROM-like binary")
|
||||||
|
check(probe:find("data/generated/maps.lua", 1, true) ~= nil,
|
||||||
|
"probe records Gold/Gen2 maps.lua visibility")
|
||||||
|
check(probe:find("loadActive=", 1, true) ~= nil,
|
||||||
|
"probe uses loadActive for generated Lua instead of newImage")
|
||||||
|
|
||||||
|
-- Gold maps.lua / gold/ folders: fused NX intro/naming/overworld crash.
|
||||||
|
GameVersion.set("gold")
|
||||||
|
love.filesystem.write("gold/data/generated/maps.lua", "return { NEW_BARK_TOWN = true }")
|
||||||
|
love.filesystem.write("gold/data/generated/oak_speech.lua", "return {}")
|
||||||
|
SwitchDiagnostics.probeAssets("gold")
|
||||||
|
probe = love.filesystem.read("nx-asset-probe.log") or ""
|
||||||
|
check(probe:find("cachePrefix=gold/", 1, true) ~= nil, "probe records gold prefix")
|
||||||
|
check(probe:find("data/generated/maps.lua", 1, true) ~= nil,
|
||||||
|
"probe lists maps.lua (the Gold cache incomplete path)")
|
||||||
|
check(probe:find("list gold", 1, true) ~= nil, "probe lists gold/ folder")
|
||||||
|
check(probe:find("list gold/data/generated", 1, true) ~= nil,
|
||||||
|
"probe lists gold/data/generated/")
|
||||||
|
|
||||||
love.system = { getOS = function() return "OS X" end }
|
love.system = { getOS = function() return "OS X" end }
|
||||||
Platform._resetForTests()
|
Platform._resetForTests()
|
||||||
@@ -104,5 +121,7 @@ love.filesystem.remove("nx-asset-probe.log")
|
|||||||
love.filesystem.remove("yellow/assets/generated/fonts/font.png")
|
love.filesystem.remove("yellow/assets/generated/fonts/font.png")
|
||||||
love.filesystem.remove("yellow/assets/generated/tilesets/reds_house.png")
|
love.filesystem.remove("yellow/assets/generated/tilesets/reds_house.png")
|
||||||
love.filesystem.remove("yellow/assets/generated/sprites/red.png")
|
love.filesystem.remove("yellow/assets/generated/sprites/red.png")
|
||||||
|
love.filesystem.remove("gold/data/generated/maps.lua")
|
||||||
|
love.filesystem.remove("gold/data/generated/oak_speech.lua")
|
||||||
|
|
||||||
T.finish()
|
T.finish()
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ local page = skin.pages[1]
|
|||||||
eq(page.name, "shell", "page name")
|
eq(page.name, "shell", "page name")
|
||||||
eq(page.imagePath, "img/back.png", "page background path")
|
eq(page.imagePath, "img/back.png", "page background path")
|
||||||
check(page.fullScreen, "full_screen parsed")
|
check(page.fullScreen, "full_screen parsed")
|
||||||
|
check(not page.aspectFromCfg, "a cfg without aspect_ratio does not lock aspect")
|
||||||
eq(page.alphaMod, 0.001, "overlay alpha_mod parsed")
|
eq(page.alphaMod, 0.001, "overlay alpha_mod parsed")
|
||||||
eq(#page.controls, 7, "seven descs parsed")
|
eq(#page.controls, 7, "seven descs parsed")
|
||||||
|
|
||||||
@@ -109,7 +110,7 @@ local sx, sy, sw, sh, fill = TouchSkin.viewport(W, H)
|
|||||||
eq(sx, 0, "viewport x") eq(sy, 0, "viewport y")
|
eq(sx, 0, "viewport x") eq(sy, 0, "viewport y")
|
||||||
eq(sw, 400, "viewport w") eq(sh, 400, "viewport h")
|
eq(sw, 400, "viewport w") eq(sh, 400, "viewport h")
|
||||||
check(fill, "viewport fill flag returned")
|
check(fill, "viewport fill flag returned")
|
||||||
eq(TouchSkin.viewport(W, H), 0, "viewport is window-relative, not page-relative")
|
eq(TouchSkin.viewport(W, H), 0, "without a locked aspect, viewport tracks the window")
|
||||||
|
|
||||||
check(TouchControls:touchpressed("f1", at(0.80, 0.75)), "press on A is captured")
|
check(TouchControls:touchpressed("f1", at(0.80, 0.75)), "press on A is captured")
|
||||||
check(Input:isDown("a"), "skin A presses GB a")
|
check(Input:isDown("a"), "skin A presses GB a")
|
||||||
@@ -195,6 +196,8 @@ if bundled then
|
|||||||
for _, btn in ipairs({ "a", "b", "start", "select", "up", "down", "left", "right" }) do
|
for _, btn in ipairs({ "a", "b", "start", "select", "up", "down", "left", "right" }) do
|
||||||
check(named[btn], "gb_anim binds GB " .. btn)
|
check(named[btn], "gb_anim binds GB " .. btn)
|
||||||
end
|
end
|
||||||
|
check(not TouchSkin.hasOrientPair(bundled),
|
||||||
|
"gb_anim is not a portrait/landscape auto-rotate overlay")
|
||||||
end
|
end
|
||||||
|
|
||||||
local tv = TouchSkin.load("assets/skins/tv_crt", "tv_crt")
|
local tv = TouchSkin.load("assets/skins/tv_crt", "tv_crt")
|
||||||
@@ -322,4 +325,104 @@ end
|
|||||||
TouchSkin.setActive(nil)
|
TouchSkin.setActive(nil)
|
||||||
TouchControls:setHotkeyHandler(nil)
|
TouchControls:setHotkeyHandler(nil)
|
||||||
|
|
||||||
|
-- ------------------------------------------ auto-rotate (#1503)
|
||||||
|
|
||||||
|
-- RetroArch overlays name a portrait page and a landscape page and wire
|
||||||
|
-- overlay_next between them. Play has to pick the one that matches the
|
||||||
|
-- window, or landscape stretches the portrait ranges into wide ovals.
|
||||||
|
local ORIENT_CFG = [[
|
||||||
|
overlays = 2
|
||||||
|
overlay0_name = "portrait"
|
||||||
|
overlay0_full_screen = true
|
||||||
|
overlay0_normalized = true
|
||||||
|
overlay0_aspect_ratio = 0.45
|
||||||
|
overlay0_descs = 1
|
||||||
|
overlay0_desc0 = "a,0.84382,0.69563,radial,0.09722,0.04375"
|
||||||
|
|
||||||
|
overlay1_name = "landscape"
|
||||||
|
overlay1_full_screen = true
|
||||||
|
overlay1_normalized = true
|
||||||
|
overlay1_aspect_ratio = 2.22222222222222
|
||||||
|
overlay1_descs = 1
|
||||||
|
overlay1_desc0 = "a,0.8307031248,0.8614400000,radial,0.0364168752,0.0813300000"
|
||||||
|
]]
|
||||||
|
local orient = assert(TouchSkin.parse(ORIENT_CFG))
|
||||||
|
check(orient.pages[1].aspectFromCfg, "portrait page locks the cfg aspect_ratio")
|
||||||
|
check(orient.pages[2].aspectFromCfg, "landscape page locks the cfg aspect_ratio")
|
||||||
|
eq(orient.pages[1].orient, "portrait", "a portrait page name locks on import")
|
||||||
|
eq(orient.pages[2].orient, "landscape", "a landscape page name locks on import")
|
||||||
|
check(TouchSkin.hasOrientPair(orient), "the pair is an auto-rotate overlay")
|
||||||
|
TouchSkin.setActive(orient)
|
||||||
|
TouchSkin.autoOrient = true
|
||||||
|
|
||||||
|
local PW, PH = 720, 1600
|
||||||
|
TouchSkin.setSurface(0, 0, PW, PH)
|
||||||
|
eq(TouchSkin.page().name, "portrait", "a tall window picks the portrait page")
|
||||||
|
local _, _, pHalfW, pHalfH =
|
||||||
|
TouchSkin.controlGeometry(TouchSkin.page(), TouchSkin.page().controls[1], PW, PH)
|
||||||
|
eq(math.floor(pHalfW + 0.5), 70, "portrait A half-width follows range_x")
|
||||||
|
eq(math.floor(pHalfH + 0.5), 70, "portrait A half-height follows range_y")
|
||||||
|
|
||||||
|
local LW, LH = 1600, 720
|
||||||
|
TouchSkin.setSurface(0, 0, LW, LH)
|
||||||
|
eq(TouchSkin.page().name, "landscape", "a wide window picks the landscape page")
|
||||||
|
local _, _, lHalfW, lHalfH =
|
||||||
|
TouchSkin.controlGeometry(TouchSkin.page(), TouchSkin.page().controls[1], LW, LH)
|
||||||
|
eq(math.floor(lHalfW + 0.5), 58, "landscape A half-width follows the landscape range")
|
||||||
|
eq(math.floor(lHalfH + 0.5), 59, "landscape A half-height follows the landscape range")
|
||||||
|
check(math.abs(lHalfW - lHalfH) < 2, "landscape A stays round instead of stretching")
|
||||||
|
|
||||||
|
-- 16:9 is not the overlay's 20:9. Letterbox to aspect_ratio so A stays round
|
||||||
|
-- instead of filling the window and turning into a tall oval (#1503).
|
||||||
|
TouchSkin.setSurface(0, 0, 1920, 1080)
|
||||||
|
eq(TouchSkin.page().name, "landscape", "16:9 still picks landscape")
|
||||||
|
local _, _, boxW, boxH = TouchSkin.pageBox(TouchSkin.page(), 1920, 1080)
|
||||||
|
eq(math.floor(boxW + 0.5), 1920, "letterbox keeps the 16:9 width")
|
||||||
|
eq(math.floor(boxH + 0.5), 864, "and the overlay's 20:9 height")
|
||||||
|
local _, _, sHalfW, sHalfH =
|
||||||
|
TouchSkin.controlGeometry(TouchSkin.page(), TouchSkin.page().controls[1], 1920, 1080)
|
||||||
|
check(math.abs(sHalfW - sHalfH) < 2, "A stays round on a 16:9 window")
|
||||||
|
|
||||||
|
local nativeOrient = TouchSkin.parseNative(TouchSkin.serialize(orient))
|
||||||
|
check(nativeOrient and nativeOrient.pages[2].aspectFromCfg,
|
||||||
|
"native export keeps the aspect lock")
|
||||||
|
|
||||||
|
-- an explicit lock wins over the page name, so a studio-authored "main"
|
||||||
|
-- page can still auto-rotate in play
|
||||||
|
local LOCK_CFG = [[
|
||||||
|
overlays = 2
|
||||||
|
overlay0_name = "main"
|
||||||
|
overlay0_full_screen = true
|
||||||
|
overlay0_descs = 1
|
||||||
|
overlay0_desc0 = "a,0.5,0.5,radial,0.05,0.05"
|
||||||
|
overlay1_name = "wide"
|
||||||
|
overlay1_full_screen = true
|
||||||
|
overlay1_descs = 1
|
||||||
|
overlay1_desc0 = "a,0.5,0.5,radial,0.05,0.05"
|
||||||
|
]]
|
||||||
|
local locked = assert(TouchSkin.parse(LOCK_CFG))
|
||||||
|
locked.pages[1].orient = "portrait"
|
||||||
|
locked.pages[2].orient = "landscape"
|
||||||
|
TouchSkin.setActive(locked)
|
||||||
|
TouchSkin.setSurface(0, 0, 1600, 720)
|
||||||
|
eq(TouchSkin.page().name, "wide", "orient lock auto-rotates a page not named landscape")
|
||||||
|
local roundTrip = TouchSkin.parseNative(TouchSkin.serialize(locked))
|
||||||
|
eq(roundTrip.pages[1].orient, "portrait", "native export keeps a portrait lock")
|
||||||
|
eq(roundTrip.pages[2].orient, "landscape", "and a landscape lock")
|
||||||
|
|
||||||
|
TouchSkin.setActive(orient)
|
||||||
|
TouchSkin.autoOrient = false
|
||||||
|
TouchSkin.pageIndex = 1
|
||||||
|
eq(TouchSkin.page().name, "portrait",
|
||||||
|
"the studio can keep a portrait page on a landscape canvas")
|
||||||
|
TouchSkin.autoOrient = true
|
||||||
|
TouchSkin.setSurface(nil)
|
||||||
|
|
||||||
|
-- pages that are not a portrait/landscape pair (gb_anim) stay put
|
||||||
|
TouchSkin.setActive(skin)
|
||||||
|
TouchSkin.setSurface(0, 0, LW, LH)
|
||||||
|
eq(TouchSkin.page().name, "shell", "a non-oriented skin does not auto-rotate")
|
||||||
|
TouchSkin.setSurface(nil)
|
||||||
|
TouchSkin.setActive(nil)
|
||||||
|
|
||||||
T.finish("touch_skin")
|
T.finish("touch_skin")
|
||||||
|
|||||||
@@ -66,13 +66,39 @@ table.sort(MOVES)
|
|||||||
-- in the peer's inbox, so one side is mid-queue when the other's action
|
-- 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
|
-- arrives (Net.loopbackPair on its own delivers instantly, which is the one
|
||||||
-- thing the real relay never does)
|
-- thing the real relay never does)
|
||||||
local function laggyPair(delayA, delayB)
|
-- mutation mode: a peer whose messages arrive with a random field (or the
|
||||||
|
-- type itself) at the wrong Lua type. What is delivered is what Session
|
||||||
|
-- would deliver -- Wire.sanitize's output, or nothing at all -- so a run
|
||||||
|
-- exercises the real receive path rather than a hand-written stand-in.
|
||||||
|
local Wire = require("src.link.Wire")
|
||||||
|
local HOSTILE = { {}, { 1, 2, 3 }, 0, -1, 999, "s", "", true, false, math.huge }
|
||||||
|
|
||||||
|
local function mutate(rnd, msg)
|
||||||
|
local keys = {}
|
||||||
|
for k in pairs(msg) do
|
||||||
|
if k ~= "type" then keys[#keys + 1] = k end
|
||||||
|
end
|
||||||
|
table.sort(keys)
|
||||||
|
if #keys == 0 or rnd(1, 100) <= 20 then
|
||||||
|
msg.type = HOSTILE[rnd(1, #HOSTILE)]
|
||||||
|
else
|
||||||
|
msg[keys[rnd(1, #keys)]] = HOSTILE[rnd(1, #HOSTILE)]
|
||||||
|
end
|
||||||
|
return msg
|
||||||
|
end
|
||||||
|
|
||||||
|
local function laggyPair(delayA, delayB, rnd, mutateRate)
|
||||||
local a, b = Net.loopbackPair()
|
local a, b = Net.loopbackPair()
|
||||||
a.wire, b.wire = {}, {}
|
a.wire, b.wire = {}, {}
|
||||||
a.delay, b.delay = delayA or 0, delayB or 0
|
a.delay, b.delay = delayA or 0, delayB or 0
|
||||||
|
a.mutateRate, b.mutateRate = mutateRate or 0, mutateRate or 0
|
||||||
local function send(self, msg)
|
local function send(self, msg)
|
||||||
if self.closed then return end
|
if self.closed then return end
|
||||||
local decoded = Json.decode(Json.encode(msg)) -- same round trip as the wire
|
local decoded = Json.decode(Json.encode(msg)) -- same round trip as the wire
|
||||||
|
if decoded and self.mutateRate > 0 then
|
||||||
|
if rnd(1, 100) <= self.mutateRate then decoded = mutate(rnd, decoded) end
|
||||||
|
decoded = Wire.sanitize(decoded)
|
||||||
|
end
|
||||||
if decoded then table.insert(self.wire, { msg = decoded, at = self.delay }) end
|
if decoded then table.insert(self.wire, { msg = decoded, at = self.delay }) end
|
||||||
end
|
end
|
||||||
local function update(self)
|
local function update(self)
|
||||||
@@ -143,7 +169,7 @@ local function firstMismatch(a, b)
|
|||||||
end
|
end
|
||||||
|
|
||||||
-- Returns nil when the run agreed, or a description of how it split.
|
-- Returns nil when the run agreed, or a description of how it split.
|
||||||
local function runOne(seed)
|
local function runOne(seed, mutateRate)
|
||||||
local rnd = makeRandom(seed)
|
local rnd = makeRandom(seed)
|
||||||
-- the two sides are deliberately different clients
|
-- the two sides are deliberately different clients
|
||||||
local optsA = { animations = false, textSpeed = 1, battleStyle = "SET" }
|
local optsA = { animations = false, textSpeed = 1, battleStyle = "SET" }
|
||||||
@@ -155,7 +181,7 @@ local function runOne(seed)
|
|||||||
gameA.save.party = randomParty(rnd, rnd(1, 4))
|
gameA.save.party = randomParty(rnd, rnd(1, 4))
|
||||||
gameB.save.party = randomParty(rnd, rnd(1, 4))
|
gameB.save.party = randomParty(rnd, rnd(1, 4))
|
||||||
|
|
||||||
local netA, netB = laggyPair(lagA, lagB)
|
local netA, netB = laggyPair(lagA, lagB, rnd, mutateRate)
|
||||||
local battleSeed = rnd(1, 2 ^ 30)
|
local battleSeed = rnd(1, 2 ^ 30)
|
||||||
local battleA = LinkBattle.newHost(gameA, netA, {
|
local battleA = LinkBattle.newHost(gameA, netA, {
|
||||||
myParty = Protocol.packParty(gameA.save.party),
|
myParty = Protocol.packParty(gameA.save.party),
|
||||||
@@ -230,6 +256,12 @@ local function runOne(seed)
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
local turn, part = firstMismatch(battleA, battleB)
|
local turn, part = firstMismatch(battleA, battleB)
|
||||||
|
if turn and mutateRate then
|
||||||
|
-- a corrupted action IS a divergence; what matters here is that the
|
||||||
|
-- match ends by its own rules (desync draw, forfeit, disconnect)
|
||||||
|
-- rather than throwing
|
||||||
|
return nil, battleA.turnCount or 0
|
||||||
|
end
|
||||||
if turn then
|
if turn then
|
||||||
return ("seed %d: turn %d %s split (lag %d/%d, steps %d/%d)"):format(
|
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
|
seed, turn, part, lagA, lagB, stepsA, stepsB), battleA.turnCount or 0
|
||||||
@@ -237,7 +269,7 @@ local function runOne(seed)
|
|||||||
end
|
end
|
||||||
-- a battle still running at the guard is a stalemate (two mons that cannot
|
-- 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
|
-- KO each other), not a split; only a finished one can be checked mirrored
|
||||||
if guard < 60000
|
if not mutateRate and guard < 60000
|
||||||
and (battleA.player.mon.hp ~= battleB.enemy.mon.hp
|
and (battleA.player.mon.hp ~= battleB.enemy.mon.hp
|
||||||
or battleA.enemy.mon.hp ~= battleB.player.mon.hp) then
|
or battleA.enemy.mon.hp ~= battleB.player.mon.hp) then
|
||||||
return ("seed %d: final HP not mirrored (%d/%d vs %d/%d)"):format(
|
return ("seed %d: final HP not mirrored (%d/%d vs %d/%d)"):format(
|
||||||
@@ -250,6 +282,8 @@ end
|
|||||||
local RUNS = tonumber(arg and arg[1]) or 40
|
local RUNS = tonumber(arg and arg[1]) or 40
|
||||||
local FIRST = tonumber(arg and arg[2]) or 1
|
local FIRST = tonumber(arg and arg[2]) or 1
|
||||||
|
|
||||||
|
local MUTATION_RUNS = tonumber(arg and arg[3]) or math.max(4, math.floor(RUNS / 4))
|
||||||
|
|
||||||
local failures, turns = 0, 0
|
local failures, turns = 0, 0
|
||||||
for seed = FIRST, FIRST + RUNS - 1 do
|
for seed = FIRST, FIRST + RUNS - 1 do
|
||||||
local ok, why, t = pcall(runOne, seed)
|
local ok, why, t = pcall(runOne, seed)
|
||||||
@@ -263,5 +297,21 @@ for seed = FIRST, FIRST + RUNS - 1 do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
print(("link desync fuzz: %d runs, %d turns, %d failures"):format(RUNS, turns, failures))
|
print(("link desync fuzz: %d runs, %d turns, %d failures"):format(RUNS, turns, failures))
|
||||||
|
|
||||||
|
local mutationFailures = 0
|
||||||
|
for seed = FIRST, FIRST + MUTATION_RUNS - 1 do
|
||||||
|
local ok, why = pcall(runOne, seed, 15)
|
||||||
|
if not ok then
|
||||||
|
mutationFailures = mutationFailures + 1
|
||||||
|
print("FAIL link mutation fuzz seed " .. seed .. ": " .. tostring(why))
|
||||||
|
elseif why then
|
||||||
|
mutationFailures = mutationFailures + 1
|
||||||
|
print("FAIL link mutation fuzz " .. why)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
print(("link mutation fuzz: %d runs, %d failures"):format(
|
||||||
|
MUTATION_RUNS, mutationFailures))
|
||||||
|
|
||||||
assert(failures == 0, failures .. " lockstep run(s) diverged")
|
assert(failures == 0, failures .. " lockstep run(s) diverged")
|
||||||
|
assert(mutationFailures == 0, mutationFailures .. " mutated run(s) threw")
|
||||||
return true
|
return true
|
||||||
|
|||||||
@@ -0,0 +1,396 @@
|
|||||||
|
-- Hostile link traffic: every message type this build reads, with every
|
||||||
|
-- field replaced by every wrong Lua type, driven through the real Session
|
||||||
|
-- choke point and then into the real consumers (trade session, link battle,
|
||||||
|
-- spectator battle, tournament screen -- including its draw).
|
||||||
|
--
|
||||||
|
-- The three payloads from the "How to Troll Pokemon Players" writeup are
|
||||||
|
-- rows in the table below: action.slot as a table, hash.parts as a number,
|
||||||
|
-- and pick.index out of range.
|
||||||
|
--
|
||||||
|
-- Self-contained; run directly or via run_link_tests.lua:
|
||||||
|
-- luajit tests/link_hostile.lua
|
||||||
|
|
||||||
|
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 Font = require("src.render.Font")
|
||||||
|
Font.load(Data)
|
||||||
|
|
||||||
|
local Input = require("src.core.Input")
|
||||||
|
Input:init()
|
||||||
|
|
||||||
|
local Json = require("src.link.Json")
|
||||||
|
local LinkBattle = require("src.link.LinkBattle")
|
||||||
|
local Net = require("src.link.Net")
|
||||||
|
local Pokemon = require("src.pokemon.Pokemon")
|
||||||
|
local Protocol = require("src.link.Protocol")
|
||||||
|
local Session = require("src.link.Session")
|
||||||
|
local Tournament = require("src.link.Tournament")
|
||||||
|
local Wire = require("src.link.Wire")
|
||||||
|
|
||||||
|
local failures = 0
|
||||||
|
local function check(cond, msg)
|
||||||
|
if cond then
|
||||||
|
print("ok " .. msg)
|
||||||
|
else
|
||||||
|
failures = failures + 1
|
||||||
|
print("FAIL " .. msg)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------- corpus
|
||||||
|
|
||||||
|
local HOSTILE = { {}, { 1, 2, 3 }, { type = "x" }, 0, -1, 123, 1.5,
|
||||||
|
math.huge, "s", "", true, false }
|
||||||
|
|
||||||
|
local function copy(v)
|
||||||
|
if type(v) ~= "table" then return v end
|
||||||
|
local out = {}
|
||||||
|
for k, val in pairs(v) do out[k] = copy(val) end
|
||||||
|
return out
|
||||||
|
end
|
||||||
|
|
||||||
|
local packedMon = Protocol.packMon(Pokemon.new(Data, "PIKACHU", 12))
|
||||||
|
|
||||||
|
local TEMPLATES = {
|
||||||
|
{ type = "hello", protocol = 2, name = "RED", mode = "battle",
|
||||||
|
engineVersion = "1.0.0", apiVersion = "1", generation = 1,
|
||||||
|
fingerprint = "abc123", linkModified = false,
|
||||||
|
mods = { { id = "demo", version = "1.0", affectsLink = true } } },
|
||||||
|
{ type = "records", pokemon = { PIKACHU = "a" }, moves = { TACKLE = "b" },
|
||||||
|
heldItems = { BERRY = "c" } },
|
||||||
|
{ type = "party", mons = { copy(packedMon) }, seed = 1234, forceLevel = 50 },
|
||||||
|
{ type = "pick", index = 1 },
|
||||||
|
{ type = "confirm", ok = true },
|
||||||
|
{ type = "action", kind = "move", slot = 1, index = 1 },
|
||||||
|
{ type = "hash", turn = 1, value = "v",
|
||||||
|
parts = { actives = "a", volatile = "b", bench = "c" } },
|
||||||
|
{ type = "replace", index = 1 },
|
||||||
|
{ type = "bye" },
|
||||||
|
{ type = "forfeit" },
|
||||||
|
{ type = "spectate", side = "host",
|
||||||
|
msg = { type = "action", kind = "move", slot = 1 } },
|
||||||
|
{ type = "hosted", code = "ABCDEF" },
|
||||||
|
{ type = "paired" },
|
||||||
|
{ type = "peer_gone" },
|
||||||
|
{ type = "join_error", reason = "not_found" },
|
||||||
|
{ type = "tournament_hosted", code = "ABCDEF", turnLimit = 6,
|
||||||
|
requiredPartySize = 3, minLevel = 5, maxLevel = 50, forceLevel = 50,
|
||||||
|
participating = true },
|
||||||
|
{ type = "tournament_host_error", reason = "party_ineligible",
|
||||||
|
requiredPartySize = 3, minLevel = 5, maxLevel = 50 },
|
||||||
|
{ type = "tournament_join_error", reason = "party_ineligible",
|
||||||
|
requiredPartySize = 3, minLevel = 5, maxLevel = 50 },
|
||||||
|
{ type = "tournament_roster", players = { "RED", "BLUE" },
|
||||||
|
spectators = { "GREEN" }, turnLimit = 6, requiredPartySize = 3,
|
||||||
|
minLevel = 5, maxLevel = 50, forceLevel = 50 },
|
||||||
|
{ type = "bracket_update", tournament = { code = "ABCDEF", turnLimit = 6,
|
||||||
|
requiredPartySize = 3, minLevel = 5, maxLevel = 50, status = "active",
|
||||||
|
round = 1, champion = "RED",
|
||||||
|
rounds = { { round = 1, matches = { { a = "RED", b = "BLUE",
|
||||||
|
winner = "RED", bye = false, state = "live" } } } } } },
|
||||||
|
{ type = "match_start", opponent = "BLUE", round = 1, turnLimit = 6,
|
||||||
|
role = "host" },
|
||||||
|
{ type = "match_start_spectate", round = 1, playerHost = "RED",
|
||||||
|
playerGuest = "BLUE" },
|
||||||
|
{ type = "tournament_bye", round = 1 },
|
||||||
|
{ type = "tournament_over", champion = "RED" },
|
||||||
|
{ type = "a_type_this_build_has_never_heard_of", payload = { n = 1 } },
|
||||||
|
}
|
||||||
|
|
||||||
|
local NESTED = {
|
||||||
|
{ "hash", { "parts", "actives" } },
|
||||||
|
{ "party", { "mons", 1 } },
|
||||||
|
{ "party", { "mons", 1, "dvs" } },
|
||||||
|
{ "party", { "mons", 1, "moves" } },
|
||||||
|
{ "party", { "mons", 1, "moves", 1 } },
|
||||||
|
{ "party", { "mons", 1, "nickname" } },
|
||||||
|
{ "party", { "mons", 1, "level" } },
|
||||||
|
{ "party", { "mons", 1, "otId" } },
|
||||||
|
{ "hello", { "mods", 1 } },
|
||||||
|
{ "spectate", { "msg" } },
|
||||||
|
{ "spectate", { "msg", "slot" } },
|
||||||
|
{ "bracket_update", { "tournament", "rounds" } },
|
||||||
|
{ "bracket_update", { "tournament", "rounds", 1, "matches" } },
|
||||||
|
{ "bracket_update", { "tournament", "rounds", 1, "matches", 1, "a" } },
|
||||||
|
{ "tournament_roster", { "players", 1 } },
|
||||||
|
}
|
||||||
|
|
||||||
|
local function templateFor(kind)
|
||||||
|
for _, t in ipairs(TEMPLATES) do
|
||||||
|
if t.type == kind then return t end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function setPath(root, path, value)
|
||||||
|
local node = root
|
||||||
|
for i = 1, #path - 1 do
|
||||||
|
node = node[path[i]]
|
||||||
|
if type(node) ~= "table" then return false end
|
||||||
|
end
|
||||||
|
node[path[#path]] = value
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
local corpus = {}
|
||||||
|
local function add(msg) corpus[#corpus + 1] = msg end
|
||||||
|
|
||||||
|
add(false); add(true); add(123); add("string"); add({}); add({ 1, 2, 3 })
|
||||||
|
add({ type = 5 }); add({ type = {} }); add({ type = true })
|
||||||
|
add({ type = ("x"):rep(4096) })
|
||||||
|
|
||||||
|
for _, template in ipairs(TEMPLATES) do
|
||||||
|
add(copy(template))
|
||||||
|
for key in pairs(template) do
|
||||||
|
if key ~= "type" then
|
||||||
|
for _, bad in ipairs(HOSTILE) do
|
||||||
|
local m = copy(template)
|
||||||
|
m[key] = copy(bad)
|
||||||
|
add(m)
|
||||||
|
end
|
||||||
|
local missing = copy(template)
|
||||||
|
missing[key] = nil
|
||||||
|
add(missing)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
for _, row in ipairs(NESTED) do
|
||||||
|
local template = templateFor(row[1])
|
||||||
|
for _, bad in ipairs(HOSTILE) do
|
||||||
|
local m = copy(template)
|
||||||
|
if setPath(m, row[2], copy(bad)) then add(m) end
|
||||||
|
end
|
||||||
|
local m = copy(template)
|
||||||
|
if setPath(m, row[2], nil) then add(m) end
|
||||||
|
end
|
||||||
|
|
||||||
|
print(("hostile corpus: %d messages"):format(#corpus))
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------- session
|
||||||
|
|
||||||
|
local function fakeTransport(inbox)
|
||||||
|
local transport = { paired = true, closed = false, error = nil,
|
||||||
|
inbox = inbox, sent = {} }
|
||||||
|
function transport:update() end
|
||||||
|
function transport:poll()
|
||||||
|
local messages = self.inbox
|
||||||
|
self.inbox = {}
|
||||||
|
return messages
|
||||||
|
end
|
||||||
|
function transport:send(m) table.insert(self.sent, m) end
|
||||||
|
function transport:close() self.closed = true end
|
||||||
|
return transport
|
||||||
|
end
|
||||||
|
|
||||||
|
local transport = fakeTransport(copy(corpus))
|
||||||
|
local session = Session.new(transport, { role = "guest", kind = "link" })
|
||||||
|
local okUpdate, updateErr = pcall(session.update, session)
|
||||||
|
check(okUpdate, "the whole hostile corpus goes through Session without throwing"
|
||||||
|
.. (okUpdate and "" or (": " .. tostring(updateErr))))
|
||||||
|
check(session:getFailure() == nil,
|
||||||
|
"a hostile peer cannot latch a terminal failure on the session")
|
||||||
|
check(session:getStatus() == "paired", "the session is still usable afterwards")
|
||||||
|
local survivors = session:poll()
|
||||||
|
check(#survivors > 0, "well-formed messages still get through")
|
||||||
|
check(session.dropped > 0, "malformed messages are counted as dropped")
|
||||||
|
|
||||||
|
for _, msg in ipairs(survivors) do
|
||||||
|
if type(msg.type) ~= "string" then
|
||||||
|
check(false, "every delivered message has a string type")
|
||||||
|
break
|
||||||
|
end
|
||||||
|
end
|
||||||
|
check(true, "every delivered message has a string type")
|
||||||
|
|
||||||
|
for _, msg in ipairs(survivors) do
|
||||||
|
local ok = pcall(Json.encode, msg)
|
||||||
|
if not ok then
|
||||||
|
check(false, "every delivered message is still encodable (" .. msg.type .. ")")
|
||||||
|
break
|
||||||
|
end
|
||||||
|
end
|
||||||
|
check(true, "every delivered message is still encodable")
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------- consumers
|
||||||
|
|
||||||
|
local function makeFakeGame(species, name)
|
||||||
|
local save = require("src.core.SaveData").newGame()
|
||||||
|
save.player.name = name or "RED"
|
||||||
|
table.insert(save.party, Pokemon.new(Data, species, 20))
|
||||||
|
local stack = { list = {} }
|
||||||
|
function stack:push(s, ...)
|
||||||
|
table.insert(self.list, s)
|
||||||
|
if s.enter then s:enter(...) end
|
||||||
|
end
|
||||||
|
function stack:pop() return 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
|
||||||
|
|
||||||
|
do
|
||||||
|
local crashed
|
||||||
|
for _, msg in ipairs(survivors) do
|
||||||
|
local party = { Pokemon.new(Data, "KADABRA", 30) }
|
||||||
|
local t = Protocol.TradeSession.new(Data, party)
|
||||||
|
local ok, err = pcall(function()
|
||||||
|
t:handle({ type = "party", mons = Protocol.packParty({
|
||||||
|
Pokemon.new(Data, "MACHOKE", 32) }) })
|
||||||
|
t:handle(msg)
|
||||||
|
t:pick(1)
|
||||||
|
t:handle(msg)
|
||||||
|
t:confirm(true)
|
||||||
|
t:handle(msg)
|
||||||
|
if t.stage == "done" then t:apply(nil) end
|
||||||
|
end)
|
||||||
|
if not ok then crashed = ("%s: %s"):format(tostring(msg.type), tostring(err)) end
|
||||||
|
if crashed then break end
|
||||||
|
end
|
||||||
|
check(not crashed, "the trade session survives every hostile message"
|
||||||
|
.. (crashed and (": " .. crashed) or ""))
|
||||||
|
end
|
||||||
|
|
||||||
|
do
|
||||||
|
local party = { Pokemon.new(Data, "KADABRA", 30) }
|
||||||
|
local t = Protocol.TradeSession.new(Data, party)
|
||||||
|
t:handle({ type = "party",
|
||||||
|
mons = Protocol.packParty({ Pokemon.new(Data, "MACHOKE", 32) }) })
|
||||||
|
t:pick(1)
|
||||||
|
t:handle(Wire.sanitize({ type = "pick", index = 0 }))
|
||||||
|
t:confirm(true)
|
||||||
|
t:handle(Wire.sanitize({ type = "confirm", ok = true }))
|
||||||
|
check(t.stage ~= "done", "an out-of-range pick never reaches a committed trade")
|
||||||
|
check(t.stage == "cancelled", "...it cancels the trade instead")
|
||||||
|
end
|
||||||
|
|
||||||
|
do
|
||||||
|
local gameA = makeFakeGame("CHARIZARD", "RED")
|
||||||
|
local gameB = makeFakeGame("BLASTOISE", "BLUE")
|
||||||
|
local netA, netB = Net.loopbackPair()
|
||||||
|
local battleA = LinkBattle.newHost(gameA, netA, {
|
||||||
|
myParty = Protocol.packParty(gameA.save.party),
|
||||||
|
theirParty = Protocol.packParty(gameB.save.party),
|
||||||
|
theirName = "BLUE", seed = 4242 })
|
||||||
|
gameA.stack:push(battleA)
|
||||||
|
local crashed
|
||||||
|
for _, msg in ipairs(survivors) do
|
||||||
|
table.insert(netA.inbox, copy(msg))
|
||||||
|
local ok, err = pcall(function()
|
||||||
|
Input.pressed = {}
|
||||||
|
gameA.stack:update(1 / 60)
|
||||||
|
end)
|
||||||
|
if not ok then
|
||||||
|
crashed = ("%s: %s"):format(tostring(msg.type), tostring(err))
|
||||||
|
break
|
||||||
|
end
|
||||||
|
end
|
||||||
|
check(not crashed, "a link battle survives every hostile message"
|
||||||
|
.. (crashed and (": " .. crashed) or ""))
|
||||||
|
end
|
||||||
|
|
||||||
|
do
|
||||||
|
local gameSpec = makeFakeGame("RATTATA", "WATCHER")
|
||||||
|
local specInbox = {}
|
||||||
|
local specNet = {
|
||||||
|
closed = false,
|
||||||
|
update = function() end,
|
||||||
|
poll = function()
|
||||||
|
local msgs = specInbox
|
||||||
|
specInbox = {}
|
||||||
|
return msgs
|
||||||
|
end,
|
||||||
|
send = function() end,
|
||||||
|
close = function() end,
|
||||||
|
}
|
||||||
|
local battle = LinkBattle.newSpectator(gameSpec, specNet, {
|
||||||
|
hostParty = Protocol.packParty(makeFakeGame("CHARIZARD").save.party),
|
||||||
|
guestParty = Protocol.packParty(makeFakeGame("BLASTOISE").save.party),
|
||||||
|
hostName = "RED", guestName = "BLUE", seed = 99 })
|
||||||
|
gameSpec.stack:push(battle)
|
||||||
|
local crashed
|
||||||
|
for _, msg in ipairs(corpus) do
|
||||||
|
for _, side in ipairs({ "host", "guest", 5, {} }) do
|
||||||
|
local wrapped = Wire.sanitize({ type = "spectate", side = side,
|
||||||
|
msg = copy(msg) })
|
||||||
|
if wrapped then table.insert(specInbox, wrapped) end
|
||||||
|
end
|
||||||
|
local ok, err = pcall(function()
|
||||||
|
Input.pressed = {}
|
||||||
|
gameSpec.stack:update(1 / 60)
|
||||||
|
end)
|
||||||
|
if not ok then
|
||||||
|
crashed = tostring(err)
|
||||||
|
break
|
||||||
|
end
|
||||||
|
end
|
||||||
|
check(not crashed, "a spectator battle survives every hostile envelope"
|
||||||
|
.. (crashed and (": " .. crashed) or ""))
|
||||||
|
end
|
||||||
|
|
||||||
|
do
|
||||||
|
local game = makeFakeGame("PIKACHU", "RED")
|
||||||
|
local exits = 0
|
||||||
|
local t = setmetatable({
|
||||||
|
game = game,
|
||||||
|
stage = "bracket",
|
||||||
|
index = 1,
|
||||||
|
settingsIndex = 1,
|
||||||
|
settings = { turnLimit = 6, requiredPartySize = 3, minLevel = "ANY",
|
||||||
|
maxLevel = "ANY", forceLevel = "ANY", participating = true },
|
||||||
|
roster = {},
|
||||||
|
spectatorRoster = {},
|
||||||
|
isCreator = false,
|
||||||
|
net = { send = function() end, close = function() end,
|
||||||
|
take = function() return nil end,
|
||||||
|
poll = function() return {} end,
|
||||||
|
hasPending = function() return false end },
|
||||||
|
}, Tournament)
|
||||||
|
t.exitWith = function(self) exits = exits + 1 end
|
||||||
|
local crashed
|
||||||
|
for _, msg in ipairs(survivors) do
|
||||||
|
local ok, err = pcall(function()
|
||||||
|
t:handleMessage(msg)
|
||||||
|
t:draw()
|
||||||
|
end)
|
||||||
|
if not ok then
|
||||||
|
crashed = ("%s: %s"):format(tostring(msg.type), tostring(err))
|
||||||
|
break
|
||||||
|
end
|
||||||
|
if type(t.roster) ~= "table" or type(t.spectatorRoster) ~= "table" then
|
||||||
|
crashed = ("%s left a non-table roster"):format(tostring(msg.type))
|
||||||
|
break
|
||||||
|
end
|
||||||
|
end
|
||||||
|
check(not crashed, "the tournament screen survives every hostile message"
|
||||||
|
.. (crashed and (": " .. crashed) or ""))
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------- json
|
||||||
|
do
|
||||||
|
local deep = ("["):rep(4096) .. ("]"):rep(4096)
|
||||||
|
local value, err = Json.decode(deep)
|
||||||
|
check(value == nil and err ~= nil, "a deeply nested document is refused")
|
||||||
|
local long = '{"type":"hello","name":"' .. ("x"):rep(1024) .. '"}'
|
||||||
|
check(Json.decode(long, 256) == nil, "a document past the caller's cap is refused")
|
||||||
|
check(Json.decode(long) ~= nil, "...and the cap is opt-in for other callers")
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------- net caps
|
||||||
|
do
|
||||||
|
local n = Net.new()
|
||||||
|
n.rxBuf = ("x"):rep(Net.MAX_LINE + 1)
|
||||||
|
n:drainLines()
|
||||||
|
check(n.closed and n.error ~= nil,
|
||||||
|
"a peer that never sends a newline closes the connection")
|
||||||
|
check(#n.rxBuf == 0, "...and the buffer is released")
|
||||||
|
end
|
||||||
|
|
||||||
|
print(("\nlink hostile: %d messages, %d failures"):format(#corpus, failures))
|
||||||
|
assert(failures == 0, failures .. " hostile-input failure(s)")
|
||||||
|
return true
|
||||||
@@ -645,6 +645,14 @@ check(fxAdvances("messages", "linkNext"),
|
|||||||
local fuzzOk, fuzzErr = pcall(dofile, "tests/link_desync_fuzz.lua")
|
local fuzzOk, fuzzErr = pcall(dofile, "tests/link_desync_fuzz.lua")
|
||||||
check(fuzzOk, "lockstep desync fuzz" .. (fuzzOk and "" or (": " .. tostring(fuzzErr))))
|
check(fuzzOk, "lockstep desync fuzz" .. (fuzzOk and "" or (": " .. tostring(fuzzErr))))
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------- hostile wire
|
||||||
|
-- Every message type crossed with every wrong Lua type, through the real
|
||||||
|
-- Session choke point and into the real consumers. The regression net for
|
||||||
|
-- the remote-crash payloads; self-contained like the fuzz above.
|
||||||
|
local hostileOk, hostileErr = pcall(dofile, "tests/link_hostile.lua")
|
||||||
|
check(hostileOk, "hostile wire suite"
|
||||||
|
.. (hostileOk and "" or (": " .. tostring(hostileErr))))
|
||||||
|
|
||||||
-- ---------------------------------------------------------------- mod link compat
|
-- ---------------------------------------------------------------- mod link compat
|
||||||
-- Self-contained like the tests/mod_*.lua suites: own bootstrap and
|
-- Self-contained like the tests/mod_*.lua suites: own bootstrap and
|
||||||
-- assert-based checks, so it lands here as a single pass/fail line.
|
-- assert-based checks, so it lands here as a single pass/fail line.
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ end
|
|||||||
-- Also gates the NX runtime modules and the NX engine suites so an NX
|
-- Also gates the NX runtime modules and the NX engine suites so an NX
|
||||||
-- runtime regression cannot slip past switch-selftest / switch-build.
|
-- runtime regression cannot slip past switch-selftest / switch-build.
|
||||||
local SWITCH_PATH_REGEX =
|
local SWITCH_PATH_REGEX =
|
||||||
[[^(scripts/build_switch\.sh$|scripts/switch/|docs/switch-.*\.md$|tests/switch_ci_workflows_test\.lua$|tests/switch_transfer_docs_test\.lua$|\.github/workflows/(ci|release|switch-artifact-comment)\.yml$|src/core/(NxAssetOverlay|Platform|GameVersion)\.lua$|src/import/CacheFs\.lua$|tests/engine/(assets_version_fallback|nx_generated_guard|nx_yellow_boot|switch_diagnostics)_test\.lua$|tests/engine/platform_nx)]]
|
[[^(scripts/build_switch\.sh$|scripts/switch/|docs/switch-.*\.md$|tests/switch_ci_workflows_test\.lua$|tests/switch_transfer_docs_test\.lua$|\.github/workflows/(ci|release|switch-artifact-comment)\.yml$|src/core/(NxAssetOverlay|Platform|GameVersion)\.lua$|src/import/CacheFs\.lua$|tests/engine/(assets_version_fallback|nx_generated_guard|nx_yellow_boot|switch_diagnostics|cache_fs_gold_nx_load)_test\.lua$|tests/engine/platform_nx)]]
|
||||||
|
|
||||||
local ci = read(".github/workflows/ci.yml")
|
local ci = read(".github/workflows/ci.yml")
|
||||||
local release = read(".github/workflows/release.yml")
|
local release = read(".github/workflows/release.yml")
|
||||||
@@ -50,6 +50,7 @@ for _, fragment in ipairs({
|
|||||||
"nx_generated_guard",
|
"nx_generated_guard",
|
||||||
"nx_yellow_boot",
|
"nx_yellow_boot",
|
||||||
"switch_diagnostics",
|
"switch_diagnostics",
|
||||||
|
"cache_fs_gold_nx_load",
|
||||||
"tests/engine/platform_nx",
|
"tests/engine/platform_nx",
|
||||||
}) do
|
}) do
|
||||||
mustContain(ci, fragment, "ci.yml path regex NX fragment")
|
mustContain(ci, fragment, "ci.yml path regex NX fragment")
|
||||||
@@ -79,6 +80,7 @@ do
|
|||||||
mustContain(block, "luajit tests/engine/assets_version_fallback_test.lua", "switch-selftest")
|
mustContain(block, "luajit tests/engine/assets_version_fallback_test.lua", "switch-selftest")
|
||||||
mustContain(block, "luajit tests/engine/nx_generated_guard_test.lua", "switch-selftest")
|
mustContain(block, "luajit tests/engine/nx_generated_guard_test.lua", "switch-selftest")
|
||||||
mustContain(block, "luajit tests/engine/nx_yellow_boot_test.lua", "switch-selftest")
|
mustContain(block, "luajit tests/engine/nx_yellow_boot_test.lua", "switch-selftest")
|
||||||
|
mustContain(block, "luajit tests/engine/cache_fs_gold_nx_load_test.lua", "switch-selftest")
|
||||||
mustNotContain(block, "continue-on-error:", "switch-selftest")
|
mustNotContain(block, "continue-on-error:", "switch-selftest")
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -185,6 +187,7 @@ mustContain(test_sh, "T0 switch transfer docs gate", "scripts/test.sh")
|
|||||||
mustContain(test_sh, "tests/engine/assets_version_fallback_test.lua", "scripts/test.sh")
|
mustContain(test_sh, "tests/engine/assets_version_fallback_test.lua", "scripts/test.sh")
|
||||||
mustContain(test_sh, "tests/engine/nx_generated_guard_test.lua", "scripts/test.sh")
|
mustContain(test_sh, "tests/engine/nx_generated_guard_test.lua", "scripts/test.sh")
|
||||||
mustContain(test_sh, "tests/engine/nx_yellow_boot_test.lua", "scripts/test.sh")
|
mustContain(test_sh, "tests/engine/nx_yellow_boot_test.lua", "scripts/test.sh")
|
||||||
|
mustContain(test_sh, "tests/engine/cache_fs_gold_nx_load_test.lua", "scripts/test.sh")
|
||||||
mustContain(build_doc, "tests/switch_ci_workflows_test.lua", "switch-build.md path list")
|
mustContain(build_doc, "tests/switch_ci_workflows_test.lua", "switch-build.md path list")
|
||||||
mustContain(build_doc, "tests/switch_transfer_docs_test.lua", "switch-build.md path list")
|
mustContain(build_doc, "tests/switch_transfer_docs_test.lua", "switch-build.md path list")
|
||||||
|
|
||||||
@@ -197,6 +200,7 @@ for _, path in ipairs({
|
|||||||
"tests/engine/assets_version_fallback_test.lua",
|
"tests/engine/assets_version_fallback_test.lua",
|
||||||
"tests/engine/nx_generated_guard_test.lua",
|
"tests/engine/nx_generated_guard_test.lua",
|
||||||
"tests/engine/nx_yellow_boot_test.lua",
|
"tests/engine/nx_yellow_boot_test.lua",
|
||||||
|
"tests/engine/cache_fs_gold_nx_load_test.lua",
|
||||||
"tests/engine/switch_diagnostics_test.lua",
|
"tests/engine/switch_diagnostics_test.lua",
|
||||||
"tests/engine/platform_nx_*",
|
"tests/engine/platform_nx_*",
|
||||||
}) do
|
}) do
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ mustContain(transfer, "FTP", "transfer")
|
|||||||
mustContain(transfer, "sdmc:/switch/gen1recomp/", "transfer")
|
mustContain(transfer, "sdmc:/switch/gen1recomp/", "transfer")
|
||||||
mustContain(transfer, "imports/", "transfer")
|
mustContain(transfer, "imports/", "transfer")
|
||||||
mustContain(transfer, "imports/mods/", "transfer")
|
mustContain(transfer, "imports/mods/", "transfer")
|
||||||
|
mustContain(transfer, "gold", "transfer")
|
||||||
mustContain(transfer, "1: SD Card", "transfer")
|
mustContain(transfer, "1: SD Card", "transfer")
|
||||||
mustContain(transfer, "Scan again", "transfer")
|
mustContain(transfer, "Scan again", "transfer")
|
||||||
mustContain(transfer, "documented example", "transfer")
|
mustContain(transfer, "documented example", "transfer")
|
||||||
@@ -70,6 +71,9 @@ mustContain(install, "PERFORMANCE", "install")
|
|||||||
mustContain(install, "Stock engine effect", "install")
|
mustContain(install, "Stock engine effect", "install")
|
||||||
mustContain(install, "## Limitations", "install")
|
mustContain(install, "## Limitations", "install")
|
||||||
mustContain(install, "Launch with title override", "install")
|
mustContain(install, "Launch with title override", "install")
|
||||||
|
mustContain(install, "Gold", "install")
|
||||||
|
mustContain(install, "imports/saves/gold/", "install")
|
||||||
|
mustContain(install, "exports/gold/", "install")
|
||||||
mustNotContain(install, "VoxelMod", "install")
|
mustNotContain(install, "VoxelMod", "install")
|
||||||
mustNotContain(install, "switch-development", "install")
|
mustNotContain(install, "switch-development", "install")
|
||||||
mustContain(build, "switch-transfer.md", "build")
|
mustContain(build, "switch-transfer.md", "build")
|
||||||
|
|||||||
Reference in New Issue
Block a user