Compare commits

...

28 Commits

Author SHA1 Message Date
bryanthaboi 74e04cb086 Merge pull request #1523 from bryanthaboi/dev
some bugs and some switch stuff
2026-08-18 10:18:58 -04:00
bryanthaboi 25ec896545 fix(audio): do not stop the move sfx source the replay just restarted
The takeover stop in Sound.playMove moved after the new source starts in
cfa84063, but an equal-id replay reuses the cached source, so the stop
killed the sound it had just restarted.
2026-08-18 10:14:44 -04:00
bryanthaboi 8240205254 ingested 2026-08-18 09:51:22 -04:00
bryanthaboi 55616fc03d CLOSES #1390 2026-08-18 09:20:07 -04:00
bryanthaboi 675971068e CLOSES #1503 2026-08-18 09:16:04 -04:00
bryanthaboi cfa8406306 CLOSES #1508 2026-08-18 04:52:00 -04:00
github-actions 0ea224d5db chore(ios): update app-repo.json [skip ci] 2026-08-17 23:13:43 -04:00
bryanthaboi c2e1db0f89 Merge pull request #1501 from bryanthaboi/dev 2026-08-17 23:03:49 -04:00
bryanthaboi 7b1e796c48 CLOSES #1496 2026-08-17 22:52:33 -04:00
github-actions 4c13770e70 chore(ios): update app-repo.json [skip ci] 2026-08-17 20:51:52 -04:00
bryanthaboi cf45cbbf92 Merge pull request #1498 from bryanthaboi/dev
more stuff
2026-08-17 20:41:58 -04:00
bryanthaboi 4e1ab1879b updated skin studio 2026-08-17 20:39:53 -04:00
bryanthaboi 917735a41c Merge branch 'dev' of https://github.com/bryanthaboi/gen1recomp into dev 2026-08-17 20:15:40 -04:00
bryanthaboi c877ea80a7 importer for skins now w file picker 2026-08-17 20:15:39 -04:00
github-actions 7d1ddf9b7c chore(ios): update app-repo.json [skip ci] 2026-08-17 20:09:51 -04:00
bryanthaboi faf82c2cec Merge pull request #1491 from AverageConsumer/codex/gen2-move-grid-hook
fix(gen2): honor mod move-grid navigation
2026-08-17 19:58:35 -04:00
bryanthaboi 5bc6036735 Merge pull request #1420 from anxiousintrovert/fix/required-import-platform-pickers
Fix required imports on Android legacy picker bridges
2026-08-17 19:58:13 -04:00
bryanthaboi 809628f0fa Merge pull request #1494 from AverageConsumer/codex/gen2-catch-previews
feat(gen2): expose stock ball catch previews
2026-08-17 19:57:37 -04:00
bryanthaboi 4817ff8bf9 Merge pull request #1492 from thibautbus/fix/museum-1f-ticket-clerk-strings
Route the museum 1F ticket clerk's remaining lines through Strings
2026-08-17 19:57:29 -04:00
anxiousintrovert 4f8739c029 Fix required imports on legacy Android picker bridges 2026-08-17 18:35:25 -05:00
AverageConsumer c655217120 feat(gen2): expose stock ball catch previews 2026-08-17 23:00:38 +02:00
thibautbus fbdfc1c053 Cover the museum 1F ticket clerk's translated lines with a targeted test 2026-08-17 21:44:43 +02:00
thibautbus 72c244b433 Route the museum 1F ticket clerk's remaining lines through Strings 2026-08-17 21:38:04 +02:00
AverageConsumer e0e030003b fix(gen2): honor mod move-grid navigation 2026-08-17 20:38:48 +02:00
github-actions ce2afb83f1 chore(ios): update app-repo.json [skip ci] 2026-08-17 14:13:48 -04:00
bryanthaboi 28f741f72f Merge pull request #1489 from bryanthaboi/dev
Update LauncherView.lua
2026-08-17 14:04:35 -04:00
bryanthaboi 22bcd95da1 Update LauncherView.lua 2026-08-17 14:03:07 -04:00
github-actions ea28f886f3 chore(ios): update app-repo.json [skip ci] 2026-08-17 13:20:07 -04:00
73 changed files with 3377 additions and 352 deletions
+4 -1
View File
@@ -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
+2 -1
View File
@@ -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.
+6 -3
View File
@@ -733,7 +733,8 @@ local function museumClerk(game, ow, done, onDecline)
local t = game.data.text or {} local t = game.data.text or {}
if game.save.flags.EVENT_BOUGHT_MUSEUM_TICKET then if game.save.flags.EVENT_BOUGHT_MUSEUM_TICKET then
game.stack:push(TextBox.new(game, game.stack:push(TextBox.new(game,
"Take your time,\nand enjoy it all!", done)) t._Museum1FScientist1TakePlentyOfTimeText
or "Take your time,\nand enjoy it all!", done))
return return
end end
-- scripts/Museum1F.asm:72 -- scripts/Museum1F.asm:72
@@ -751,10 +752,12 @@ local function museumClerk(game, ow, done, onDecline)
{ money = money })) { money = money }))
elseif yes then elseif yes then
game.stack:push(TextBox.new(game, game.stack:push(TextBox.new(game,
"You don't have\nenough money.", onDecline or done, { money = money })) t._Museum1FScientist1DontHaveEnoughMoneyText
or "You don't have\nenough money.", onDecline or done, { money = money }))
else else
game.stack:push(TextBox.new(game, game.stack:push(TextBox.new(game,
"Come again!", onDecline or done, { money = money })) t._Museum1FScientist1ComeAgainText
or "Come again!", onDecline or done, { money = money }))
end end
end })) end }))
end end
+120
View File
@@ -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.
+3 -2
View File
@@ -540,8 +540,9 @@ gains a field instead of the name gaining a prefix.
`battle.crit`, `battle.accuracy`, `battle.turn_order`, `battle.crit`, `battle.accuracy`, `battle.turn_order`,
`battle.enemy_action`, `battle.run`, `battle.exp_award`, `exp.gain`, `battle.enemy_action`, `battle.run`, `battle.exp_award`, `exp.gain`,
`catch.rate`, `trainer.party`, `battle.overlay`, `battle.low_health_alarm`, `catch.rate`, `trainer.party`, `battle.overlay`, `battle.low_health_alarm`,
`battle.catch_exp`, `battle.bottom_ui_visible` and `battle.catch_exp`, `battle.bottom_ui_visible`,
`battle.status_hud_visible`. One payload difference: Gen 1's vanilla `battle.status_hud_visible` and `battle.move_grid_navigation`. One payload
difference: Gen 1's vanilla
`battle.low_health_alarm` link reads `ctx.battle.data`, and Gold's battle `battle.low_health_alarm` link reads `ctx.battle.data`, and Gold's battle
screen has no `.data` field, so the Gen 2 site **adds** `ctx.data` beside the screen has no `.data` field, so the Gen 2 site **adds** `ctx.data` beside the
Gen 1 keys. A mod that calls `nextFn` is unaffected; one that reaches through Gen 1 keys. A mod that calls `nextFn` is unaffected; one that reaches through
+4 -4
View File
@@ -268,10 +268,10 @@ an optional stock `catchChance` percentage.
`prompt` describes the currently visible choice (`menu`, `moves`, `party`, `prompt` describes the currently visible choice (`menu`, `moves`, `party`,
`advance`, `safari`, or `mimic`) and is `locked` when another screen or battle `advance`, `safari`, or `mimic`) and is `locked` when another screen or battle
phase owns input. Generation-specific features remain optional: Gen 1 includes phase owns input. Generation-specific features remain optional: Gen 1 includes
battle medicine, balls, catch previews, Safari balls, and Mimic choices; battle medicine, balls, catch previews, Safari balls, and Mimic choices. Gold
Gold currently returns an empty `items` list rather than guessing at its exposes balls and their exact stock catch previews; targeted medicine remains
pocketed PACK flow. Callers should ignore unknown fields and tolerate absent screen-owned and is omitted rather than guessing at its pocketed PACK flow.
optional ones. Callers should ignore unknown fields and tolerate absent optional ones.
## Battle menu intents ## Battle menu intents
+21 -5
View File
@@ -6,6 +6,7 @@ control layout, and the rectangle the Game Boy screen is drawn into. Engine:
(draw and input), `src/render/Renderer.lua` (the screen viewport), (draw and input), `src/render/Renderer.lua` (the screen viewport),
`src/ui/SkinStudio.lua` (the desktop editor). Tests: `src/ui/SkinStudio.lua` (the desktop editor). Tests:
`tests/engine/touch_skin_test.lua`, `tests/engine/skin_studio_test.lua`, `tests/engine/touch_skin_test.lua`, `tests/engine/skin_studio_test.lua`,
`tests/engine/skin_studio_image_import.lua`,
`tests/engine/launcher_skins_tab.lua`. `tests/engine/launcher_skins_tab.lua`.
Skins are picked in the launcher's **Skins** tab, which also imports them and Skins are picked in the launcher's **Skins** tab, which also imports them and
@@ -25,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 |
@@ -159,8 +160,25 @@ The Super Game Boy preset locks the viewport to the real screen window,
resize. X / Y / W / H are in canvas pixels, so a control can be typed to the resize. X / Y / W / H are in canvas pixels, so a control can be typed to the
coordinate its art was drawn at. Bind, hitbox shape, hit reach and idle and 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. Drop a PNG per page. The cutout is itself a draggable element with a 10:9 lock.
or JPG on the window to import art into the skin.
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
images already in the skin folder; the **Import** button beside each one opens
the host file picker (`src/core/FilePicker.lua`: osascript, PowerShell,
zenity/kdialog) and copies the chosen PNG or JPG into `img/` under the name in
the SKIN field, then assigns it to that slot. Dropping a PNG or JPG on the
window does the same for whichever slot was last touched. A new bezel does not
move the screen cutout: press **Detect screen from bezel** to measure it out of
the art's alpha.
**Testing.** **Test** makes the canvas live: clicking presses real Game Boy **Testing.** **Test** makes the canvas live: clicking presses real Game Boy
buttons and the footer reports what is held. **Play** saves the skin, selects buttons and the footer reports what is held. **Play** saves the skin, selects
@@ -175,5 +193,3 @@ straight back into `skins/` and still opens in RetroArch.
## Not implemented ## Not implemented
RetroArch's `analog_*`, `dpad_area`, `abxy_area` and `retrok_*` desc types. RetroArch's `analog_*`, `dpad_area`, `abxy_area` and `retrok_*` desc types.
Image assignment cycles through art already in the skin folder; there is no
file browser, so new art arrives by drag and drop.
+3 -1
View File
@@ -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
View File
@@ -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.
+4 -4
View File
@@ -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`,
+39 -3
View File
@@ -506,6 +506,7 @@ function love.update(dt)
if TouchEditor then return TouchEditor.update(dt) end if TouchEditor then return TouchEditor.update(dt) end
if Studio then return Studio.update(dt) end if Studio then return Studio.update(dt) end
if Importer then return Importer:update(dt) end if Importer then return Importer:update(dt) end
if not Game then return end
-- Scripted runs (autopilot / POKEPORT_DRIVER) observe and act exactly -- Scripted runs (autopilot / POKEPORT_DRIVER) observe and act exactly
-- once per Game:update, so they must keep a 1:1 relationship with the -- once per Game:update, so they must keep a 1:1 relationship with the
@@ -602,12 +603,14 @@ function love.keypressed(key, scancode, isrepeat)
if TouchEditor then return TouchEditor.keypressed(key) end if TouchEditor then return TouchEditor.keypressed(key) end
if Studio then return Studio.keypressed(key) end if Studio then return Studio.keypressed(key) end
if Importer then return Importer:keypressed(key) end if Importer then return Importer:keypressed(key) end
if not Game then return end
Game:keypressed(key) Game:keypressed(key)
end end
function love.keyreleased(key) function love.keyreleased(key)
if editorMode or TouchEditor or Studio then return end if editorMode or TouchEditor or Studio then return end
if Importer then return end if Importer then return end
if not Game then return end
Game:keyreleased(key) Game:keyreleased(key)
end end
@@ -625,7 +628,9 @@ function love.gamepadpressed(joystick, button)
end end
return return
end end
if Studio then return end
if Importer then return Importer:gamepadpressed(joystick, button) end if Importer then return Importer:gamepadpressed(joystick, button) end
if not Game then return end
Game:gamepadpressed(joystick, button) Game:gamepadpressed(joystick, button)
end end
@@ -643,7 +648,9 @@ function love.gamepadreleased(joystick, button)
end end
return return
end end
if Studio then return end
if Importer then return Importer:gamepadreleased(joystick, button) end if Importer then return Importer:gamepadreleased(joystick, button) end
if not Game then return end
Game:gamepadreleased(joystick, button) Game:gamepadreleased(joystick, button)
end end
@@ -661,7 +668,9 @@ function love.gamepadaxis(joystick, axis, value)
end end
return return
end end
if Studio then return end
if Importer then return Importer:gamepadaxis(joystick, axis, value) end if Importer then return Importer:gamepadaxis(joystick, axis, value) end
if not Game then return end
Game:gamepadaxis(joystick, axis, value) Game:gamepadaxis(joystick, axis, value)
end end
@@ -679,7 +688,9 @@ function love.joystickpressed(joystick, button)
end end
return return
end end
if Studio then return end
if Importer then return Importer:joystickpressed(joystick, button) end if Importer then return Importer:joystickpressed(joystick, button) end
if not Game then return end
Game:joystickpressed(joystick, button) Game:joystickpressed(joystick, button)
end end
@@ -697,7 +708,9 @@ function love.joystickreleased(joystick, button)
end end
return return
end end
if Studio then return end
if Importer then return Importer:joystickreleased(joystick, button) end if Importer then return Importer:joystickreleased(joystick, button) end
if not Game then return end
Game:joystickreleased(joystick, button) Game:joystickreleased(joystick, button)
end end
@@ -715,7 +728,9 @@ function love.joystickaxis(joystick, axis, value)
end end
return return
end end
if Studio then return end
if Importer then return Importer:joystickaxis(joystick, axis, value) end if Importer then return Importer:joystickaxis(joystick, axis, value) end
if not Game then return end
Game:joystickaxis(joystick, axis, value) Game:joystickaxis(joystick, axis, value)
end end
@@ -733,21 +748,25 @@ function love.joystickhat(joystick, hat, direction)
end end
return return
end end
if Studio then return end
if Importer then return Importer:joystickhat(joystick, hat, direction) end if Importer then return Importer:joystickhat(joystick, hat, direction) end
if not Game then return end
Game:joystickhat(joystick, hat, direction) Game:joystickhat(joystick, hat, direction)
end end
function love.joystickadded(joystick) function love.joystickadded(joystick)
SwitchDiagnostics.onJoystickEvent("joystickadded", joystick) SwitchDiagnostics.onJoystickEvent("joystickadded", joystick)
if editorMode or TouchEditor then return end if editorMode or TouchEditor or Studio then return end
if Importer then return end if Importer then return end
if not Game then return end
Game:joystickadded(joystick) Game:joystickadded(joystick)
end end
function love.joystickremoved(joystick) function love.joystickremoved(joystick)
SwitchDiagnostics.onJoystickEvent("joystickremoved", joystick) SwitchDiagnostics.onJoystickEvent("joystickremoved", joystick)
if editorMode or TouchEditor then return end if editorMode or TouchEditor or Studio then return end
if Importer then return end if Importer then return end
if not Game then return end
Game:joystickremoved(joystick) Game:joystickremoved(joystick)
end end
@@ -756,26 +775,36 @@ end
-- unfocused, so reset input on either transition rather than trust it. -- unfocused, so reset input on either transition rather than trust it.
function love.focus(f) function love.focus(f)
if editorMode or TouchEditor then return end if editorMode or TouchEditor then return end
if Studio then
if Studio.focus then Studio.focus(f) end
return
end
if Importer then if Importer then
require("src.core.Input"):reset() require("src.core.Input"):reset()
if Importer.focus then Importer:focus(f) end if Importer.focus then Importer:focus(f) end
return return
end end
if not Game then return end
Game:focus(f) Game:focus(f)
end end
-- v is true when the window becomes visible again, false on minimize. -- v is true when the window becomes visible again, false on minimize.
function love.visible(v) function love.visible(v)
if editorMode or TouchEditor then return end if editorMode or TouchEditor then return end
if Studio then
if Studio.visible then Studio.visible(v) end
return
end
if Importer then if Importer then
require("src.core.Input"):reset() require("src.core.Input"):reset()
return return
end end
if not Game then return end
Game:visible(v) Game:visible(v)
end end
function love.lowmemory() function love.lowmemory()
if editorMode or TouchEditor or Importer then return end if editorMode or TouchEditor or Studio or Importer then return end
if Game then Game:onResume() end if Game then Game:onResume() end
end end
@@ -796,12 +825,14 @@ function love.touchpressed(id, x, y, dx, dy, pressure)
if love.system.getOS() == "iOS" then return end if love.system.getOS() == "iOS" then return end
return TouchEditor.touchpressed(id, x, y) return TouchEditor.touchpressed(id, x, y)
end end
if Studio then return end
if Importer then if Importer then
-- Both mobiles: FlexLove scroll needs the real touch stream. Clicks are -- Both mobiles: FlexLove scroll needs the real touch stream. Clicks are
-- polled inside the view; the istouch filter on mousepressed still drops -- polled inside the view; the istouch filter on mousepressed still drops
-- Android's synthesized mouse twin so Import cannot double-fire (#553). -- Android's synthesized mouse twin so Import cannot double-fire (#553).
return Importer:touchpressed(id, x, y, dx, dy, pressure) return Importer:touchpressed(id, x, y, dx, dy, pressure)
end end
if not Game then return end
Game:touchpressed(id, x, y, dx, dy, pressure) Game:touchpressed(id, x, y, dx, dy, pressure)
end end
@@ -811,9 +842,11 @@ function love.touchmoved(id, x, y, dx, dy, pressure)
if love.system.getOS() == "iOS" then return end if love.system.getOS() == "iOS" then return end
return TouchEditor.touchmoved(id, x, y) return TouchEditor.touchmoved(id, x, y)
end end
if Studio then return end
if Importer then if Importer then
return Importer:touchmoved(id, x, y, dx, dy, pressure) return Importer:touchmoved(id, x, y, dx, dy, pressure)
end end
if not Game then return end
Game:touchmoved(id, x, y, dx, dy, pressure) Game:touchmoved(id, x, y, dx, dy, pressure)
end end
@@ -823,9 +856,11 @@ function love.touchreleased(id, x, y, dx, dy, pressure)
if love.system.getOS() == "iOS" then return end if love.system.getOS() == "iOS" then return end
return TouchEditor.touchreleased(id, x, y) return TouchEditor.touchreleased(id, x, y)
end end
if Studio then return end
if Importer then if Importer then
return Importer:touchreleased(id, x, y, dx, dy, pressure) return Importer:touchreleased(id, x, y, dx, dy, pressure)
end end
if not Game then return end
Game:touchreleased(id, x, y, dx, dy, pressure) Game:touchreleased(id, x, y, dx, dy, pressure)
end end
@@ -837,6 +872,7 @@ function love.wheelmoved(x, y)
if TouchEditor then return end if TouchEditor then return end
if Studio then return Studio.wheelmoved(x, y) end if Studio then return Studio.wheelmoved(x, y) end
if Importer then return end if Importer then return end
if not Game then return end
Game:wheelmoved(x, y) Game:wheelmoved(x, y)
end end
+35
View File
@@ -12,6 +12,41 @@
"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",
"date": "2026-08-18",
"size": 13575387,
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.2/gen1recomp++-0.2.2-ios.ipa",
"localizedDescription": "Download the correct version for your computer below.\n\n## Contributors\n\n- @AverageConsumer\n- @bryanthaboi"
},
{
"version": "0.2.1",
"date": "2026-08-17",
"size": 13575320,
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.1/gen1recomp++-0.2.1-ios.ipa",
"localizedDescription": "Download the correct version for your computer below.\n\n## Contributors\n\n- @bryanthaboi"
},
{
"version": "0.2.0",
"date": "2026-08-17",
"size": 13575299,
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.0/gen1recomp++-0.2.0-ios.ipa",
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #1396 Nurse dialogue & options\n- #1398 Alignment of options for changing Pokemon\n- #1400 Flying Bug\n- #1401 [Gold] battlergfx $d9/$da load the wrong row count (jumptable crossed vs macro names)\n- #1406 Magikarp salesman dialogue issues\n- #1407 Not able to nickname Magikarp\n- #1411 No indication for stone evolutions\n- #1413 Using a stone closes menu\n- #1415 Super Nerd dialogue issues\n- #1416 (Pokémon Gold) Pokédex doesn't register other trainers' pokémon as seen\n- #1417 (Pokémon Gold) Pokémon you get in trade aren't being registered as caught\n- #1419 (Pokémon Gold) Deposited pokémon don't get healed\n- #1421 (Pokémon Gold) Bad status and catch state appears on the HUD before they should\n- #1422 (Pokémon Gold) Impossible to have the pokédex register Ditto as caught after it transforms\n- #1423 (Pokémon Gold) No save prompt before changing boxes in the PC\n- #1424 (Pokémon Gold) Quantity for owned TMs not being displayed\n- #1425 (Pokémon Gold) Items quantity in your bag should be alligned to the right\n- #1427 (Pokémon Gold) Can't switch items' position in your bag\n- #1428 (Pokémon Gold) Game doesn't show how many pokémon other trainers have\n- #1429 Pikachu not sliding in before its cry. Stuck on standard pokeball release animation.\n- #1431 Shiny sparkle does not play on your sent out shiny pokemon\n- #1432 Experimental marked mods don't install Android\n- #1433 (Pokémon Gold) Missing prompt for depositing pokémon\n- #1435 When npcs stop you to talk or when you walk up to npcs to talk to them sometimes the player has the wrong sprite\n- #1437 Issues with player sprite on map\n- #1440 hold a direction during cutscene and face the wrong way\n- #1441 Magnet Train missing animation\n- #1442 Radio dial is missing in PokeGear radio\n- #1443 Skipping production logo also skips battle scene\n- #1444 Pokemon lack type immunity to status moves\n- #1447 Soft-lock on Cinnabar Island\n- #1449 Visual error on Route 28\n- #1456 Activating all mods doesn't work properly\n- #1461 #1265 didnt got fixed.\n- #1464 Experiance shared in battle\n- #1465 Changing Touch Layout crashes launcher\n- #1466 #1403 Still Happens\n- #1467 A clearer definition of the use of AI for this reconstruction\n- #1468 [Gold] BICYCLE is broken and some pokegear bug\n- #1469 [Gold] status effects aren't shown in the party overlay or the summary screen of the pokemon\n- #1470 Mod updater doesn't work properly when AppImage is running through Steam or Game Mode (Steam Deck)\n\n## Contributors\n\n- @bryanthaboi"
},
{ {
"version": "0.1.99", "version": "0.1.99",
"date": "2026-08-17", "date": "2026-08-17",
+10 -4
View File
@@ -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"
+3 -1
View File
@@ -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
+1
View File
@@ -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
+21 -3
View File
@@ -39,6 +39,21 @@ local function messageCopy(screen)
return #lines > 0 and lines or nil return #lines > 0 and lines or nil
end end
local function itemCopies(game, screen, catchable)
local out = {}
for id, count in pairs((game.save and game.save.inventory) or {}) do
local def = game.data.items and game.data.items[id]
if count > 0 and def and def.pocket == "BALL" then
out[#out + 1] = { id = id, name = def.name or id, count = count,
ball = true, needsTarget = false,
catchChance = catchable and screen.catchChance
and screen:catchChance(id) or nil }
end
end
table.sort(out, function(a, b) return a.name < b.name end)
return out
end
local function signature(game, screen, top) local function signature(game, screen, top)
if not screen then return "none" end if not screen then return "none" end
local battle = screen.battle or {} local battle = screen.battle or {}
@@ -56,6 +71,9 @@ local function signature(game, screen, top)
parts[#parts + 1] = tostring(mon.hp) parts[#parts + 1] = tostring(mon.hp)
parts[#parts + 1] = tostring(mon.status) parts[#parts + 1] = tostring(mon.status)
end end
for _, item in ipairs(itemCopies(game, screen, false)) do
parts[#parts + 1] = item.id .. "=" .. tostring(item.count)
end
return table.concat(parts, "|") return table.concat(parts, "|")
end end
@@ -111,9 +129,9 @@ function BattleAPI:snapshot()
player = monCopy(game.data, battle.player, true), player = monCopy(game.data, battle.player, true),
enemy = monCopy(game.data, battle.enemy, true), enemy = monCopy(game.data, battle.enemy, true),
party = party, moves = moveCopies(game, battle), party = party, moves = moveCopies(game, battle),
-- Gold's PACK is pocketed and target selection is screen-owned. Omit it -- Targeted medicine remains screen-owned, but balls are complete semantic
-- until the engine can expose the same semantic item records as Gen 1. -- records and can safely expose the same read-only preview as Gen 1.
items = {} } items = itemCopies(game, screen, battle.wild and not screen.tutorial) }
end end
local MENU_CHOICES = { fight = true, party = true, item = true, run = true } local MENU_CHOICES = { fight = true, party = true, item = true, run = true }
+9
View File
@@ -305,6 +305,15 @@ function Catching.rate(opts)
return math.min(255, rate), false return math.min(255, rate), false
end end
-- Exact stock catch probability for read-only previews. A catch.rate hook
-- may replace the roll entirely, so nil is safer than presenting a guess.
function Catching.chance(opts)
if Runtime.wantsHook("catch.rate") then return nil end
local rate, guaranteed = Catching.rate(opts)
if guaranteed or rate >= 255 then return 100 end
return rate * 100 / 256
end
-- The status half of the rate, off the merged `statuses` record the same way -- The status half of the rate, off the merged `statuses` record the same way
-- src/battle/Catching.lua reads record.catchBonus on Gen 1. Gold's records -- src/battle/Catching.lua reads record.catchBonus on Gen 1. Gold's records
-- live on src/battle/gen2/Battle.lua (Battle.STATUSES) and carry BOTH numbers: -- live on src/battle/gen2/Battle.lua (Battle.STATUSES) and carry BOTH numbers:
+2 -1
View File
@@ -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
View File
@@ -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
+109
View File
@@ -0,0 +1,109 @@
local Platform = require("src.core.Platform")
local HostShell = require("src.core.HostShell")
local FilePicker = {}
FilePicker.IMAGE = {
label = "Image",
exts = { "png", "jpg", "jpeg" },
tempName = "pokeport_image_pick",
}
local function trim(value)
return value and value:gsub("^%s+", ""):gsub("%s+$", "") or ""
end
local function shellSafe(s)
s = tostring(s):gsub("%%", "%%%%")
return s:gsub('"', '\\"'):gsub("'", "''")
end
local function commandOutput(command)
if not Platform.canSpawnProcess() then return nil end
local pipe = HostShell.popen(command)
if not pipe then return nil end
local result = pipe:read("*a")
HostShell.pclose(pipe)
result = trim(result)
return result ~= "" and result or nil
end
local function appleTypes(exts)
local out = {}
for _, ext in ipairs(exts) do out[#out + 1] = '"' .. ext .. '"' end
return table.concat(out, ", ")
end
local function windowsPatterns(exts)
local out = {}
for _, ext in ipairs(exts) do out[#out + 1] = "*." .. ext end
return table.concat(out, ";")
end
local function globPatterns(exts)
local out = {}
for _, ext in ipairs(exts) do out[#out + 1] = "*." .. ext end
return table.concat(out, " ")
end
function FilePicker.available()
return Platform.canSpawnProcess()
end
function FilePicker.matches(name, kind)
local lower = tostring(name or ""):lower()
for _, ext in ipairs(kind.exts) do
if lower:match("%." .. ext .. "$") then return true end
end
return false
end
function FilePicker.open(prompt, kind)
if not Platform.canSpawnProcess() then return nil end
local title = shellSafe(prompt)
local platform = love.system.getOS()
if platform == "OS X" then
return commandOutput(
([[osascript -e 'POSIX path of (choose file with prompt "%s" of type {%s})' 2>/dev/null]])
:format(title, appleTypes(kind.exts)))
elseif platform == "Windows" then
local script = table.concat({
"Add-Type -AssemblyName System.Windows.Forms;",
"$d=New-Object System.Windows.Forms.OpenFileDialog;",
"$d.Title='" .. title .. "';",
"$d.Filter='" .. kind.label .. " (" .. windowsPatterns(kind.exts) .. ")|"
.. windowsPatterns(kind.exts) .. "|All files (*.*)|*.*';",
"if($d.ShowDialog() -eq 'OK'){",
"$n=[IO.Path]::GetFileName($d.FileName) -replace '[^\\x20-\\x7E]','_';",
"$t=Join-Path $env:TEMP $n;",
"Copy-Item -LiteralPath $d.FileName -Destination $t -Force;",
"[Console]::OutputEncoding=[Text.Encoding]::UTF8;",
"[Console]::Write($t)}",
})
return commandOutput(
'powershell -NoProfile -STA -Command "' .. script .. '"')
elseif platform == "Linux" then
local path = commandOutput(
([[zenity --file-selection --title="%s" --file-filter="%s | %s" 2>/dev/null]])
:format(title, kind.label, globPatterns(kind.exts)))
if path then return path end
return commandOutput(([[kdialog --getopenfilename "$HOME" "%s|%s" 2>/dev/null]])
:format(globPatterns(kind.exts), kind.label))
end
return nil
end
function FilePicker.read(path)
local file, openError = io.open(path, "rb")
if not file then return nil, openError end
local data = file:read("*a")
file:close()
if not data or data == "" then return nil, "empty file" end
return data
end
function FilePicker.basename(path)
return tostring(path or ""):match("([^/\\]+)$") or tostring(path or "")
end
return FilePicker
+34 -1
View File
@@ -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
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) self.stack:update(dt)
end
-- 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
View File
@@ -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
View File
@@ -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
View File
@@ -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
local function pruneMoveSfx()
for ch, cur in pairs(moveSfxChannels) do
if not sourceAlive(cur) then moveSfxChannels[ch] = nil end
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
View File
@@ -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
+14 -2
View File
@@ -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,6 +233,12 @@ 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)
if path:sub(-4) == ".lua" then
local CacheFs = require("src.import.CacheFs")
local loaded, err = CacheFs.loadActive(path)
lines[#lines + 1] = "loadActive=" .. (loaded ~= nil and "ok"
or ("FAIL " .. tostring(err):gsub("%s+", " "):sub(1, 160)))
else
lines[#lines + 1] = "newImage=" .. probeOpen("image", resolved) lines[#lines + 1] = "newImage=" .. probeOpen("image", resolved)
lines[#lines + 1] = "newImageData=" .. probeOpen("imageData", resolved) lines[#lines + 1] = "newImageData=" .. probeOpen("imageData", resolved)
if prefix ~= "" and resolved ~= versioned then if prefix ~= "" and resolved ~= versioned then
@@ -237,10 +246,13 @@ function SwitchDiagnostics.probeAssets(version)
lines[#lines + 1] = "newImageData_versioned=" .. probeOpen("imageData", 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
View File
@@ -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)
+55 -14
View File
@@ -2017,6 +2017,23 @@ local function buildSkinsPanel(imp, x, y, w, availH, m)
local gap = m.gap local gap = m.gap
local cy = y local cy = y
local title = Strings("Skins/Borders")
local bh = m.btnH
local importLabel = imp:_skinsImportButtonLabel()
local importW = Kit.textWidth("small", importLabel) + math.floor(24 * m.s)
if Kit.textWidth("button", title) + importW + math.floor(24 * m.s) > w then
importLabel = Strings("Import")
importW = Kit.textWidth("small", importLabel) + math.floor(20 * m.s)
end
local place = Layout.rightCluster(x, w, math.floor(6 * m.s))
btn(imp, place(importW), cy, importW, bh, "skins-import", importLabel, {
kind = "accent", font = "small",
action = function() imp:chooseSkin() end })
Kit.text("button", Kit.ellipsize("button", title,
math.max(0, w - importW - math.floor(12 * m.s))), x,
cy + math.floor((bh - Kit.textHeight("button")) / 2), PAL.heading)
cy = cy + bh + math.floor(8 * m.s)
if imp._skinNotice then if imp._skinNotice then
cy = cy + Kit.textWrapped("small", imp._skinNotice.text, x, cy, w, cy = cy + Kit.textWrapped("small", imp._skinNotice.text, x, cy, w,
imp._skinNotice.ok and PAL.green or PAL.red, 2) + math.floor(8 * m.s) imp._skinNotice.ok and PAL.green or PAL.red, 2) + math.floor(8 * m.s)
@@ -2075,13 +2092,39 @@ local function buildSkinsPanel(imp, x, y, w, availH, m)
cy = cy + rowH + math.floor(4 * m.s) cy = cy + rowH + math.floor(4 * m.s)
end end
local entries = { false }
for _, entry in ipairs(skins) do entries[#entries + 1] = entry end
local TouchSkin = require("src.core.TouchSkin")
local hint = Strings(
"You can also drop a skin .zip on this window, or put a folder in %s/ of your save directory. RetroArch overlay .cfg files work as-is.",
TouchSkin.USER_ROOT)
local hintH = Kit.wrapHeight("small", hint, w, 3)
local importH = math.floor(10 * m.s) + hintH
local rowGap = math.floor(4 * m.s)
local pagerH = math.max(Kit.tapMin(), math.floor(30 * m.s))
local listTop = cy
local listH = availH - (cy - y) - importH
local perPage = Kit.rowsThatFit(listH, rowH, rowGap, 1, 20)
if #entries > perPage then
perPage = Kit.rowsThatFit(listH - pagerH - gap, rowH, rowGap, 1, 20)
end
local first, last, cur, pages = Kit.pageBounds(page(imp, "skins"),
#entries, perPage)
setPage(imp, "skins", cur)
setPage(imp, "skins",
Kit.wheelPage(x, listTop, w, listH, cur, #entries, perPage))
for i = first, last do
local entry = entries[i]
if not entry then
skinRow("skin-none", nil, Strings("Built-in pad"), skinRow("skin-none", nil, Strings("Built-in pad"),
Strings("The default on-screen buttons."), active == nil, Strings("The default on-screen buttons."), active == nil,
imp.onEditTouchControls and function() imp.onEditTouchControls and function()
imp.onEditTouchControls(imp.modScope or "red") imp.onEditTouchControls(imp.modScope or "red")
end or nil) end or nil)
else
for _, entry in ipairs(skins) do
local bits = {} local bits = {}
bits[#bits + 1] = entry.source == "user" and Strings("installed") bits[#bits + 1] = entry.source == "user" and Strings("installed")
or Strings("bundled") or Strings("bundled")
@@ -2100,6 +2143,7 @@ local function buildSkinsPanel(imp, x, y, w, availH, m)
skinRow("skin-" .. entry.id, entry.id, entry.id, skinRow("skin-" .. entry.id, entry.id, entry.id,
table.concat(bits, " \194\183 "), active == entry.id, configure) table.concat(bits, " \194\183 "), active == entry.id, configure)
end end
end
if #skins == 0 then if #skins == 0 then
Kit.emptyBox(x, cy, w, math.floor(72 * m.s), Kit.emptyBox(x, cy, w, math.floor(72 * m.s),
@@ -2107,18 +2151,14 @@ local function buildSkinsPanel(imp, x, y, w, availH, m)
cy = cy + math.floor(72 * m.s) + gap cy = cy + math.floor(72 * m.s) + gap
end end
cy = cy + math.floor(6 * m.s) if pages > 1 then
local TouchSkin = require("src.core.TouchSkin") setPage(imp, "skins",
Kit.caption(x, cy, Strings("IMPORT")) Kit.pager(x, cy, w, cur, #entries, perPage, "skins"))
cy = cy + Kit.textHeight("small") + math.floor(6 * m.s) cy = cy + pagerH + gap
local boxH = math.floor(76 * m.s) end
Kit.card(x, cy, w, boxH, "muted")
Kit.textWrapped("small", Strings( cy = cy + math.floor(10 * m.s)
"Drop a skin .zip on this window to install it, or put a folder in the skins folder of your save directory. RetroArch overlay .cfg files work as-is."), Kit.textWrapped("small", hint, x, cy, w, PAL.muted, 3)
x + math.floor(14 * m.s), cy + math.floor(12 * m.s),
w - math.floor(28 * m.s), PAL.muted, 3)
Kit.text("small", TouchSkin.USER_ROOT .. "/", x + math.floor(14 * m.s),
cy + boxH - Kit.textHeight("small") - math.floor(10 * m.s), PAL.faint)
end end
local function buildFindPanel(imp, x, y, w, availH, m) local function buildFindPanel(imp, x, y, w, availH, m)
@@ -3850,6 +3890,7 @@ local function modalUp(imp)
or imp._appPatchNotes or imp._appPatchNotes
or imp._findDetails or imp._modVersions or imp._modDepResolver or imp._sortPopup or imp._findDetails or imp._modVersions or imp._modDepResolver or imp._sortPopup
or imp._filterPopup or imp._modScopePopup or imp._indexManage or imp._filterPopup or imp._modScopePopup or imp._indexManage
or imp._gamePopup
or imp._modActions or imp._modImports or imp._modActions or imp._modImports
or imp._modHeaderActionsPopup or imp._profilesPopup or imp._singleProfileActions or imp._profileSavePrompt or imp._modHeaderActionsPopup or imp._profilesPopup or imp._singleProfileActions or imp._profileSavePrompt
or imp._profileRenamePrompt or imp._findEntry or imp._gameManage) ~= nil or imp._profileRenamePrompt or imp._findEntry or imp._gameManage) ~= nil
+171 -35
View File
@@ -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
if not isOverride then
for _, path in ipairs(VERSION_REQUIRED_FILES[version] or {}) do for _, path in ipairs(VERSION_REQUIRED_FILES[version] or {}) 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
local path = prefix .. REQUIRED_FILES[1] end
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
@@ -988,8 +999,18 @@ local function pickerHasKind(kind)
return false return false
end end
local function findPendingRequiredImport() local function findPendingRequiredImport(self)
local names = { "picked_required_import.bin", "picked_stadium.z64" } local names = { "picked_required_import.bin", "picked_stadium.z64" }
-- Builds released before required_import was added to the Android JNI bridge
-- only understand the long-standing "rom" picker kind. While a required
-- import request is in flight, it is safe to treat its staging name as a
-- dependency file: the pending IDs below select the same validation/copy
-- path as a current bridge. Never scan picked_rom.gb otherwise, since that
-- remains reserved for an ordinary game-ROM import.
if self and self.requiredImportLegacyRomPick
and self.pickerPendingKind == "required_import" then
names[#names + 1] = "picked_rom.gb"
end
for _, name in ipairs(names) do for _, name in ipairs(names) do
if love.filesystem.getInfo(name, "file") then return name end if love.filesystem.getInfo(name, "file") then return name end
end end
@@ -1089,6 +1110,39 @@ local function chooseZip()
return nil return nil
end end
local function chooseSkinZip()
local prompt = shellSafe(Strings("Choose a skin .zip"))
local platform = love.system.getOS()
if platform == "OS X" then
return commandOutput(
([[osascript -e 'POSIX path of (choose file with prompt "%s" of type {"zip"})' 2>/dev/null]])
:format(prompt))
elseif platform == "Windows" then
local script = table.concat({
"Add-Type -AssemblyName System.Windows.Forms;",
"$d=New-Object System.Windows.Forms.OpenFileDialog;",
"$d.Title='" .. prompt .. "';",
"$d.Filter='Skin archive (*.zip)|*.zip|All files (*.*)|*.*';",
"if($d.ShowDialog() -eq 'OK'){",
"$n=[IO.Path]::GetFileName($d.FileName) -replace '[^\\x20-\\x7E]','_';",
"$t=Join-Path $env:TEMP $n;",
"Copy-Item -LiteralPath $d.FileName -Destination $t -Force;",
"[Console]::OutputEncoding=[Text.Encoding]::UTF8;",
"[Console]::Write($t)}",
})
return commandOutput(
'powershell -NoProfile -STA -Command "' .. script .. '"')
elseif platform == "Linux" then
local path = commandOutput(
([[zenity --file-selection --title="%s" --file-filter="Skin archive | *.zip" 2>/dev/null]])
:format(prompt))
if path then return path end
return commandOutput(
[[kdialog --getopenfilename "$HOME" "*.zip|Skin archive" 2>/dev/null]])
end
return nil
end
-- Open a native picker for a raw .sav battery save (mirrors chooseZip's per-OS -- Open a native picker for a raw .sav battery save (mirrors chooseZip's per-OS
-- dialogs). Returns the chosen absolute path or nil. Android uses -- dialogs). Returns the chosen absolute path or nil. Android uses
-- love.system.pickFile("sav") instead -- see RomImporter:chooseSaveImport. -- love.system.pickFile("sav") instead -- see RomImporter:chooseSaveImport.
@@ -1471,14 +1525,23 @@ function RomImporter:focus(f)
local text = "Could not read the picked file. Reopen the picker and choose " local text = "Could not read the picked file. Reopen the picker and choose "
.. "it with the Files (Documents) app, or copy it into: " .. "it with the Files (Documents) app, or copy it into: "
.. love.filesystem.getSaveDirectory() .. love.filesystem.getSaveDirectory()
local legacyRequiredPick = self.requiredImportLegacyRomPick
and self.pickerPendingKind == "required_import"
if pickError:find("picked_required_import", 1, true) if pickError:find("picked_required_import", 1, true)
or pickError:find("picked_stadium", 1, true) then or pickError:find("picked_stadium", 1, true)
or (legacyRequiredPick and pickError:find("picked_rom", 1, true)) then
self.modNotice = { ok = false, text = text } self.modNotice = { ok = false, text = text }
self.pickerPendingKind = nil self.pickerPendingKind = nil
self.pickerPendingModId = nil self.pickerPendingModId = nil
self.pickerPendingImportId = nil self.pickerPendingImportId = nil
self.requiredImportLegacyRomPick = nil
elseif pickError:find("picked_mod", 1, true) then elseif pickError:find("picked_mod", 1, true) then
if self.pickerPendingKind == "skin" then
self.pickerPendingKind = nil
self._skinNotice = { ok = false, text = text }
else
self.modNotice = { ok = false, text = text } self.modNotice = { ok = false, text = text }
end
elseif pickError:find("picked_save", 1, true) then elseif pickError:find("picked_save", 1, true) then
local version = self.androidPendingVersion or self:_savedropTarget() local version = self.androidPendingVersion or self:_savedropTarget()
self.androidPendingVersion = nil self.androidPendingVersion = nil
@@ -1488,11 +1551,12 @@ function RomImporter:focus(f)
end end
return return
end end
local requiredName = findPendingRequiredImport() local requiredName = findPendingRequiredImport(self)
if requiredName then if requiredName then
local modId, importId = self.pickerPendingModId, self.pickerPendingImportId local modId, importId = self.pickerPendingModId, self.pickerPendingImportId
self.pickerPendingKind = nil self.pickerPendingKind = nil
self.pickerPendingModId, self.pickerPendingImportId = nil, nil self.pickerPendingModId, self.pickerPendingImportId = nil, nil
self.requiredImportLegacyRomPick = nil
local imported = modId and importId local imported = modId and importId
and self:_importRequiredSource(modId, importId, requiredName) and self:_importRequiredSource(modId, importId, requiredName)
consumePick(self, requiredName, requiredName, imported) consumePick(self, requiredName, requiredName, imported)
@@ -1504,6 +1568,13 @@ function RomImporter:focus(f)
end end
local modName = findPendingMod(false, self.pickSkip) local modName = findPendingMod(false, self.pickSkip)
if modName then if modName then
if self.pickerPendingKind == "skin" then
self.pickerPendingKind = nil
self:_installSkinZip(modName)
consumePick(self, modName, "picked_mod.zip",
self._skinNotice and self._skinNotice.ok)
return
end
self:_installMod(modName) self:_installMod(modName)
consumePick(self, modName, "picked_mod.zip", consumePick(self, modName, "picked_mod.zip",
self.modNotice and self.modNotice.ok) self.modNotice and self.modNotice.ok)
@@ -2024,7 +2095,17 @@ function RomImporter:chooseRequiredImport(modId, importId)
return return
end end
if self.nativePicker then if self.nativePicker then
if self.mobileFileBridge and not pickerHasKind("required_import") then -- Android 13+ uses the Storage Access Framework for both paths. Some
-- Android 15 installs carry the newer Lua launcher with an older native
-- bridge, however, so they do not advertise required_import yet. Fall
-- back to that bridge's known "rom" picker and quarantine its result by
-- the pending required-import IDs. iOS has a different asynchronous
-- bridge and deliberately keeps the explicit capability requirement.
local legacyAndroidPicker = self.mobileFileBridge
and love.system.getOS() == "Android"
and not pickerHasKind("required_import")
if self.mobileFileBridge and not pickerHasKind("required_import")
and not legacyAndroidPicker then
requiredImportNotice(self, modId, importId, requiredImportNotice(self, modId, importId,
"This app build cannot pick required mod files yet. Update the app and try again.") "This app build cannot pick required mod files yet. Update the app and try again.")
self.modNotice = nil self.modNotice = nil
@@ -2033,10 +2114,12 @@ function RomImporter:chooseRequiredImport(modId, importId)
self.pickerPendingKind = "required_import" self.pickerPendingKind = "required_import"
self.pickerPendingModId = modId self.pickerPendingModId = modId
self.pickerPendingImportId = importId self.pickerPendingImportId = importId
if not pickFile("required_import") then self.requiredImportLegacyRomPick = legacyAndroidPicker or nil
if not pickFile(legacyAndroidPicker and "rom" or "required_import") then
self.pickerPendingKind = nil self.pickerPendingKind = nil
self.pickerPendingModId = nil self.pickerPendingModId = nil
self.pickerPendingImportId = nil self.pickerPendingImportId = nil
self.requiredImportLegacyRomPick = nil
requiredImportNotice(self, modId, importId, "Could not open the file picker.") requiredImportNotice(self, modId, importId, "Could not open the file picker.")
self.modNotice = nil self.modNotice = nil
elseif self.android then elseif self.android then
@@ -2498,6 +2581,11 @@ function RomImporter:update(dt)
if Platform.isUWP() and self.modNotice and self.modNotice.ok then if Platform.isUWP() and self.modNotice and self.modNotice.ok then
os.remove(path) os.remove(path)
end end
elseif kind == "skin" then
self:_installSkinZip(path)
if Platform.isUWP() and self._skinNotice and self._skinNotice.ok then
os.remove(path)
end
elseif kind == "sav" then elseif kind == "sav" then
local target = version or self:_savedropTarget() local target = version or self:_savedropTarget()
self:_importSave(target, path) self:_importSave(target, path)
@@ -2520,6 +2608,8 @@ function RomImporter:update(dt)
self.pickerPendingModId, self.pickerPendingImportId = nil, nil self.pickerPendingModId, self.pickerPendingImportId = nil, nil
elseif kind == "mod" then elseif kind == "mod" then
self.modNotice = { ok = false, text = errorText } self.modNotice = { ok = false, text = errorText }
elseif kind == "skin" then
self._skinNotice = { ok = false, text = errorText }
elseif kind == "sav" then elseif kind == "sav" then
self.saveNotice[version] = { ok = false, text = errorText } self.saveNotice[version] = { ok = false, text = errorText }
else else
@@ -3047,13 +3137,27 @@ function RomImporter:_useSkin(id)
} }
end end
function RomImporter:_installSkinZip(file) function RomImporter:_installSkinZip(source)
if self.workState == "working" then return end
self.tab = "skins"
local TouchSkin = require("src.core.TouchSkin") local TouchSkin = require("src.core.TouchSkin")
local name = file:getFilename() or "" local name, data, readError
local data, readError = readDroppedFile(file) if type(source) == "string" then
name = source
if not source:match("^/") and not source:match("^%a:[/\\]")
and not source:match("^[Ss][Dd][Mm][Cc]:") then
data = love.filesystem.read(source)
end
if not data then data, readError = readExternalPath(source) end
if not data then data = love.filesystem.read(source) end
else
name = source:getFilename() or ""
data, readError = readDroppedFile(source)
end
if not data then if not data then
self._skinNotice = { ok = false, self._skinNotice = { ok = false,
text = "Could not read the dropped file: " .. tostring(readError) } text = "Could not read the skin archive: "
.. tostring(readError or name) }
return return
end end
local id, err = TouchSkin.installArchive(name, data) local id, err = TouchSkin.installArchive(name, data)
@@ -3065,6 +3169,51 @@ function RomImporter:_installSkinZip(file)
self._skinNotice = { ok = true, text = "Imported " .. id } self._skinNotice = { ok = true, text = "Imported " .. id }
end end
function RomImporter:_skinsImportButtonLabel()
if self.isNX then return Strings("Scan again") end
return Strings("Import skin .zip")
end
function RomImporter:chooseSkin()
if self.workState == "working" then return end
if self.isNX then
local found = #self:_ensureSkins(true)
self._skinNotice = { ok = true, text = Strings(
"%d skins found. Copy a skin .zip into %s/ over MTP, then scan again.",
found, require("src.core.TouchSkin").USER_ROOT) }
return
end
if self.nativePicker and love.system.getPickedFile then
self.pickerPendingKind = "skin"
if not pickFile("mod") then
self.pickerPendingKind = nil
self._skinNotice = { ok = false, text = "Could not open the file picker." }
end
return
end
if self.android then
local name = findPendingMod(true, self.pickSkip)
if name then
self:_installSkinZip(name)
consumePick(self, name, "picked_mod.zip",
self._skinNotice and self._skinNotice.ok)
return
end
self.pickerPendingKind = "skin"
if not pickFile("mod") then
self.pickerPendingKind = nil
self._skinNotice = { ok = false,
text = "Could not open the file picker. Copy a skin .zip via USB." }
else
self.pickPending = true
self.pickTimer = 0
end
return
end
local path = chooseSkinZip()
if path then self:_installSkinZip(path) end
end
function RomImporter:_toggleFindSearchFocus() function RomImporter:_toggleFindSearchFocus()
self._findSearchFocus = not self._findSearchFocus self._findSearchFocus = not self._findSearchFocus
if self._findSearchFocus then if self._findSearchFocus then
@@ -4186,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
+1 -1
View File
@@ -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
+6 -2
View File
@@ -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
View File
@@ -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
+3 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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"
failureDetail = ("message %d must be a table with string type")
:format(index)
end
break
end
self._inbox[#self._inbox + 1] = message self._inbox[#self._inbox + 1] = message
else
self.dropped = (self.dropped or 0) + 1
local label = type(raw) == "table" and tostring(raw.type) or type(raw)
Logger.warn("link: dropped malformed message (%s)", label)
end
end end
end end
+344
View File
@@ -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
+1
View File
@@ -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),
+2 -1
View File
@@ -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
+225 -27
View File
@@ -4,6 +4,7 @@ local PAL = Theme.PAL
local TouchSkin = require("src.core.TouchSkin") local TouchSkin = require("src.core.TouchSkin")
local TouchControls = require("src.core.TouchControls") local TouchControls = require("src.core.TouchControls")
local SaveData = require("src.core.SaveData") local SaveData = require("src.core.SaveData")
local FilePicker = require("src.core.FilePicker")
local Studio = {} local Studio = {}
@@ -19,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 },
@@ -63,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
@@ -76,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)
@@ -101,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
@@ -129,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
@@ -136,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
@@ -214,12 +318,19 @@ function Studio.cycleImage(dir)
markDirty() markDirty()
end end
function Studio.imageTargetLabel()
local target = Studio.imageTarget
if target == "bezel" or not Studio.selectedControl() then return "bezel" end
return target == "pressed" and "pressed art" or "idle art"
end
function Studio.assignImage(rel) function Studio.assignImage(rel)
local page, ctl = Studio.page(), Studio.selectedControl() local page, ctl = Studio.page(), Studio.selectedControl()
local img = TouchSkin.resolveImage(Studio.skin.root, rel) local img = TouchSkin.resolveImage(Studio.skin.root, rel)
if ctl and Studio.imageTarget == "pressed" then local target = Studio.imageTarget
if ctl and target == "pressed" then
ctl.pressedImagePath, ctl.pressedImage = rel, img ctl.pressedImagePath, ctl.pressedImage = rel, img
elseif ctl and Studio.imageTarget == "idle" then elseif ctl and target == "idle" then
ctl.imagePath, ctl.image = rel, img ctl.imagePath, ctl.image = rel, img
elseif page then elseif page then
page.imagePath, page.image = rel, img page.imagePath, page.image = rel, img
@@ -228,11 +339,69 @@ function Studio.assignImage(rel)
Studio.dirty = true Studio.dirty = true
end end
local function commitSkinId()
local skin = Studio.skin
if not skin then return end
local id = (Studio.skinIdField or ""):gsub("[^%w_%-]", "")
if id == "" or id == skin.id then return end
TouchSkin.saveTo(skin, id)
Studio.available = TouchSkin.list()
end
function Studio.adoptImage(name, data, target)
if not Studio.skin then return false end
if target then Studio.imageTarget = target end
if not FilePicker.matches(name, FilePicker.IMAGE) then
Studio.status = "Pick a PNG or JPG."
return false
end
commitSkinId()
local rel, err = TouchSkin.importImage(Studio.skin, name, data)
if not rel then
Studio.status = "Import failed: " .. tostring(err)
return false
end
local where = Studio.imageTargetLabel()
Studio.assignImage(rel)
Studio.skinIdField = Studio.skin.id
Studio.status = "Imported " .. rel .. " as " .. where
if where == "bezel" and not Studio.canvas().lockViewport then
Studio.status = Studio.status
.. " -- use Detect screen from bezel to place the screen"
end
return true
end
function Studio.importImageFile(target)
if not Studio.skin then return end
target = target or Studio.imageTarget
Studio.imageTarget = target
if target ~= "bezel" and not Studio.selectedControl() then
Studio.status = "Select a control first, or import a bezel image."
return
end
if not FilePicker.available() then
Studio.status = "No file picker here -- drag a PNG onto the window instead."
return
end
local prompt = (target == "bezel") and "Choose a bezel image"
or "Choose a button image"
local path = FilePicker.open(prompt, FilePicker.IMAGE)
if not path then return end
local base = FilePicker.basename(path)
local data, err = FilePicker.read(path)
if not data then
Studio.status = "Could not read " .. base .. ": " .. tostring(err)
return
end
Studio.adoptImage(base, data, target)
end
function Studio.filedropped(file) function Studio.filedropped(file)
if not Studio.skin then return end if not Studio.skin then return end
local path = (file.getFilename and file:getFilename()) or "" local path = (file.getFilename and file:getFilename()) or ""
local base = path:match("([^/\\]+)$") or path local base = FilePicker.basename(path)
if not base:lower():match("%.png$") and not base:lower():match("%.jpe?g$") then if not FilePicker.matches(base, FilePicker.IMAGE) then
Studio.status = "Drop a PNG or JPG to use it as art." Studio.status = "Drop a PNG or JPG to use it as art."
return return
end end
@@ -246,17 +415,7 @@ function Studio.filedropped(file)
Studio.status = "Could not read " .. base Studio.status = "Could not read " .. base
return return
end end
local rel, err = TouchSkin.importImage(Studio.skin, base, data) Studio.adoptImage(base, data)
if not rel then
Studio.status = "Import failed: " .. tostring(err)
return
end
Studio.assignImage(rel)
local where = Studio.selectedControl()
and (Studio.imageTarget == "pressed" and "pressed art" or "idle art")
or "bezel"
Studio.status = "Imported " .. rel .. " as " .. where
Studio.skinIdField = Studio.skin.id
end end
function Studio.detectViewport() function Studio.detectViewport()
@@ -302,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
@@ -376,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)
@@ -484,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()
@@ -609,18 +772,35 @@ 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)"
if Kit.button(x, cy, w, rowH, "Bezel: " .. bezel, { id = "bezel" }) then local pickW = 82 * Kit.scale
local cycleW = w - pickW - gap
if Kit.button(x, cy, cycleW, rowH, "Bezel: " .. bezel, { id = "bezel" }) then
Studio.imageTarget = "bezel" Studio.imageTarget = "bezel"
Studio.cycleImage(1) Studio.cycleImage(1)
end end
if Kit.button(x + cycleW + gap, cy, pickW, rowH, "Import",
{ id = "bezelpick" }) then
Studio.importImageFile("bezel")
end
cy = cy + rowH + gap cy = cy + rowH + gap
local vpLabel = page.viewport and "Screen cutout: ON" or "Screen cutout: OFF" local vpLabel = page.viewport and "Screen cutout: ON" or "Screen cutout: OFF"
if Kit.button(x, cy, half, rowH, vpLabel, { id = "vp", if Kit.button(x, cy, half, rowH, vpLabel, { id = "vp",
@@ -701,17 +881,25 @@ local function inspectorBody(x, y, w)
Kit.text("small", ("canvas %dx%d px"):format(canvas.w, canvas.h), x, cy, PAL.faint) Kit.text("small", ("canvas %dx%d px"):format(canvas.w, canvas.h), x, cy, PAL.faint)
cy = cy + Kit.textHeight("small") + gap cy = cy + Kit.textHeight("small") + gap
local pickW = 82 * Kit.scale
local artW = w - pickW - gap
local idle = ctl.imagePath or "(none)" local idle = ctl.imagePath or "(none)"
if Kit.button(x, cy, w, rowH, "Idle art: " .. idle, { id = "img" }) then if Kit.button(x, cy, artW, rowH, "Idle art: " .. idle, { id = "img" }) then
Studio.imageTarget = "idle" Studio.imageTarget = "idle"
Studio.cycleImage(1) Studio.cycleImage(1)
end end
if Kit.button(x + artW + gap, cy, pickW, rowH, "Import", { id = "imgpick" }) then
Studio.importImageFile("idle")
end
cy = cy + rowH + gap cy = cy + rowH + gap
local pressed = ctl.pressedImagePath or "(none)" local pressed = ctl.pressedImagePath or "(none)"
if Kit.button(x, cy, w, rowH, "Pressed art: " .. pressed, { id = "imgp" }) then if Kit.button(x, cy, artW, rowH, "Pressed art: " .. pressed, { id = "imgp" }) then
Studio.imageTarget = "pressed" Studio.imageTarget = "pressed"
Studio.cycleImage(1) Studio.cycleImage(1)
end end
if Kit.button(x + artW + gap, cy, pickW, rowH, "Import", { id = "imgppick" }) then
Studio.importImageFile("pressed")
end
return cy + rowH return cy + rowH
end end
@@ -885,6 +1073,16 @@ function Studio.wheelmoved(_, dy)
Studio.wheel = dy Studio.wheel = dy
end end
function Studio.focus()
Studio.drag = nil
Studio.clicked = false
if Studio.testing then TouchControls:reset() end
end
function Studio.visible()
Studio.focus()
end
function Studio.textinput(text) function Studio.textinput(text)
Kit.textinput(text) Kit.textinput(text)
end end
+4 -2
View File
@@ -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())
+55 -31
View File
@@ -44,6 +44,12 @@ local BattleState = {}
BattleState.__index = BattleState BattleState.__index = BattleState
BattleState.isOpaque = true BattleState.isOpaque = true
function BattleState:moveGridNavigation()
if not Runtime.wantsHook("battle.move_grid_navigation") then return false end
return Runtime.call("battle.move_grid_navigation", function() return false end,
self) == true
end
-- Armed while a battle line waits for PromptButton (home/text.asm). Any -- Armed while a battle line waits for PromptButton (home/text.asm). Any
-- positive value means "hold until A/B"; the cart never times these out, so -- positive value means "hold until A/B"; the cart never times these out, so
-- the victory jingle can keep looping through the post-win prompts. -- the victory jingle can keep looping through the post-win prompts.
@@ -2043,7 +2049,22 @@ function BattleState:update(_dt)
if self.phase == "moves" then if self.phase == "moves" then
local moves = self:playerMoves() local moves = self:playerMoves()
if input:wasPressed("up") then local grid
if self:moveGridNavigation() then
local index, count = self.moveIndex, #moves
if input:wasPressed("left") or input:wasPressed("right") then
local other = math.floor((index - 1) / 2) * 2
+ (1 - (index - 1) % 2) + 1
grid = other <= count and other or index
elseif input:wasPressed("up") or input:wasPressed("down") then
local other = (1 - math.floor((index - 1) / 2)) * 2
+ (index - 1) % 2 + 1
grid = other <= count and other or index
end
end
if grid then
self.moveIndex = grid
elseif input:wasPressed("up") then
self.moveIndex = self.moveIndex > 1 and self.moveIndex - 1 or #moves self.moveIndex = self.moveIndex > 1 and self.moveIndex - 1 or #moves
elseif input:wasPressed("down") then elseif input:wasPressed("down") then
self.moveIndex = self.moveIndex < #moves and self.moveIndex + 1 or 1 self.moveIndex = self.moveIndex < #moves and self.moveIndex + 1 or 1
@@ -2945,6 +2966,37 @@ function BattleState:openDexEntry(species)
}) })
end end
function BattleState:catchOptions(itemId)
local data = self.game and self.game.data or {}
local battle = self.battle
local enemy = battle and battle.enemy
if not enemy then return nil end
local enemyDef = data.pokemon and data.pokemon[enemy.species]
local dexEntry = data.gen2Pokedex and data.gen2Pokedex[enemy.species]
local evolveItem
for _, entry in ipairs((enemyDef and enemyDef.evolutions) or {}) do
if entry.method == "EVOLVE_ITEM" then evolveItem = entry.item end
end
local player = battle.player
return {
battle = battle, mon = enemy, def = enemyDef,
maxHp = enemy.maxHp or (enemy.stats and enemy.stats.hp), hp = enemy.hp,
catchRate = enemyDef and enemyDef.catchRate or 45, ball = itemId,
status = enemy.status, random = battle.random,
weight = dexEntry and dexEntry.weight, level = enemy.level,
playerLevel = player and player.level,
fishing = battle.battleType == "fish", species = enemy.species,
gender = enemy.gender, playerSpecies = player and player.species,
playerGender = player and player.gender, evolveItem = evolveItem,
}
end
function BattleState:catchChance(itemId)
if self.tutorial then return 100 end
local opts = self:catchOptions(itemId)
return opts and Catching.chance(opts) or nil
end
-- Items in battle: balls try a catch, the stat items apply their stage, and -- Items in battle: balls try a catch, the stat items apply their stage, and
-- everything with a ported party effect runs the same item_effects.asm routine -- everything with a ported party effect runs the same item_effects.asm routine
-- the field pack runs. Anything else reports that it cannot be used, which is -- the field pack runs. Anything else reports that it cannot be used, which is
@@ -2974,7 +3026,6 @@ function BattleState:useItem(itemId)
return return
end end
local enemy = self.battle.enemy local enemy = self.battle.enemy
local enemyDef = data.pokemon and data.pokemon[enemy.species]
local caught, rate local caught, rate
if self.tutorial then if self.tutorial then
-- `ld a, [wBattleType] / cp BATTLETYPE_TUTORIAL / -- `ld a, [wBattleType] / cp BATTLETYPE_TUTORIAL /
@@ -2988,35 +3039,8 @@ function BattleState:useItem(itemId)
caught, rate = true, 255 caught, rate = true, 255
else else
-- The specialty-ball conditions (BallMultiplierFunctionTable): each one -- The specialty-ball conditions (BallMultiplierFunctionTable): each one
-- is something this screen already knows. Heavy Ball reads the dex -- is also used by the read-only preview, so both paths stay exact.
-- weight, Moon Ball the species' stone row, Love Ball both genders, caught, rate = Catching.attempt(self:catchOptions(itemId))
-- Level Ball the two levels, Lure Ball wBattleType.
local dexEntry = data.gen2Pokedex and data.gen2Pokedex[enemy.species]
local evolveItem
for _, entry in ipairs((enemyDef and enemyDef.evolutions) or {}) do
if entry.method == "EVOLVE_ITEM" then evolveItem = entry.item end
end
local player = self.battle.player
caught, rate = Catching.attempt({
battle = self.battle,
mon = enemy,
def = enemyDef,
maxHp = enemy.maxHp or (enemy.stats and enemy.stats.hp),
hp = enemy.hp,
catchRate = enemyDef and enemyDef.catchRate or 45,
ball = itemId,
status = enemy.status,
random = self.battle.random,
weight = dexEntry and dexEntry.weight,
level = enemy.level,
playerLevel = player and player.level,
fishing = self.battle.battleType == "fish",
species = enemy.species,
gender = enemy.gender,
playerSpecies = player and player.species,
playerGender = player and player.gender,
evolveItem = evolveItem,
})
end end
-- wWildMon carries the answer through the animation, and -- wWildMon carries the answer through the animation, and
-- wThrownBallWobbleCount is the counter GetPokeBallWobble bumps once per -- wThrownBallWobbleCount is the counter GetPokeBallWobble bumps once per
+4 -5
View File
@@ -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
+15
View File
@@ -94,6 +94,21 @@ return function(game)
imp:_useSkin(nil) imp:_useSkin(nil)
shot("skins_tab_imported.png") shot("skins_tab_imported.png")
-- import button: the picker hands back an absolute host path, not a drop
local picked = dir .. "/picked_skin.zip"
local pf = io.open(picked, "wb")
pf:write(raw)
pf:close()
imp:_installSkinZip(picked)
U.log("path import notice:", imp._skinNotice.text)
U.log("picked_skin found:", tostring(TouchSkin.find("picked_skin") ~= nil))
U.log("import label:", imp:_skinsImportButtonLabel())
shot("skins_tab_path_imported.png")
love.window.setMode(520, 760, { resizable = true, highdpi = true })
U.wait(3)
shot("skins_tab_narrow.png")
U.log("done") U.log("done")
love.event.quit() love.event.quit()
while true do coroutine.yield() end while true do coroutine.yield() end
+94
View File
@@ -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
+51
View File
@@ -0,0 +1,51 @@
-- Driver: the studio's side of the alt-tab crash. Leaving the window mid-drag
-- must drop the drag and the queued click and still draw; main.lua's routing of
-- love.focus / love.visible / pad events while the studio owns the window is
-- pinned by tests/engine/skin_studio_image_import.lua (a driver run boots a
-- game, so main.lua's Studio branch is not live here).
-- POKEPORT_DRIVER=tests/drivers/skin_studio_focus_test.lua love .
return function(game)
local U = dofile("tests/drivers/util.lua")
local Studio = require("src.ui.SkinStudio")
local TouchControls = require("src.core.TouchControls")
love.window.setMode(1280, 720, { resizable = true, highdpi = true })
U.wait(2)
Studio.load({ version = "red", skinId = "gb_anim", onClose = function() end })
U.wait(2)
U.log("skin:", Studio.skin and Studio.skin.id)
Studio.drag = { kind = "control-move", mx = 1, my = 1, bx = 0, by = 0,
bw = 10, bh = 10 }
Studio.clicked = true
local okFocus, errFocus = pcall(Studio.focus, false)
U.log("focus lost:", okFocus, errFocus or "")
U.log("drag:", tostring(Studio.drag), "click:", tostring(Studio.clicked))
local droppedDrag = Studio.drag == nil and Studio.clicked == false
-- a held test press must not survive the trip out of the window either
Studio.testing = true
TouchControls:setPreview(false)
TouchControls.held = TouchControls.held or {}
TouchControls.held.start = true
pcall(Studio.focus, false)
local held = next(TouchControls.held or {})
U.log("held after focus loss:", tostring(held))
local okVisible = pcall(Studio.visible, false)
U.log("minimize:", okVisible)
local okDraw, errDraw = pcall(Studio.draw)
U.log("draw after alt-tab:", okDraw, errDraw or "")
Studio.unload()
if droppedDrag and held == nil and okVisible and okDraw then
U.log("RESULT pass")
else
U.log("RESULT FAIL")
end
love.event.quit()
while true do coroutine.yield() end
end
+120
View File
@@ -0,0 +1,120 @@
-- Driver: imports a bezel image into a fresh skin through the studio's Import
-- button and shoots the result, with the native dialog stubbed out (the real
-- one blocks on osascript / zenity / PowerShell). Also covers button art and
-- the drag-and-drop path.
-- SHOT_DIR=/tmp/studioimport POKEPORT_DRIVER=tests/drivers/skin_studio_import_shot.lua love .
return function(game)
local U = dofile("tests/drivers/util.lua")
local Studio = require("src.ui.SkinStudio")
local FilePicker = require("src.core.FilePicker")
local TouchSkin = require("src.core.TouchSkin")
local dir = os.getenv("SHOT_DIR") or "/tmp/studioimport"
os.execute('mkdir -p "' .. dir .. '" 2>/dev/null')
love.window.setMode(1440, 900, { resizable = true, highdpi = true })
U.wait(2)
local pending = nil
love.draw = function()
Studio.draw()
if pending then
local path = pending
pending = nil
love.graphics.captureScreenshot(function(imagedata)
local f = io.open(path, "wb")
if f then f:write(imagedata:encode("png"):getString()) f:close() end
end)
end
end
local function shot(name)
pending = dir .. "/" .. name
for _ = 1, 90 do
if not pending then break end
coroutine.yield()
end
U.wait(3)
local f = io.open(dir .. "/" .. name, "rb")
U.log(f and "shot" or "FAIL shot", name)
if f then f:close() end
end
Studio.load({ version = "red", onClose = function() end })
U.wait(3)
-- start from New so the run does not depend on the saved skin choice
Studio.skin = TouchSkin.newSkin("import_probe")
Studio.skinIdField = "import_probe"
Studio.pageIndex, Studio.selected, Studio.images = 1, nil, {}
U.log("skin:", Studio.skin.id, "bezel:", tostring(Studio.page().imagePath))
-- what the native dialog would hand back
local picked = "assets/skins/gb_anim/img/gb_back.png"
local realOpen = FilePicker.open
FilePicker.open = function(prompt, kind)
U.log("picker prompt:", prompt, "exts:", table.concat(kind.exts, "/"))
return picked
end
Studio.importImageFile("bezel")
U.log("status:", tostring(Studio.status))
U.log("bezel now:", tostring(Studio.page().imagePath),
"loaded:", tostring(Studio.page().image ~= nil))
U.log("skin root:", Studio.skin.root)
U.log("file on disk:",
tostring(love.filesystem.getInfo(Studio.skin.root .. "/img/gb_back.png") ~= nil))
local viewport = Studio.page().viewport
U.log("viewport left alone:", viewport and
("%.3f %.3f %.3f %.3f"):format(viewport.x, viewport.y, viewport.w, viewport.h)
or "none")
shot("import_bezel.png")
-- button art goes onto the selected control, not the page
Studio.addControl()
FilePicker.open = function() return "assets/skins/gb_anim/img/gbc_a.png" end
Studio.importImageFile("idle")
local ctl = Studio.selectedControl()
U.log("idle art:", tostring(ctl.imagePath),
"bezel untouched:", tostring(Studio.page().imagePath))
FilePicker.open = function() return "assets/skins/gb_anim/img/gbc_b.png" end
Studio.importImageFile("pressed")
U.log("pressed art:", tostring(ctl.pressedImagePath))
shot("import_button_art.png")
-- cancelling the dialog changes nothing
local before = Studio.page().imagePath
FilePicker.open = function() return nil end
Studio.importImageFile("bezel")
U.log("after cancel:", tostring(Studio.page().imagePath),
"unchanged:", tostring(before == Studio.page().imagePath))
-- the drop path shares the import, and refuses non-art
Studio.imageTarget = "bezel"
local raw = love.filesystem.read("assets/skins/tv_crt/img/tv-integer.png")
Studio.filedropped({
getFilename = function() return "/tmp/tv_frame.png" end,
open = function() return true end,
read = function() return raw end,
close = function() return true end,
})
U.log("dropped bezel:", tostring(Studio.page().imagePath))
Studio.filedropped({
getFilename = function() return "/tmp/notes.txt" end,
open = function() return true end,
read = function() return "nope" end,
close = function() return true end,
})
U.log("after dropping a txt:", tostring(Studio.page().imagePath),
"status:", tostring(Studio.status))
shot("import_dropped.png")
love.window.setMode(760, 900, { resizable = true, highdpi = true })
U.wait(3)
shot("import_narrow.png")
FilePicker.open = realOpen
local saved = TouchSkin.find("import_probe")
U.log("skin discoverable:", tostring(saved ~= nil))
Studio.unload()
U.log("done")
love.event.quit()
while true do coroutine.yield() end
end
+27 -4
View File
@@ -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")
+11
View File
@@ -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()
+3
View File
@@ -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()
+1
View File
@@ -421,6 +421,7 @@ local GEN2_HOOKS = {
-- nextFn gets nil there). -- nextFn gets nil there).
"battle.catch_exp", "battle.low_health_alarm", "battle.overlay", "battle.catch_exp", "battle.low_health_alarm", "battle.overlay",
"battle.bottom_ui_visible", "battle.status_hud_visible", "battle.bottom_ui_visible", "battle.status_hud_visible",
"battle.move_grid_navigation",
-- One pic path resolver for both games: the Gen 1 site is the SHARED -- One pic path resolver for both games: the Gen 1 site is the SHARED
-- src/pokemon/Sprites.lua and Gold's own battle screen calls the same hook -- src/pokemon/Sprites.lua and Gold's own battle screen calls the same hook
-- with the Gen 1 ctx keys plus `letter` and `shiny`, which Red has no -- with the Gen 1 ctx keys plus `letter` and `shiny`, which Red has no
+23 -19
View File
@@ -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
@@ -0,0 +1,68 @@
-- The museum 1F ticket clerk's take-your-time/not-enough-money/come-again
-- lines used to be bare English literals, invisible to game.data.text no
-- matter what a translation mod put there. tests/engine/museum_money_box_
-- bug1335.lua only ever runs with an empty game.data.text, so it can't
-- tell a properly-wired t._Key or "..." fallback apart from a literal that
-- never looked at t at all -- every assertion there passes either way.
-- This test populates game.data.text with translated values and checks
-- they actually reach the pushed TextBox, for all three lines.
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
package.loaded["src.render.TextBox"] = {
new = function(_, text, onDone, opts)
return { text = text, onDone = onDone, opts = opts }
end,
}
local M = assert(loadfile("data/scripts/story2.lua"))()
local clerk = M.MUSEUM_1F.talk.TEXT_MUSEUM1F_SCIENTIST1
local TRANSLATED = {
_Museum1FScientist1TakePlentyOfTimeText = "Prends ton temps\net profite bien !",
_Museum1FScientist1DontHaveEnoughMoneyText = "Tu n'as pas assez\nd'argent.",
_Museum1FScientist1ComeAgainText = "Reviens vite !",
}
local pushed
local function mkGame(cash)
pushed = {}
return {
data = { text = TRANSLATED },
save = { money = cash, flags = {} },
stack = { push = function(_, box) pushed[#pushed + 1] = box end },
}
end
-- already ticketed: take-your-time line
local g = mkGame(3000)
g.save.flags.EVENT_BOUGHT_MUSEUM_TICKET = true
clerk(g, nil, nil, function() end)
T.eq(pushed[1].text, TRANSLATED._Museum1FScientist1TakePlentyOfTimeText,
"an existing ticket holder gets the translated take-your-time line")
-- YES but short on cash: not-enough-money line
g = mkGame(20)
clerk(g, nil, nil, function() end)
pushed[1].opts.choice(true)
T.eq(pushed[2].text, TRANSLATED._Museum1FScientist1DontHaveEnoughMoneyText,
"short on cash opens the translated not-enough-money box")
-- NO: come-again line
g = mkGame(3000)
clerk(g, nil, nil, function() end)
pushed[1].opts.choice(false)
T.eq(pushed[2].text, TRANSLATED._Museum1FScientist1ComeAgainText,
"declining opens the translated come-again box")
-- unpopulated game.data.text still falls back to the English literal
g = { data = { text = {} }, save = { money = 3000, flags = {} },
stack = { push = function(_, box) pushed[#pushed + 1] = box end } }
pushed = {}
g.save.flags.EVENT_BOUGHT_MUSEUM_TICKET = true
clerk(g, nil, nil, function() end)
T.check(tostring(pushed[1].text):find("Take your time", 1, true) ~= nil,
"an empty catalog still falls back to the English take-your-time line")
T.finish("museum_1f_clerk_translation_test")
+17
View File
@@ -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()
+118
View File
@@ -0,0 +1,118 @@
-- Skins tab Import button: picker plumbing + path/drop install (SKINIMP-01..05).
package.path = "./?.lua;./?/init.lua;" .. package.path
if not _G.love then _G.love = require("tests.love_stub") end
local S = require("tests.harness").suite("skin import picker")
local eq = S.eq
local check = S.check
local RomImporter = require("src.import.RomImporter")
local TouchSkin = require("src.core.TouchSkin")
love.system = love.system or {}
local saved = {
pickFile = love.system.pickFile,
getPickedFile = love.system.getPickedFile,
getPickError = love.system.getPickError,
installArchive = TouchSkin.installArchive,
}
local installed
TouchSkin.installArchive = function(name, data)
installed = { name = name, data = data }
if not data or data == "" then return nil, "empty archive" end
return (name:match("([^/\\]+)$") or name):gsub("%.[Zz][Ii][Pp]$", "")
end
local function freshImporter(fields)
local imp = RomImporter.new(function() end, { launcher = true })
imp.tab = "skins"
imp._skins = {}
imp._ensureSkins = function(self) return self._skins end
for k, v in pairs(fields or {}) do imp[k] = v end
return imp
end
-- SKINIMP-01: a picker path installs as a skin, not a mod
installed = nil
local imp = freshImporter()
local tmp = os.tmpname() .. ".zip"
local f = assert(io.open(tmp, "wb"))
f:write("PK\3\4skin-archive")
f:close()
imp:_installSkinZip(tmp)
os.remove(tmp)
check(installed ~= nil, "an absolute picker path reaches TouchSkin.installArchive")
eq(installed.data, "PK\3\4skin-archive", "the picked file's bytes are installed")
check(imp._skinNotice and imp._skinNotice.ok, "a good pick reports success")
-- SKINIMP-02: a dropped file still installs, and both paths land on the tab
installed = nil
imp = freshImporter({ tab = "red" })
imp:filedropped({
getFilename = function() return "/tmp/dropped_skin.zip" end,
getSize = function() return 4 end,
open = function() return true end,
read = function() return "PKZP" end,
close = function() return true end,
})
check(installed == nil, "a zip dropped off the skins tab is still a mod")
installed = nil
imp = freshImporter()
imp:filedropped({
getFilename = function() return "/tmp/dropped_skin.zip" end,
getSize = function() return 4 end,
open = function() return true end,
read = function() return "PKZP" end,
close = function() return true end,
})
eq(installed and installed.name, "/tmp/dropped_skin.zip",
"a zip dropped on the skins tab installs as a skin")
eq(imp.tab, "skins", "the skin install stays on the skins tab")
-- SKINIMP-03: the mobile bridge borrows the mod picker kind
local requestedKind
love.system.pickFile = function(kind)
requestedKind = kind
return true
end
love.system.getPickedFile = function() return nil end
love.system.getPickError = function() return nil end
imp = freshImporter({ nativePicker = true })
imp:chooseSkin()
eq(requestedKind, "mod", "chooseSkin opens the .zip picker")
eq(imp.pickerPendingKind, "skin", "the pending pick is routed to the skins tab")
-- SKINIMP-04: the picked file comes back through update() as a skin
installed = nil
love.system.getPickedFile = function()
love.system.getPickedFile = function() return nil end
return "/picked/from_bridge.zip"
end
imp._installSkinZip = function(self, source)
self.installedSource = source
self._skinNotice = { ok = true, text = "Imported from_bridge" }
end
imp:update(0)
eq(imp.installedSource, "/picked/from_bridge.zip",
"update() hands the picked path to the skin installer")
eq(imp.pickerPendingKind, nil, "the pending kind clears once consumed")
-- SKINIMP-05: a picker error lands on the skins notice, not the mods one
love.system.getPickedFile = function() return nil end
love.system.getPickError = function()
love.system.getPickError = function() return nil end
return "picker refused"
end
imp = freshImporter({ nativePicker = true, pickerPendingKind = "skin" })
imp:update(0)
check(imp._skinNotice and not imp._skinNotice.ok,
"a failed skin pick reports on the skins tab")
eq(imp.modNotice, nil, "a failed skin pick leaves the mods notice alone")
love.system.pickFile = saved.pickFile
love.system.getPickedFile = saved.getPickedFile
love.system.getPickError = saved.getPickError
TouchSkin.installArchive = saved.installArchive
S.finish()
+137
View File
@@ -0,0 +1,137 @@
-- Skin Studio art import: the native image picker behind the "Import" buttons
-- (bezel / idle / pressed), and the alt-tab crash that took the studio down --
-- love.focus fell through to Game:focus with no game booted.
-- luajit tests/engine/skin_studio_image_import.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, eq = T.check, T.eq
local FilePicker = require("src.core.FilePicker")
local TouchSkin = require("src.core.TouchSkin")
local Studio = require("src.ui.SkinStudio")
local function session()
Studio.skin = TouchSkin.newSkin("t")
Studio.skinIdField = "t"
Studio.pageIndex = 1
Studio.selected = nil
Studio.canvasIndex = 1
Studio.drag = nil
Studio.dirty = false
Studio.status = nil
Studio.testing = false
Studio.images = {}
Studio.imageTarget = "idle"
return Studio.skin
end
-- ------------------------------------------------------------ file picker
check(FilePicker.matches("bezel.png", FilePicker.IMAGE), "png is art")
check(FilePicker.matches("BEZEL.PNG", FilePicker.IMAGE), "case does not matter")
check(FilePicker.matches("shot.jpg", FilePicker.IMAGE), "jpg is art")
check(FilePicker.matches("shot.jpeg", FilePicker.IMAGE), "jpeg is art")
check(not FilePicker.matches("skin.zip", FilePicker.IMAGE), "a zip is not art")
eq(FilePicker.basename("/home/me/art/bezel.png"), "bezel.png", "posix basename")
eq(FilePicker.basename("C:\\Users\\me\\bezel.png"), "bezel.png", "windows basename")
eq(FilePicker.basename("bezel.png"), "bezel.png", "a bare name is its own basename")
-- ----------------------------------------------------------- import target
session()
Studio.addControl()
local ctl = Studio.selectedControl()
check(Studio.adoptImage("bezel.png", "PNGDATA", "bezel"), "bezel import succeeds")
eq(Studio.page().imagePath, "img/bezel.png", "bezel art lands on the page")
eq(ctl.imagePath, nil, "and not on the selected control")
check(Studio.dirty, "importing marks the skin dirty")
check(Studio.status:find("bezel", 1, true) ~= nil, "the status names the target")
check(Studio.adoptImage("a_button.png", "PNGDATA", "idle"), "idle import succeeds")
eq(ctl.imagePath, "img/a_button.png", "idle art lands on the control")
eq(Studio.page().imagePath, "img/bezel.png", "and leaves the bezel alone")
check(Studio.adoptImage("a_down.png", "PNGDATA", "pressed"), "pressed import succeeds")
eq(ctl.pressedImagePath, "img/a_down.png", "pressed art lands on the control")
eq(ctl.imagePath, "img/a_button.png", "idle art survives")
check(not Studio.adoptImage("notes.txt", "junk", "bezel"), "non-art is refused")
eq(Studio.page().imagePath, "img/bezel.png", "and changes nothing")
-- the imported files are listed for the cycle buttons
local listed = {}
for _, rel in ipairs(Studio.images or {}) do listed[rel] = true end
check(listed["img/bezel.png"], "the imported bezel joins the image list")
-- -------------------------------------------------------- picker plumbing
local realOpen, realRead = FilePicker.open, FilePicker.read
local asked
FilePicker.open = function(prompt, kind)
asked = { prompt = prompt, kind = kind }
return "/tmp/some folder/frame.png"
end
FilePicker.read = function() return "PNGDATA" end
session()
Studio.importImageFile("bezel")
check(asked ~= nil, "the bezel button opens a picker")
eq(asked.kind, FilePicker.IMAGE, "and asks for images, not archives")
check(asked.prompt:lower():find("bezel", 1, true) ~= nil, "with a bezel prompt")
eq(Studio.page().imagePath, "img/frame.png", "the pick becomes the bezel")
eq(Studio.imageTarget, "bezel", "and the target sticks for the cycle button")
-- no control selected: an art import says so instead of writing to the page
session()
asked = nil
Studio.importImageFile("idle")
check(asked == nil, "no picker opens without a control to paint")
eq(Studio.page().imagePath, nil, "and the bezel is not overwritten")
check(Studio.status ~= nil, "the studio explains why")
-- a cancelled dialog leaves the skin exactly as it was
session()
FilePicker.open = function() return nil end
Studio.importImageFile("bezel")
eq(Studio.page().imagePath, nil, "cancelling imports nothing")
check(not Studio.dirty, "and does not dirty the skin")
FilePicker.open, FilePicker.read = realOpen, realRead
-- --------------------------------------------------------- focus / alt-tab
session()
Studio.drag = { kind = "control-move", mx = 1, my = 1, bx = 0, by = 0, bw = 1, bh = 1 }
Studio.clicked = true
Studio.focus(false)
eq(Studio.drag, nil, "losing focus drops the in-flight drag")
eq(Studio.clicked, false, "and the queued click")
check(pcall(Studio.visible, false), "minimizing is survivable too")
-- main.lua used to hand focus / visibility / pad events straight to Game while
-- the studio owned the window, and the launcher has no Game -- alt-tab took the
-- app down with "attempt to index a nil value (global 'Game')".
local f = assert(io.open("main.lua", "r"))
local src = f:read("*a")
f:close()
local checked = 0
for name, body in ("\n" .. src):gmatch("\nfunction love%.([%w_]+)%b()\n(.-)\nend\n") do
if body:find("Game:") then
checked = checked + 1
check(body:find("Studio") ~= nil,
"love." .. name .. " checks Studio before dispatching to Game")
check(body:find("not Game then") ~= nil
or body:find("if Game then") ~= nil
or body:find("Game and ") ~= nil,
"love." .. name .. " tolerates a nil Game (launcher / studio session)")
end
end
check(checked >= 10, "the handler scan actually found handlers (" .. checked .. ")")
check(src:find("Studio.focus", 1, true) ~= nil, "love.focus routes to the studio")
check(src:find("Studio.visible", 1, true) ~= nil, "so does love.visible")
T.finish("skin_studio_image_import")
+49
View File
@@ -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")
+19
View File
@@ -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()
+104 -1
View File
@@ -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")
+5
View File
@@ -490,6 +490,11 @@ check("hurt is easier to catch", hurtRate > fullRate, true)
local asleepRate = Catching.rate({ local asleepRate = Catching.rate({
maxHp = 60, hp = 1, catchRate = 45, ball = "POKE_BALL", status = "sleep" }) maxHp = 60, hp = 1, catchRate = 45, ball = "POKE_BALL", status = "sleep" })
check("sleep adds 10", asleepRate, math.min(255, hurtRate + 10)) check("sleep adds 10", asleepRate, math.min(255, hurtRate + 10))
checkNear("preview converts the exact byte roll", Catching.chance({
maxHp = 60, hp = 60, catchRate = 45, ball = "POKE_BALL" }),
fullRate * 100 / 256, 0.000001)
check("master ball preview is certain", Catching.chance({
maxHp = 60, hp = 60, catchRate = 1, ball = "MASTER_BALL" }), 100)
-- The cart's bug: burn/poison/paralysis add nothing. -- The cart's bug: burn/poison/paralysis add nothing.
check("poison adds nothing (cart bug)", Catching.rate({ check("poison adds nothing (cart bug)", Catching.rate({
maxHp = 60, hp = 1, catchRate = 45, ball = "POKE_BALL", maxHp = 60, hp = 1, catchRate = 45, ball = "POKE_BALL",
+10
View File
@@ -282,6 +282,16 @@ local function runToMenu(screen, cap)
return false return false
end end
do
local screen = newScreen({ inventory = {
MASTER_BALL = 1, POKE_BALL = 1,
} })
eq(screen:catchChance("MASTER_BALL"), 100,
"the battle screen exposes a certain Master Ball preview")
check(type(screen:catchChance("POKE_BALL")) == "number",
"the battle screen exposes the live wild catch preview")
end
-- ---- BattleMenu empties the textbox --------------------------------------- -- ---- BattleMenu empties the textbox ---------------------------------------
do do
local screen = newScreen() local screen = newScreen()
+54 -4
View File
@@ -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
+396
View File
@@ -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
+13 -1
View File
@@ -163,14 +163,23 @@ function screen2:chooseMove(slot)
return true return true
end end
function screen2:cancelMove() self.phase = "menu" return true end function screen2:cancelMove() self.phase = "menu" return true end
function screen2:catchChance(ball)
return ball == "MASTER_BALL" and 100 or 37.5
end
local game2 = { local game2 = {
data = { data = {
pokemon = { CHIKORITA = { name = "CHIKORITA" }, pokemon = { CHIKORITA = { name = "CHIKORITA" },
RATTATA = { name = "RATTATA" } }, RATTATA = { name = "RATTATA" } },
moves = { TACKLE = { name = "TACKLE", type = "NORMAL", moves = { TACKLE = { name = "TACKLE", type = "NORMAL",
power = 35, accuracy = 95, pp = 35 } }, power = 35, accuracy = 95, pp = 35 } },
items = {
MASTER_BALL = { name = "MASTER BALL", pocket = "BALL" },
POTION = { name = "POTION", pocket = "ITEM" },
}, },
save = { party = { player2 } }, stack = { states = { screen2 } }, },
save = { party = { player2 }, inventory = {
MASTER_BALL = 1, POTION = 2,
} }, stack = { states = { screen2 } },
} }
local api2 = require("src.battle.gen2.BattleAPI").new(game2) local api2 = require("src.battle.gen2.BattleAPI").new(game2)
@@ -179,6 +188,9 @@ check(snapshot2 and snapshot2.kind == "wild" and snapshot2.prompt == "menu",
"Gold battle is discovered through its screen id") "Gold battle is discovered through its screen id")
eq(snapshot2.player.maxHp, 21, "Gold max HP uses the mon field") eq(snapshot2.player.maxHp, 21, "Gold max HP uses the mon field")
eq(snapshot2.moves[1].name, "TACKLE", "Gold moves are copied") eq(snapshot2.moves[1].name, "TACKLE", "Gold moves are copied")
eq(#snapshot2.items, 1, "Gold exposes balls without guessing targeted items")
eq(snapshot2.items[1].catchChance, 100,
"Gold ball records expose the exact catch preview")
snapshot2.player.hp = 0 snapshot2.player.hp = 0
snapshot2.moves[1].pp = 0 snapshot2.moves[1].pp = 0
eq(player2.hp, 20, "changing a snapshot cannot change a Gold Pokemon") eq(player2.hp, 20, "changing a snapshot cannot change a Gold Pokemon")
+36
View File
@@ -266,13 +266,49 @@ end
do do
local BattleState = require("src.battle.BattleState") local BattleState = require("src.battle.BattleState")
local Gen2BattleState = require("src.ui.gen2.BattleState")
local battle = { wideLayout = function() return false end } local battle = { wideLayout = function() return false end }
check(not BattleState.moveGridNavigation(battle), check(not BattleState.moveGridNavigation(battle),
"classic move navigation stays a list without a mod") "classic move navigation stays a list without a mod")
check(not Gen2BattleState.moveGridNavigation({}),
"Gold move navigation stays a list without a mod")
local unsub = wrap("battle.move_grid_navigation", function() return true end) local unsub = wrap("battle.move_grid_navigation", function() return true end)
check(BattleState.moveGridNavigation(battle), check(BattleState.moveGridNavigation(battle),
"a mod can opt the classic move menu into grid navigation") "a mod can opt the classic move menu into grid navigation")
check(Gen2BattleState.moveGridNavigation({}),
"the same hook opts Gold's move menu into grid navigation")
local pressed, moveCount = "right", 4
local gold = setmetatable({
phase = "moves", moveIndex = 1,
slideFrame = math.huge,
game = { input = {
wasPressed = function(_, key) return key == pressed end,
} },
updateAlarm = function() end,
stepHpAnim = function() return false end,
playerMoves = function()
local moves = {}
for i = 1, moveCount do moves[i] = {} end
return moves
end,
}, { __index = Gen2BattleState })
gold:update(0)
check(gold.moveIndex == 2,
"Gold grid navigation moves right across a companion move row")
pressed, gold.moveIndex = "down", 1
gold:update(0)
check(gold.moveIndex == 3,
"Gold grid navigation moves down the companion move column")
pressed, moveCount, gold.moveIndex = "down", 3, 2
gold:update(0)
check(gold.moveIndex == 2,
"Gold grid navigation does not select an empty fourth move slot")
unsub() unsub()
pressed, gold.moveIndex = "right", 1
gold:update(0)
check(gold.moveIndex == 1,
"removing the hook restores Gold's native vertical move list")
battle.wideLayout = function() return true end battle.wideLayout = function() return true end
check(BattleState.moveGridNavigation(battle), check(BattleState.moveGridNavigation(battle),
"the native wide move grid remains enabled without a mod") "the native wide move grid remains enabled without a mod")
@@ -166,6 +166,47 @@ eq(ri._requiredImported.importId, "source", "focus routes to the pending declara
check(love.filesystem.getInfo("picked_required_import.bin") == nil, check(love.filesystem.getInfo("picked_required_import.bin") == nil,
"focus removes the staged required-file pick") "focus removes the staged required-file pick")
-- Android releases with the updated launcher but the older native bridge do
-- not advertise required_import. They still support the established ROM SAF
-- picker, whose result must be quarantined to the pending dependency request.
love.system.pickFileKinds = function() return "rom,mod,sav" end
pickCalls = {}
ri = freshImporter({ red = true, blue = true })
ri.nativePicker = true
ri.mobileFileBridge = true
ri.mods = { {
id = "needs_source",
manifest = { id = "needs_source", name = "Needs Source",
required_imports = { { id = "source", name = "Source", file = "source.bin",
format = "raw", md5 = { "00000000000000000000000000000000" } } } },
} }
ri:chooseRequiredImport("needs_source", "source")
eq(pickCalls[1], "rom", "legacy Android bridge falls back to its ROM SAF picker")
check(ri.requiredImportLegacyRomPick,
"legacy Android ROM picker result is marked as a required import")
ri._importRequiredSource = function(self, modId, importId, source)
self._requiredImported = { modId = modId, importId = importId, source = source }
return true
end
love.filesystem.write("picked_rom.gb", "source bytes")
ri:focus(true)
eq(ri._requiredImported.source, "picked_rom.gb",
"legacy Android ROM staging name is routed to the required import")
eq(ri._requiredImported.modId, "needs_source",
"legacy Android picker preserves the requested mod")
check(love.filesystem.getInfo("picked_rom.gb") == nil,
"legacy Android dependency pick is removed after import")
-- A legacy bridge reports a failed copy using that same staging basename; it
-- must stay on the dependency page rather than becoming a game-ROM error.
ri:chooseRequiredImport("needs_source", "source")
love.filesystem.write("pick_error.flag", "picked_rom.gb")
ri:focus(true)
check(ri.modNotice ~= nil and ri.modNotice.ok == false,
"legacy Android picker errors are shown as dependency import errors")
check(ri.pickerPendingKind == nil,
"legacy Android picker error clears the pending dependency request")
love.system.getOS = saved.getOS love.system.getOS = saved.getOS
love.system.pickFile = saved.pickFile love.system.pickFile = saved.pickFile
love.system.pickFileKinds = saved.pickFileKinds love.system.pickFileKinds = saved.pickFileKinds
@@ -174,5 +215,6 @@ love.filesystem.remove("usb_mod.zip")
love.filesystem.remove("picked_mod.zip") love.filesystem.remove("picked_mod.zip")
love.filesystem.remove("picked_save.sav") love.filesystem.remove("picked_save.sav")
love.filesystem.remove("picked_required_import.bin") love.filesystem.remove("picked_required_import.bin")
love.filesystem.remove("picked_rom.gb")
S.finish() S.finish()
+8
View File
@@ -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.
+5 -1
View File
@@ -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
+4
View File
@@ -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")