mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-18 19:54:21 +02:00
Compare commits
31 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5ba49bdf3f | |||
| abf95ce1c4 | |||
| bd1046f398 | |||
| d5ad830fb8 | |||
| c2c7fdafcf | |||
| c01bda3570 | |||
| 74e04cb086 | |||
| 25ec896545 | |||
| 8240205254 | |||
| 55616fc03d | |||
| 675971068e | |||
| cfa8406306 | |||
| 0ea224d5db | |||
| c2e1db0f89 | |||
| 7b1e796c48 | |||
| 4c13770e70 | |||
| cf45cbbf92 | |||
| 4e1ab1879b | |||
| 917735a41c | |||
| c877ea80a7 | |||
| 7d1ddf9b7c | |||
| faf82c2cec | |||
| 5bc6036735 | |||
| 809628f0fa | |||
| 4817ff8bf9 | |||
| 4f8739c029 | |||
| c655217120 | |||
| fbdfc1c053 | |||
| 72c244b433 | |||
| e0e030003b | |||
| ce2afb83f1 |
@@ -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
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ And before you say, "that's not a recomp", you're wrong. Recomp is an acronym. *
|
|||||||
[Click Here for the AI Use Disclosure!](AIDisclosure.md)
|
[Click Here for the AI Use Disclosure!](AIDisclosure.md)
|
||||||
|
|
||||||
> [!CAUTION]
|
> [!CAUTION]
|
||||||
> **We are NOT affiliated with the website `gen1recomp[.]com`** That website is not run by this project, was not authorized by us, and we have no idea who operates it. It is impersonating this project; do not download anything from it, and treat anything it hosts or claims as untrustworthy. Even if the site currently links back to this repository, the people behind it can change its content at any time, so nothing on it should ever be trusted. This GitHub repository and the Discord linked below are the only official sources for this project.
|
> **We are NOT affiliated with the website `gen1recomp[.]com`** That website is not run by this project, was not authorized by us, and we have no idea who operates it. It is impersonating this project; do not download anything from it, and treat anything it hosts or claims as untrustworthy. Even if the site currently links back to this repository, the people behind it can change its content at any time, so nothing on it should ever be trusted. This GitHub repository and the Discord linked below are the only official sources for this project. Also, as I assumed would eventually happen, the idiot that made that website now pumped it full of adware. Please stay away from that website.
|
||||||
|
|
||||||
<p align="center"><img src="https://raw.githubusercontent.com/bryanthaboi/gen1recomp/refs/heads/dev/assets/logo/logo.png"></p>
|
<p align="center"><img src="https://raw.githubusercontent.com/bryanthaboi/gen1recomp/refs/heads/dev/assets/logo/logo.png"></p>
|
||||||
|
|
||||||
@@ -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.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ Features intentionally added beyond the original Pokémon Red, Blue, and Yellow
|
|||||||
* **Mobile touch controls** with editable layouts, vibration, and orientation settings
|
* **Mobile touch controls** with editable layouts, vibration, and orientation settings
|
||||||
* **Touch skins** in RetroArch overlay format, with bezel art, per-button press states, and Super Game Boy borders
|
* **Touch skins** in RetroArch overlay format, with bezel art, per-button press states, and Super Game Boy borders
|
||||||
* **Pokédex diploma and printer image exports**
|
* **Pokédex diploma and printer image exports**
|
||||||
|
* **Mod download counts** from the index feed, with Most-downloaded and Trending sorts
|
||||||
|
|
||||||
## Gen 2 Specifics
|
## Gen 2 Specifics
|
||||||
|
|
||||||
|
|||||||
+21
-5
@@ -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.
|
|
||||||
|
|||||||
@@ -169,6 +169,7 @@ the NX runtime modules `src/core/NxAssetOverlay.lua`, `src/core/Platform.lua`,
|
|||||||
`tests/engine/assets_version_fallback_test.lua`,
|
`tests/engine/assets_version_fallback_test.lua`,
|
||||||
`tests/engine/nx_generated_guard_test.lua`,
|
`tests/engine/nx_generated_guard_test.lua`,
|
||||||
`tests/engine/nx_yellow_boot_test.lua`,
|
`tests/engine/nx_yellow_boot_test.lua`,
|
||||||
|
`tests/engine/cache_fs_gold_nx_load_test.lua`,
|
||||||
`tests/engine/switch_diagnostics_test.lua`, `tests/engine/platform_nx_*`,
|
`tests/engine/switch_diagnostics_test.lua`, `tests/engine/platform_nx_*`,
|
||||||
or the Switch-related workflow YAML), CI runs:
|
or the Switch-related workflow YAML), CI runs:
|
||||||
|
|
||||||
@@ -179,7 +180,8 @@ or the Switch-related workflow YAML), CI runs:
|
|||||||
`luajit tests/switch_transfer_docs_test.lua`, and the NX engine suites
|
`luajit tests/switch_transfer_docs_test.lua`, and the NX engine suites
|
||||||
headlessly (`luajit tests/engine/assets_version_fallback_test.lua`,
|
headlessly (`luajit tests/engine/assets_version_fallback_test.lua`,
|
||||||
`luajit tests/engine/nx_generated_guard_test.lua`,
|
`luajit tests/engine/nx_generated_guard_test.lua`,
|
||||||
`luajit tests/engine/nx_yellow_boot_test.lua`).
|
`luajit tests/engine/nx_yellow_boot_test.lua`,
|
||||||
|
`luajit tests/engine/cache_fs_gold_nx_load_test.lua`).
|
||||||
2. **Fused NRO build** only on the **main** repository
|
2. **Fused NRO build** only on the **main** repository
|
||||||
(`bryanthaboi/gen1recomp`), on the self-hosted Mac runner
|
(`bryanthaboi/gen1recomp`), on the self-hosted Mac runner
|
||||||
(`scripts/build_switch.sh --fetch --fused`), and only when the workflow
|
(`scripts/build_switch.sh --fetch --fused`), and only when the workflow
|
||||||
|
|||||||
+13
-9
@@ -3,7 +3,7 @@
|
|||||||
Every GitHub Release that includes Switch support ships an SD-ready zip:
|
Every GitHub Release that includes Switch support ships an SD-ready zip:
|
||||||
`gen1recomp-*-switch.zip`. Extract it at the root of your microSD (install
|
`gen1recomp-*-switch.zip`. Extract it at the root of your microSD (install
|
||||||
or update, same steps), launch with **title override**, then import your
|
or update, same steps), launch with **title override**, then import your
|
||||||
own legal `.gb` ROM.
|
own legal `.gb` / `.gbc` ROM.
|
||||||
|
|
||||||
> You need a console that can run Switch homebrew (custom firmware / hbmenu).
|
> You need a console that can run Switch homebrew (custom firmware / hbmenu).
|
||||||
> This project does not help you set that up.
|
> This project does not help you set that up.
|
||||||
@@ -91,13 +91,14 @@ Do **not** launch from the Album applet path for normal play.
|
|||||||
|
|
||||||
This project ships **no** game data. On first launch:
|
This project ships **no** game data. On first launch:
|
||||||
|
|
||||||
1. Put your own legally obtained Pokémon Red, Blue (`.gb`), or Yellow
|
1. Put your own legally obtained Pokémon Red, Blue (`.gb`), Yellow, or
|
||||||
(`.gbc`) dump into `switch/gen1recomp/pokemon-love2d/imports/` (the
|
Gold (`.gbc`) dump into `switch/gen1recomp/pokemon-love2d/imports/` (the
|
||||||
launcher also shows the live save-dir path). All three can sit in the
|
launcher also shows the live save-dir path). All four can sit in the
|
||||||
same folder.
|
same folder.
|
||||||
2. Use **Scan again** on that game's tab (Red / Blue / Yellow). Rescan
|
2. Use **Scan again** on that game's tab (Red / Blue / Yellow / Gold).
|
||||||
matches by ROM SHA-1 for the open tab only. A Red dump never imports
|
Rescan matches by ROM SHA-1 for the open tab only. A Red dump never
|
||||||
from the Yellow tab (and vice versa).
|
imports from the Yellow tab (and vice versa). Gold is Beta in the
|
||||||
|
launcher; a clean US Gold dump is enough to Play.
|
||||||
|
|
||||||
## 5. Import / Export a raw `.sav`
|
## 5. Import / Export a raw `.sav`
|
||||||
|
|
||||||
@@ -109,10 +110,13 @@ SD / FTP, same transfer methods as ROMs. Paths are **per game**:
|
|||||||
| Red | `imports/saves/red/` | `exports/red/` |
|
| Red | `imports/saves/red/` | `exports/red/` |
|
||||||
| Blue | `imports/saves/blue/` | `exports/blue/` |
|
| Blue | `imports/saves/blue/` | `exports/blue/` |
|
||||||
| Yellow | `imports/saves/yellow/` | `exports/yellow/` |
|
| Yellow | `imports/saves/yellow/` | `exports/yellow/` |
|
||||||
|
| Gold | `imports/saves/gold/` | `exports/gold/` |
|
||||||
|
|
||||||
(Under the save dir `pokemon-love2d/`. The zip already creates these folders.)
|
(Under the save dir `pokemon-love2d/`. The zip already creates these folders.
|
||||||
|
Gold cart `.sav` import/export is not supported yet -- the folders exist so
|
||||||
|
MTP browsing matches the other games. Gold progress still saves in-engine.)
|
||||||
|
|
||||||
1. Copy a Gen1 `.sav` (32 KB) into that game's inbox under the save dir
|
1. Copy a Gen 1 `.sav` (32 KB) into that game's inbox under the save dir
|
||||||
([switch-transfer.md](switch-transfer.md)).
|
([switch-transfer.md](switch-transfer.md)).
|
||||||
2. With the game's ROM already imported, open **that game's tab** →
|
2. With the game's ROM already imported, open **that game's tab** →
|
||||||
**SAVE FILES** → **Import save**. Only that folder is scanned.
|
**SAVE FILES** → **Import save**. Only that folder is scanned.
|
||||||
|
|||||||
@@ -22,8 +22,8 @@ Player install (what to download, title override) stays in
|
|||||||
| Loose iteration pair | `sdmc:/switch/gen1recomp/gen1recomp.nro` **and** `game.love` beside it |
|
| Loose iteration pair | `sdmc:/switch/gen1recomp/gen1recomp.nro` **and** `game.love` beside it |
|
||||||
| ROM inbox | LÖVE save dir → `imports/` (launcher shows the live `getSaveDirectory()` path; under MTP often `1: SD Card/<save identity>/imports/`) |
|
| ROM inbox | LÖVE save dir → `imports/` (launcher shows the live `getSaveDirectory()` path; under MTP often `1: SD Card/<save identity>/imports/`) |
|
||||||
| Mod zip inbox | Same save dir → `imports/mods/` then MODS → **Scan again** |
|
| Mod zip inbox | Same save dir → `imports/mods/` then MODS → **Scan again** |
|
||||||
| Save `.sav` inbox | Same save dir → `imports/saves/red\|blue\|yellow/` then that game's SAVE FILES → **Import save** |
|
| Save `.sav` inbox | Same save dir → `imports/saves/red\|blue\|yellow\|gold/` then that game's SAVE FILES → **Import save** (Gold cart `.sav` not supported yet) |
|
||||||
| Save exports | Same save dir → `exports/red\|blue\|yellow/` (pull after **Export save**; MTP / SD / FTP) |
|
| Save exports | Same save dir → `exports/red\|blue\|yellow\|gold/` (pull after **Export save**; Gold cart `.sav` not supported yet) |
|
||||||
| Opt-in diagnostics | Empty `switch-debug.txt` in the save dir → `switch.log` |
|
| Opt-in diagnostics | Empty `switch-debug.txt` in the save dir → `switch.log` |
|
||||||
| Lua error log | `lua-error.log` in the save dir |
|
| Lua error log | `lua-error.log` in the save dir |
|
||||||
|
|
||||||
@@ -54,8 +54,8 @@ macOS, not a Mac-only requirement.
|
|||||||
3. Create `switch/gen1recomp/` if needed; extract the release zip at SD root
|
3. Create `switch/gen1recomp/` if needed; extract the release zip at SD root
|
||||||
(or copy NRO / `game.love` for loose).
|
(or copy NRO / `game.love` for loose).
|
||||||
4. For ROMs/mods/saves, open the save-dir `imports/`, `imports/mods/`,
|
4. For ROMs/mods/saves, open the save-dir `imports/`, `imports/mods/`,
|
||||||
`imports/saves/<red|blue|yellow>/`, or `exports/<red|blue|yellow>/` path the
|
`imports/saves/<red|blue|yellow|gold>/`, or `exports/<red|blue|yellow|gold>/`
|
||||||
launcher prints.
|
path the launcher prints.
|
||||||
5. Wait for the queue; refresh; exit MTP responder; title-override launch.
|
5. Wait for the queue; refresh; exit MTP responder; title-override launch.
|
||||||
|
|
||||||
macOS clients often create AppleDouble sidecars (`._Something.zip`,
|
macOS clients often create AppleDouble sidecars (`._Something.zip`,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,41 @@
|
|||||||
"tintColor": "3b5ca8",
|
"tintColor": "3b5ca8",
|
||||||
"category": "games",
|
"category": "games",
|
||||||
"versions": [
|
"versions": [
|
||||||
|
{
|
||||||
|
"version": "0.2.5",
|
||||||
|
"date": "2026-08-18",
|
||||||
|
"size": 13586158,
|
||||||
|
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.5/gen1recomp++-0.2.5-ios.ipa",
|
||||||
|
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #1390 switch and gold\n- #1503 Retroarch Skin Problem\n- #1508 Please check #1412 & #1414 again, we had a misunderstanding\n\n## Contributors\n\n- @bryanthaboi"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"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",
|
"version": "0.2.0",
|
||||||
"date": "2026-08-17",
|
"date": "2026-08-17",
|
||||||
|
|||||||
@@ -71,15 +71,15 @@ First install or update (same steps):
|
|||||||
your saves, imported ROMs, mods, and options. Re-extracting only
|
your saves, imported ROMs, mods, and options. Re-extracting only
|
||||||
replaces the NRO(s) and these help files.
|
replaces the NRO(s) and these help files.
|
||||||
3. Launch with title override (hold R on HOME, open any title → hbmenu).
|
3. Launch with title override (hold R on HOME, open any title → hbmenu).
|
||||||
4. Copy a legal Pokemon Red/Blue .gb into:
|
4. Copy a legal Pokemon Red/Blue .gb or Yellow/Gold .gbc into:
|
||||||
switch/gen1recomp/pokemon-love2d/imports/
|
switch/gen1recomp/pokemon-love2d/imports/
|
||||||
then use Scan again in the launcher if needed.
|
then use Scan again in the launcher if needed.
|
||||||
|
|
||||||
Inboxes (drop files here via MTP / SD / FTP):
|
Inboxes (drop files here via MTP / SD / FTP):
|
||||||
imports/ — ROM .gb / .gbc
|
imports/ — ROM .gb / .gbc
|
||||||
imports/mods/ — community mod .zip
|
imports/mods/ — community mod .zip
|
||||||
imports/saves/red|blue|yellow/ — raw .sav import
|
imports/saves/red|blue|yellow|gold/ — raw .sav import (Gold cart .sav not yet)
|
||||||
exports/red|blue|yellow/ — pull after Export save
|
exports/red|blue|yellow|gold/ — pull after Export save (Gold not yet)
|
||||||
|
|
||||||
Full guide: https://github.com/bryanthaboi/gen1recomp/blob/main/docs/switch-install.md
|
Full guide: https://github.com/bryanthaboi/gen1recomp/blob/main/docs/switch-install.md
|
||||||
EOF
|
EOF
|
||||||
@@ -92,7 +92,7 @@ write_readme() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
write_readme "$SAVE_ROOT/imports/README.txt" \
|
write_readme "$SAVE_ROOT/imports/README.txt" \
|
||||||
"Put a legal Pokemon Red or Blue .gb / .gbc here, then Scan again in the launcher."
|
"Put a legal Pokemon Red/Blue .gb or Yellow/Gold .gbc here, then Scan again in the launcher."
|
||||||
write_readme "$SAVE_ROOT/imports/mods/README.txt" \
|
write_readme "$SAVE_ROOT/imports/mods/README.txt" \
|
||||||
"Put community mod .zip files here, then MODS → Scan again."
|
"Put community mod .zip files here, then MODS → Scan again."
|
||||||
write_readme "$SAVE_ROOT/imports/saves/red/README.txt" \
|
write_readme "$SAVE_ROOT/imports/saves/red/README.txt" \
|
||||||
@@ -101,12 +101,16 @@ write_readme "$SAVE_ROOT/imports/saves/blue/README.txt" \
|
|||||||
"Put a Blue .sav (32 KB) here, then Blue tab → SAVE FILES → Import save."
|
"Put a Blue .sav (32 KB) here, then Blue tab → SAVE FILES → Import save."
|
||||||
write_readme "$SAVE_ROOT/imports/saves/yellow/README.txt" \
|
write_readme "$SAVE_ROOT/imports/saves/yellow/README.txt" \
|
||||||
"Put a Yellow .sav (32 KB) here, then Yellow tab → SAVE FILES → Import save."
|
"Put a Yellow .sav (32 KB) here, then Yellow tab → SAVE FILES → Import save."
|
||||||
|
write_readme "$SAVE_ROOT/imports/saves/gold/README.txt" \
|
||||||
|
"Gold cart .sav import is not supported yet. Folder reserved so MTP matches the other games."
|
||||||
write_readme "$SAVE_ROOT/exports/red/README.txt" \
|
write_readme "$SAVE_ROOT/exports/red/README.txt" \
|
||||||
"After Export save (Red), copy the .sav out of this folder via MTP / SD / FTP."
|
"After Export save (Red), copy the .sav out of this folder via MTP / SD / FTP."
|
||||||
write_readme "$SAVE_ROOT/exports/blue/README.txt" \
|
write_readme "$SAVE_ROOT/exports/blue/README.txt" \
|
||||||
"After Export save (Blue), copy the .sav out of this folder via MTP / SD / FTP."
|
"After Export save (Blue), copy the .sav out of this folder via MTP / SD / FTP."
|
||||||
write_readme "$SAVE_ROOT/exports/yellow/README.txt" \
|
write_readme "$SAVE_ROOT/exports/yellow/README.txt" \
|
||||||
"After Export save (Yellow), copy the .sav out of this folder via MTP / SD / FTP."
|
"After Export save (Yellow), copy the .sav out of this folder via MTP / SD / FTP."
|
||||||
|
write_readme "$SAVE_ROOT/exports/gold/README.txt" \
|
||||||
|
"Gold cart .sav export is not supported yet. Folder reserved so MTP matches the other games."
|
||||||
|
|
||||||
rm -f "$OUT_ZIP"
|
rm -f "$OUT_ZIP"
|
||||||
(
|
(
|
||||||
@@ -135,9 +139,11 @@ REQUIRED=(
|
|||||||
"switch/gen1recomp/pokemon-love2d/imports/saves/red/README.txt"
|
"switch/gen1recomp/pokemon-love2d/imports/saves/red/README.txt"
|
||||||
"switch/gen1recomp/pokemon-love2d/imports/saves/blue/README.txt"
|
"switch/gen1recomp/pokemon-love2d/imports/saves/blue/README.txt"
|
||||||
"switch/gen1recomp/pokemon-love2d/imports/saves/yellow/README.txt"
|
"switch/gen1recomp/pokemon-love2d/imports/saves/yellow/README.txt"
|
||||||
|
"switch/gen1recomp/pokemon-love2d/imports/saves/gold/README.txt"
|
||||||
"switch/gen1recomp/pokemon-love2d/exports/red/README.txt"
|
"switch/gen1recomp/pokemon-love2d/exports/red/README.txt"
|
||||||
"switch/gen1recomp/pokemon-love2d/exports/blue/README.txt"
|
"switch/gen1recomp/pokemon-love2d/exports/blue/README.txt"
|
||||||
"switch/gen1recomp/pokemon-love2d/exports/yellow/README.txt"
|
"switch/gen1recomp/pokemon-love2d/exports/yellow/README.txt"
|
||||||
|
"switch/gen1recomp/pokemon-love2d/exports/gold/README.txt"
|
||||||
)
|
)
|
||||||
for rel in "${REQUIRED[@]}"; do
|
for rel in "${REQUIRED[@]}"; do
|
||||||
printf '%s\n' "$LISTING" | grep -Fq "$rel" || fail "zip missing $rel"
|
printf '%s\n' "$LISTING" | grep -Fq "$rel" || fail "zip missing $rel"
|
||||||
|
|||||||
@@ -270,9 +270,11 @@ for rel in \
|
|||||||
"switch/gen1recomp/pokemon-love2d/imports/saves/red/README.txt" \
|
"switch/gen1recomp/pokemon-love2d/imports/saves/red/README.txt" \
|
||||||
"switch/gen1recomp/pokemon-love2d/imports/saves/blue/README.txt" \
|
"switch/gen1recomp/pokemon-love2d/imports/saves/blue/README.txt" \
|
||||||
"switch/gen1recomp/pokemon-love2d/imports/saves/yellow/README.txt" \
|
"switch/gen1recomp/pokemon-love2d/imports/saves/yellow/README.txt" \
|
||||||
|
"switch/gen1recomp/pokemon-love2d/imports/saves/gold/README.txt" \
|
||||||
"switch/gen1recomp/pokemon-love2d/exports/red/README.txt" \
|
"switch/gen1recomp/pokemon-love2d/exports/red/README.txt" \
|
||||||
"switch/gen1recomp/pokemon-love2d/exports/blue/README.txt" \
|
"switch/gen1recomp/pokemon-love2d/exports/blue/README.txt" \
|
||||||
"switch/gen1recomp/pokemon-love2d/exports/yellow/README.txt"
|
"switch/gen1recomp/pokemon-love2d/exports/yellow/README.txt" \
|
||||||
|
"switch/gen1recomp/pokemon-love2d/exports/gold/README.txt"
|
||||||
do
|
do
|
||||||
printf '%s\n' "$ZIP_LIST" | grep -Fq "$rel" || PACK_MISSING="${PACK_MISSING} ${rel}"
|
printf '%s\n' "$ZIP_LIST" | grep -Fq "$rel" || PACK_MISSING="${PACK_MISSING} ${rel}"
|
||||||
done
|
done
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ run_tier "T0 switch transfer docs gate" "$LUA" tests/switch_transfer_docs_test.l
|
|||||||
run_tier "T0 NX asset overlay fallback" "$LUA" tests/engine/assets_version_fallback_test.lua
|
run_tier "T0 NX asset overlay fallback" "$LUA" tests/engine/assets_version_fallback_test.lua
|
||||||
run_tier "T0 NX generated-path static guard" "$LUA" tests/engine/nx_generated_guard_test.lua
|
run_tier "T0 NX generated-path static guard" "$LUA" tests/engine/nx_generated_guard_test.lua
|
||||||
run_tier "T0 NX Yellow/Blue boot (dynamic paths)" "$LUA" tests/engine/nx_yellow_boot_test.lua
|
run_tier "T0 NX Yellow/Blue boot (dynamic paths)" "$LUA" tests/engine/nx_yellow_boot_test.lua
|
||||||
|
run_tier "T0 NX Gold cache load (maps.lua prefix)" "$LUA" tests/engine/cache_fs_gold_nx_load_test.lua
|
||||||
run_tier "T0 touch-controls pad cursor" "$LUA" tests/engine/touch_controls_pad_cursor_test.lua
|
run_tier "T0 touch-controls pad cursor" "$LUA" tests/engine/touch_controls_pad_cursor_test.lua
|
||||||
run_tier "T1/T2 engine invariants + parity gates" "$LUA" tests/run_engine.lua
|
run_tier "T1/T2 engine invariants + parity gates" "$LUA" tests/run_engine.lua
|
||||||
run_tier "T4 mod-SDK" "$LUA" tests/run_modkit.lua
|
run_tier "T4 mod-SDK" "$LUA" tests/run_modkit.lua
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -424,11 +424,12 @@ local function renderEffect(data, header, options)
|
|||||||
return love.audio.newSource(sd, "static")
|
return love.audio.newSource(sd, "static")
|
||||||
end
|
end
|
||||||
|
|
||||||
function ChipAudio.newSfx(data, name, pitch, tempo, header)
|
function ChipAudio.newSfx(data, name, pitch, tempo, header, plainFrames)
|
||||||
header = header or data.audio.sfx[name]
|
header = header or data.audio.sfx[name]
|
||||||
return renderEffect(data, header, {
|
return renderEffect(data, header, {
|
||||||
frequencyOffset = pitch or 0,
|
frequencyOffset = pitch or 0,
|
||||||
frameTicks = 0x80 + (tempo or 0x80),
|
frameTicks = 0x80 + (tempo or 0x80),
|
||||||
|
plainFrames = plainFrames,
|
||||||
})
|
})
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
+12
-6
@@ -280,6 +280,7 @@ function Channel.new(engine, spec, options)
|
|||||||
allowLoops = options.allowLoops ~= false,
|
allowLoops = options.allowLoops ~= false,
|
||||||
frequencyOffset = options.frequencyOffset or 0,
|
frequencyOffset = options.frequencyOffset or 0,
|
||||||
frameTicks = options.frameTicks or FRAME_TICKS,
|
frameTicks = options.frameTicks or FRAME_TICKS,
|
||||||
|
plainTicks = (options.plainFrames or 0) * FRAME_TICKS,
|
||||||
speed = 12,
|
speed = 12,
|
||||||
noteLength = 1, -- Gen 2 CHANNEL_NOTE_LENGTH (note_type)
|
noteLength = 1, -- Gen 2 CHANNEL_NOTE_LENGTH (note_type)
|
||||||
durationModifier = 0, -- Gen 2 fractional-frame carry
|
durationModifier = 0, -- Gen 2 fractional-frame carry
|
||||||
@@ -349,8 +350,9 @@ function Channel:frequencyGen2(note, octave)
|
|||||||
return bit.band(register + self.frequencyOffset, 0x7FF)
|
return bit.band(register + self.frequencyOffset, 0x7FF)
|
||||||
end
|
end
|
||||||
|
|
||||||
function Channel:durationTicks(length)
|
function Channel:durationTicks(length, plain)
|
||||||
local tempo = self.sfx and self.frameTicks or self.engine.tempo
|
local tempo = self.sfx and (plain and FRAME_TICKS or self.frameTicks)
|
||||||
|
or self.engine.tempo
|
||||||
local speed = self.sfx and (self.executeMusic and self.speed or 1)
|
local speed = self.sfx and (self.executeMusic and self.speed or 1)
|
||||||
or self.speed
|
or self.speed
|
||||||
return length * speed * tempo
|
return length * speed * tempo
|
||||||
@@ -586,6 +588,9 @@ function Channel:nextEvent()
|
|||||||
local packed = self:byte()
|
local packed = self:byte()
|
||||||
local volume = bit.rshift(packed, 4)
|
local volume = bit.rshift(packed, 4)
|
||||||
local fade = fadeValue(bit.band(packed, 0x0F))
|
local fade = fadeValue(bit.band(packed, 0x0F))
|
||||||
|
-- audio/engine_2.asm:991-1013, :1015-1033, :1077-1096
|
||||||
|
local plain = self.timeTicks < self.plainTicks
|
||||||
|
local offset = plain and 0 or self.frequencyOffset
|
||||||
if self.noise then
|
if self.noise then
|
||||||
-- Audio2_ApplyWavePatternAndFrequency adds wFrequencyModifier to the
|
-- Audio2_ApplyWavePatternAndFrequency adds wFrequencyModifier to the
|
||||||
-- frequency low byte for every channel at or past CHAN5, the noise
|
-- frequency low byte for every channel at or past CHAN5, the noise
|
||||||
@@ -595,12 +600,12 @@ function Channel:nextEvent()
|
|||||||
-- byte that noise does not use for frequency. Dropping it left the
|
-- byte that noise does not use for frequency. Dropping it left the
|
||||||
-- battle hit sounds at their unmodified pitches, where super effective
|
-- battle hit sounds at their unmodified pitches, where super effective
|
||||||
-- reads as the duller of the two (#826).
|
-- reads as the duller of the two (#826).
|
||||||
local parameter = bit.band(self:byte() + self.frequencyOffset, 0xFF)
|
local parameter = bit.band(self:byte() + offset, 0xFF)
|
||||||
return self:noiseEvent(
|
return self:noiseEvent(
|
||||||
self:durationTicks(length), volume, fade, parameter)
|
self:durationTicks(length, plain), volume, fade, parameter)
|
||||||
end
|
end
|
||||||
local register = bit.band(self:word() + self.frequencyOffset, 0x7FF)
|
local register = bit.band(self:word() + offset, 0x7FF)
|
||||||
return self:tone(self:durationTicks(length), register, volume, fade)
|
return self:tone(self:durationTicks(length, plain), register, volume, fade)
|
||||||
elseif command == 0x10 then
|
elseif command == 0x10 then
|
||||||
local packed = self:byte()
|
local packed = self:byte()
|
||||||
self.sweep = {
|
self.sweep = {
|
||||||
@@ -1260,6 +1265,7 @@ function Engine.new(data, header, options)
|
|||||||
allowLoops = options.allowLoops,
|
allowLoops = options.allowLoops,
|
||||||
frequencyOffset = options.frequencyOffset,
|
frequencyOffset = options.frequencyOffset,
|
||||||
frameTicks = frameTicks,
|
frameTicks = frameTicks,
|
||||||
|
plainFrames = options.plainFrames,
|
||||||
})
|
})
|
||||||
end
|
end
|
||||||
return engine
|
return engine
|
||||||
|
|||||||
@@ -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
|
||||||
+35
-2
@@ -222,6 +222,26 @@ function Game:touchSkinHotkey(action, pressed)
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
function Game:breakLink(err)
|
||||||
|
Logger.error("link: torn down after an error\n%s", tostring(err))
|
||||||
|
self.linkSession = nil
|
||||||
|
local net = self.linkNet
|
||||||
|
self.linkNet = nil
|
||||||
|
if net then pcall(net.close, net) end
|
||||||
|
pcall(ModRuntime.emit, "link.ended", { reason = "error" })
|
||||||
|
local stack = self.stack
|
||||||
|
local guard = 0
|
||||||
|
while #stack.states > 1 and stack:top() ~= self.overworld and guard < 64 do
|
||||||
|
guard = guard + 1
|
||||||
|
pcall(stack.pop, stack)
|
||||||
|
end
|
||||||
|
pcall(function()
|
||||||
|
local Strings = require("src.core.Strings")
|
||||||
|
local TextBox = require("src.render.TextBox")
|
||||||
|
stack:push(TextBox.new(self, Strings("The link was\nbroken.")))
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
function Game:step(dt)
|
function Game:step(dt)
|
||||||
-- Tool mods (autoplay, accessibility drivers, input visualizers) act on
|
-- Tool mods (autoplay, accessibility drivers, input visualizers) act on
|
||||||
-- the same fixed-step boundary as a physical controller. Run them before
|
-- the same fixed-step boundary as a physical controller. Run them before
|
||||||
@@ -249,9 +269,22 @@ function Game:step(dt)
|
|||||||
-- stall just because PartyMenu/ChoiceBox/NamingScreen is temporarily
|
-- stall just because PartyMenu/ChoiceBox/NamingScreen is temporarily
|
||||||
-- on top of BattleState (see LinkBattle.new)
|
-- on top of BattleState (see LinkBattle.new)
|
||||||
if self.linkNet and not self.linkNet.closed then
|
if self.linkNet and not self.linkNet.closed then
|
||||||
self.linkNet:update()
|
local ok, err = pcall(self.linkNet.update, self.linkNet)
|
||||||
|
if not ok then
|
||||||
|
self:breakLink(err)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if self.linkSession or self.linkNet then
|
||||||
|
local ok, err = xpcall(function() self.stack:update(dt) end,
|
||||||
|
function(e) return debug.traceback(tostring(e), 2) end)
|
||||||
|
if not ok then
|
||||||
|
self:breakLink(err)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
else
|
||||||
|
self.stack:update(dt)
|
||||||
end
|
end
|
||||||
self.stack:update(dt)
|
|
||||||
-- play time for the trainer card / save screen
|
-- play time for the trainer card / save screen
|
||||||
self.save.playTime = (self.save.playTime or 0) + dt
|
self.save.playTime = (self.save.playTime or 0) + dt
|
||||||
-- Music.update is NOT serviced here: it decrements fade counters and
|
-- Music.update is NOT serviced here: it decrements fade counters and
|
||||||
|
|||||||
+6
-5
@@ -120,11 +120,11 @@ local function visibleBaseState(stack)
|
|||||||
end
|
end
|
||||||
|
|
||||||
local function loadGenerated(path)
|
local function loadGenerated(path)
|
||||||
local chunk = love.filesystem.load(path)
|
-- CacheFs.loadActive, not love.filesystem.load: Gold's cache lives under
|
||||||
if not chunk then return nil end
|
-- gold/ and fused NX often cannot mount that tree onto data/generated/.
|
||||||
local ok, data = pcall(chunk)
|
local CacheFs = require("src.import.CacheFs")
|
||||||
if ok then return data end
|
local data = CacheFs.loadActive(path)
|
||||||
return nil
|
return data
|
||||||
end
|
end
|
||||||
|
|
||||||
-- NewGame (engine/menus/intro_menu.asm) calls OakSpeech, and OakSpeech's first
|
-- NewGame (engine/menus/intro_menu.asm) calls OakSpeech, and OakSpeech's first
|
||||||
@@ -1968,6 +1968,7 @@ function Game2:applyOptions()
|
|||||||
})
|
})
|
||||||
require("src.core.VideoMode").applyOptions(options)
|
require("src.core.VideoMode").applyOptions(options)
|
||||||
require("src.core.FrameCap").applyOptions(options)
|
require("src.core.FrameCap").applyOptions(options)
|
||||||
|
require("src.world.gen2.BorderFill").applyOptions(options)
|
||||||
local GBCFX = require("src.render.GBCFX")
|
local GBCFX = require("src.render.GBCFX")
|
||||||
if GBCFX.applyOptions(options) and self.save then
|
if GBCFX.applyOptions(options) and self.save then
|
||||||
-- applyOptions returns true when it had to clear an unsupported level.
|
-- applyOptions returns true when it had to clear an unsupported level.
|
||||||
|
|||||||
+30
-13
@@ -1,10 +1,11 @@
|
|||||||
-- NX-only asset overlay: fused love-nx cannot reliably mount
|
-- NX-only asset overlay: fused love-nx cannot reliably mount
|
||||||
-- blue|yellow/assets/generated onto the un-prefixed assets/generated, so
|
-- blue|yellow|gold/{assets,data}/generated onto the un-prefixed paths, so
|
||||||
-- instead of teaching every call site about versioned caches, this module
|
-- instead of teaching every call site about versioned caches, this module
|
||||||
-- wraps EVERY read-side love entry point that accepts a filesystem path
|
-- wraps EVERY read-side love entry point that accepts a filesystem path
|
||||||
-- once at boot: any string path under assets/generated/ that does not
|
-- once at boot: any string path under assets/generated/ or data/generated/
|
||||||
-- resolve falls back to the active version's prefixed copy
|
-- prefers the active version's prefixed copy (yellow|blue|gold/...) when
|
||||||
-- (yellow|blue/assets/generated/...). Covering the whole read surface --
|
-- that file exists, so leftover unprefixed Red cache cannot shadow it.
|
||||||
|
-- Covering the whole read surface --
|
||||||
-- not just the loaders we happened to need -- is what keeps future states
|
-- not just the loaders we happened to need -- is what keeps future states
|
||||||
-- and mods inside the fallback without anyone updating this file.
|
-- and mods inside the fallback without anyone updating this file.
|
||||||
--
|
--
|
||||||
@@ -18,27 +19,43 @@
|
|||||||
-- * the chip-audio worker (src/core/chip_worker.lua) is a separate Lua
|
-- * the chip-audio worker (src/core/chip_worker.lua) is a separate Lua
|
||||||
-- state without these wrappers; ChipAudio.slimAudio hands it the prefix
|
-- state without these wrappers; ChipAudio.slimAudio hands it the prefix
|
||||||
-- explicitly as audio.programPrefix.
|
-- explicitly as audio.programPrefix.
|
||||||
-- * data/generated module loads go through CacheFs.readActive, which
|
-- * Gen 1 Data:load and Gold Game2/World go through CacheFs.readActive /
|
||||||
-- already implements the same fallback for require bytes.
|
-- CacheFs.loadActive, which implement the same fallback for Lua bytes.
|
||||||
|
-- data/generated is still rewritten here so any leftover
|
||||||
|
-- love.filesystem.load("data/generated/...") call (the Gold intro /
|
||||||
|
-- naming / maps hole on 0.2.4) stays inside the overlay.
|
||||||
|
|
||||||
local GameVersion = require("src.core.GameVersion")
|
local GameVersion = require("src.core.GameVersion")
|
||||||
|
|
||||||
local GENERATED = "assets/generated/"
|
local GENERATED_PREFIXES = {
|
||||||
|
"assets/generated/",
|
||||||
|
"data/generated/",
|
||||||
|
}
|
||||||
|
|
||||||
local NxAssetOverlay = {}
|
local NxAssetOverlay = {}
|
||||||
|
|
||||||
local originals -- raw love functions, non-nil while installed
|
local originals -- raw love functions, non-nil while installed
|
||||||
|
|
||||||
-- Resolve `path` to the versioned copy when the un-prefixed file is missing
|
-- Resolve `path` to the active version's prefixed copy when that file
|
||||||
-- and the active version (Blue/Yellow) carries it. Returns nil when the
|
-- exists (gold|yellow|blue|red/{assets,data}/generated/...). The versioned
|
||||||
-- caller's path should be used untouched (non-generated path, Red, the real
|
-- tree wins over a leftover un-prefixed file so a pre-#899 Red root cache
|
||||||
-- file exists, or no versioned copy).
|
-- (assets/generated/font.png in the save dir) cannot shadow Gold/Blue/
|
||||||
|
-- Yellow art that shares a name. Returns nil when the caller's path
|
||||||
|
-- should be used untouched (non-generated path, empty prefix, or no
|
||||||
|
-- versioned copy).
|
||||||
local function versioned(path)
|
local function versioned(path)
|
||||||
if type(path) ~= "string" then return nil end
|
if type(path) ~= "string" then return nil end
|
||||||
if path:sub(1, #GENERATED) ~= GENERATED then return nil end
|
local generated = false
|
||||||
|
for i = 1, #GENERATED_PREFIXES do
|
||||||
|
local gen = GENERATED_PREFIXES[i]
|
||||||
|
if path:sub(1, #gen) == gen then
|
||||||
|
generated = true
|
||||||
|
break
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if not generated then return nil end
|
||||||
local prefix = GameVersion.cachePrefix()
|
local prefix = GameVersion.cachePrefix()
|
||||||
if prefix == "" then return nil end
|
if prefix == "" then return nil end
|
||||||
if originals.getInfo(path) then return nil end
|
|
||||||
local candidate = prefix .. path
|
local candidate = prefix .. path
|
||||||
if originals.getInfo(candidate) then return candidate end
|
if originals.getInfo(candidate) then return candidate end
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
+109
-36
@@ -8,6 +8,7 @@
|
|||||||
local Assets = require("src.render.Assets")
|
local Assets = require("src.render.Assets")
|
||||||
local Logger = require("src.core.Logger")
|
local Logger = require("src.core.Logger")
|
||||||
local Runtime = require("src.mods.Runtime")
|
local Runtime = require("src.mods.Runtime")
|
||||||
|
local bit = require("bit")
|
||||||
|
|
||||||
local Sound = {}
|
local Sound = {}
|
||||||
|
|
||||||
@@ -142,10 +143,10 @@ local function newFileSource(def)
|
|||||||
return s
|
return s
|
||||||
end
|
end
|
||||||
|
|
||||||
local function newSfxSource(data, key, def, pitch, tempo)
|
local function newSfxSource(data, key, def, pitch, tempo, plain)
|
||||||
if isChipDef(def) then
|
if isChipDef(def) then
|
||||||
local ok, s = pcall(require("src.core.ChipAudio").newSfx,
|
local ok, s = pcall(require("src.core.ChipAudio").newSfx,
|
||||||
data, key:match("^([^@]+)") or key, pitch, tempo, def)
|
data, key:match("^([^@]+)") or key, pitch, tempo, def, plain)
|
||||||
if not ok then return nil, tostring(s) end
|
if not ok then return nil, tostring(s) end
|
||||||
if not s then return nil, "no source" end
|
if not s then return nil, "no source" end
|
||||||
return s
|
return s
|
||||||
@@ -153,12 +154,12 @@ local function newSfxSource(data, key, def, pitch, tempo)
|
|||||||
return newFileSource(def)
|
return newFileSource(def)
|
||||||
end
|
end
|
||||||
|
|
||||||
local function playPath(data, key, def, pitch, tempo)
|
local function playPath(data, key, def, pitch, tempo, plain)
|
||||||
if not love.audio or not def then return nil end
|
if not love.audio or not def then return nil end
|
||||||
local src = cache[key]
|
local src = cache[key]
|
||||||
if src == false then return nil end -- known bad, already logged
|
if src == false then return nil end -- known bad, already logged
|
||||||
if not src then
|
if not src then
|
||||||
local s, err = newSfxSource(data, key, def, pitch, tempo)
|
local s, err = newSfxSource(data, key, def, pitch, tempo, plain)
|
||||||
if not s then
|
if not s then
|
||||||
cache[key] = false
|
cache[key] = false
|
||||||
reportBadDef("sfx", key, owner(data, "sfx", key), err)
|
reportBadDef("sfx", key, owner(data, "sfx", key), err)
|
||||||
@@ -413,50 +414,109 @@ end
|
|||||||
-- still sounding when the second row starts, so the original never plays
|
-- still sounding when the second row starts, so the original never plays
|
||||||
-- SFX_BATTLE_2A (CHAN5+6+8) at all -- unguarded, its tail is heard running
|
-- SFX_BATTLE_2A (CHAN5+6+8) at all -- unguarded, its tail is heard running
|
||||||
-- past the end of the animation (#844).
|
-- past the end of the animation (#844).
|
||||||
local lastMoveSfx -- { src, rank, engine, channels } of the last row sound
|
local moveSfxChannels = {} -- software channel (5-8) -> { src, address, engine }
|
||||||
|
|
||||||
local function channelsOverlap(a, b)
|
local function sourceAlive(entry)
|
||||||
if not (a and b) then return false end
|
local ok, playing = pcall(entry.src.isPlaying, entry.src)
|
||||||
for _, x in ipairs(a) do
|
return ok and playing
|
||||||
for _, y in ipairs(b) do
|
end
|
||||||
if x == y then return true end
|
|
||||||
end
|
local function pruneMoveSfx()
|
||||||
|
for ch, cur in pairs(moveSfxChannels) do
|
||||||
|
if not sourceAlive(cur) then moveSfxChannels[ch] = nil end
|
||||||
end
|
end
|
||||||
return false
|
|
||||||
end
|
end
|
||||||
|
|
||||||
-- would PlaySound start this def now? Taking a channel over also stops the
|
-- would PlaySound start this def now? Taking a channel over also stops the
|
||||||
-- sound that held it, the way .playChannel resets the channel.
|
-- sound that held it, the way .playChannel resets the channel.
|
||||||
local function sfxChannelGate(data, def)
|
local function sfxChannelGate(data, def)
|
||||||
local cur = lastMoveSfx
|
pruneMoveSfx()
|
||||||
if not cur then return true end
|
|
||||||
local ok, playing = pcall(cur.src.isPlaying, cur.src)
|
|
||||||
if not (ok and playing) then
|
|
||||||
lastMoveSfx = nil
|
|
||||||
return true
|
|
||||||
end
|
|
||||||
-- an unrankable def (file asset, or another engine's bank) has no
|
-- an unrankable def (file asset, or another engine's bank) has no
|
||||||
-- comparable sound id: leave it to the mixer, as before
|
-- comparable sound id: leave it to the mixer, as before
|
||||||
if type(def) ~= "table" or not def.address or def.engine ~= cur.engine then
|
if type(def) ~= "table" or not def.address then return true end
|
||||||
return true
|
|
||||||
end
|
|
||||||
local channels = require("src.core.ChipSynth").effectChannels(data, def)
|
local channels = require("src.core.ChipSynth").effectChannels(data, def)
|
||||||
if not channelsOverlap(channels, cur.channels) then return true end
|
if not channels then return true end
|
||||||
if def.address > cur.rank then return false end
|
local takeover
|
||||||
pcall(cur.src.stop, cur.src)
|
for _, ch in ipairs(channels) do
|
||||||
lastMoveSfx = nil
|
local cur = moveSfxChannels[ch]
|
||||||
return true
|
if cur and cur.engine == def.engine then
|
||||||
|
if def.address > cur.address then return false end
|
||||||
|
takeover = takeover or {}
|
||||||
|
takeover[cur.src] = true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if takeover then
|
||||||
|
for ch, cur in pairs(moveSfxChannels) do
|
||||||
|
if takeover[cur.src] then moveSfxChannels[ch] = nil end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return true, takeover
|
||||||
end
|
end
|
||||||
|
|
||||||
local function noteMoveSfx(data, def, src)
|
local function noteMoveSfx(data, def, src)
|
||||||
if not src or type(def) ~= "table" or not def.address then
|
if not src or type(def) ~= "table" or not def.address then
|
||||||
lastMoveSfx = nil
|
moveSfxChannels = {}
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
lastMoveSfx = {
|
local channels = require("src.core.ChipSynth").effectChannels(data, def)
|
||||||
src = src, rank = def.address, engine = def.engine,
|
if not channels then
|
||||||
channels = require("src.core.ChipSynth").effectChannels(data, def),
|
moveSfxChannels = {}
|
||||||
}
|
return
|
||||||
|
end
|
||||||
|
local entry = { src = src, address = def.address, engine = def.engine }
|
||||||
|
for _, ch in ipairs(channels) do moveSfxChannels[ch] = entry end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- constants/music_constants.asm:4
|
||||||
|
local function sfxHeaderId(def)
|
||||||
|
if type(def) ~= "table" or not def.address then return nil end
|
||||||
|
local rel = def.address - 0x4000
|
||||||
|
if rel <= 0 or rel % 3 ~= 0 then return nil end
|
||||||
|
return rel / 3
|
||||||
|
end
|
||||||
|
|
||||||
|
local function remainingFrames(src)
|
||||||
|
local ok, dur = pcall(src.getDuration, src, "seconds")
|
||||||
|
if not ok or type(dur) ~= "number" then return nil end
|
||||||
|
local pos
|
||||||
|
ok, pos = pcall(src.tell, src, "seconds")
|
||||||
|
if not ok or type(pos) ~= "number" then return nil end
|
||||||
|
return math.max(0, math.ceil((dur - pos) * 60))
|
||||||
|
end
|
||||||
|
|
||||||
|
-- audio/engine_2.asm:1077-1096, :991-1013, :1015-1033
|
||||||
|
local function plainMoveFrames(data, def, channels)
|
||||||
|
local id = sfxHeaderId(def)
|
||||||
|
local sfx = data.audio and data.audio.sfx
|
||||||
|
if not (id and sfx and channels) then return 0 end
|
||||||
|
local first = sfxHeaderId(sfx.Peck) -- constants/music_constants.asm:178
|
||||||
|
local last = sfxHeaderId(sfx.Trainer_Appeared) -- constants/music_constants.asm:228
|
||||||
|
if not (first and last) then return 0 end
|
||||||
|
local claimed = {}
|
||||||
|
for _, ch in ipairs(channels) do claimed[ch] = true end
|
||||||
|
local base = (claimed[5] or claimed[8]) and id or 0
|
||||||
|
local others = {}
|
||||||
|
for _, ch in ipairs({ 5, 8 }) do
|
||||||
|
local cur = (not claimed[ch]) and moveSfxChannels[ch] or nil
|
||||||
|
if cur and cur.engine == def.engine and sourceAlive(cur) then
|
||||||
|
local otherId = sfxHeaderId(cur)
|
||||||
|
local rem = remainingFrames(cur.src)
|
||||||
|
if otherId and rem and rem > 0 then
|
||||||
|
others[#others + 1] = { id = otherId, rem = rem }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
table.sort(others, function(a, b) return a.rem < b.rem end)
|
||||||
|
local plain = 0
|
||||||
|
for drop = 0, #others do
|
||||||
|
local combined = base
|
||||||
|
for i = drop + 1, #others do
|
||||||
|
combined = bit.bor(combined, others[i].id)
|
||||||
|
end
|
||||||
|
if combined >= first and combined <= last then return plain end
|
||||||
|
if drop < #others then plain = others[drop + 1].rem end
|
||||||
|
end
|
||||||
|
return plain
|
||||||
end
|
end
|
||||||
|
|
||||||
function Sound.playMove(data, anim)
|
function Sound.playMove(data, anim)
|
||||||
@@ -466,13 +526,20 @@ function Sound.playMove(data, anim)
|
|||||||
local name = anim.sound
|
local name = anim.sound
|
||||||
local pitch, tempo = anim.pitch or 0, anim.tempo or 0x80
|
local pitch, tempo = anim.pitch or 0, anim.tempo or 0x80
|
||||||
local def = sfx[name]
|
local def = sfx[name]
|
||||||
if not sfxChannelGate(data, def) then return end
|
local allowed, superseded = sfxChannelGate(data, def)
|
||||||
|
if not allowed then return end
|
||||||
local src
|
local src
|
||||||
-- a chip program synthesizes the modified variant on demand; a file def
|
-- a chip program synthesizes the modified variant on demand; a file def
|
||||||
-- can only reach for a pre-rendered one
|
-- can only reach for a pre-rendered one
|
||||||
if isChipDef(def) then
|
if isChipDef(def) then
|
||||||
src = playPath(data, ("%s@%02x%02x"):format(name, pitch, tempo),
|
local plain = 0
|
||||||
def, pitch, tempo)
|
if pitch ~= 0 or tempo ~= 0x80 then
|
||||||
|
local channels = require("src.core.ChipSynth").effectChannels(data, def)
|
||||||
|
plain = plainMoveFrames(data, def, channels)
|
||||||
|
end
|
||||||
|
local key = ("%s@%02x%02x"):format(name, pitch, tempo)
|
||||||
|
if plain > 0 then key = ("%s~%d"):format(key, plain) end
|
||||||
|
src = playPath(data, key, def, pitch, tempo, plain)
|
||||||
else
|
else
|
||||||
local key = ("%s@%02x%02x"):format(name, pitch, tempo)
|
local key = ("%s@%02x%02x"):format(name, pitch, tempo)
|
||||||
if (pitch ~= 0 or tempo ~= 0x80) and sfx[key] then
|
if (pitch ~= 0 or tempo ~= 0x80) and sfx[key] then
|
||||||
@@ -481,6 +548,12 @@ function Sound.playMove(data, anim)
|
|||||||
src = playPath(data, name, def)
|
src = playPath(data, name, def)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
-- audio/engine_2.asm:1537
|
||||||
|
if superseded then
|
||||||
|
for old in pairs(superseded) do
|
||||||
|
if old ~= src then pcall(old.stop, old) end
|
||||||
|
end
|
||||||
|
end
|
||||||
if src then
|
if src then
|
||||||
played("move", name)
|
played("move", name)
|
||||||
noteMoveSfx(data, def, src)
|
noteMoveSfx(data, def, src)
|
||||||
@@ -711,7 +784,7 @@ end
|
|||||||
-- hot reload / jukebox A-B: drop one key's sources (its pitch-tempo
|
-- hot reload / jukebox A-B: drop one key's sources (its pitch-tempo
|
||||||
-- variants included) or all of them, so the next play re-resolves the def
|
-- variants included) or all of them, so the next play re-resolves the def
|
||||||
function Sound.invalidate(name)
|
function Sound.invalidate(name)
|
||||||
lastMoveSfx = nil -- its source is about to be dropped or stopped
|
moveSfxChannels = {} -- their sources are about to be dropped or stopped
|
||||||
-- Same for wCurSFX, and a reloaded table can repoint the id order.
|
-- Same for wCurSFX, and a reloaded table can repoint the id order.
|
||||||
curSfx = nil
|
curSfx = nil
|
||||||
sfxIds = nil
|
sfxIds = nil
|
||||||
|
|||||||
+90
-4
@@ -142,7 +142,11 @@ function TouchSkin.parse(text)
|
|||||||
controls = {},
|
controls = {},
|
||||||
}
|
}
|
||||||
if page.imagePath == "" then page.imagePath = nil end
|
if page.imagePath == "" then page.imagePath = nil end
|
||||||
if not page.aspect or page.aspect <= 0 then
|
-- An explicit aspect_ratio is the overlay's design aspect. RetroArch
|
||||||
|
-- letterboxes to it even when full_screen is set, so range_x/range_y
|
||||||
|
-- that were authored as a circle stay a circle. #1503
|
||||||
|
page.aspectFromCfg = page.aspect ~= nil and page.aspect > 0
|
||||||
|
if not page.aspectFromCfg then
|
||||||
page.aspect = page.name:lower():find("portrait", 1, true)
|
page.aspect = page.name:lower():find("portrait", 1, true)
|
||||||
and PORTRAIT_ASPECT or DEFAULT_ASPECT
|
and PORTRAIT_ASPECT or DEFAULT_ASPECT
|
||||||
end
|
end
|
||||||
@@ -170,6 +174,12 @@ function TouchSkin.parse(text)
|
|||||||
pages[#pages + 1] = page
|
pages[#pages + 1] = page
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- RetroArch auto-rotate: overlay names containing portrait / landscape
|
||||||
|
-- are the lock. Stamp it so the studio does not need a second click. #1503
|
||||||
|
for _, page in ipairs(pages) do
|
||||||
|
if not page.orient then page.orient = TouchSkin.pageOrient(page) end
|
||||||
|
end
|
||||||
|
|
||||||
return { pages = pages }
|
return { pages = pages }
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -255,6 +265,9 @@ function TouchSkin.parseNative(text)
|
|||||||
rangeMod = num(raw.rangeMod, 1),
|
rangeMod = num(raw.rangeMod, 1),
|
||||||
alphaMod = num(raw.alphaMod, 1),
|
alphaMod = num(raw.alphaMod, 1),
|
||||||
aspect = num(raw.aspect, DEFAULT_ASPECT),
|
aspect = num(raw.aspect, DEFAULT_ASPECT),
|
||||||
|
aspectFromCfg = raw.fitAspect == true,
|
||||||
|
orient = (raw.orient == "portrait" or raw.orient == "landscape"
|
||||||
|
or raw.orient == "any") and raw.orient or nil,
|
||||||
rect = { x = 0, y = 0, w = 1, h = 1 },
|
rect = { x = 0, y = 0, w = 1, h = 1 },
|
||||||
controls = {},
|
controls = {},
|
||||||
}
|
}
|
||||||
@@ -287,6 +300,7 @@ function TouchSkin.parseNative(text)
|
|||||||
nextTarget = c.nextTarget,
|
nextTarget = c.nextTarget,
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
if not page.orient then page.orient = TouchSkin.pageOrient(page) end
|
||||||
pages[#pages + 1] = page
|
pages[#pages + 1] = page
|
||||||
end
|
end
|
||||||
return { pages = pages, name = data.name, author = data.author,
|
return { pages = pages, name = data.name, author = data.author,
|
||||||
@@ -309,6 +323,9 @@ function TouchSkin.toNative(skin)
|
|||||||
rangeMod = page.rangeMod,
|
rangeMod = page.rangeMod,
|
||||||
alphaMod = page.alphaMod,
|
alphaMod = page.alphaMod,
|
||||||
aspect = page.aspect,
|
aspect = page.aspect,
|
||||||
|
fitAspect = page.aspectFromCfg or nil,
|
||||||
|
orient = (page.orient == "portrait" or page.orient == "landscape"
|
||||||
|
or page.orient == "any") and page.orient or nil,
|
||||||
controls = {},
|
controls = {},
|
||||||
}
|
}
|
||||||
if page.rect and (page.rect.x ~= 0 or page.rect.y ~= 0
|
if page.rect and (page.rect.x ~= 0 or page.rect.y ~= 0
|
||||||
@@ -704,6 +721,10 @@ end
|
|||||||
|
|
||||||
TouchSkin.active = nil
|
TouchSkin.active = nil
|
||||||
TouchSkin.pageIndex = 1
|
TouchSkin.pageIndex = 1
|
||||||
|
-- RetroArch Auto-Rotate Overlay (1.7.9, default on mobile): a cfg whose
|
||||||
|
-- pages are named portrait / landscape is swapped to match the display.
|
||||||
|
-- The Skin Studio turns this off so PAGE and the canvas preset stay independent.
|
||||||
|
TouchSkin.autoOrient = true
|
||||||
|
|
||||||
TouchSkin.surfaceRect = nil
|
TouchSkin.surfaceRect = nil
|
||||||
|
|
||||||
@@ -732,9 +753,68 @@ function TouchSkin.select(id)
|
|||||||
return TouchSkin.setActive(skin)
|
return TouchSkin.setActive(skin)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
local function displaySize()
|
||||||
|
local r = TouchSkin.surfaceRect
|
||||||
|
if r and r.w and r.h and r.w > 0 and r.h > 0 then return r.w, r.h end
|
||||||
|
if love and love.graphics and love.graphics.getDimensions then
|
||||||
|
return love.graphics.getDimensions()
|
||||||
|
end
|
||||||
|
return 0, 0
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Explicit lock (studio) wins; otherwise the page name, the RetroArch
|
||||||
|
-- auto-rotate convention. "any" means unlocked even if the name says
|
||||||
|
-- portrait or landscape.
|
||||||
|
function TouchSkin.pageOrient(page)
|
||||||
|
if not page then return nil end
|
||||||
|
if page.orient == "any" then return nil end
|
||||||
|
if page.orient == "portrait" or page.orient == "landscape" then
|
||||||
|
return page.orient
|
||||||
|
end
|
||||||
|
local n = tostring(page.name or ""):lower()
|
||||||
|
if n:find("landscape", 1, true) then return "landscape" end
|
||||||
|
if n:find("portrait", 1, true) then return "portrait" end
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
function TouchSkin.hasOrientPair(skin)
|
||||||
|
local saw = {}
|
||||||
|
for _, page in ipairs(skin and skin.pages or {}) do
|
||||||
|
local o = TouchSkin.pageOrient(page)
|
||||||
|
if o then saw[o] = true end
|
||||||
|
end
|
||||||
|
return saw.portrait == true and saw.landscape == true
|
||||||
|
end
|
||||||
|
|
||||||
|
local function findOrientPage(skin, keyword)
|
||||||
|
for i, page in ipairs(skin.pages or {}) do
|
||||||
|
if TouchSkin.pageOrient(page) == keyword then return i end
|
||||||
|
end
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
-- If the current page is the wrong orientation of a portrait/landscape pair,
|
||||||
|
-- jump to the matching one. Pages locked to neither (gb_anim's GameBoy /
|
||||||
|
-- GameBoyColor) are left alone. #1503
|
||||||
|
function TouchSkin.syncOrientation(w, h)
|
||||||
|
if not TouchSkin.autoOrient then return end
|
||||||
|
local skin = TouchSkin.active
|
||||||
|
if not skin or not w or not h or w <= 0 or h <= 0 then return end
|
||||||
|
local want = w > h and "landscape" or "portrait"
|
||||||
|
local unwant = w > h and "portrait" or "landscape"
|
||||||
|
local page = skin.pages[TouchSkin.pageIndex] or skin.pages[1]
|
||||||
|
local current = TouchSkin.pageOrient(page)
|
||||||
|
if current == want then return end
|
||||||
|
if current ~= unwant then return end
|
||||||
|
local idx = findOrientPage(skin, want)
|
||||||
|
if idx then TouchSkin.pageIndex = idx end
|
||||||
|
end
|
||||||
|
|
||||||
function TouchSkin.page()
|
function TouchSkin.page()
|
||||||
local skin = TouchSkin.active
|
local skin = TouchSkin.active
|
||||||
if not skin then return nil end
|
if not skin then return nil end
|
||||||
|
local w, h = displaySize()
|
||||||
|
TouchSkin.syncOrientation(w, h)
|
||||||
return skin.pages[TouchSkin.pageIndex] or skin.pages[1]
|
return skin.pages[TouchSkin.pageIndex] or skin.pages[1]
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -766,7 +846,12 @@ function TouchSkin.pageBox(page, w, h, ox, oy)
|
|||||||
ox, oy = ox or 0, oy or 0
|
ox, oy = ox or 0, oy or 0
|
||||||
if not page then return ox, oy, w, h end
|
if not page then return ox, oy, w, h end
|
||||||
local bx, by, bw, bh = ox, oy, w, h
|
local bx, by, bw, bh = ox, oy, w, h
|
||||||
if not page.fullScreen and h > 0 then
|
-- full_screen means "relative to the window, not the game viewport".
|
||||||
|
-- When the cfg also names an aspect_ratio, that window is then fitted
|
||||||
|
-- to the overlay's design aspect so buttons do not stretch. #1503
|
||||||
|
local fit = ((not page.fullScreen) or page.aspectFromCfg)
|
||||||
|
and page.aspect and page.aspect > 0 and h > 0
|
||||||
|
if fit then
|
||||||
local displayAspect = w / h
|
local displayAspect = w / h
|
||||||
if displayAspect > page.aspect then
|
if displayAspect > page.aspect then
|
||||||
bw = h * page.aspect
|
bw = h * page.aspect
|
||||||
@@ -833,8 +918,9 @@ function TouchSkin.viewport(w, h, ox, oy)
|
|||||||
local page = TouchSkin.page()
|
local page = TouchSkin.page()
|
||||||
if not page or not page.viewport or not TouchSkin.drawable() then return nil end
|
if not page or not page.viewport or not TouchSkin.drawable() then return nil end
|
||||||
local v = page.viewport
|
local v = page.viewport
|
||||||
local x, y = (ox or 0) + v.x * w, (oy or 0) + v.y * h
|
local bx, by, bw, bh = TouchSkin.pageBox(page, w, h, ox, oy)
|
||||||
local vw, vh = v.w * w, v.h * h
|
local x, y = bx + v.x * bw, by + v.y * bh
|
||||||
|
local vw, vh = v.w * bw, v.h * bh
|
||||||
if vw <= 0 or vh <= 0 then return nil end
|
if vw <= 0 or vh <= 0 then return nil end
|
||||||
return x, y, vw, vh, page.viewportFill == true, page.viewportExpand == true
|
return x, y, vw, vh, page.viewportFill == true, page.viewportExpand == true
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -274,6 +274,9 @@ Save.DEFAULT_OPTIONS = {
|
|||||||
color = "gbc",
|
color = "gbc",
|
||||||
videoMode = "windowed",
|
videoMode = "windowed",
|
||||||
fpsCap = 60,
|
fpsCap = 60,
|
||||||
|
-- VOID FILL: fade | water | trees | black. fade is each map header's own
|
||||||
|
-- border block with the dissolve across a boundary (#1418).
|
||||||
|
voidFill = "fade",
|
||||||
musicVol = 7, -- 0-7, like the GB's NR50 master volume
|
musicVol = 7, -- 0-7, like the GB's NR50 master volume
|
||||||
sfxVol = 7, -- 0-7
|
sfxVol = 7, -- 0-7
|
||||||
musicFilter = 0, -- low-pass steps, 0 = off
|
musicFilter = 0, -- low-pass steps, 0 = off
|
||||||
|
|||||||
@@ -220,6 +220,9 @@ function SwitchDiagnostics.probeAssets(version)
|
|||||||
"assets/generated/tilesets/reds_house.png",
|
"assets/generated/tilesets/reds_house.png",
|
||||||
"assets/generated/sprites/red.png",
|
"assets/generated/sprites/red.png",
|
||||||
"assets/generated/sprites/monster.png",
|
"assets/generated/sprites/monster.png",
|
||||||
|
"data/generated/maps.lua",
|
||||||
|
"data/generated/oak_speech.lua",
|
||||||
|
"data/generated/font.lua",
|
||||||
}
|
}
|
||||||
for _, path in ipairs(samples) do
|
for _, path in ipairs(samples) do
|
||||||
local versioned = prefix ~= "" and (prefix .. path) or path
|
local versioned = prefix ~= "" and (prefix .. path) or path
|
||||||
@@ -230,17 +233,26 @@ function SwitchDiagnostics.probeAssets(version)
|
|||||||
lines[#lines + 1] = "versioned=" .. probeInfo(filesystem, versioned)
|
lines[#lines + 1] = "versioned=" .. probeInfo(filesystem, versioned)
|
||||||
end
|
end
|
||||||
lines[#lines + 1] = "resolve=" .. tostring(resolved)
|
lines[#lines + 1] = "resolve=" .. tostring(resolved)
|
||||||
lines[#lines + 1] = "newImage=" .. probeOpen("image", resolved)
|
if path:sub(-4) == ".lua" then
|
||||||
lines[#lines + 1] = "newImageData=" .. probeOpen("imageData", resolved)
|
local CacheFs = require("src.import.CacheFs")
|
||||||
if prefix ~= "" and resolved ~= versioned then
|
local loaded, err = CacheFs.loadActive(path)
|
||||||
lines[#lines + 1] = "newImage_versioned=" .. probeOpen("image", versioned)
|
lines[#lines + 1] = "loadActive=" .. (loaded ~= nil and "ok"
|
||||||
lines[#lines + 1] = "newImageData_versioned=" .. probeOpen("imageData", versioned)
|
or ("FAIL " .. tostring(err):gsub("%s+", " "):sub(1, 160)))
|
||||||
|
else
|
||||||
|
lines[#lines + 1] = "newImage=" .. probeOpen("image", resolved)
|
||||||
|
lines[#lines + 1] = "newImageData=" .. probeOpen("imageData", resolved)
|
||||||
|
if prefix ~= "" and resolved ~= versioned then
|
||||||
|
lines[#lines + 1] = "newImage_versioned=" .. probeOpen("image", versioned)
|
||||||
|
lines[#lines + 1] = "newImageData_versioned=" .. probeOpen("imageData", versioned)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Shallow listing so we can see if the extract tree exists at all.
|
-- Shallow listing so we can see if the extract tree exists at all.
|
||||||
local roots = { "yellow", "blue", "assets", "yellow/assets/generated",
|
local roots = { "yellow", "blue", "gold", "assets", "yellow/assets/generated",
|
||||||
"yellow/assets/generated/sprites", "blue/assets/generated/sprites" }
|
"yellow/assets/generated/sprites", "blue/assets/generated/sprites",
|
||||||
|
"gold/assets/generated", "gold/assets/generated/sprites",
|
||||||
|
"gold/data/generated" }
|
||||||
for _, dir in ipairs(roots) do
|
for _, dir in ipairs(roots) do
|
||||||
local info = filesystem.getInfo(dir)
|
local info = filesystem.getInfo(dir)
|
||||||
if info and info.type == "directory" and filesystem.getDirectoryItems then
|
if info and info.type == "directory" and filesystem.getDirectoryItems then
|
||||||
|
|||||||
+28
-2
@@ -34,7 +34,7 @@ local SEP = package.config:sub(1, 1)
|
|||||||
|
|
||||||
-- Cache-relative paths are prefixed with this before every read/write, so a
|
-- Cache-relative paths are prefixed with this before every read/write, so a
|
||||||
-- version's import lands under its GameVersion.cachePrefix (red/, blue/,
|
-- version's import lands under its GameVersion.cachePrefix (red/, blue/,
|
||||||
-- yellow/). The launcher sets it per import / per readiness check; it stays
|
-- yellow/, gold/). The launcher sets it per import / per readiness check; it stays
|
||||||
-- "" outside those flows. Runtime *reads* (require / newImage) do NOT go
|
-- "" outside those flows. Runtime *reads* (require / newImage) do NOT go
|
||||||
-- through here -- CacheFs.mountVersion overlays the active version's subtree
|
-- through here -- CacheFs.mountVersion overlays the active version's subtree
|
||||||
-- onto the un-prefixed paths instead.
|
-- onto the un-prefixed paths instead.
|
||||||
@@ -305,7 +305,7 @@ function CacheFs.read(rel)
|
|||||||
end
|
end
|
||||||
|
|
||||||
-- Read cache-relative `rel` for the active GameVersion when PhysFS may hide
|
-- Read cache-relative `rel` for the active GameVersion when PhysFS may hide
|
||||||
-- prefixed Blue/Yellow trees (fused NX mount hole). Same order Data:load
|
-- prefixed Blue/Yellow/Gold trees (fused NX mount hole). Same order Data:load
|
||||||
-- already used: active version prefix with CacheFs.prefix cleared, then
|
-- already used: active version prefix with CacheFs.prefix cleared, then
|
||||||
-- `rel` under the caller's CacheFs.prefix. Returns the bytes or nil.
|
-- `rel` under the caller's CacheFs.prefix. Returns the bytes or nil.
|
||||||
function CacheFs.readActive(rel)
|
function CacheFs.readActive(rel)
|
||||||
@@ -322,6 +322,32 @@ function CacheFs.readActive(rel)
|
|||||||
return nil
|
return nil
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- Load a generated Lua table the way Data:load does: versioned save-dir
|
||||||
|
-- bytes first (gold/data/generated/maps.lua), then the un-prefixed path.
|
||||||
|
-- Game2/World used love.filesystem.load("data/generated/...") which misses
|
||||||
|
-- on fused NX when the gold/ overlay mount fails -- intro art still loads
|
||||||
|
-- via NxAssetOverlay, but oak_speech.lua / font.lua / maps.lua do not.
|
||||||
|
function CacheFs.loadActive(rel)
|
||||||
|
local bytes = CacheFs.readActive(rel)
|
||||||
|
if type(bytes) == "string" then
|
||||||
|
local GameVersion = require("src.core.GameVersion")
|
||||||
|
local loader = loadstring or load
|
||||||
|
local chunk, err = loader(bytes, "@" .. GameVersion.cachePrefix() .. rel)
|
||||||
|
if not chunk then return nil, err end
|
||||||
|
local ok, value = pcall(chunk)
|
||||||
|
if not ok then return nil, value end
|
||||||
|
return value
|
||||||
|
end
|
||||||
|
if love and love.filesystem and love.filesystem.load then
|
||||||
|
local chunk, err = love.filesystem.load(rel)
|
||||||
|
if not chunk then return nil, err end
|
||||||
|
local ok, value = pcall(chunk)
|
||||||
|
if not ok then return nil, value end
|
||||||
|
return value
|
||||||
|
end
|
||||||
|
return nil, "Could not open file " .. rel .. ". Does not exist."
|
||||||
|
end
|
||||||
|
|
||||||
-- does cache-relative `rel` exist as a file?
|
-- does cache-relative `rel` exist as a file?
|
||||||
function CacheFs.exists(rel)
|
function CacheFs.exists(rel)
|
||||||
rel = withPrefix(rel)
|
rel = withPrefix(rel)
|
||||||
|
|||||||
@@ -529,9 +529,9 @@ end
|
|||||||
-- Gold reads NONE of the rows above. Its OPTION screen writes a different
|
-- Gold reads NONE of the rows above. Its OPTION screen writes a different
|
||||||
-- set of names, several of which collide with Gen 1's at a different TYPE
|
-- set of names, several of which collide with Gen 1's at a different TYPE
|
||||||
-- (battleStyle "SHIFT" vs "shift", textSpeed a label vs a frame delay), and
|
-- (battleStyle "SHIFT" vs "shift", textSpeed a label vs a frame delay), and
|
||||||
-- its renderer has no battle layout, no SGB palette packs and no void fill --
|
-- its renderer has no battle layout and no SGB palette packs -- so a gear
|
||||||
-- so a gear opened on the Gold tab used to offer a dozen controls that did
|
-- opened on the Gold tab used to offer a dozen controls that did nothing
|
||||||
-- nothing and hide the seven that the cart itself has.
|
-- and hide the seven that the cart itself has.
|
||||||
--
|
--
|
||||||
-- The block lives in options.lua under `gold`, which is exactly where
|
-- The block lives in options.lua under `gold`, which is exactly where
|
||||||
-- src/core/gen2/Save.lua loadOptions reads it, so an edit here is live on the
|
-- src/core/gen2/Save.lua loadOptions reads it, so an edit here is live on the
|
||||||
@@ -617,6 +617,21 @@ local function gen2Rows(opts, hooks)
|
|||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
local okFill, BorderFill = pcall(require, "src.world.gen2.BorderFill")
|
||||||
|
if okFill and BorderFill.VOID_FILLS then
|
||||||
|
add(Strings("VOID FILL"),
|
||||||
|
function() return BorderFill.voidFillLabel(opts.voidFill) end,
|
||||||
|
function(dir)
|
||||||
|
local modes = BorderFill.VOID_FILLS
|
||||||
|
local cur, idx = opts.voidFill or "fade", 1
|
||||||
|
for i, m in ipairs(modes) do
|
||||||
|
if m == cur then idx = i break end
|
||||||
|
end
|
||||||
|
opts.voidFill = modes[wrapIndex(idx - 1 + dir, #modes) + 1]
|
||||||
|
return true
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
-- Same #136 gate as the Gen 1 row and the in-game one.
|
-- Same #136 gate as the Gen 1 row and the in-game one.
|
||||||
local okFx, GBCFX = pcall(require, "src.render.GBCFX")
|
local okFx, GBCFX = pcall(require, "src.render.GBCFX")
|
||||||
if okFx and GBCFX.isSupported() then
|
if okFx and GBCFX.isSupported() then
|
||||||
|
|||||||
+124
-56
@@ -1626,13 +1626,17 @@ end
|
|||||||
-- The persisted sort choice both mod panels share. The chooser itself is a
|
-- The persisted sort choice both mod panels share. The chooser itself is a
|
||||||
-- popup (buildSortModal); panels just read the current key and offer a
|
-- popup (buildSortModal); panels just read the current key and offer a
|
||||||
-- "Sort" button, which is what freed the chip row's two lines of space.
|
-- "Sort" button, which is what freed the chip row's two lines of space.
|
||||||
local function sortDefs()
|
local function sortDefs(scope)
|
||||||
return {
|
local defs = {
|
||||||
{ key = "name", label = Strings("Name") },
|
{ key = "name", label = Strings("Name") },
|
||||||
{ key = "popularity", label = Strings("Popularity") },
|
{ key = "popularity", label = Strings("Most downloaded") },
|
||||||
{ key = "release", label = Strings("Release date") },
|
|
||||||
{ key = "updated", label = Strings("Last updated") },
|
|
||||||
}
|
}
|
||||||
|
if scope == "find" then
|
||||||
|
defs[#defs + 1] = { key = "trending", label = Strings("Trending") }
|
||||||
|
end
|
||||||
|
defs[#defs + 1] = { key = "release", label = Strings("Release date") }
|
||||||
|
defs[#defs + 1] = { key = "updated", label = Strings("Last updated") }
|
||||||
|
return defs
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Sorting is decorate-sort-undecorate: the key is computed once per entry
|
-- Sorting is decorate-sort-undecorate: the key is computed once per entry
|
||||||
@@ -1684,7 +1688,7 @@ local function sortCacheOk(cache, src, key, rev, pending)
|
|||||||
return pending and (Kit.time - (cache.at or 0)) < RESORT_DEBOUNCE
|
return pending and (Kit.time - (cache.at or 0)) < RESORT_DEBOUNCE
|
||||||
end
|
end
|
||||||
|
|
||||||
local function currentSort(imp)
|
local function currentSort(imp, scope)
|
||||||
local sortKey = imp.modSort
|
local sortKey = imp.modSort
|
||||||
if sortKey == nil then
|
if sortKey == nil then
|
||||||
local ok, opts = pcall(require("src.core.SaveData").loadOptions)
|
local ok, opts = pcall(require("src.core.SaveData").loadOptions)
|
||||||
@@ -1694,6 +1698,7 @@ local function currentSort(imp)
|
|||||||
sortKey = sortKey or "popularity"
|
sortKey = sortKey or "popularity"
|
||||||
imp.modSort = sortKey
|
imp.modSort = sortKey
|
||||||
end
|
end
|
||||||
|
if sortKey == "trending" and scope ~= "find" then return "popularity" end
|
||||||
return sortKey
|
return sortKey
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -1760,7 +1765,7 @@ local function buildModsPanel(imp, x, y, w, availH, m)
|
|||||||
action = function() imp:_syncModUpdateInfo(true) end })
|
action = function() imp:_syncModUpdateInfo(true) end })
|
||||||
btn(imp, place(sortW), cy, sortW, bh, "mods-sort", Strings("Sort"), {
|
btn(imp, place(sortW), cy, sortW, bh, "mods-sort", Strings("Sort"), {
|
||||||
font = "small",
|
font = "small",
|
||||||
action = function() imp._sortPopup = true end })
|
action = function() imp._sortPopup = "mods" end })
|
||||||
elseif medReq <= w then
|
elseif medReq <= w then
|
||||||
-- Tier 2 (Medium / Compact): Surface Import, Updates, and Sort directly
|
-- Tier 2 (Medium / Compact): Surface Import, Updates, and Sort directly
|
||||||
btn(imp, place(importW), cy, importW, bh, "mods-import", importLabel, {
|
btn(imp, place(importW), cy, importW, bh, "mods-import", importLabel, {
|
||||||
@@ -1771,7 +1776,7 @@ local function buildModsPanel(imp, x, y, w, availH, m)
|
|||||||
action = function() imp:_syncModUpdateInfo(true) end })
|
action = function() imp:_syncModUpdateInfo(true) end })
|
||||||
btn(imp, place(sortW), cy, sortW, bh, "mods-sort", Strings("Sort"), {
|
btn(imp, place(sortW), cy, sortW, bh, "mods-sort", Strings("Sort"), {
|
||||||
font = "small",
|
font = "small",
|
||||||
action = function() imp._sortPopup = true end })
|
action = function() imp._sortPopup = "mods" end })
|
||||||
btn(imp, place(moreW), cy, moreW, bh, "mods-more-actions", Strings("More..."), {
|
btn(imp, place(moreW), cy, moreW, bh, "mods-more-actions", Strings("More..."), {
|
||||||
font = "small",
|
font = "small",
|
||||||
action = function() imp._modHeaderActionsPopup = true end })
|
action = function() imp._modHeaderActionsPopup = true end })
|
||||||
@@ -1787,7 +1792,7 @@ local function buildModsPanel(imp, x, y, w, availH, m)
|
|||||||
action = function() imp:chooseMod() end })
|
action = function() imp:chooseMod() end })
|
||||||
btn(imp, place(sortW), cy, sortW, bh, "mods-sort", Strings("Sort"), {
|
btn(imp, place(sortW), cy, sortW, bh, "mods-sort", Strings("Sort"), {
|
||||||
font = "small",
|
font = "small",
|
||||||
action = function() imp._sortPopup = true end })
|
action = function() imp._sortPopup = "mods" end })
|
||||||
btn(imp, place(moreW), cy, moreW, bh, "mods-more-actions", Strings("More..."), {
|
btn(imp, place(moreW), cy, moreW, bh, "mods-more-actions", Strings("More..."), {
|
||||||
font = "small",
|
font = "small",
|
||||||
action = function() imp._modHeaderActionsPopup = true end })
|
action = function() imp._modHeaderActionsPopup = true end })
|
||||||
@@ -1819,7 +1824,7 @@ local function buildModsPanel(imp, x, y, w, availH, m)
|
|||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
local sortKey = currentSort(imp)
|
local sortKey = currentSort(imp, "mods")
|
||||||
|
|
||||||
-- Immediate mode paints this panel every frame; re-sorting the whole list
|
-- Immediate mode paints this panel every frame; re-sorting the whole list
|
||||||
-- per frame (with lowercased-string allocations in the comparator) fed the
|
-- per frame (with lowercased-string allocations in the comparator) fed the
|
||||||
@@ -2017,6 +2022,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,30 +2097,57 @@ 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
|
||||||
|
|
||||||
skinRow("skin-none", nil, Strings("Built-in pad"),
|
local entries = { false }
|
||||||
Strings("The default on-screen buttons."), active == nil,
|
for _, entry in ipairs(skins) do entries[#entries + 1] = entry end
|
||||||
imp.onEditTouchControls and function()
|
|
||||||
imp.onEditTouchControls(imp.modScope or "red")
|
|
||||||
end or nil)
|
|
||||||
|
|
||||||
for _, entry in ipairs(skins) do
|
local TouchSkin = require("src.core.TouchSkin")
|
||||||
local bits = {}
|
local hint = Strings(
|
||||||
bits[#bits + 1] = entry.source == "user" and Strings("installed")
|
"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.",
|
||||||
or Strings("bundled")
|
TouchSkin.USER_ROOT)
|
||||||
if entry.controls > 0 then
|
local hintH = Kit.wrapHeight("small", hint, w, 3)
|
||||||
bits[#bits + 1] = entry.controls .. " " .. Strings("buttons")
|
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"),
|
||||||
|
Strings("The default on-screen buttons."), active == nil,
|
||||||
|
imp.onEditTouchControls and function()
|
||||||
|
imp.onEditTouchControls(imp.modScope or "red")
|
||||||
|
end or nil)
|
||||||
else
|
else
|
||||||
bits[#bits + 1] = Strings("bezel only")
|
local bits = {}
|
||||||
|
bits[#bits + 1] = entry.source == "user" and Strings("installed")
|
||||||
|
or Strings("bundled")
|
||||||
|
if entry.controls > 0 then
|
||||||
|
bits[#bits + 1] = entry.controls .. " " .. Strings("buttons")
|
||||||
|
else
|
||||||
|
bits[#bits + 1] = Strings("bezel only")
|
||||||
|
end
|
||||||
|
if entry.pages > 1 then
|
||||||
|
bits[#bits + 1] = entry.pages .. " " .. Strings("pages")
|
||||||
|
end
|
||||||
|
if entry.screen then bits[#bits + 1] = Strings("screen cutout") end
|
||||||
|
local configure = imp.onOpenSkinStudio and function()
|
||||||
|
imp.onOpenSkinStudio(imp.modScope or "red", entry.id)
|
||||||
|
end or nil
|
||||||
|
skinRow("skin-" .. entry.id, entry.id, entry.id,
|
||||||
|
table.concat(bits, " \194\183 "), active == entry.id, configure)
|
||||||
end
|
end
|
||||||
if entry.pages > 1 then
|
|
||||||
bits[#bits + 1] = entry.pages .. " " .. Strings("pages")
|
|
||||||
end
|
|
||||||
if entry.screen then bits[#bits + 1] = Strings("screen cutout") end
|
|
||||||
local configure = imp.onOpenSkinStudio and function()
|
|
||||||
imp.onOpenSkinStudio(imp.modScope or "red", entry.id)
|
|
||||||
end or nil
|
|
||||||
skinRow("skin-" .. entry.id, entry.id, entry.id,
|
|
||||||
table.concat(bits, " \194\183 "), active == entry.id, configure)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
if #skins == 0 then
|
if #skins == 0 then
|
||||||
@@ -2107,18 +2156,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)
|
||||||
@@ -2170,7 +2215,7 @@ local function buildFindPanel(imp, x, y, w, availH, m)
|
|||||||
local sw = Kit.textWidth("small", Strings("Sort")) + math.floor(20 * m.s)
|
local sw = Kit.textWidth("small", Strings("Sort")) + math.floor(20 * m.s)
|
||||||
btn(imp, place(sw), cy, sw, fieldH, "find-sort", Strings("Sort"), {
|
btn(imp, place(sw), cy, sw, fieldH, "find-sort", Strings("Sort"), {
|
||||||
font = "small",
|
font = "small",
|
||||||
action = function() imp._sortPopup = true end })
|
action = function() imp._sortPopup = "find" end })
|
||||||
-- The Filter button carries its state: blue while a category is active,
|
-- The Filter button carries its state: blue while a category is active,
|
||||||
-- so a filtered-down list never reads as "the index shrank".
|
-- so a filtered-down list never reads as "the index shrank".
|
||||||
local fw = Kit.textWidth("small", Strings("Filter")) + math.floor(20 * m.s)
|
local fw = Kit.textWidth("small", Strings("Filter")) + math.floor(20 * m.s)
|
||||||
@@ -2190,7 +2235,7 @@ local function buildFindPanel(imp, x, y, w, availH, m)
|
|||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
local sortKey = currentSort(imp)
|
local sortKey = currentSort(imp, "find")
|
||||||
|
|
||||||
-- Same caching rule as the MODS tab: the comparator allocates, so only
|
-- Same caching rule as the MODS tab: the comparator allocates, so only
|
||||||
-- re-sort when the inputs actually change.
|
-- re-sort when the inputs actually change.
|
||||||
@@ -2209,6 +2254,7 @@ local function buildFindPanel(imp, x, y, w, availH, m)
|
|||||||
-- fetch for every entry in the index (see _findStatsCached).
|
-- fetch for every entry in the index (see _findStatsCached).
|
||||||
local stats = imp:_findStatsCached(entry)
|
local stats = imp:_findStatsCached(entry)
|
||||||
if sortKey == "popularity" then return stats and stats.total or -1 end
|
if sortKey == "popularity" then return stats and stats.total or -1 end
|
||||||
|
if sortKey == "trending" then return stats and stats.recent or -1 end
|
||||||
if sortKey == "release" then return stats and stats.first or "0000-00-00" end
|
if sortKey == "release" then return stats and stats.first or "0000-00-00" end
|
||||||
return stats and stats.latest or "0000-00-00"
|
return stats and stats.latest or "0000-00-00"
|
||||||
end,
|
end,
|
||||||
@@ -2296,13 +2342,14 @@ local function buildFindPanel(imp, x, y, w, availH, m)
|
|||||||
bx, ly, PAL.heading)
|
bx, ly, PAL.heading)
|
||||||
local by2 = ly + Kit.textHeight("button") + math.floor(4 * m.s)
|
local by2 = ly + Kit.textHeight("button") + math.floor(4 * m.s)
|
||||||
-- meta and stats on one line, the download count first (and green)
|
-- meta and stats on one line, the download count first (and green)
|
||||||
-- because it is what the default Popularity sort is ordering by: a
|
-- because it is what the default Most-downloaded sort is ordering by: a
|
||||||
-- narrow window ellipsizes the tail, and the count must survive that.
|
-- narrow window ellipsizes the tail, and the count must survive that.
|
||||||
local stats = imp:_findStats(entry)
|
local stats = imp:_findStats(entry)
|
||||||
local baseCol = note and PAL.green or PAL.detail
|
local baseCol = note and PAL.green or PAL.detail
|
||||||
local lead = "v" .. tostring(ModIndex.displayVersion(entry))
|
local lead = "v" .. tostring(ModIndex.displayVersion(entry))
|
||||||
if note then lead = lead .. " - " .. note end
|
if note then lead = lead .. " - " .. note end
|
||||||
local dl = stats and ModUpdate.downloadsLine(stats.total) or nil
|
local dl = stats and ModUpdate.downloadsShort(stats.total) or nil
|
||||||
|
local hasCount = stats ~= nil and stats.total ~= nil
|
||||||
local dates = stats and ModUpdate.datesLine(stats.first, stats.latest)
|
local dates = stats and ModUpdate.datesLine(stats.first, stats.latest)
|
||||||
or nil
|
or nil
|
||||||
local rest = {}
|
local rest = {}
|
||||||
@@ -2312,11 +2359,13 @@ local function buildFindPanel(imp, x, y, w, availH, m)
|
|||||||
end
|
end
|
||||||
if dates then
|
if dates then
|
||||||
rest[#rest + 1] = dates
|
rest[#rest + 1] = dates
|
||||||
elseif not dl and (entry.summary or "") ~= "" then
|
elseif not hasCount and (entry.summary or "") ~= "" then
|
||||||
rest[#rest + 1] = entry.summary
|
rest[#rest + 1] = entry.summary
|
||||||
end
|
end
|
||||||
local segs = { { lead, baseCol } }
|
local segs = { { lead, baseCol } }
|
||||||
if dl then segs[#segs + 1] = { " - " .. dl, PAL.green } end
|
if dl then
|
||||||
|
segs[#segs + 1] = { " - " .. dl, hasCount and PAL.green or PAL.faint }
|
||||||
|
end
|
||||||
if #rest > 0 then
|
if #rest > 0 then
|
||||||
segs[#segs + 1] = { " - " .. table.concat(rest, " - "), baseCol }
|
segs[#segs + 1] = { " - " .. table.concat(rest, " - "), baseCol }
|
||||||
end
|
end
|
||||||
@@ -2904,7 +2953,7 @@ local function buildModHeaderActionsModal(imp, m)
|
|||||||
{ label = Strings("Check for updates"), action = function() imp:_syncModUpdateInfo(true) end },
|
{ label = Strings("Check for updates"), action = function() imp:_syncModUpdateInfo(true) end },
|
||||||
{ label = Strings("Enable all mods"), kind = "good", action = function() imp:_setAllMods(true) end },
|
{ label = Strings("Enable all mods"), kind = "good", action = function() imp:_setAllMods(true) end },
|
||||||
{ label = Strings("Disable all mods"), kind = "warn", action = function() imp:_setAllMods(false) end },
|
{ label = Strings("Disable all mods"), kind = "warn", action = function() imp:_setAllMods(false) end },
|
||||||
{ label = Strings("Sort mods..."), action = function() imp._sortPopup = true end },
|
{ label = Strings("Sort mods..."), action = function() imp._sortPopup = "mods" end },
|
||||||
}
|
}
|
||||||
local h = pad + Kit.textHeight("button") + math.floor(12 * m.s)
|
local h = pad + Kit.textHeight("button") + math.floor(12 * m.s)
|
||||||
+ #btns * (m.btnH + gap) + m.btnH + pad
|
+ #btns * (m.btnH + gap) + m.btnH + pad
|
||||||
@@ -2932,7 +2981,8 @@ end
|
|||||||
-- Sort chooser, shared by the MODS and FIND MODS tabs (they share the
|
-- Sort chooser, shared by the MODS and FIND MODS tabs (they share the
|
||||||
-- persisted key, so one popup serves both).
|
-- persisted key, so one popup serves both).
|
||||||
local function buildSortModal(imp, m)
|
local function buildSortModal(imp, m)
|
||||||
local defs = sortDefs()
|
local scope = imp._sortPopup
|
||||||
|
local defs = sortDefs(scope)
|
||||||
local pad = math.floor(18 * m.s)
|
local pad = math.floor(18 * m.s)
|
||||||
local w = math.floor(360 * m.s)
|
local w = math.floor(360 * m.s)
|
||||||
local gap = math.floor(8 * m.s)
|
local gap = math.floor(8 * m.s)
|
||||||
@@ -2942,7 +2992,7 @@ local function buildSortModal(imp, m)
|
|||||||
local cy = py + pad
|
local cy = py + pad
|
||||||
Kit.text("button", Strings("Sort by"), px + pad, cy, PAL.heading)
|
Kit.text("button", Strings("Sort by"), px + pad, cy, PAL.heading)
|
||||||
cy = cy + Kit.textHeight("button") + math.floor(12 * m.s)
|
cy = cy + Kit.textHeight("button") + math.floor(12 * m.s)
|
||||||
local cur = currentSort(imp)
|
local cur = currentSort(imp, scope)
|
||||||
for _, s in ipairs(defs) do
|
for _, s in ipairs(defs) do
|
||||||
local key = s.key
|
local key = s.key
|
||||||
btn(imp, px + pad, cy, pw - 2 * pad, m.btnH, "sortpop-" .. key, s.label, {
|
btn(imp, px + pad, cy, pw - 2 * pad, m.btnH, "sortpop-" .. key, s.label, {
|
||||||
@@ -3322,25 +3372,43 @@ local function buildFindEntryModal(imp, m)
|
|||||||
local gap = math.floor(8 * m.s)
|
local gap = math.floor(8 * m.s)
|
||||||
local nBtns = 3 -- install row, details/source row, close row
|
local nBtns = 3 -- install row, details/source row, close row
|
||||||
local noteH = note and (Kit.textHeight("small") + math.floor(4 * m.s)) or 0
|
local noteH = note and (Kit.textHeight("small") + math.floor(4 * m.s)) or 0
|
||||||
|
local stats = imp:_findStats(entry)
|
||||||
|
local trend = {}
|
||||||
|
local trendLine = stats and ModUpdate.trendingLine(stats.recent, stats.windowDays)
|
||||||
|
if trendLine then trend[#trend + 1] = trendLine end
|
||||||
|
if stats and stats.asOf then
|
||||||
|
trend[#trend + 1] = Strings("counts approximate, as of %s",
|
||||||
|
tostring(stats.asOf):match("^%d%d%d%d%-%d%d%-%d%d") or stats.asOf)
|
||||||
|
end
|
||||||
|
trend = (#trend > 0) and table.concat(trend, " - ") or nil
|
||||||
|
local trendH = trend and (Kit.textHeight("small") + math.floor(2 * m.s)) or 0
|
||||||
local h = pad + Kit.textHeight("button") + math.floor(4 * m.s)
|
local h = pad + Kit.textHeight("button") + math.floor(4 * m.s)
|
||||||
+ Kit.textHeight("small") + noteH + math.floor(12 * m.s)
|
+ Kit.textHeight("small") + trendH + noteH + math.floor(12 * m.s)
|
||||||
+ nBtns * (m.btnH + gap) - gap + pad
|
+ nBtns * (m.btnH + gap) - gap + pad
|
||||||
local px, py, pw = modalPanel(m, w, h)
|
local px, py, pw = modalPanel(m, w, h)
|
||||||
local cy = py + pad
|
local cy = py + pad
|
||||||
Kit.text("button", Kit.ellipsize("button", entry.title or entry.id,
|
Kit.text("button", Kit.ellipsize("button", entry.title or entry.id,
|
||||||
pw - 2 * pad), px + pad, cy, PAL.heading)
|
pw - 2 * pad), px + pad, cy, PAL.heading)
|
||||||
cy = cy + Kit.textHeight("button") + math.floor(4 * m.s)
|
cy = cy + Kit.textHeight("button") + math.floor(4 * m.s)
|
||||||
local stats = imp:_findStats(entry)
|
|
||||||
local lead = "v" .. tostring(ModIndex.displayVersion(entry))
|
local lead = "v" .. tostring(ModIndex.displayVersion(entry))
|
||||||
if entry.author then lead = lead .. " - " .. entry.author end
|
if entry.author then lead = lead .. " - " .. entry.author end
|
||||||
if entry.categories and entry.categories[1] then
|
if entry.categories and entry.categories[1] then
|
||||||
lead = lead .. " - " .. entry.categories[1]
|
lead = lead .. " - " .. entry.categories[1]
|
||||||
end
|
end
|
||||||
local dl = stats and ModUpdate.downloadsLine(stats.total) or nil
|
local dl = stats and ModUpdate.downloadsShort(stats.total) or nil
|
||||||
|
local hasCount = stats ~= nil and stats.total ~= nil
|
||||||
local segs = { { lead, PAL.detail } }
|
local segs = { { lead, PAL.detail } }
|
||||||
if dl then segs[#segs + 1] = { " - " .. dl, PAL.green } end
|
if dl then
|
||||||
|
segs[#segs + 1] = { " - " .. dl, hasCount and PAL.green or PAL.faint }
|
||||||
|
end
|
||||||
segLine("small", segs, px + pad, cy, pw - 2 * pad)
|
segLine("small", segs, px + pad, cy, pw - 2 * pad)
|
||||||
cy = cy + Kit.textHeight("small")
|
cy = cy + Kit.textHeight("small")
|
||||||
|
if trend then
|
||||||
|
cy = cy + math.floor(2 * m.s)
|
||||||
|
Kit.text("small", Kit.ellipsize("small", trend, pw - 2 * pad),
|
||||||
|
px + pad, cy, PAL.muted)
|
||||||
|
cy = cy + Kit.textHeight("small")
|
||||||
|
end
|
||||||
if note then
|
if note then
|
||||||
cy = cy + math.floor(4 * m.s)
|
cy = cy + math.floor(4 * m.s)
|
||||||
Kit.text("small", note, px + pad, cy, PAL.green)
|
Kit.text("small", note, px + pad, cy, PAL.green)
|
||||||
|
|||||||
+182
-41
@@ -192,20 +192,28 @@ local PAL = {
|
|||||||
chipInkGold = { 58, 44, 0 }, -- #3a2c00 dark "Y" on the gold chip
|
chipInkGold = { 58, 44, 0 }, -- #3a2c00 dark "Y" on the gold chip
|
||||||
}
|
}
|
||||||
|
|
||||||
|
-- Per-version required cache files. Gold replaces the Gen 1 list entirely
|
||||||
|
-- (VERSION_REQUIRED_FILES_OVERRIDE); Yellow adds a few extra markers.
|
||||||
|
local function requiredFilesFor(version)
|
||||||
|
local override = VERSION_REQUIRED_FILES_OVERRIDE[version]
|
||||||
|
if override then return override, true end
|
||||||
|
return REQUIRED_FILES, false
|
||||||
|
end
|
||||||
|
|
||||||
-- CacheFs.exists checks the game folder directly for a portable install,
|
-- CacheFs.exists checks the game folder directly for a portable install,
|
||||||
-- otherwise the save directory through love.filesystem. It honors
|
-- otherwise the save directory through love.filesystem. It honors
|
||||||
-- CacheFs.prefix, so we point it at the version's cache subtree (red/,
|
-- CacheFs.prefix, so we point it at the version's cache subtree (red/,
|
||||||
-- blue/, yellow/).
|
-- blue/, yellow/, gold/).
|
||||||
local function allRequiredFilesExist(version)
|
local function allRequiredFilesExist(version)
|
||||||
local CacheFs = require("src.import.CacheFs")
|
local CacheFs = require("src.import.CacheFs")
|
||||||
local saved = CacheFs.prefix
|
local saved = CacheFs.prefix
|
||||||
CacheFs.prefix = GameVersion.cachePrefix(version)
|
CacheFs.prefix = GameVersion.cachePrefix(version)
|
||||||
local ok = true
|
local ok = true
|
||||||
local required = VERSION_REQUIRED_FILES_OVERRIDE[version] or REQUIRED_FILES
|
local required, isOverride = requiredFilesFor(version)
|
||||||
for _, path in ipairs(required) do
|
for _, path in ipairs(required) do
|
||||||
if not CacheFs.exists(path) then ok = false; break end
|
if not CacheFs.exists(path) then ok = false; break end
|
||||||
end
|
end
|
||||||
if ok and not VERSION_REQUIRED_FILES_OVERRIDE[version] then
|
if ok and not isOverride then
|
||||||
for _, path in ipairs(VERSION_REQUIRED_FILES[version] or {}) do
|
for _, path in ipairs(VERSION_REQUIRED_FILES[version] or {}) do
|
||||||
if not CacheFs.exists(path) then ok = false; break end
|
if not CacheFs.exists(path) then ok = false; break end
|
||||||
end
|
end
|
||||||
@@ -215,20 +223,23 @@ local function allRequiredFilesExist(version)
|
|||||||
end
|
end
|
||||||
|
|
||||||
-- A developer checkout / Python build leaves generated data in the physfs
|
-- A developer checkout / Python build leaves generated data in the physfs
|
||||||
-- source: Red at the historical root, Blue/Yellow in their versioned trees.
|
-- source: Red at the historical root, Blue/Yellow/Gold in their versioned
|
||||||
-- Imported Red caches still live under red/. Check source paths directly so
|
-- trees. Imported Red caches still live under red/. Check source paths
|
||||||
-- that cache prefix cannot hide Red's source tree, and keep save-dir caches
|
-- directly so that cache prefix cannot hide Red's source tree, and keep
|
||||||
-- from counting as current source data.
|
-- save-dir caches from counting as current source data.
|
||||||
local function sourceTreeHasData(version)
|
local function sourceTreeHasData(version)
|
||||||
if not love.filesystem.getRealDirectory then return false end
|
if not love.filesystem.getRealDirectory then return false end
|
||||||
local prefix = version == "red" and "" or GameVersion.cachePrefix(version)
|
local prefix = version == "red" and "" or GameVersion.cachePrefix(version)
|
||||||
for _, path in ipairs(REQUIRED_FILES) do
|
local required, isOverride = requiredFilesFor(version)
|
||||||
|
for _, path in ipairs(required) do
|
||||||
if love.filesystem.getInfo(prefix .. path, "file") == nil then return false end
|
if love.filesystem.getInfo(prefix .. path, "file") == nil then return false end
|
||||||
end
|
end
|
||||||
for _, path in ipairs(VERSION_REQUIRED_FILES[version] or {}) do
|
if not isOverride then
|
||||||
if love.filesystem.getInfo(prefix .. path, "file") == nil then return false end
|
for _, path in ipairs(VERSION_REQUIRED_FILES[version] or {}) do
|
||||||
|
if love.filesystem.getInfo(prefix .. path, "file") == nil then return false end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
local path = prefix .. REQUIRED_FILES[1]
|
local path = prefix .. required[1]
|
||||||
local real = love.filesystem.getRealDirectory(path)
|
local real = love.filesystem.getRealDirectory(path)
|
||||||
return real == love.filesystem.getSource()
|
return real == love.filesystem.getSource()
|
||||||
end
|
end
|
||||||
@@ -285,7 +296,7 @@ local function purgeSaveDirCache()
|
|||||||
return true
|
return true
|
||||||
end
|
end
|
||||||
-- Purge each version's stale save-directory copy (under its red/ / blue/
|
-- Purge each version's stale save-directory copy (under its red/ / blue/
|
||||||
-- / yellow/ prefix) so it cannot shadow the portable game-folder cache.
|
-- / yellow/ / gold/ prefix) so it cannot shadow the portable game-folder cache.
|
||||||
for _, version in ipairs(GameVersion.ORDER) do
|
for _, version in ipairs(GameVersion.ORDER) do
|
||||||
local prefix = GameVersion.cachePrefix(version)
|
local prefix = GameVersion.cachePrefix(version)
|
||||||
if saveDirHas(prefix .. MARKER_PATH) or saveDirHas(prefix .. REQUIRED_FILES[1]) then
|
if saveDirHas(prefix .. MARKER_PATH) or saveDirHas(prefix .. REQUIRED_FILES[1]) then
|
||||||
@@ -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
|
||||||
self.modNotice = { ok = false, text = text }
|
if self.pickerPendingKind == "skin" then
|
||||||
|
self.pickerPendingKind = nil
|
||||||
|
self._skinNotice = { ok = false, text = text }
|
||||||
|
else
|
||||||
|
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
|
||||||
@@ -4313,9 +4449,14 @@ function RomImporter:_findStatsCached(entry)
|
|||||||
end
|
end
|
||||||
return nil -- retry window open; _requestFindStats decides what to do
|
return nil -- retry window open; _requestFindStats decides what to do
|
||||||
end
|
end
|
||||||
if entry.downloads ~= nil or entry.first_release or entry.last_release then
|
local ModIndex = require("src.mods.ModIndex")
|
||||||
cached = { total = entry.downloads, first = entry.first_release,
|
local dl = ModIndex.downloadStats(entry)
|
||||||
latest = entry.last_release, done = true }
|
local dates = ModIndex.releaseDates(entry)
|
||||||
|
if dl or dates then
|
||||||
|
cached = { total = dl and dl.total, recent = dl and dl.recent,
|
||||||
|
windowDays = dl and dl.window_days, asOf = dl and dl.as_of,
|
||||||
|
first = dates and dates.first, latest = dates and dates.latest,
|
||||||
|
done = true }
|
||||||
self._findStatsCache[entry.id] = cached
|
self._findStatsCache[entry.id] = cached
|
||||||
return cached
|
return cached
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -141,7 +141,7 @@ function SaveFileIO.exportActiveSlot(version)
|
|||||||
fs.createDirectory("exports")
|
fs.createDirectory("exports")
|
||||||
fs.createDirectory("exports/" .. version)
|
fs.createDirectory("exports/" .. version)
|
||||||
end
|
end
|
||||||
-- Per-game folder so MTP browsing matches inbox layout (red/blue/yellow).
|
-- Per-game folder so MTP browsing matches inbox layout (red/blue/yellow/gold).
|
||||||
local rel = ("exports/%s/gen1recomp-%s-%s.sav"):format(version, version, slotId)
|
local rel = ("exports/%s/gen1recomp-%s-%s.sav"):format(version, version, slotId)
|
||||||
local ok, writeErr = fs.write(rel, bytes)
|
local ok, writeErr = fs.write(rel, bytes)
|
||||||
if not ok then return false, "could not write the export: " .. tostring(writeErr) end
|
if not ok then return false, "could not write the export: " .. tostring(writeErr) end
|
||||||
|
|||||||
@@ -270,7 +270,10 @@ end
|
|||||||
|
|
||||||
local function index(mods)
|
local function index(mods)
|
||||||
local byId = {}
|
local byId = {}
|
||||||
for _, mod in ipairs(mods or {}) do byId[tostring(mod.id)] = mod end
|
if type(mods) ~= "table" then return byId end
|
||||||
|
for _, mod in ipairs(mods) do
|
||||||
|
if type(mod) == "table" then byId[tostring(mod.id)] = mod end
|
||||||
|
end
|
||||||
return byId
|
return byId
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -327,7 +330,8 @@ end
|
|||||||
-- lines for the incompatibility screen: what differs, then what still works
|
-- lines for the incompatibility screen: what differs, then what still works
|
||||||
function Handshake.describe(localHello, remoteHello, verdict, mode)
|
function Handshake.describe(localHello, remoteHello, verdict, mode)
|
||||||
local lines = {}
|
local lines = {}
|
||||||
local peer = (remoteHello and remoteHello.name) or "THEY"
|
local peerName = remoteHello and remoteHello.name
|
||||||
|
local peer = type(peerName) == "string" and peerName or "THEY"
|
||||||
if verdict == "refused" then
|
if verdict == "refused" then
|
||||||
-- checked before the v1 arm for the same reason checkCompat checks it
|
-- checked before the v1 arm for the same reason checkCompat checks it
|
||||||
-- first: a Gen 1 peer meeting a Gen 2 one has no `protocol` to read yet
|
-- first: a Gen 1 peer meeting a Gen 2 one has no `protocol` to read yet
|
||||||
|
|||||||
+13
-5
@@ -4,6 +4,8 @@
|
|||||||
|
|
||||||
local Json = {}
|
local Json = {}
|
||||||
|
|
||||||
|
Json.MAX_DEPTH = 64
|
||||||
|
|
||||||
local function encodeValue(v, out)
|
local function encodeValue(v, out)
|
||||||
local t = type(v)
|
local t = type(v)
|
||||||
if v == nil then
|
if v == nil then
|
||||||
@@ -109,7 +111,9 @@ local function decodeString(s, i)
|
|||||||
error("unterminated string")
|
error("unterminated string")
|
||||||
end
|
end
|
||||||
|
|
||||||
decodeValue = function(s, i)
|
decodeValue = function(s, i, depth)
|
||||||
|
depth = (depth or 0) + 1
|
||||||
|
assert(depth <= Json.MAX_DEPTH, "json nested too deeply")
|
||||||
i = skipWs(s, i)
|
i = skipWs(s, i)
|
||||||
local c = s:sub(i, i)
|
local c = s:sub(i, i)
|
||||||
if c == '"' then
|
if c == '"' then
|
||||||
@@ -124,7 +128,7 @@ decodeValue = function(s, i)
|
|||||||
i = skipWs(s, i)
|
i = skipWs(s, i)
|
||||||
assert(s:sub(i, i) == ":", "expected :")
|
assert(s:sub(i, i) == ":", "expected :")
|
||||||
local val
|
local val
|
||||||
val, i = decodeValue(s, i + 1)
|
val, i = decodeValue(s, i + 1, depth)
|
||||||
obj[key] = val
|
obj[key] = val
|
||||||
i = skipWs(s, i)
|
i = skipWs(s, i)
|
||||||
local d = s:sub(i, i)
|
local d = s:sub(i, i)
|
||||||
@@ -138,7 +142,7 @@ decodeValue = function(s, i)
|
|||||||
if s:sub(i, i) == "]" then return arr, i + 1 end
|
if s:sub(i, i) == "]" then return arr, i + 1 end
|
||||||
while true do
|
while true do
|
||||||
local val
|
local val
|
||||||
val, i = decodeValue(s, i)
|
val, i = decodeValue(s, i, depth)
|
||||||
arr[#arr + 1] = val
|
arr[#arr + 1] = val
|
||||||
i = skipWs(s, i)
|
i = skipWs(s, i)
|
||||||
local d = s:sub(i, i)
|
local d = s:sub(i, i)
|
||||||
@@ -162,9 +166,13 @@ decodeValue = function(s, i)
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
function Json.decode(s)
|
function Json.decode(s, maxLength)
|
||||||
|
if type(s) ~= "string" then return nil, "json input is not a string" end
|
||||||
|
if maxLength and #s > maxLength then
|
||||||
|
return nil, ("json input is %d bytes (max %d)"):format(#s, maxLength)
|
||||||
|
end
|
||||||
local ok, v = pcall(function()
|
local ok, v = pcall(function()
|
||||||
local val = select(1, decodeValue(s, 1))
|
local val = select(1, decodeValue(s, 1, 0))
|
||||||
return val
|
return val
|
||||||
end)
|
end)
|
||||||
if ok then return v end
|
if ok then return v end
|
||||||
|
|||||||
@@ -29,7 +29,9 @@ local LinkBattle = {}
|
|||||||
-- Deterministic Park-Miller PRNG: both sides must roll identical
|
-- Deterministic Park-Miller PRNG: both sides must roll identical
|
||||||
-- streams, so love.math.random can't be used.
|
-- streams, so love.math.random can't be used.
|
||||||
local function makeRng(seed)
|
local function makeRng(seed)
|
||||||
local s = seed % 2147483647
|
local s = tonumber(seed) or 1
|
||||||
|
if s ~= s or s == math.huge or s == -math.huge then s = 1 end
|
||||||
|
s = math.floor(s) % 2147483647
|
||||||
if s <= 0 then s = s + 2147483646 end
|
if s <= 0 then s = s + 2147483646 end
|
||||||
return function(a, b)
|
return function(a, b)
|
||||||
s = (s * 16807) % 2147483647
|
s = (s * 16807) % 2147483647
|
||||||
|
|||||||
+19
-5
@@ -40,6 +40,10 @@ Net.__index = Net
|
|||||||
Net.DEFAULT_PORT = 7777
|
Net.DEFAULT_PORT = 7777
|
||||||
Net.DEFAULT_RELAY_ADDRESS = "147.182.215.255:7778"
|
Net.DEFAULT_RELAY_ADDRESS = "147.182.215.255:7778"
|
||||||
|
|
||||||
|
Net.MAX_LINE = 256 * 1024
|
||||||
|
Net.MAX_RX_PER_FRAME = 512 * 1024
|
||||||
|
Net.ENET_BANDWIDTH = 256 * 1024
|
||||||
|
|
||||||
function Net.available()
|
function Net.available()
|
||||||
return enet ~= nil
|
return enet ~= nil
|
||||||
end
|
end
|
||||||
@@ -115,7 +119,8 @@ function Net:host(port)
|
|||||||
return false
|
return false
|
||||||
end
|
end
|
||||||
port = tonumber(port) or Net.defaultPort()
|
port = tonumber(port) or Net.defaultPort()
|
||||||
local ok, h, err = pcall(enet.host_create, ("*:%d"):format(port), 2, 1)
|
local ok, h, err = pcall(enet.host_create, ("*:%d"):format(port), 2, 1,
|
||||||
|
Net.ENET_BANDWIDTH, Net.ENET_BANDWIDTH)
|
||||||
if not ok or not h then
|
if not ok or not h then
|
||||||
self.error = ("can't open UDP port %d (%s)"):format(
|
self.error = ("can't open UDP port %d (%s)"):format(
|
||||||
port, tostring(ok and err or h))
|
port, tostring(ok and err or h))
|
||||||
@@ -255,7 +260,7 @@ local function handleGenericRelayControl(self, msg)
|
|||||||
end
|
end
|
||||||
|
|
||||||
function Net:handleTCPLine(line)
|
function Net:handleTCPLine(line)
|
||||||
local msg = Json.decode(line)
|
local msg = Json.decode(line, Net.MAX_LINE)
|
||||||
if msg == nil then
|
if msg == nil then
|
||||||
Logger.warn("link: bad relay message %q", line:sub(1, 60))
|
Logger.warn("link: bad relay message %q", line:sub(1, 60))
|
||||||
return
|
return
|
||||||
@@ -277,6 +282,11 @@ function Net:drainLines()
|
|||||||
self.rxBuf = self.rxBuf:sub(nl + 1)
|
self.rxBuf = self.rxBuf:sub(nl + 1)
|
||||||
if #line > 0 then self:handleTCPLine(line) end
|
if #line > 0 then self:handleTCPLine(line) end
|
||||||
end
|
end
|
||||||
|
if #self.rxBuf > Net.MAX_LINE then
|
||||||
|
self.rxBuf = ""
|
||||||
|
self.error = Strings("The other side\nsent bad data.")
|
||||||
|
self.closed = true
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
-- non-blocking pump for the relay TCP backend: flush queued writes, drain
|
-- non-blocking pump for the relay TCP backend: flush queued writes, drain
|
||||||
@@ -300,10 +310,14 @@ function Net:updateTCP()
|
|||||||
return
|
return
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
while true do
|
local budget = Net.MAX_RX_PER_FRAME
|
||||||
|
while budget > 0 do
|
||||||
local data, err, partial = sock:receive(8192)
|
local data, err, partial = sock:receive(8192)
|
||||||
local chunk = data or partial or ""
|
local chunk = data or partial or ""
|
||||||
if #chunk > 0 then self.rxBuf = self.rxBuf .. chunk end
|
if #chunk > 0 then
|
||||||
|
self.rxBuf = self.rxBuf .. chunk
|
||||||
|
budget = budget - #chunk
|
||||||
|
end
|
||||||
if err == "closed" then
|
if err == "closed" then
|
||||||
self.closed = true
|
self.closed = true
|
||||||
break
|
break
|
||||||
@@ -350,7 +364,7 @@ function Net:update()
|
|||||||
for _, msg in ipairs(queued) do self:send(msg) end
|
for _, msg in ipairs(queued) do self:send(msg) end
|
||||||
end
|
end
|
||||||
elseif event.type == "receive" then
|
elseif event.type == "receive" then
|
||||||
local msg = Json.decode(event.data)
|
local msg = Json.decode(event.data, Net.MAX_LINE)
|
||||||
if msg ~= nil then
|
if msg ~= nil then
|
||||||
table.insert(self.inbox, msg)
|
table.insert(self.inbox, msg)
|
||||||
else
|
else
|
||||||
|
|||||||
+89
-42
@@ -17,6 +17,25 @@ local Runtime = require("src.mods.Runtime")
|
|||||||
|
|
||||||
local Protocol = {}
|
local Protocol = {}
|
||||||
|
|
||||||
|
local MAX_WIRE_NAME = 40
|
||||||
|
|
||||||
|
local function num(v, default)
|
||||||
|
local n = tonumber(v)
|
||||||
|
if n == nil or n ~= n or n == math.huge or n == -math.huge then
|
||||||
|
return default
|
||||||
|
end
|
||||||
|
return n
|
||||||
|
end
|
||||||
|
|
||||||
|
local function tbl(v)
|
||||||
|
return type(v) == "table" and v or {}
|
||||||
|
end
|
||||||
|
|
||||||
|
local function text(v)
|
||||||
|
if type(v) ~= "string" then return nil end
|
||||||
|
return v:sub(1, MAX_WIRE_NAME)
|
||||||
|
end
|
||||||
|
|
||||||
Protocol.hello = Handshake.hello
|
Protocol.hello = Handshake.hello
|
||||||
Protocol.checkCompat = Handshake.checkCompat
|
Protocol.checkCompat = Handshake.checkCompat
|
||||||
|
|
||||||
@@ -78,6 +97,10 @@ function Protocol.unpackMon(data, packed, opts)
|
|||||||
local Stats = require("src.pokemon.Stats")
|
local Stats = require("src.pokemon.Stats")
|
||||||
local Growth = require("src.pokemon.Growth")
|
local Growth = require("src.pokemon.Growth")
|
||||||
local strict = opts and opts.strict
|
local strict = opts and opts.strict
|
||||||
|
if type(packed) ~= "table" then
|
||||||
|
if strict then return nil, "unknown POKéMON" end
|
||||||
|
return nil
|
||||||
|
end
|
||||||
-- forceLevel comes from an "auto-level" ruling. The picker's ANY choice
|
-- forceLevel comes from an "auto-level" ruling. The picker's ANY choice
|
||||||
-- ("use each mon's real level", Gen1's only mode) is a string sentinel on
|
-- ("use each mon's real level", Gen1's only mode) is a string sentinel on
|
||||||
-- the LinkState/Tournament side (see levelForWire) that must mean "no
|
-- the LinkState/Tournament side (see levelForWire) that must mean "no
|
||||||
@@ -91,7 +114,7 @@ function Protocol.unpackMon(data, packed, opts)
|
|||||||
if strict then return nil, "unknown POKéMON" end
|
if strict then return nil, "unknown POKéMON" end
|
||||||
return nil
|
return nil
|
||||||
end
|
end
|
||||||
local level = math.max(2, math.min(100, math.floor(packed.level or 5)))
|
local level = math.max(2, math.min(100, math.floor(num(packed.level, 5))))
|
||||||
-- "auto-level" tournaments/matches: every participant's real level is
|
-- "auto-level" tournaments/matches: every participant's real level is
|
||||||
-- ignored and everyone rebuilds at the same fixed level instead, so a
|
-- ignored and everyone rebuilds at the same fixed level instead, so a
|
||||||
-- Lv12 and a Lv100 party can battle on equal footing. Both sides pass
|
-- Lv12 and a Lv100 party can battle on equal footing. Both sides pass
|
||||||
@@ -99,25 +122,27 @@ function Protocol.unpackMon(data, packed, opts)
|
|||||||
if forceLevel then
|
if forceLevel then
|
||||||
level = math.max(2, math.min(100, math.floor(forceLevel)))
|
level = math.max(2, math.min(100, math.floor(forceLevel)))
|
||||||
end
|
end
|
||||||
|
local packedDvs, packedStatExp = tbl(packed.dvs), tbl(packed.statExp)
|
||||||
local dvs = {}
|
local dvs = {}
|
||||||
for _, k in ipairs({ "hp", "attack", "defense", "speed", "special" }) do
|
for _, k in ipairs({ "hp", "attack", "defense", "speed", "special" }) do
|
||||||
dvs[k] = math.max(0, math.min(15, math.floor((packed.dvs or {})[k] or 0)))
|
dvs[k] = math.max(0, math.min(15, math.floor(num(packedDvs[k], 0))))
|
||||||
end
|
end
|
||||||
local statExp = {}
|
local statExp = {}
|
||||||
for _, k in ipairs({ "hp", "attack", "defense", "speed", "special" }) do
|
for _, k in ipairs({ "hp", "attack", "defense", "speed", "special" }) do
|
||||||
statExp[k] = math.max(0, math.min(65535, math.floor((packed.statExp or {})[k] or 0)))
|
statExp[k] = math.max(0, math.min(65535, math.floor(num(packedStatExp[k], 0))))
|
||||||
end
|
end
|
||||||
local stats = Stats.calc(def, level, dvs, statExp)
|
local stats = Stats.calc(def, level, dvs, statExp)
|
||||||
local moves = {}
|
local moves = {}
|
||||||
for _, mv in ipairs(packed.moves or {}) do
|
for _, entry in ipairs(tbl(packed.moves)) do
|
||||||
|
local mv = tbl(entry)
|
||||||
local mdef = data.moves[mv.id]
|
local mdef = data.moves[mv.id]
|
||||||
if mdef and #moves < 4 then
|
if mdef and #moves < 4 then
|
||||||
local ppUps = math.max(0, math.min(3, math.floor(mv.ppUps or 0)))
|
local ppUps = math.max(0, math.min(3, math.floor(num(mv.ppUps, 0))))
|
||||||
local maxPP = mdef.pp + ppUps * math.floor(mdef.pp / 5)
|
local maxPP = mdef.pp + ppUps * math.floor(mdef.pp / 5)
|
||||||
local entry = { id = mv.id,
|
local move = { id = mv.id,
|
||||||
pp = math.max(0, math.min(maxPP, math.floor(mv.pp or 0))) }
|
pp = math.max(0, math.min(maxPP, math.floor(num(mv.pp, 0)))) }
|
||||||
if mv.ppUps ~= nil then entry.ppUps = ppUps end
|
if mv.ppUps ~= nil then move.ppUps = ppUps end
|
||||||
table.insert(moves, entry)
|
table.insert(moves, move)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
if #moves == 0 then
|
if #moves == 0 then
|
||||||
@@ -132,27 +157,29 @@ function Protocol.unpackMon(data, packed, opts)
|
|||||||
-- same as a standardized tournament format would
|
-- same as a standardized tournament format would
|
||||||
local forced = forceLevel
|
local forced = forceLevel
|
||||||
local hp = forced and stats.hp
|
local hp = forced and stats.hp
|
||||||
or math.max(0, math.min(stats.hp, math.floor(packed.hp or stats.hp)))
|
or math.max(0, math.min(stats.hp, math.floor(num(packed.hp, stats.hp))))
|
||||||
local status = forced and nil or packed.status
|
local status = forced and nil or text(packed.status)
|
||||||
-- preserve the sender's original-trainer identity (party_struct MON_OTID +
|
-- preserve the sender's original-trainer identity (party_struct MON_OTID +
|
||||||
-- wPartyMonOT on a real cable), clamped/typed like every other field so a
|
-- wPartyMonOT on a real cable), clamped/typed like every other field so a
|
||||||
-- tampered packet can't inject a bad ID or a huge name. Left nil when the
|
-- tampered packet can't inject a bad ID or a huge name. Left nil when the
|
||||||
-- packet omits them (a v1/old peer) -- no worse than before for that legacy
|
-- packet omits them (a v1/old peer) -- no worse than before for that legacy
|
||||||
-- path, and once ot is set the load-time stampOT backfill (mon.ot or ...)
|
-- path, and once ot is set the load-time stampOT backfill (mon.ot or ...)
|
||||||
-- becomes a no-op so the sender's identity survives save/reload (#215).
|
-- becomes a no-op so the sender's identity survives save/reload (#215).
|
||||||
local otId = packed.otId
|
local packedOtId = num(packed.otId)
|
||||||
and math.max(0, math.min(65535, math.floor(packed.otId))) or nil
|
local otId = packedOtId
|
||||||
|
and math.max(0, math.min(65535, math.floor(packedOtId))) or nil
|
||||||
local ot = type(packed.ot) == "string" and packed.ot:sub(1, 10) or nil
|
local ot = type(packed.ot) == "string" and packed.ot:sub(1, 10) or nil
|
||||||
return {
|
return {
|
||||||
species = packed.species,
|
species = packed.species,
|
||||||
level = level,
|
level = level,
|
||||||
exp = math.max(0, math.floor(packed.exp or Growth.expForLevel(def.growthRate, level))),
|
exp = math.max(0, math.floor(num(packed.exp,
|
||||||
|
Growth.expForLevel(def.growthRate, level)))),
|
||||||
dvs = dvs,
|
dvs = dvs,
|
||||||
statExp = statExp,
|
statExp = statExp,
|
||||||
stats = stats,
|
stats = stats,
|
||||||
hp = hp,
|
hp = hp,
|
||||||
status = status,
|
status = status,
|
||||||
nickname = packed.nickname,
|
nickname = text(packed.nickname),
|
||||||
ot = ot,
|
ot = ot,
|
||||||
otId = otId,
|
otId = otId,
|
||||||
moves = moves,
|
moves = moves,
|
||||||
@@ -246,6 +273,10 @@ end
|
|||||||
function Protocol.unpackMon2(data, packed, opts)
|
function Protocol.unpackMon2(data, packed, opts)
|
||||||
local Mon = require("src.battle.gen2.Mon")
|
local Mon = require("src.battle.gen2.Mon")
|
||||||
local strict = opts and opts.strict
|
local strict = opts and opts.strict
|
||||||
|
if type(packed) ~= "table" then
|
||||||
|
if strict then return nil, "unknown POKéMON" end
|
||||||
|
return nil
|
||||||
|
end
|
||||||
local forceLevel = opts and tonumber(opts.forceLevel) or nil
|
local forceLevel = opts and tonumber(opts.forceLevel) or nil
|
||||||
local def = data and data.pokemon and data.pokemon[packed.species]
|
local def = data and data.pokemon and data.pokemon[packed.species]
|
||||||
if not def then
|
if not def then
|
||||||
@@ -253,31 +284,33 @@ function Protocol.unpackMon2(data, packed, opts)
|
|||||||
return nil
|
return nil
|
||||||
end
|
end
|
||||||
local level = math.max(1, math.min(Mon.MAX_LEVEL,
|
local level = math.max(1, math.min(Mon.MAX_LEVEL,
|
||||||
math.floor(packed.level or 5)))
|
math.floor(num(packed.level, 5))))
|
||||||
if forceLevel then
|
if forceLevel then
|
||||||
level = math.max(1, math.min(Mon.MAX_LEVEL, math.floor(forceLevel)))
|
level = math.max(1, math.min(Mon.MAX_LEVEL, math.floor(forceLevel)))
|
||||||
end
|
end
|
||||||
|
local packedDvs, packedStatExp = tbl(packed.dvs), tbl(packed.statExp)
|
||||||
local dvs = {}
|
local dvs = {}
|
||||||
for _, k in ipairs(GEN2_DVS) do
|
for _, k in ipairs(GEN2_DVS) do
|
||||||
dvs[k] = math.max(0, math.min(Mon.MAX_DV,
|
dvs[k] = math.max(0, math.min(Mon.MAX_DV,
|
||||||
math.floor((packed.dvs or {})[k] or 0)))
|
math.floor(num(packedDvs[k], 0))))
|
||||||
end
|
end
|
||||||
-- derived, never taken from the packet (see GEN2_DVS above)
|
-- derived, never taken from the packet (see GEN2_DVS above)
|
||||||
dvs.hp = Mon.hpDV(dvs)
|
dvs.hp = Mon.hpDV(dvs)
|
||||||
local statExp = {}
|
local statExp = {}
|
||||||
for _, k in ipairs(GEN2_STAT_EXP) do
|
for _, k in ipairs(GEN2_STAT_EXP) do
|
||||||
statExp[k] = math.max(0, math.min(65535,
|
statExp[k] = math.max(0, math.min(65535,
|
||||||
math.floor((packed.statExp or {})[k] or 0)))
|
math.floor(num(packedStatExp[k], 0))))
|
||||||
end
|
end
|
||||||
local stats = Mon.stats(def.baseStats, dvs, level, statExp)
|
local stats = Mon.stats(def.baseStats, dvs, level, statExp)
|
||||||
local moves = {}
|
local moves = {}
|
||||||
for _, mv in ipairs(packed.moves or {}) do
|
for _, packedMove in ipairs(tbl(packed.moves)) do
|
||||||
|
local mv = tbl(packedMove)
|
||||||
local mdef = data.moves and data.moves[mv.id]
|
local mdef = data.moves and data.moves[mv.id]
|
||||||
if mdef and #moves < 4 then
|
if mdef and #moves < 4 then
|
||||||
local ppUps = math.max(0, math.min(3, math.floor(mv.ppUps or 0)))
|
local ppUps = math.max(0, math.min(3, math.floor(num(mv.ppUps, 0))))
|
||||||
local maxPp = (mdef.pp or 0) + ppUps * math.floor((mdef.pp or 0) / 5)
|
local maxPp = (mdef.pp or 0) + ppUps * math.floor((mdef.pp or 0) / 5)
|
||||||
local entry = { id = mv.id, maxPp = maxPp,
|
local entry = { id = mv.id, maxPp = maxPp,
|
||||||
pp = math.max(0, math.min(maxPp, math.floor(mv.pp or 0))) }
|
pp = math.max(0, math.min(maxPp, math.floor(num(mv.pp, 0)))) }
|
||||||
if mv.ppUps ~= nil then entry.ppUps = ppUps end
|
if mv.ppUps ~= nil then entry.ppUps = ppUps end
|
||||||
table.insert(moves, entry)
|
table.insert(moves, entry)
|
||||||
end
|
end
|
||||||
@@ -296,26 +329,27 @@ function Protocol.unpackMon2(data, packed, opts)
|
|||||||
-- an item the peer cannot represent), and the Gen 2 arm of
|
-- an item the peer cannot represent), and the Gen 2 arm of
|
||||||
-- Protocol.eligibleParty is what keeps it from ever reaching here on a
|
-- Protocol.eligibleParty is what keeps it from ever reaching here on a
|
||||||
-- negotiated trade.
|
-- negotiated trade.
|
||||||
local item = packed.item
|
local item = type(packed.item) == "string" and packed.item or nil
|
||||||
if item ~= nil and not (data.items and data.items[item]) then
|
if item ~= nil and not (data.items and data.items[item]) then
|
||||||
if strict then return nil, "unknown item" end
|
if strict then return nil, "unknown item" end
|
||||||
item = nil
|
item = nil
|
||||||
end
|
end
|
||||||
local forced = forceLevel
|
local forced = forceLevel
|
||||||
local hp = forced and stats.hp
|
local hp = forced and stats.hp
|
||||||
or math.max(0, math.min(stats.hp, math.floor(packed.hp or stats.hp)))
|
or math.max(0, math.min(stats.hp, math.floor(num(packed.hp, stats.hp))))
|
||||||
local status = forced and nil or packed.status
|
local status = forced and nil or text(packed.status)
|
||||||
local otId = packed.otId
|
local packedOtId = num(packed.otId)
|
||||||
and math.max(0, math.min(65535, math.floor(packed.otId))) or nil
|
local otId = packedOtId
|
||||||
|
and math.max(0, math.min(65535, math.floor(packedOtId))) or nil
|
||||||
local ot = type(packed.ot) == "string" and packed.ot:sub(1, 10) or nil
|
local ot = type(packed.ot) == "string" and packed.ot:sub(1, 10) or nil
|
||||||
local growth = Mon.growthFor(data, def.growthRate)
|
local growth = Mon.growthFor(data, def.growthRate)
|
||||||
local mon = {
|
local mon = {
|
||||||
species = packed.species,
|
species = packed.species,
|
||||||
name = def.name or packed.species,
|
name = def.name or packed.species,
|
||||||
nickname = packed.nickname,
|
nickname = text(packed.nickname),
|
||||||
level = level,
|
level = level,
|
||||||
experience = math.max(0, math.floor(packed.experience
|
experience = math.max(0, math.floor(num(packed.experience,
|
||||||
or Mon.experienceForLevel(growth, level))),
|
Mon.experienceForLevel(growth, level)))),
|
||||||
dvs = dvs,
|
dvs = dvs,
|
||||||
statExp = statExp,
|
statExp = statExp,
|
||||||
stats = stats,
|
stats = stats,
|
||||||
@@ -328,10 +362,10 @@ function Protocol.unpackMon2(data, packed, opts)
|
|||||||
-- GiveEgg starts a hatched mon at 120 and a caught one at 70; a traded mon
|
-- GiveEgg starts a hatched mon at 120 and a caught one at 70; a traded mon
|
||||||
-- keeps what it arrived with, clamped to the byte the cart stores it in
|
-- keeps what it arrived with, clamped to the byte the cart stores it in
|
||||||
happiness = math.max(0, math.min(255,
|
happiness = math.max(0, math.min(255,
|
||||||
math.floor(packed.happiness or 70))),
|
math.floor(num(packed.happiness, 70)))),
|
||||||
pokerus = math.max(0, math.min(255, math.floor(packed.pokerus or 0))),
|
pokerus = math.max(0, math.min(255, math.floor(num(packed.pokerus, 0)))),
|
||||||
caughtLevel = math.max(1, math.min(Mon.MAX_LEVEL,
|
caughtLevel = math.max(1, math.min(Mon.MAX_LEVEL,
|
||||||
math.floor(packed.caughtLevel or level))),
|
math.floor(num(packed.caughtLevel, level)))),
|
||||||
ot = ot,
|
ot = ot,
|
||||||
otName = ot,
|
otName = ot,
|
||||||
otId = otId,
|
otId = otId,
|
||||||
@@ -339,7 +373,7 @@ function Protocol.unpackMon2(data, packed, opts)
|
|||||||
}
|
}
|
||||||
if packed.isEgg then
|
if packed.isEgg then
|
||||||
mon.isEgg = true
|
mon.isEgg = true
|
||||||
mon.eggSteps = math.max(0, math.floor(packed.eggSteps or 0))
|
mon.eggSteps = math.max(0, math.floor(num(packed.eggSteps, 0)))
|
||||||
end
|
end
|
||||||
-- Derived from the DVs on the RECEIVING side, exactly as they were derived on
|
-- Derived from the DVs on the RECEIVING side, exactly as they were derived on
|
||||||
-- the sending one: shininess, gender and an Unown's letter are all functions
|
-- the sending one: shininess, gender and an Unown's letter are all functions
|
||||||
@@ -401,17 +435,19 @@ end
|
|||||||
-- which slots are in play and a pick can never land on a different mon.
|
-- which slots are in play and a pick can never land on a different mon.
|
||||||
function Protocol.eligibleParty(party, myRecords, theirRecords)
|
function Protocol.eligibleParty(party, myRecords, theirRecords)
|
||||||
local eligible, reasons = {}, {}
|
local eligible, reasons = {}, {}
|
||||||
theirRecords = theirRecords or {}
|
theirRecords = tbl(theirRecords)
|
||||||
local theirSpecies = theirRecords.pokemon or {}
|
myRecords = tbl(myRecords)
|
||||||
local theirMoves = theirRecords.moves or {}
|
local theirSpecies = tbl(theirRecords.pokemon)
|
||||||
local mySpecies = (myRecords or {}).pokemon or {}
|
local theirMoves = tbl(theirRecords.moves)
|
||||||
local myMoves = (myRecords or {}).moves or {}
|
local mySpecies = tbl(myRecords.pokemon)
|
||||||
|
local myMoves = tbl(myRecords.moves)
|
||||||
-- Gen 2 only, and absent on both sides of a Gen 1 trade, which is what keeps
|
-- Gen 2 only, and absent on both sides of a Gen 1 trade, which is what keeps
|
||||||
-- the loop below unchanged for Red: a mon with no `item` never reaches the
|
-- the loop below unchanged for Red: a mon with no `item` never reaches the
|
||||||
-- held-item arm at all.
|
-- held-item arm at all.
|
||||||
local theirHeld = theirRecords.heldItems
|
local theirHeld = theirRecords.heldItems ~= nil
|
||||||
local myHeld = (myRecords or {}).heldItems or {}
|
and tbl(theirRecords.heldItems) or nil
|
||||||
for i, mon in ipairs(party or {}) do
|
local myHeld = tbl(myRecords.heldItems)
|
||||||
|
for i, mon in ipairs(tbl(party)) do
|
||||||
local reason
|
local reason
|
||||||
if not theirSpecies[mon.species] then
|
if not theirSpecies[mon.species] then
|
||||||
reason = "not on the other game"
|
reason = "not on the other game"
|
||||||
@@ -551,7 +587,7 @@ function TradeSession:handle(msg)
|
|||||||
end
|
end
|
||||||
if self.stage == "waitParty" then self.stage = "picking" end
|
if self.stage == "waitParty" then self.stage = "picking" end
|
||||||
elseif msg.type == "pick" then
|
elseif msg.type == "pick" then
|
||||||
self.theirPick = msg.index
|
self.theirPick = num(msg.index)
|
||||||
self:advance()
|
self:advance()
|
||||||
elseif msg.type == "confirm" then
|
elseif msg.type == "confirm" then
|
||||||
self.theirConfirm = msg.ok
|
self.theirConfirm = msg.ok
|
||||||
@@ -583,7 +619,18 @@ function TradeSession:confirm(ok)
|
|||||||
return { type = "confirm", ok = ok }
|
return { type = "confirm", ok = ok }
|
||||||
end
|
end
|
||||||
|
|
||||||
|
function TradeSession:pickResolves()
|
||||||
|
local index = self.theirPick
|
||||||
|
if type(index) ~= "number" then return false end
|
||||||
|
return self.theirParty ~= nil and self.theirParty[index] ~= nil
|
||||||
|
end
|
||||||
|
|
||||||
function TradeSession:advance()
|
function TradeSession:advance()
|
||||||
|
if self.theirPick ~= nil and self.theirParty and not self:pickResolves() then
|
||||||
|
self.stage = "cancelled"
|
||||||
|
self.error = "the other game picked a POKéMON that isn't there"
|
||||||
|
return
|
||||||
|
end
|
||||||
if self.stage == "picking" and self.myPick then
|
if self.stage == "picking" and self.myPick then
|
||||||
self.stage = self.theirPick and "confirming" or "waitPick"
|
self.stage = self.theirPick and "confirming" or "waitPick"
|
||||||
elseif self.stage == "waitPick" and self.theirPick then
|
elseif self.stage == "waitPick" and self.theirPick then
|
||||||
|
|||||||
+12
-9
@@ -1,3 +1,6 @@
|
|||||||
|
local Logger = require("src.core.Logger")
|
||||||
|
local Wire = require("src.link.Wire")
|
||||||
|
|
||||||
local Session = {}
|
local Session = {}
|
||||||
Session.__index = Session
|
Session.__index = Session
|
||||||
|
|
||||||
@@ -23,6 +26,7 @@ function Session.new(transport, options)
|
|||||||
_status = "connecting",
|
_status = "connecting",
|
||||||
_terminal = nil,
|
_terminal = nil,
|
||||||
_transportCloseCalled = false,
|
_transportCloseCalled = false,
|
||||||
|
dropped = 0,
|
||||||
paired = false,
|
paired = false,
|
||||||
closed = false,
|
closed = false,
|
||||||
error = nil,
|
error = nil,
|
||||||
@@ -120,16 +124,15 @@ function Session:update()
|
|||||||
end
|
end
|
||||||
else
|
else
|
||||||
for index = 1, #messages do
|
for index = 1, #messages do
|
||||||
local message = messages[index]
|
local raw = messages[index]
|
||||||
if type(message) ~= "table" or type(message.type) ~= "string" then
|
local ok, message = pcall(Wire.sanitize, raw)
|
||||||
if not failureReason then
|
if ok and message then
|
||||||
failureReason = "protocol_error"
|
self._inbox[#self._inbox + 1] = message
|
||||||
failureDetail = ("message %d must be a table with string type")
|
else
|
||||||
:format(index)
|
self.dropped = (self.dropped or 0) + 1
|
||||||
end
|
local label = type(raw) == "table" and tostring(raw.type) or type(raw)
|
||||||
break
|
Logger.warn("link: dropped malformed message (%s)", label)
|
||||||
end
|
end
|
||||||
self._inbox[#self._inbox + 1] = message
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,344 @@
|
|||||||
|
local Wire = {}
|
||||||
|
|
||||||
|
local MAX_INT = 2147483647
|
||||||
|
local MAX_STRING = 64
|
||||||
|
local MAX_NAME = 40
|
||||||
|
local MAX_LIST = 64
|
||||||
|
local MAX_PARTY = 32
|
||||||
|
local MAX_MOVES = 8
|
||||||
|
local MAX_MODS = 256
|
||||||
|
local MAX_RECORDS = 4096
|
||||||
|
local MAX_EXTRA_DEPTH = 8
|
||||||
|
local MAX_ROUNDS = 16
|
||||||
|
local MAX_MATCHES = 128
|
||||||
|
|
||||||
|
function Wire.num(v, default, min, max)
|
||||||
|
local n = tonumber(v)
|
||||||
|
if n == nil or n ~= n then return default end
|
||||||
|
min = min or -MAX_INT
|
||||||
|
max = max or MAX_INT
|
||||||
|
if n < min then return min end
|
||||||
|
if n > max then return max end
|
||||||
|
return math.floor(n)
|
||||||
|
end
|
||||||
|
|
||||||
|
function Wire.str(v, default, maxLen)
|
||||||
|
if type(v) ~= "string" then return default end
|
||||||
|
maxLen = maxLen or MAX_STRING
|
||||||
|
if #v > maxLen then return v:sub(1, maxLen) end
|
||||||
|
return v
|
||||||
|
end
|
||||||
|
|
||||||
|
function Wire.bool(v, default)
|
||||||
|
if type(v) == "boolean" then return v end
|
||||||
|
return default
|
||||||
|
end
|
||||||
|
|
||||||
|
function Wire.list(v, maxN, fn)
|
||||||
|
local out = {}
|
||||||
|
if type(v) ~= "table" then return out end
|
||||||
|
local n = math.min(#v, maxN or MAX_LIST)
|
||||||
|
for i = 1, n do
|
||||||
|
local entry = fn(v[i])
|
||||||
|
if entry ~= nil then out[#out + 1] = entry end
|
||||||
|
end
|
||||||
|
return out
|
||||||
|
end
|
||||||
|
|
||||||
|
function Wire.records(v)
|
||||||
|
local out = {}
|
||||||
|
if type(v) ~= "table" then return out end
|
||||||
|
local n = 0
|
||||||
|
for k, val in pairs(v) do
|
||||||
|
if type(k) == "string" then
|
||||||
|
out[k] = Wire.str(val, nil, MAX_STRING) or tostring(Wire.num(val, 0))
|
||||||
|
n = n + 1
|
||||||
|
if n >= MAX_RECORDS then break end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return out
|
||||||
|
end
|
||||||
|
|
||||||
|
function Wire.plain(v, depth)
|
||||||
|
if type(v) ~= "table" then return nil end
|
||||||
|
depth = depth or 0
|
||||||
|
if depth > MAX_EXTRA_DEPTH then return nil end
|
||||||
|
local out = {}
|
||||||
|
for k, val in pairs(v) do
|
||||||
|
local kt, vt = type(k), type(val)
|
||||||
|
if kt == "string" or kt == "number" then
|
||||||
|
if vt == "string" then out[k] = Wire.str(val, nil, MAX_STRING)
|
||||||
|
elseif vt == "number" or vt == "boolean" then out[k] = val
|
||||||
|
elseif vt == "table" then out[k] = Wire.plain(val, depth + 1) end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return out
|
||||||
|
end
|
||||||
|
|
||||||
|
local STAT_KEYS = { "hp", "attack", "defense", "speed", "special" }
|
||||||
|
|
||||||
|
local function statMap(v)
|
||||||
|
local out = {}
|
||||||
|
if type(v) ~= "table" then return out end
|
||||||
|
for _, k in ipairs(STAT_KEYS) do
|
||||||
|
out[k] = Wire.num(v[k], nil, 0, 65535)
|
||||||
|
end
|
||||||
|
return out
|
||||||
|
end
|
||||||
|
|
||||||
|
local function move(v)
|
||||||
|
if type(v) ~= "table" then return { id = nil } end
|
||||||
|
return {
|
||||||
|
id = Wire.str(v.id, nil, MAX_STRING),
|
||||||
|
pp = Wire.num(v.pp, nil, 0, 255),
|
||||||
|
ppUps = Wire.num(v.ppUps, nil, 0, 255),
|
||||||
|
maxPp = Wire.num(v.maxPp, nil, 0, 255),
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
local function mon(v)
|
||||||
|
if type(v) ~= "table" then return {} end
|
||||||
|
return {
|
||||||
|
species = Wire.str(v.species, nil, MAX_STRING),
|
||||||
|
level = Wire.num(v.level, nil, 0, 65535),
|
||||||
|
exp = Wire.num(v.exp, nil, 0, MAX_INT),
|
||||||
|
experience = Wire.num(v.experience, nil, 0, MAX_INT),
|
||||||
|
hp = Wire.num(v.hp, nil, 0, 65535),
|
||||||
|
status = Wire.str(v.status, nil, MAX_STRING),
|
||||||
|
nickname = Wire.str(v.nickname, nil, MAX_NAME),
|
||||||
|
dvs = statMap(v.dvs),
|
||||||
|
statExp = statMap(v.statExp),
|
||||||
|
moves = Wire.list(v.moves, MAX_MOVES, move),
|
||||||
|
ot = Wire.str(v.ot, nil, MAX_NAME),
|
||||||
|
otId = Wire.num(v.otId, nil, 0, MAX_INT),
|
||||||
|
item = Wire.str(v.item, nil, MAX_STRING),
|
||||||
|
happiness = Wire.num(v.happiness, nil, 0, 65535),
|
||||||
|
pokerus = Wire.num(v.pokerus, nil, 0, 65535),
|
||||||
|
caughtLevel = Wire.num(v.caughtLevel, nil, 0, 65535),
|
||||||
|
isEgg = Wire.bool(v.isEgg, nil),
|
||||||
|
eggSteps = Wire.num(v.eggSteps, nil, 0, MAX_INT),
|
||||||
|
extra = Wire.plain(v.extra),
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
local function modEntry(v)
|
||||||
|
if type(v) ~= "table" then return nil end
|
||||||
|
return {
|
||||||
|
id = Wire.str(v.id, nil, MAX_NAME),
|
||||||
|
version = Wire.str(v.version, nil, MAX_NAME)
|
||||||
|
or Wire.num(v.version, nil, 0, MAX_INT),
|
||||||
|
affectsLink = Wire.bool(v.affectsLink, nil),
|
||||||
|
language = Wire.bool(v.language, nil),
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
local function name(v)
|
||||||
|
return Wire.str(v, nil, MAX_NAME)
|
||||||
|
end
|
||||||
|
|
||||||
|
local sanitize
|
||||||
|
|
||||||
|
local SCHEMAS = {}
|
||||||
|
|
||||||
|
SCHEMAS.hello = function(m)
|
||||||
|
return {
|
||||||
|
protocol = Wire.num(m.protocol, nil, 0, MAX_INT),
|
||||||
|
name = name(m.name),
|
||||||
|
mode = Wire.str(m.mode, nil, MAX_STRING),
|
||||||
|
engineVersion = Wire.str(m.engineVersion, nil, MAX_STRING),
|
||||||
|
apiVersion = Wire.str(m.apiVersion, nil, MAX_STRING),
|
||||||
|
generation = Wire.num(m.generation, nil, 0, 255),
|
||||||
|
fingerprint = Wire.str(m.fingerprint, nil, MAX_STRING),
|
||||||
|
linkModified = Wire.bool(m.linkModified, nil),
|
||||||
|
mods = Wire.list(m.mods, MAX_MODS, modEntry),
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
SCHEMAS.records = function(m)
|
||||||
|
return {
|
||||||
|
pokemon = Wire.records(m.pokemon),
|
||||||
|
moves = Wire.records(m.moves),
|
||||||
|
heldItems = m.heldItems ~= nil and Wire.records(m.heldItems) or nil,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
SCHEMAS.party = function(m)
|
||||||
|
return {
|
||||||
|
mons = Wire.list(m.mons, MAX_PARTY, mon),
|
||||||
|
seed = Wire.num(m.seed, nil, 0, MAX_INT),
|
||||||
|
forceLevel = Wire.num(m.forceLevel, nil, 0, 65535),
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
SCHEMAS.pick = function(m)
|
||||||
|
return { index = Wire.num(m.index, nil, -MAX_INT, MAX_INT) }
|
||||||
|
end
|
||||||
|
|
||||||
|
SCHEMAS.confirm = function(m)
|
||||||
|
return { ok = Wire.bool(m.ok, false) }
|
||||||
|
end
|
||||||
|
|
||||||
|
SCHEMAS.action = function(m)
|
||||||
|
return {
|
||||||
|
kind = Wire.str(m.kind, "", MAX_STRING),
|
||||||
|
slot = Wire.num(m.slot, nil, 1, MAX_MOVES),
|
||||||
|
index = Wire.num(m.index, nil, 1, MAX_PARTY),
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
SCHEMAS.hash = function(m)
|
||||||
|
local parts
|
||||||
|
if type(m.parts) == "table" then
|
||||||
|
parts = {
|
||||||
|
actives = Wire.str(m.parts.actives, nil, MAX_STRING),
|
||||||
|
volatile = Wire.str(m.parts.volatile, nil, MAX_STRING),
|
||||||
|
bench = Wire.str(m.parts.bench, nil, MAX_STRING),
|
||||||
|
}
|
||||||
|
end
|
||||||
|
return {
|
||||||
|
turn = Wire.num(m.turn, 0, 0, MAX_INT),
|
||||||
|
value = Wire.str(m.value, nil, MAX_STRING),
|
||||||
|
parts = parts,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
SCHEMAS.replace = function(m)
|
||||||
|
return { index = Wire.num(m.index, 1, 1, MAX_PARTY) }
|
||||||
|
end
|
||||||
|
|
||||||
|
SCHEMAS.bye = function() return {} end
|
||||||
|
SCHEMAS.forfeit = function() return {} end
|
||||||
|
|
||||||
|
SCHEMAS.hosted = function(m)
|
||||||
|
return { code = Wire.str(m.code, nil, MAX_NAME) }
|
||||||
|
end
|
||||||
|
SCHEMAS.paired = function() return {} end
|
||||||
|
SCHEMAS.peer_gone = function() return {} end
|
||||||
|
SCHEMAS.join_error = function(m)
|
||||||
|
return { reason = Wire.str(m.reason, "", MAX_STRING) }
|
||||||
|
end
|
||||||
|
|
||||||
|
local function rule(m)
|
||||||
|
return {
|
||||||
|
requiredPartySize = Wire.num(m.requiredPartySize, nil, 0, 255),
|
||||||
|
minLevel = Wire.num(m.minLevel, nil, 0, 65535),
|
||||||
|
maxLevel = Wire.num(m.maxLevel, nil, 0, 65535),
|
||||||
|
turnLimit = Wire.num(m.turnLimit, nil, 0, 65535),
|
||||||
|
forceLevel = Wire.num(m.forceLevel, nil, 0, 65535),
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
SCHEMAS.tournament_hosted = function(m)
|
||||||
|
local out = rule(m)
|
||||||
|
out.code = Wire.str(m.code, nil, MAX_NAME)
|
||||||
|
out.participating = Wire.bool(m.participating, nil)
|
||||||
|
return out
|
||||||
|
end
|
||||||
|
|
||||||
|
SCHEMAS.tournament_host_error = function(m)
|
||||||
|
local out = rule(m)
|
||||||
|
out.reason = Wire.str(m.reason, "", MAX_STRING)
|
||||||
|
return out
|
||||||
|
end
|
||||||
|
|
||||||
|
SCHEMAS.tournament_join_error = SCHEMAS.tournament_host_error
|
||||||
|
|
||||||
|
SCHEMAS.tournament_roster = function(m)
|
||||||
|
local out = rule(m)
|
||||||
|
out.players = Wire.list(m.players, MAX_MATCHES, name)
|
||||||
|
out.spectators = Wire.list(m.spectators, MAX_MATCHES, name)
|
||||||
|
return out
|
||||||
|
end
|
||||||
|
|
||||||
|
local function match(v)
|
||||||
|
if type(v) ~= "table" then return nil end
|
||||||
|
return {
|
||||||
|
a = name(v.a), b = name(v.b), winner = name(v.winner),
|
||||||
|
bye = Wire.bool(v.bye, false),
|
||||||
|
state = Wire.str(v.state, nil, MAX_STRING),
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
local function round(v)
|
||||||
|
if type(v) ~= "table" then return nil end
|
||||||
|
return {
|
||||||
|
round = Wire.num(v.round, 0, 0, MAX_ROUNDS),
|
||||||
|
matches = Wire.list(v.matches, MAX_MATCHES, match),
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
SCHEMAS.bracket_update = function(m)
|
||||||
|
local t = type(m.tournament) == "table" and m.tournament or {}
|
||||||
|
local out = rule(t)
|
||||||
|
out.code = Wire.str(t.code, nil, MAX_NAME)
|
||||||
|
out.status = Wire.str(t.status, nil, MAX_STRING)
|
||||||
|
out.round = Wire.num(t.round, 0, 0, MAX_ROUNDS)
|
||||||
|
out.champion = name(t.champion)
|
||||||
|
out.rounds = Wire.list(t.rounds, MAX_ROUNDS, round)
|
||||||
|
return { tournament = out }
|
||||||
|
end
|
||||||
|
|
||||||
|
SCHEMAS.match_start = function(m)
|
||||||
|
return {
|
||||||
|
opponent = name(m.opponent),
|
||||||
|
round = Wire.num(m.round, 0, 0, MAX_ROUNDS),
|
||||||
|
turnLimit = Wire.num(m.turnLimit, nil, 0, 65535),
|
||||||
|
role = Wire.str(m.role, "", MAX_STRING),
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
SCHEMAS.match_start_spectate = function(m)
|
||||||
|
return {
|
||||||
|
round = Wire.num(m.round, 0, 0, MAX_ROUNDS),
|
||||||
|
playerHost = name(m.playerHost),
|
||||||
|
playerGuest = name(m.playerGuest),
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
SCHEMAS.tournament_bye = function(m)
|
||||||
|
return { round = Wire.num(m.round, 0, 0, MAX_ROUNDS) }
|
||||||
|
end
|
||||||
|
|
||||||
|
SCHEMAS.tournament_over = function(m)
|
||||||
|
return { champion = name(m.champion) }
|
||||||
|
end
|
||||||
|
|
||||||
|
local SPECTATABLE = {
|
||||||
|
action = true, replace = true, bye = true, forfeit = true,
|
||||||
|
hello = true, party = true, hash = true,
|
||||||
|
}
|
||||||
|
|
||||||
|
SCHEMAS.spectate = function(m)
|
||||||
|
if type(m.msg) ~= "table" or not SPECTATABLE[m.msg.type] then return nil end
|
||||||
|
local inner = sanitize(m.msg)
|
||||||
|
if not inner then return nil end
|
||||||
|
return { side = Wire.str(m.side, "", MAX_STRING), msg = inner }
|
||||||
|
end
|
||||||
|
|
||||||
|
Wire.SCHEMAS = SCHEMAS
|
||||||
|
|
||||||
|
local function passthrough(m)
|
||||||
|
local out = Wire.plain(m) or {}
|
||||||
|
out.type = nil
|
||||||
|
return out
|
||||||
|
end
|
||||||
|
|
||||||
|
sanitize = function(msg)
|
||||||
|
if type(msg) ~= "table" then return nil end
|
||||||
|
local kind = msg.type
|
||||||
|
if type(kind) ~= "string" or #kind > MAX_STRING then return nil end
|
||||||
|
local schema = SCHEMAS[kind]
|
||||||
|
local out
|
||||||
|
if schema then
|
||||||
|
out = schema(msg)
|
||||||
|
if not out then return nil end
|
||||||
|
else
|
||||||
|
out = passthrough(msg)
|
||||||
|
end
|
||||||
|
out.type = kind
|
||||||
|
return out
|
||||||
|
end
|
||||||
|
|
||||||
|
Wire.sanitize = sanitize
|
||||||
|
|
||||||
|
return Wire
|
||||||
+43
-2
@@ -26,6 +26,7 @@ local ModIndex = {}
|
|||||||
-- single repo's releases; a whole index is heavier and changes more slowly.
|
-- single repo's releases; a whole index is heavier and changes more slowly.
|
||||||
ModIndex.CACHE_TTL = 24 * 60 * 60
|
ModIndex.CACHE_TTL = 24 * 60 * 60
|
||||||
ModIndex.SCHEMA_VERSION = 1
|
ModIndex.SCHEMA_VERSION = 1
|
||||||
|
ModIndex.CACHE_VERSION = 2
|
||||||
|
|
||||||
-- ------- pure: source resolution
|
-- ------- pure: source resolution
|
||||||
|
|
||||||
@@ -149,6 +150,43 @@ local function parseLatest(raw)
|
|||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
local function parseDownloads(raw)
|
||||||
|
if type(raw) == "number" or type(raw) == "string" then
|
||||||
|
local n = tonumber(raw)
|
||||||
|
return n and { total = n } or nil
|
||||||
|
end
|
||||||
|
if type(raw) ~= "table" then return nil end
|
||||||
|
local out = {
|
||||||
|
total = tonumber(raw.total),
|
||||||
|
recent = tonumber(raw.recent),
|
||||||
|
window_days = tonumber(raw.window_days),
|
||||||
|
as_of = str(raw.as_of),
|
||||||
|
}
|
||||||
|
if out.total == nil and out.recent == nil then return nil end
|
||||||
|
return out
|
||||||
|
end
|
||||||
|
|
||||||
|
-- downloadStats(entry) -> { total, recent, window_days, as_of } | nil
|
||||||
|
function ModIndex.downloadStats(entry)
|
||||||
|
if type(entry) ~= "table" then return nil end
|
||||||
|
return parseDownloads(entry.downloads)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function isoDay(v)
|
||||||
|
if type(v) ~= "string" then return nil end
|
||||||
|
return v:match("^(%d%d%d%d%-%d%d%-%d%d)")
|
||||||
|
end
|
||||||
|
|
||||||
|
-- releaseDates(entry) -> { first, latest } | nil
|
||||||
|
function ModIndex.releaseDates(entry)
|
||||||
|
if type(entry) ~= "table" then return nil end
|
||||||
|
local first = isoDay(entry.first_release)
|
||||||
|
local latest = isoDay(entry.last_release)
|
||||||
|
or isoDay(entry.latest and entry.latest.published_at)
|
||||||
|
if not (first or latest) then return nil end
|
||||||
|
return { first = first, latest = latest }
|
||||||
|
end
|
||||||
|
|
||||||
local function parseEntry(raw)
|
local function parseEntry(raw)
|
||||||
if type(raw) ~= "table" or not str(raw.id) then return nil end
|
if type(raw) ~= "table" or not str(raw.id) then return nil end
|
||||||
return {
|
return {
|
||||||
@@ -160,6 +198,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),
|
||||||
@@ -174,11 +213,11 @@ local function parseEntry(raw)
|
|||||||
conflicts = raw.conflicts,
|
conflicts = raw.conflicts,
|
||||||
thumbnail = str(raw.thumbnail),
|
thumbnail = str(raw.thumbnail),
|
||||||
description_url = str(raw.description_url),
|
description_url = str(raw.description_url),
|
||||||
-- Optional release stats a feed author can publish: total downloads
|
-- Optional release stats a feed author can publish: download counts
|
||||||
-- across all releases plus first/last release dates. Additive-only,
|
-- across all releases plus first/last release dates. Additive-only,
|
||||||
-- so a feed that carries them stays readable by every build that
|
-- so a feed that carries them stays readable by every build that
|
||||||
-- predates them (and one that does not still renders fine here).
|
-- predates them (and one that does not still renders fine here).
|
||||||
downloads = tonumber(raw.downloads),
|
downloads = parseDownloads(raw.downloads),
|
||||||
first_release = str(raw.first_release),
|
first_release = str(raw.first_release),
|
||||||
last_release = str(raw.last_release),
|
last_release = str(raw.last_release),
|
||||||
latest = parseLatest(raw.latest),
|
latest = parseLatest(raw.latest),
|
||||||
@@ -515,6 +554,7 @@ function ModIndex.cacheFresh(entry, now, ttl)
|
|||||||
now = now or os.time()
|
now = now or os.time()
|
||||||
ttl = ttl or ModIndex.CACHE_TTL
|
ttl = ttl or ModIndex.CACHE_TTL
|
||||||
return entry ~= nil and type(entry.checkedAt) == "number"
|
return entry ~= nil and type(entry.checkedAt) == "number"
|
||||||
|
and entry.version == ModIndex.CACHE_VERSION
|
||||||
and (now - entry.checkedAt) < ttl
|
and (now - entry.checkedAt) < ttl
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -526,6 +566,7 @@ function ModIndex.writeCache(feed, index)
|
|||||||
opts.modIndexCache = opts.modIndexCache or {}
|
opts.modIndexCache = opts.modIndexCache or {}
|
||||||
opts.modIndexCache[feed] = {
|
opts.modIndexCache[feed] = {
|
||||||
checkedAt = os.time(),
|
checkedAt = os.time(),
|
||||||
|
version = ModIndex.CACHE_VERSION,
|
||||||
generatedAt = index.generatedAt,
|
generatedAt = index.generatedAt,
|
||||||
categories = index.categories,
|
categories = index.categories,
|
||||||
mods = index.mods,
|
mods = index.mods,
|
||||||
|
|||||||
+34
-1
@@ -265,9 +265,42 @@ function ModUpdate.downloadsLine(total)
|
|||||||
ModUpdate.formatCount(total))
|
ModUpdate.formatCount(total))
|
||||||
end
|
end
|
||||||
|
|
||||||
|
ModUpdate.NO_COUNT = "?"
|
||||||
|
|
||||||
|
function ModUpdate.abbrevCount(n)
|
||||||
|
n = tonumber(n)
|
||||||
|
if not n or n ~= n or n < 0 then return ModUpdate.NO_COUNT end
|
||||||
|
n = math.floor(n)
|
||||||
|
if n < 1000 then return tostring(n) end
|
||||||
|
local units, idx, value = { "k", "M", "B" }, 0, n
|
||||||
|
repeat
|
||||||
|
value = value / 1000
|
||||||
|
idx = idx + 1
|
||||||
|
until idx >= #units or math.floor(value * 10 + 0.5) / 10 < 1000
|
||||||
|
local scaled = math.floor(value * 10 + 0.5) / 10
|
||||||
|
local shown = (scaled % 1 == 0) and tostring(math.floor(scaled))
|
||||||
|
or string.format("%.1f", scaled)
|
||||||
|
return shown .. units[idx]
|
||||||
|
end
|
||||||
|
|
||||||
|
function ModUpdate.downloadsShort(total)
|
||||||
|
return Strings("%s downloads", ModUpdate.abbrevCount(total))
|
||||||
|
end
|
||||||
|
|
||||||
|
function ModUpdate.trendingLine(recent, windowDays)
|
||||||
|
if recent == nil then return nil end
|
||||||
|
if not windowDays then
|
||||||
|
return Strings("%s recently", ModUpdate.abbrevCount(recent))
|
||||||
|
end
|
||||||
|
return Strings("%s in the last %d days", ModUpdate.abbrevCount(recent),
|
||||||
|
math.floor(windowDays))
|
||||||
|
end
|
||||||
|
|
||||||
function ModUpdate.datesLine(first, latest)
|
function ModUpdate.datesLine(first, latest)
|
||||||
if not (first or latest) then return nil end
|
if not (first or latest) then return nil end
|
||||||
return Strings("Released %s - Updated %s", first or "?", latest or "?")
|
if not first then return Strings("Updated %s", latest) end
|
||||||
|
if not latest then return Strings("Released %s", first) end
|
||||||
|
return Strings("Released %s - Updated %s", first, latest)
|
||||||
end
|
end
|
||||||
|
|
||||||
function ModUpdate.statsLine(total, first, latest)
|
function ModUpdate.statsLine(total, first, latest)
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
+33
-16
@@ -24,14 +24,18 @@
|
|||||||
-- MoneyTopRightMenuHeader menu_coords 11, 0, 19, 2 -- a 9x3 box, and
|
-- MoneyTopRightMenuHeader menu_coords 11, 0, 19, 2 -- a 9x3 box, and
|
||||||
-- PlaceMoneyTextbox writes the amount at
|
-- PlaceMoneyTextbox writes the amount at
|
||||||
-- MenuBoxCoord2Tile + SCREEN_WIDTH + 1 = (12,1)
|
-- MenuBoxCoord2Tile + SCREEN_WIDTH + 1 = (12,1)
|
||||||
-- MenuHeader_Buy menu_coords 1, 3, 19, 11 -- a 19x9 box holding 4
|
-- MenuHeader_Buy menu_coords 1, 3, 19, 11 -- a 19x9 rect holding 4
|
||||||
-- entries of two rows. ScrollingMenu_UpdateDisplay
|
-- entries of two rows. BuyMenuLoop copies that
|
||||||
-- starts at (2,4); PlaceMenuItemName prints the name
|
-- header and calls ScrollingMenu; it never calls
|
||||||
-- there and .PrintBCDPrices is handed that origin
|
-- InitScrollingMenu or MenuBox, so the rect is NOT
|
||||||
-- plus the menu's own width (8) plus SCREEN_WIDTH, so
|
-- a framed window. ScrollingMenu_UpdateDisplay
|
||||||
-- the price lands at (10,5). The ▲ sits on the box's
|
-- calls ClearWholeMenuBox on it (spaces, no border)
|
||||||
-- top right corner (19,3) and the ▼ on its bottom
|
-- and starts printing at (2,4); PlaceMenuItemName
|
||||||
-- right (19,11).
|
-- prints the name there and .PrintBCDPrices is
|
||||||
|
-- handed that origin plus the menu's own width (8)
|
||||||
|
-- plus SCREEN_WIDTH, so the price lands at (10,5).
|
||||||
|
-- The ▲ sits on the rect's top right (19,3) and
|
||||||
|
-- the ▼ on its bottom right (19,11).
|
||||||
-- UpdateItemDescription Textbox (0,12) interior 18x4 -- a 20x6 box -- with
|
-- UpdateItemDescription Textbox (0,12) interior 18x4 -- a 20x6 box -- with
|
||||||
-- the description at (1,14)
|
-- the description at (1,14)
|
||||||
-- BuyItem_MenuHeader menu_coords 7, 15, 19, 17 -- a 13x3 box, and
|
-- BuyItem_MenuHeader menu_coords 7, 15, 19, 17 -- a 13x3 box, and
|
||||||
@@ -75,7 +79,9 @@ local SFX_TRANSACTION = "Sfx_Transaction"
|
|||||||
|
|
||||||
local MartMenu = {}
|
local MartMenu = {}
|
||||||
MartMenu.__index = MartMenu
|
MartMenu.__index = MartMenu
|
||||||
-- engine/items/mart.asm:54
|
-- The top menu overlays the mart (StandardMart .HowMayIHelpYou /
|
||||||
|
-- .AnythingElse: LoadStandardMenuHeader + PrintText). BuyMenu is
|
||||||
|
-- FadeToMenu + BlankScreen, so enterBuy shadows this for the list.
|
||||||
MartMenu.isOpaque = false
|
MartMenu.isOpaque = false
|
||||||
|
|
||||||
-- constants/mart_constants.asm. The `pokemart` macro emits this as one byte
|
-- constants/mart_constants.asm. The `pokemart` macro emits this as one byte
|
||||||
@@ -563,6 +569,10 @@ function MartMenu:enterBuy()
|
|||||||
self.phase = "buy"
|
self.phase = "buy"
|
||||||
self.index = 1
|
self.index = 1
|
||||||
self.scroll = 0
|
self.scroll = 0
|
||||||
|
-- BuyMenu (engine/items/mart.asm): `call FadeToMenu / farcall BlankScreen`.
|
||||||
|
-- BlankScreen fills the tilemap with spaces and the palettes with white, so
|
||||||
|
-- the mart must not keep drawing in the letterbox under the list.
|
||||||
|
self.isOpaque = (self.phase == "buy")
|
||||||
end
|
end
|
||||||
|
|
||||||
function MartMenu:total()
|
function MartMenu:total()
|
||||||
@@ -618,6 +628,8 @@ end
|
|||||||
-- BuyMenu returns into StandardMart .Buy, which falls through to
|
-- BuyMenu returns into StandardMart .Buy, which falls through to
|
||||||
-- .AnythingElse; the other three dialog kinds end on their come-again line.
|
-- .AnythingElse; the other three dialog kinds end on their come-again line.
|
||||||
function MartMenu:leaveBuy()
|
function MartMenu:leaveBuy()
|
||||||
|
-- CloseSubmenu restores the map before .AnythingElse / the come-again line.
|
||||||
|
self.isOpaque = nil
|
||||||
if self.martType == "STANDARD" then
|
if self.martType == "STANDARD" then
|
||||||
self:enterTop(self.text.askMore)
|
self:enterTop(self.text.askMore)
|
||||||
else
|
else
|
||||||
@@ -885,11 +897,10 @@ function MartMenu:description()
|
|||||||
return def and def.description or nil
|
return def and def.description or nil
|
||||||
end
|
end
|
||||||
|
|
||||||
-- The bottom visible entry's price row IS the box's bottom border row
|
-- The bottom visible entry's price row is MenuHeader_Buy's last line
|
||||||
-- (entry row 10 + SCREEN_WIDTH = 11, MenuHeader_Buy's own last line), and a
|
-- (entry row 10 + SCREEN_WIDTH = 11). A GB glyph REPLACES the tile it
|
||||||
-- GB glyph REPLACES the tile it prints over, border and all. White under
|
-- prints over, so white under the seven money tiles first is that
|
||||||
-- the seven money tiles first is that replacement; on the three rows clear
|
-- replacement. On a BlankScreen field it is white over white.
|
||||||
-- of the border it repaints white over white.
|
|
||||||
local function printPriceOpaque(amount, ty)
|
local function printPriceOpaque(amount, ty)
|
||||||
local G = love.graphics
|
local G = love.graphics
|
||||||
G.setColor(1, 1, 1, 1)
|
G.setColor(1, 1, 1, 1)
|
||||||
@@ -899,8 +910,10 @@ local function printPriceOpaque(amount, ty)
|
|||||||
end
|
end
|
||||||
|
|
||||||
function MartMenu:drawBuyList()
|
function MartMenu:drawBuyList()
|
||||||
-- engine/items/mart.asm:542
|
-- ScrollingMenu_UpdateDisplay (engine/menus/scrolling_menu.asm) calls
|
||||||
Chrome.box(LIST_BOX_X, LIST_BOX_Y, LIST_BOX_W, LIST_BOX_H)
|
-- ClearWholeMenuBox, not MenuBox: the MenuHeader_Buy rect is a cleared
|
||||||
|
-- field, not a framed window. BlankScreen already filled the tilemap
|
||||||
|
-- with spaces, so the names just land on white.
|
||||||
for row = 1, VISIBLE_ROWS do
|
for row = 1, VISIBLE_ROWS do
|
||||||
local i = row + self.scroll
|
local i = row + self.scroll
|
||||||
local ty = LIST_Y + (row - 1) * LIST_SPACING
|
local ty = LIST_Y + (row - 1) * LIST_SPACING
|
||||||
@@ -960,6 +973,10 @@ function MartMenu:drawUnder()
|
|||||||
self:drawTopMenu()
|
self:drawTopMenu()
|
||||||
self:drawTextBox(self.topLines)
|
self:drawTextBox(self.topLines)
|
||||||
elseif phase == "buy" or phase == "buyQuantity" then
|
elseif phase == "buy" or phase == "buyQuantity" then
|
||||||
|
-- BlankScreen (engine/overworld/player_object.asm): the whole tilemap
|
||||||
|
-- is spaces before PlaceMoneyTopRight / the scrolling list / the
|
||||||
|
-- description textbox go down. Only those last two are framed.
|
||||||
|
Chrome.clear()
|
||||||
self:drawMoneyBox()
|
self:drawMoneyBox()
|
||||||
self:drawBuyList()
|
self:drawBuyList()
|
||||||
self:drawDescription()
|
self:drawDescription()
|
||||||
|
|||||||
@@ -134,6 +134,19 @@ local ROWS = {
|
|||||||
text = function(options)
|
text = function(options)
|
||||||
return require("src.render.Zoom").offsetLabel(options.zoom or 0)
|
return require("src.render.Zoom").offsetLabel(options.zoom or 0)
|
||||||
end },
|
end },
|
||||||
|
-- VOID FILL: FADE is each map's own border block with the dissolve across
|
||||||
|
-- a boundary; WATER / TREES force one outdoor block; BLACK is a flat void.
|
||||||
|
-- #1418. Same key the Gen 1 OPTION screen uses, different ladder (FADE
|
||||||
|
-- is Gold's default because that is already what the maps call for).
|
||||||
|
{ label = "VOID FILL", key = "voidFill", port = true,
|
||||||
|
cycle = function(options, delta)
|
||||||
|
local BorderFill = require("src.world.gen2.BorderFill")
|
||||||
|
BorderFill.setVoidFill(options.voidFill or "fade")
|
||||||
|
options.voidFill = BorderFill.cycle(delta)
|
||||||
|
end,
|
||||||
|
text = function(options)
|
||||||
|
return require("src.world.gen2.BorderFill").voidFillLabel(options.voidFill)
|
||||||
|
end },
|
||||||
{ label = "TILT", key = "tilt", port = true,
|
{ label = "TILT", key = "tilt", port = true,
|
||||||
cycle = function(options, delta)
|
cycle = function(options, delta)
|
||||||
local Tilt = require("src.render.Tilt")
|
local Tilt = require("src.render.Tilt")
|
||||||
|
|||||||
@@ -329,6 +329,10 @@ function OverworldState:setMap(mapId, x, y, facing, opts)
|
|||||||
MapLoader.invalidateAll()
|
MapLoader.invalidateAll()
|
||||||
end
|
end
|
||||||
self.map = MapLoader.load(Game.data, mapId)
|
self.map = MapLoader.load(Game.data, mapId)
|
||||||
|
-- EnterMap -> ClearVariablesOnEnterMap zeroes wStepCounter
|
||||||
|
-- (engine/overworld/clear_variables.asm:7), but a connection crossing
|
||||||
|
-- never reaches EnterMap (home/overworld.asm:675 .loadNewMap)
|
||||||
|
if not (opts and opts.seamless) then Game.save.poisonSteps = 0 end
|
||||||
-- STRENGTH deactivates on every real map load (home/overworld.asm
|
-- STRENGTH deactivates on every real map load (home/overworld.asm
|
||||||
-- EnterMap -> ResetUsingStrengthOutOfBattleBit clears BIT_STRENGTH_ACTIVE
|
-- EnterMap -> ResetUsingStrengthOutOfBattleBit clears BIT_STRENGTH_ACTIVE
|
||||||
-- of wStatusFlags1). setMap is the single choke point for every map-id
|
-- of wStatusFlags1). setMap is the single choke point for every map-id
|
||||||
@@ -4296,6 +4300,9 @@ end
|
|||||||
-- battle is optional; when given, Oak's Lab OPP_RIVAL1 losses skip the
|
-- battle is optional; when given, Oak's Lab OPP_RIVAL1 losses skip the
|
||||||
-- blackout (pret HandlePlayerBlackOut) so the map script can HealParty.
|
-- blackout (pret HandlePlayerBlackOut) so the map script can HealParty.
|
||||||
function OverworldState:afterBattle(result, battle)
|
function OverworldState:afterBattle(result, battle)
|
||||||
|
-- .battleOccurred tail-jumps to EnterMap (home/overworld.asm:353), so
|
||||||
|
-- ClearVariablesOnEnterMap zeroes wStepCounter on every battle return
|
||||||
|
Game.save.poisonSteps = 0
|
||||||
if battle and battle.kind == "wild" then
|
if battle and battle.kind == "wild" then
|
||||||
self.wildEncounterGraceSteps = WILD_ENCOUNTER_GRACE_STEPS
|
self.wildEncounterGraceSteps = WILD_ENCOUNTER_GRACE_STEPS
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -152,16 +152,95 @@ function BorderFill.bake(atlas, tileset, blockId, bgSet, waterFrame)
|
|||||||
return img
|
return img
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- VOID FILL (#1418): the beyond-edge scenery under survey zoom. FADE (the
|
||||||
|
-- default) is each map header's own border block with the dissolve below;
|
||||||
|
-- WATER / TREES force one block on outdoor tilesets that actually have that
|
||||||
|
-- scenery; BLACK is a flat void. Indoor and cave tilesets have no tree wall
|
||||||
|
-- or water metatile, so water/trees fall through to the map's own border
|
||||||
|
-- rather than painting a random block.
|
||||||
|
--
|
||||||
|
-- Canonical ids are the wMapBorderBlock values already used as those fills
|
||||||
|
-- in data/maps/attributes.asm: New Bark / Route 30 $05 trees, Cherrygrove
|
||||||
|
-- $35 water, Pallet $0f trees, Cinnabar $43 water, Ilex Forest $05 trees.
|
||||||
|
BorderFill.VOID_FILLS = { "fade", "water", "trees", "black" }
|
||||||
|
BorderFill.voidFill = "fade"
|
||||||
|
|
||||||
|
local FILL_BLOCKS = {
|
||||||
|
TILESET_JOHTO = { trees = 0x05, water = 0x35 },
|
||||||
|
TILESET_JOHTO_MODERN = { trees = 0x05, water = 0x35 },
|
||||||
|
TILESET_KANTO = { trees = 0x0f, water = 0x43 },
|
||||||
|
TILESET_FOREST = { trees = 0x05 },
|
||||||
|
}
|
||||||
|
|
||||||
|
function BorderFill.setVoidFill(mode)
|
||||||
|
local ok = false
|
||||||
|
for _, name in ipairs(BorderFill.VOID_FILLS) do
|
||||||
|
if name == mode then ok = true; break end
|
||||||
|
end
|
||||||
|
BorderFill.voidFill = ok and mode or "fade"
|
||||||
|
end
|
||||||
|
|
||||||
|
function BorderFill.cycle(delta)
|
||||||
|
local cur = BorderFill.voidFill or "fade"
|
||||||
|
local at = 1
|
||||||
|
for i, name in ipairs(BorderFill.VOID_FILLS) do
|
||||||
|
if name == cur then at = i; break end
|
||||||
|
end
|
||||||
|
local n = #BorderFill.VOID_FILLS
|
||||||
|
at = (at - 1 + (delta or 1)) % n + 1
|
||||||
|
BorderFill.setVoidFill(BorderFill.VOID_FILLS[at])
|
||||||
|
return BorderFill.voidFill
|
||||||
|
end
|
||||||
|
|
||||||
|
function BorderFill.applyOptions(opts)
|
||||||
|
BorderFill.setVoidFill(opts and opts.voidFill or "fade")
|
||||||
|
end
|
||||||
|
|
||||||
|
function BorderFill.voidFillLabel(mode)
|
||||||
|
mode = mode or BorderFill.voidFill or "fade"
|
||||||
|
if mode == "water" then return "WATER" end
|
||||||
|
if mode == "trees" then return "TREES" end
|
||||||
|
if mode == "black" then return "BLACK" end
|
||||||
|
-- trailing space blanks the 5-char WATER/TREES/BLACK from the value column
|
||||||
|
return "FADE "
|
||||||
|
end
|
||||||
|
|
||||||
|
-- The metatile the void should bake, or false when BLACK skips tiling.
|
||||||
|
function BorderFill.fillBlock(def)
|
||||||
|
local mode = BorderFill.voidFill or "fade"
|
||||||
|
if mode == "black" then return false end
|
||||||
|
if (mode == "water" or mode == "trees") and def then
|
||||||
|
local fills = FILL_BLOCKS[def.tileset]
|
||||||
|
local block = fills and fills[mode]
|
||||||
|
if block ~= nil then return block end
|
||||||
|
end
|
||||||
|
return def and def.borderBlock or 0
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Crossfade identity: FADE dissolves per map (Cherrygrove water -> Route 30
|
||||||
|
-- trees). WATER/TREES dissolve only when the forced block itself changes
|
||||||
|
-- (Johto water -> Kanto water), so walking two Johto routes does not fade
|
||||||
|
-- identical water against itself. BLACK is one sheet everywhere.
|
||||||
|
function BorderFill.fillKey(def)
|
||||||
|
local mode = BorderFill.voidFill or "fade"
|
||||||
|
if mode == "black" then return "black" end
|
||||||
|
local block = BorderFill.fillBlock(def)
|
||||||
|
if mode == "water" or mode == "trees" then
|
||||||
|
return mode .. "|" .. tostring(def and def.tileset) .. "|" .. tostring(block)
|
||||||
|
end
|
||||||
|
return "fade|" .. tostring(def and def.id)
|
||||||
|
end
|
||||||
|
|
||||||
-- Each map header carries its OWN border block, so crossing a boundary swaps
|
-- Each map header carries its OWN border block, so crossing a boundary swaps
|
||||||
-- the whole void from one block to another: Cherrygrove's water becomes Route
|
-- the whole void from one block to another: Cherrygrove's water becomes Route
|
||||||
-- 30's trees between one frame and the next. On a 20x18 viewport that is a few
|
-- 30's trees between one frame and the next. On a 20x18 viewport that is a few
|
||||||
-- pixels at the screen edge and nobody sees it; under survey zoom the void is
|
-- pixels at the screen edge and nobody sees it; under survey zoom the void is
|
||||||
-- most of the window, and the swap reads as the background popping.
|
-- most of the window, and the swap reads as the background popping.
|
||||||
--
|
--
|
||||||
-- So the swap is dissolved rather than cut. `key` is the map the image belongs
|
-- So the swap is dissolved rather than cut. `key` is the fill identity
|
||||||
-- to, not the image itself: the same block gets re-baked by the daytime
|
-- (BorderFill.fillKey), not the image itself: the same block gets re-baked by
|
||||||
-- rollover, the COLOR option and the two-frame cave flicker, and a dissolve on
|
-- the daytime rollover, the COLOR option and the two-frame cave flicker, and a
|
||||||
-- any of those would smear the flicker into mush.
|
-- dissolve on any of those would smear the flicker into mush.
|
||||||
BorderFill.CROSSFADE_FRAMES = 20
|
BorderFill.CROSSFADE_FRAMES = 20
|
||||||
|
|
||||||
-- The bookkeeping half, love-free so it can be checked without a canvas.
|
-- The bookkeeping half, love-free so it can be checked without a canvas.
|
||||||
|
|||||||
+27
-10
@@ -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.
|
||||||
@@ -7901,8 +7900,10 @@ function World:borderWaterFrame(def, tileset)
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
if not tile then return nil end
|
if not tile then return nil end
|
||||||
|
local fill = BorderFill.fillBlock(def)
|
||||||
|
if fill == false then return nil end
|
||||||
local block = tileset.blocks
|
local block = tileset.blocks
|
||||||
and tileset.blocks[BorderFill.blockFor(0, def.borderBlock) + 1]
|
and tileset.blocks[BorderFill.blockFor(0, fill) + 1]
|
||||||
if not block then return nil end
|
if not block then return nil end
|
||||||
local found = false
|
local found = false
|
||||||
for i = 1, 16 do
|
for i = 1, 16 do
|
||||||
@@ -7934,11 +7935,19 @@ function World:borderImageFor(mapId)
|
|||||||
if not def then return nil end
|
if not def then return nil end
|
||||||
local tileset = self.tilesets and self.tilesets[def.tileset]
|
local tileset = self.tilesets and self.tilesets[def.tileset]
|
||||||
if not tileset then return nil end
|
if not tileset then return nil end
|
||||||
|
-- VOID FILL black skips the tiled bake; drawGround paints a flat void.
|
||||||
|
local fill = BorderFill.fillBlock(def)
|
||||||
|
if fill == false then return nil end
|
||||||
|
local blockId = BorderFill.blockFor(0, fill)
|
||||||
-- A border block made of water animates with the rest of the map, so this
|
-- A border block made of water animates with the rest of the map, so this
|
||||||
-- frame's row joins the key: four bakes per map instead of one.
|
-- frame's row joins the key: four bakes per map instead of one. The VOID
|
||||||
|
-- FILL mode is in the key so switching FADE/WATER/TREES does not keep a
|
||||||
|
-- stale bake (#1418).
|
||||||
local waterFrame = self:borderWaterFrame(def, tileset)
|
local waterFrame = self:borderWaterFrame(def, tileset)
|
||||||
local cacheKey = BorderFill.cacheKey(mapId .. "|" .. tostring(daytime)
|
local cacheKey = BorderFill.cacheKey(mapId .. "|" .. tostring(daytime)
|
||||||
.. "|" .. tostring(GbcPalette.mode) .. "|" .. tostring(flicker)
|
.. "|" .. tostring(GbcPalette.mode) .. "|" .. tostring(flicker)
|
||||||
|
.. "|" .. tostring(BorderFill.voidFill or "fade")
|
||||||
|
.. "|" .. tostring(blockId)
|
||||||
.. "|" .. tostring(waterFrame and waterFrame.row or 0))
|
.. "|" .. tostring(waterFrame and waterFrame.row or 0))
|
||||||
local cached = self.mapImages[cacheKey]
|
local cached = self.mapImages[cacheKey]
|
||||||
if cached ~= nil then return cached or nil end
|
if cached ~= nil then return cached or nil end
|
||||||
@@ -7950,7 +7959,7 @@ function World:borderImageFor(mapId)
|
|||||||
bgSet = Palettes.withCaveFlicker(bgSet, flicker or 1)
|
bgSet = Palettes.withCaveFlicker(bgSet, flicker or 1)
|
||||||
end
|
end
|
||||||
local ok, img = pcall(BorderFill.bake, atlas, tileset,
|
local ok, img = pcall(BorderFill.bake, atlas, tileset,
|
||||||
BorderFill.blockFor(0, def.borderBlock), bgSet, waterFrame)
|
blockId, bgSet, waterFrame)
|
||||||
-- `false` rather than nil: a bake that cannot be made (a headless run with
|
-- `false` rather than nil: a bake that cannot be made (a headless run with
|
||||||
-- no canvas support) must not be retried once per frame forever.
|
-- no canvas support) must not be retried once per frame forever.
|
||||||
self.mapImages[cacheKey] = (ok and img) or false
|
self.mapImages[cacheKey] = (ok and img) or false
|
||||||
@@ -9720,8 +9729,16 @@ function World:drawGround(s)
|
|||||||
else
|
else
|
||||||
bw, bh = GameViewport.dimensions()
|
bw, bh = GameViewport.dimensions()
|
||||||
end
|
end
|
||||||
BorderFill.draw(self, self:borderImageFor(self.map.id),
|
if BorderFill.fillBlock(self.map.def) == false then
|
||||||
cam.x, cam.y, bw, bh, s, self.map.id)
|
-- BLACK: World:draw clears to a brown letterbox, so the void itself
|
||||||
|
-- has to be an actual black sheet or the map sits on that colour.
|
||||||
|
G.setColor(0, 0, 0, 1)
|
||||||
|
G.rectangle("fill", 0, 0, bw, bh)
|
||||||
|
G.setColor(1, 1, 1, 1)
|
||||||
|
else
|
||||||
|
BorderFill.draw(self, self:borderImageFor(self.map.id),
|
||||||
|
cam.x, cam.y, bw, bh, s, BorderFill.fillKey(self.map.def))
|
||||||
|
end
|
||||||
end
|
end
|
||||||
for _, nb in ipairs(self.neighbors) do
|
for _, nb in ipairs(self.neighbors) do
|
||||||
G.draw(nb.image,
|
G.draw(nb.image,
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
return function(game)
|
||||||
|
local U = dofile("tests/drivers/util.lua")
|
||||||
|
local DIR = os.getenv("SHOT_DIR") or "/tmp/bug1412"
|
||||||
|
local Pokemon = require("src.pokemon.Pokemon")
|
||||||
|
local Evolution = require("src.pokemon.Evolution")
|
||||||
|
local Screens = require("src.ui.Screens")
|
||||||
|
local TradeAnim = require("src.ui.TradeAnim")
|
||||||
|
local TextBox = require("src.render.TextBox")
|
||||||
|
|
||||||
|
local function check(label, ok)
|
||||||
|
U.log(ok and "PASS" or "FAIL", label)
|
||||||
|
return ok
|
||||||
|
end
|
||||||
|
|
||||||
|
U.log("Issue #1412: mon pics must be mirrored on the evolution and trade")
|
||||||
|
U.log("screens, and only there; battle keeps the raw orientation.")
|
||||||
|
|
||||||
|
U.teleport(game, "ROUTE_1", 5, 5, "down")
|
||||||
|
U.wait(10)
|
||||||
|
check("the overworld fixture is ready", game.overworld ~= nil)
|
||||||
|
|
||||||
|
local mon = Pokemon.new(game.data, "PIKACHU", 20)
|
||||||
|
game.save.party = { mon }
|
||||||
|
-- engine/movie/evolution.asm:103
|
||||||
|
Evolution.evolve(game, mon, "RAICHU", nil, "ITEM")
|
||||||
|
U.wait(12)
|
||||||
|
local top = game.stack:top()
|
||||||
|
check("the evolution screen opened",
|
||||||
|
top and top.screenId == "EvolutionState")
|
||||||
|
U.shot(game, DIR .. "/bug1412_evo_old_pikachu.png")
|
||||||
|
|
||||||
|
for _ = 1, 600 do
|
||||||
|
if top.done then break end
|
||||||
|
U.wait(1)
|
||||||
|
end
|
||||||
|
check("the evolution finished", top.done and not top.canceled)
|
||||||
|
U.wait(2)
|
||||||
|
U.shot(game, DIR .. "/bug1412_evo_new_raichu.png")
|
||||||
|
for _ = 1, 60 do
|
||||||
|
if game.stack:top() == game.overworld then break end
|
||||||
|
U.tap(game, "a")
|
||||||
|
U.wait(4)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- engine/movie/trade.asm:751
|
||||||
|
local sent = Pokemon.new(game.data, "SPEAROW", 10)
|
||||||
|
local recv = Pokemon.new(game.data, "FARFETCHD", 10)
|
||||||
|
recv.nickname = "DUX"
|
||||||
|
recv.traded = true
|
||||||
|
recv.ot = "TRAINER"
|
||||||
|
U.teleport(game, "ROUTE_1", 5, 5, "down")
|
||||||
|
local anim = Screens.push(game, "TradeAnim", {
|
||||||
|
sent = sent, received = recv, enemyName = "TRAINER",
|
||||||
|
})
|
||||||
|
check("the trade cinematic opened", getmetatable(anim) == TradeAnim)
|
||||||
|
for _ = 1, 200 do
|
||||||
|
if anim.sub == "hold" then break end
|
||||||
|
U.wait(1)
|
||||||
|
end
|
||||||
|
check("the sent SPEAROW pic is on screen",
|
||||||
|
anim.phase == "show_player" and anim.sub == "hold" and anim.monVisible)
|
||||||
|
U.shot(game, DIR .. "/bug1412_trade_sent_spearow.png")
|
||||||
|
|
||||||
|
local function topIsText()
|
||||||
|
return getmetatable(game.stack:top()) == TextBox
|
||||||
|
end
|
||||||
|
for _ = 1, 8000 do
|
||||||
|
if anim.phase == "show_enemy" and anim.monVisible then break end
|
||||||
|
if anim.phase == "done" then break end
|
||||||
|
if anim.waitingText or topIsText() then
|
||||||
|
U.wait(1)
|
||||||
|
else
|
||||||
|
anim:update(1 / 60)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
U.wait(2)
|
||||||
|
check("the received FARFETCH'D pic is on screen",
|
||||||
|
anim.phase == "show_enemy" and anim.monVisible)
|
||||||
|
U.shot(game, DIR .. "/bug1412_trade_recv_farfetchd.png")
|
||||||
|
|
||||||
|
U.log("shots in", DIR)
|
||||||
|
U.log("Right: all four pics are mirrored left-right compared to the same")
|
||||||
|
U.log("mon's battle front sprite (compare the hardware capture on #1412:")
|
||||||
|
U.log("PIKACHU's face points the other way than it does in battle).")
|
||||||
|
U.log("Wrong: any of the four matches the battle orientation.")
|
||||||
|
U.log("Battle, pokedex, title, Hall of Fame, League PC, credits, and the")
|
||||||
|
U.log("museum fossils are untouched and must still match the cart.")
|
||||||
|
|
||||||
|
while true do
|
||||||
|
coroutine.yield()
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
-- Gold VOID FILL (#1418): FADE (each map's own border, dissolving across a
|
||||||
|
-- boundary), WATER, TREES, or BLACK. Parks you on Cherrygrove zoomed out so
|
||||||
|
-- the void is most of the window, cycles the four modes, then hands the pad
|
||||||
|
-- over. Do not add POKEPORT_SPEED: the water anim and the dissolve both run
|
||||||
|
-- on the real clock.
|
||||||
|
--
|
||||||
|
-- POKEPORT_IDENTITY=gold-dev POKEPORT_GAME=gold \
|
||||||
|
-- POKEPORT_DRIVER=tests/drivers/gold_void_fill_bug1418_test.lua love .
|
||||||
|
|
||||||
|
local U = require("tests.drivers.util")
|
||||||
|
|
||||||
|
return function(game)
|
||||||
|
local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-void-fill"
|
||||||
|
local BorderFill = require("src.world.gen2.BorderFill")
|
||||||
|
local Tilt = require("src.render.Tilt")
|
||||||
|
local Zoom = require("src.render.Zoom")
|
||||||
|
local failures = 0
|
||||||
|
|
||||||
|
local function ok(label, condition, detail)
|
||||||
|
if condition then
|
||||||
|
print("[void] ok " .. label)
|
||||||
|
else
|
||||||
|
failures = failures + 1
|
||||||
|
print("[void] FAIL " .. label .. " " .. tostring(detail))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
U.wait(45)
|
||||||
|
local world = game.world
|
||||||
|
assert(world and world.map, "gold world did not boot")
|
||||||
|
|
||||||
|
-- Cherrygrove's header border is water ($35). FADE shows that water,
|
||||||
|
-- TREES replaces it, BLACK drops the sheet. attributes.asm:123.
|
||||||
|
world:setMap("CHERRYGROVE_CITY", 22, 16, "down")
|
||||||
|
U.wait(10)
|
||||||
|
|
||||||
|
Zoom.offset = -3
|
||||||
|
world:rebuildNeighbors()
|
||||||
|
world:rebuildPeople({ seamless = true })
|
||||||
|
Tilt.setLevel(3)
|
||||||
|
for _ = 1, 40 do
|
||||||
|
Tilt.update(1 / 60)
|
||||||
|
U.wait(1)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function show(mode, file, label)
|
||||||
|
BorderFill.setVoidFill(mode)
|
||||||
|
if game.options then game.options.voidFill = mode end
|
||||||
|
if game.save and game.save.options then
|
||||||
|
game.save.options.voidFill = mode
|
||||||
|
end
|
||||||
|
U.wait(8)
|
||||||
|
ok(label, BorderFill.voidFill == mode, BorderFill.voidFill)
|
||||||
|
U.shot(game, out .. "/" .. file)
|
||||||
|
end
|
||||||
|
|
||||||
|
show("fade", "01-fade.png", "FADE is the live fill")
|
||||||
|
show("water", "02-water.png", "WATER forces the water block")
|
||||||
|
show("trees", "03-trees.png", "TREES forces the tree wall")
|
||||||
|
show("black", "04-black.png", "BLACK drops the tiled void")
|
||||||
|
show("fade", "05-fade-again.png", "and FADE restores the map's own border")
|
||||||
|
|
||||||
|
Tilt.setLevel(0)
|
||||||
|
Zoom.offset = 0
|
||||||
|
world:rebuildNeighbors()
|
||||||
|
world:rebuildPeople({ seamless = true })
|
||||||
|
|
||||||
|
if failures > 0 then
|
||||||
|
print(("[driver] FAIL gold void fill: %d check(s)"):format(failures))
|
||||||
|
return
|
||||||
|
end
|
||||||
|
print("[driver] PASS gold void fill in " .. out)
|
||||||
|
U.log("cycled fade/water/trees/black on cherrygrove (#1418).")
|
||||||
|
U.log("OPTION > VOID FILL, zoom out (-) to see the void.")
|
||||||
|
|
||||||
|
while true do
|
||||||
|
coroutine.yield()
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
-- Driver: renders the launcher's FIND MODS tab against a canned index whose
|
||||||
|
-- listings carry the feed's `downloads` object, so the card line, the sort
|
||||||
|
-- popup and the per-mod popup can be eyeballed with real numbers, real
|
||||||
|
-- unknowns and a real zero in the same list.
|
||||||
|
-- SHOT_DIR=/tmp/finddl POKEPORT_DRIVER=tests/drivers/launcher_find_downloads_shot.lua love .
|
||||||
|
return function(game)
|
||||||
|
local U = dofile("tests/drivers/util.lua")
|
||||||
|
local RomImporter = require("src.import.RomImporter")
|
||||||
|
|
||||||
|
local dir = os.getenv("SHOT_DIR") or "/tmp/finddl"
|
||||||
|
os.execute('mkdir -p "' .. dir .. '" 2>/dev/null')
|
||||||
|
love.window.setMode(1024, 768, { resizable = true, highdpi = true })
|
||||||
|
U.wait(2)
|
||||||
|
|
||||||
|
local imp = RomImporter.new(function() end, { launcher = true })
|
||||||
|
|
||||||
|
local function mod(id, title, downloads)
|
||||||
|
return {
|
||||||
|
id = id, title = title, author = "someone", version = "1.0.0",
|
||||||
|
summary = "A canned listing for the screenshot.",
|
||||||
|
categories = { "GAMEPLAY" }, tags = {}, games = {},
|
||||||
|
update_check = "ok",
|
||||||
|
downloads = downloads,
|
||||||
|
latest = { version = "1.0.0", zip = {
|
||||||
|
url = "https://example.invalid/" .. id .. ".zip" } },
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
-- the canned listing IS the fixture: never let the tab go and fetch the
|
||||||
|
-- player's real sources over it
|
||||||
|
-- new() prewarms a real fetch; drop the in-flight handle and the pump
|
||||||
|
imp._refreshFindSources = function() end
|
||||||
|
imp._refreshFind = function() end
|
||||||
|
imp._pumpFindFetch = function() end
|
||||||
|
imp._findFetch = nil
|
||||||
|
imp.findSources = { { feed = "https://example.invalid/data/index.json",
|
||||||
|
base = "https://example.invalid/",
|
||||||
|
label = "example/index" } }
|
||||||
|
imp.findIndex = { schemaVersion = 1, categories = { "GAMEPLAY" }, mods = {
|
||||||
|
mod("busy", "Busy Mod",
|
||||||
|
{ total = 1578, recent = 388, window_days = 30,
|
||||||
|
as_of = "2026-08-18T05:17:00.000Z" }),
|
||||||
|
mod("huge", "Huge Mod",
|
||||||
|
{ total = 1234567, recent = 24000, window_days = 30,
|
||||||
|
as_of = "2026-08-18T05:17:00.000Z" }),
|
||||||
|
mod("young", "Young Mod",
|
||||||
|
{ total = 42, as_of = "2026-08-18T05:17:00.000Z" }),
|
||||||
|
mod("counted-zero", "Counted Zero Mod", { total = 0 }),
|
||||||
|
mod("unknown", "Unknown Mod", nil),
|
||||||
|
} }
|
||||||
|
imp.findLoaded = true
|
||||||
|
imp:_switchTab("find")
|
||||||
|
U.wait(3)
|
||||||
|
|
||||||
|
local pending = nil
|
||||||
|
love.draw = function()
|
||||||
|
imp: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
|
||||||
|
imp:update(1 / 60)
|
||||||
|
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
|
||||||
|
|
||||||
|
local ModUpdate = require("src.mods.ModUpdate")
|
||||||
|
for _, e in ipairs(imp.findIndex.mods) do
|
||||||
|
local s = imp:_findStats(e)
|
||||||
|
U.log((" %-13s %s | trending=%s"):format(e.id,
|
||||||
|
ModUpdate.downloadsShort(s and s.total),
|
||||||
|
tostring(s and ModUpdate.trendingLine(s.recent, s.windowDays))))
|
||||||
|
end
|
||||||
|
|
||||||
|
for _, key in ipairs({ "popularity", "trending", "name" }) do
|
||||||
|
imp.modSort = key
|
||||||
|
imp._findSortCache = nil
|
||||||
|
U.wait(2)
|
||||||
|
shot("find_sort_" .. key .. ".png")
|
||||||
|
local order = {}
|
||||||
|
for _, e in ipairs(imp._findSortCache.list) do order[#order + 1] = e.id end
|
||||||
|
U.log("sort " .. key .. ":", table.concat(order, ", "))
|
||||||
|
end
|
||||||
|
|
||||||
|
imp.modSort = "popularity"
|
||||||
|
imp._sortPopup = "find"
|
||||||
|
U.wait(2)
|
||||||
|
shot("find_sort_popup.png")
|
||||||
|
imp._sortPopup = nil
|
||||||
|
|
||||||
|
imp._findEntry = imp.findIndex.mods[1]
|
||||||
|
U.wait(2)
|
||||||
|
shot("find_entry_counted.png")
|
||||||
|
imp._findEntry = imp.findIndex.mods[5]
|
||||||
|
U.wait(2)
|
||||||
|
shot("find_entry_unknown.png")
|
||||||
|
imp._findEntry = nil
|
||||||
|
|
||||||
|
-- the installed-mods tab shares the persisted sort key; Trending there has
|
||||||
|
-- nothing to sort by, so it must degrade rather than reorder on nothing
|
||||||
|
imp.modSort = "trending"
|
||||||
|
imp:_switchTab("mods")
|
||||||
|
U.wait(3)
|
||||||
|
shot("mods_tab_trending_fallback.png")
|
||||||
|
|
||||||
|
U.log("done")
|
||||||
|
love.event.quit()
|
||||||
|
end
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
-- Driver: opens FIND MODS against the player's real index sources, lets the
|
||||||
|
-- prewarm fetch land, and reports what the panel resolved without asking
|
||||||
|
-- GitHub anything -- the check behind "sorting only covers the pages I
|
||||||
|
-- visited". Shoots the first and last page of the download sort.
|
||||||
|
-- SHOT_DIR=/tmp/findreal POKEPORT_DRIVER=tests/drivers/launcher_find_real_index.lua love .
|
||||||
|
return function(game)
|
||||||
|
local U = dofile("tests/drivers/util.lua")
|
||||||
|
local ModUpdate = require("src.mods.ModUpdate")
|
||||||
|
|
||||||
|
local fetched = 0
|
||||||
|
local realBegin = ModUpdate.beginFetchReleases
|
||||||
|
ModUpdate.beginFetchReleases = function(...)
|
||||||
|
fetched = fetched + 1
|
||||||
|
return realBegin(...)
|
||||||
|
end
|
||||||
|
|
||||||
|
local RomImporter = require("src.import.RomImporter")
|
||||||
|
local dir = os.getenv("SHOT_DIR") or "/tmp/findreal"
|
||||||
|
os.execute('mkdir -p "' .. dir .. '" 2>/dev/null')
|
||||||
|
love.window.setMode(1024, 768, { resizable = true, highdpi = true })
|
||||||
|
U.wait(2)
|
||||||
|
|
||||||
|
local imp = RomImporter.new(function() end, { launcher = true })
|
||||||
|
imp:_switchTab("find")
|
||||||
|
for _ = 1, 900 do
|
||||||
|
imp:update(1 / 60)
|
||||||
|
coroutine.yield()
|
||||||
|
if imp.findLoaded and not imp._findFetch then break end
|
||||||
|
end
|
||||||
|
|
||||||
|
local mods = (imp.findIndex and imp.findIndex.mods) or {}
|
||||||
|
U.log("index mods:", #mods)
|
||||||
|
U.log("stale copy:", tostring(imp.findIndex and imp.findIndex.stale))
|
||||||
|
|
||||||
|
local pending = nil
|
||||||
|
love.draw = function()
|
||||||
|
imp: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
|
||||||
|
imp:update(1 / 60)
|
||||||
|
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
|
||||||
|
|
||||||
|
local counted, dated, unknown = 0, 0, 0
|
||||||
|
for _, e in ipairs(mods) do
|
||||||
|
local s = imp:_findStatsCached(e)
|
||||||
|
if s and s.total then counted = counted + 1 else unknown = unknown + 1 end
|
||||||
|
if s and s.latest then dated = dated + 1 end
|
||||||
|
end
|
||||||
|
U.log(("counts=%d dated=%d unknown=%d github requests=%d")
|
||||||
|
:format(counted, dated, unknown, fetched))
|
||||||
|
|
||||||
|
imp.modSort = "popularity"
|
||||||
|
imp._findSortCache = nil
|
||||||
|
U.wait(3)
|
||||||
|
shot("real_page1.png")
|
||||||
|
local sorted = imp._findSortCache and imp._findSortCache.list or {}
|
||||||
|
for i = 1, math.min(3, #sorted) do
|
||||||
|
local s = imp:_findStatsCached(sorted[i])
|
||||||
|
U.log((" #%d %s %s"):format(i, sorted[i].title,
|
||||||
|
ModUpdate.downloadsShort(s and s.total)))
|
||||||
|
end
|
||||||
|
for i = math.max(1, #sorted - 2), #sorted do
|
||||||
|
local s = imp:_findStatsCached(sorted[i])
|
||||||
|
U.log((" #%d %s %s"):format(i, sorted[i].title,
|
||||||
|
ModUpdate.downloadsShort(s and s.total)))
|
||||||
|
end
|
||||||
|
|
||||||
|
-- the last page is the one a page-by-page resolve would have left unsorted
|
||||||
|
imp._pages["find"] = 99
|
||||||
|
U.wait(3)
|
||||||
|
U.log("github requests after sorting:", fetched)
|
||||||
|
shot("real_last_page.png")
|
||||||
|
|
||||||
|
U.log("done")
|
||||||
|
love.event.quit()
|
||||||
|
end
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -97,10 +97,11 @@ local launcher = love.graphics.newImage("assets/launcher/gear.png")
|
|||||||
eq(launcher.path, "assets/launcher/gear.png",
|
eq(launcher.path, "assets/launcher/gear.png",
|
||||||
"overlay leaves non-generated paths alone")
|
"overlay leaves non-generated paths alone")
|
||||||
|
|
||||||
-- The real un-prefixed file wins when it exists
|
-- Leftover un-prefixed Red cache must not shadow the versioned copy
|
||||||
|
-- (pre-#899 assets/generated at the save-dir root).
|
||||||
love.filesystem.write(PNG, "root-png-bytes")
|
love.filesystem.write(PNG, "root-png-bytes")
|
||||||
eq(love.filesystem.read(PNG), "root-png-bytes",
|
eq(love.filesystem.read(PNG), "yellow-png-bytes",
|
||||||
"overlay prefers the real un-prefixed file over the versioned copy")
|
"overlay prefers the versioned copy over leftover unprefixed")
|
||||||
clearPath(PNG)
|
clearPath(PNG)
|
||||||
|
|
||||||
-- Blue gets the same treatment
|
-- Blue gets the same treatment
|
||||||
@@ -110,7 +111,29 @@ clearPath("yellow/" .. PNG)
|
|||||||
eq(love.filesystem.read(PNG), "blue-png-bytes",
|
eq(love.filesystem.read(PNG), "blue-png-bytes",
|
||||||
"overlay maps generated reads to blue/ for Blue")
|
"overlay maps generated reads to blue/ for Blue")
|
||||||
|
|
||||||
-- Red has no prefix: nothing is rewritten
|
-- Leftover unprefixed font (old Red root) must not beat Gold's copy.
|
||||||
|
GameVersion.set("gold")
|
||||||
|
local FONT = "assets/generated/fonts/font.png"
|
||||||
|
love.filesystem.write(FONT, "stale-red-font")
|
||||||
|
love.filesystem.write("gold/" .. FONT, "gold-font")
|
||||||
|
eq(love.filesystem.read(FONT), "gold-font",
|
||||||
|
"Gold versioned font wins over leftover unprefixed Red font")
|
||||||
|
clearPath(FONT)
|
||||||
|
clearPath("gold/" .. FONT)
|
||||||
|
|
||||||
|
-- Gold data/generated: fused NX hides gold/maps.lua at the unprefixed path,
|
||||||
|
-- which is the "Gold cache incomplete / maps.lua Does not exist" crash.
|
||||||
|
local MAPS = "data/generated/maps.lua"
|
||||||
|
love.filesystem.write("gold/" .. MAPS, "return { NEW_BARK_TOWN = true }")
|
||||||
|
local mapsChunk = love.filesystem.load(MAPS)
|
||||||
|
eq(type(mapsChunk) == "function" and mapsChunk().NEW_BARK_TOWN or nil, true,
|
||||||
|
"wrapped filesystem.load resolves gold/data/generated/maps.lua")
|
||||||
|
eq(love.filesystem.read(MAPS), "return { NEW_BARK_TOWN = true }",
|
||||||
|
"wrapped filesystem.read returns gold maps.lua bytes")
|
||||||
|
clearPath("gold/" .. MAPS)
|
||||||
|
|
||||||
|
-- Red has no empty prefix anymore (cachePrefix is red/), but with no
|
||||||
|
-- red/ copy the unprefixed miss stays a miss.
|
||||||
GameVersion.set("red")
|
GameVersion.set("red")
|
||||||
clearPath("blue/" .. PNG)
|
clearPath("blue/" .. PNG)
|
||||||
eq(love.filesystem.read(PNG), nil, "Red keeps the stock miss behavior")
|
eq(love.filesystem.read(PNG), nil, "Red keeps the stock miss behavior")
|
||||||
|
|||||||
@@ -49,5 +49,16 @@ check(CacheFs.mountVersion("yellow") == true, "mountVersion(yellow) returns true
|
|||||||
eq(love.filesystem.read("assets/generated/fonts/font.png"), "yellow-font",
|
eq(love.filesystem.read("assets/generated/fonts/font.png"), "yellow-font",
|
||||||
"Yellow mount exposes fonts/font.png at the unprefixed path")
|
"Yellow mount exposes fonts/font.png at the unprefixed path")
|
||||||
|
|
||||||
|
-- Gold-only: same overlay contract (the Switch intro/maps.lua hole)
|
||||||
|
love.filesystem._mounts = {}
|
||||||
|
GameVersion.set("gold")
|
||||||
|
love.filesystem.write("gold/data/generated/maps.lua", "return { ok = true }")
|
||||||
|
love.filesystem.write("gold/assets/generated/fonts/font.png", "gold-font")
|
||||||
|
check(CacheFs.mountVersion("gold") == true, "mountVersion(gold) returns true")
|
||||||
|
eq(love.filesystem.read("assets/generated/fonts/font.png"), "gold-font",
|
||||||
|
"Gold mount exposes fonts/font.png at the unprefixed path")
|
||||||
|
eq(love.filesystem.read("data/generated/maps.lua"), "return { ok = true }",
|
||||||
|
"Gold mount exposes maps.lua at the unprefixed path")
|
||||||
|
|
||||||
GameVersion.set("red")
|
GameVersion.set("red")
|
||||||
T.finish()
|
T.finish()
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
-- Gold on fused NX: gold/data/generated exists, but the overlay mount
|
||||||
|
-- onto data/generated often fails. Game2/World used to love.filesystem.load
|
||||||
|
-- the unprefixed path and crash with "Gold cache incomplete / maps.lua
|
||||||
|
-- Does not exist" after a textless intro. CacheFs.loadActive must read the
|
||||||
|
-- versioned file without any mount.
|
||||||
|
-- Self-contained: luajit tests/engine/cache_fs_gold_nx_load_test.lua
|
||||||
|
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||||
|
if not _G.love then _G.love = require("tests.love_stub") end
|
||||||
|
|
||||||
|
local T = require("tests.harness")
|
||||||
|
local check = T.check
|
||||||
|
local eq = T.eq
|
||||||
|
|
||||||
|
local CacheFs = require("src.import.CacheFs")
|
||||||
|
local GameVersion = require("src.core.GameVersion")
|
||||||
|
|
||||||
|
local savedVersion = GameVersion.get()
|
||||||
|
local savedPrefix = CacheFs.prefix
|
||||||
|
|
||||||
|
love.filesystem._mounts = {}
|
||||||
|
GameVersion.set("gold")
|
||||||
|
CacheFs.prefix = GameVersion.cachePrefix()
|
||||||
|
|
||||||
|
love.filesystem.write("gold/data/generated/maps.lua",
|
||||||
|
"return { NEW_BARK_TOWN = { id = 1 } }")
|
||||||
|
love.filesystem.write("gold/data/generated/oak_speech.lua",
|
||||||
|
"return { text = { _OakText1 = 'Hello!' } }")
|
||||||
|
love.filesystem.write("gold/data/generated/font.lua",
|
||||||
|
"return { width = 8 }")
|
||||||
|
|
||||||
|
-- Unprefixed path is a miss: the NX mount hole.
|
||||||
|
eq(love.filesystem.read("data/generated/maps.lua"), nil,
|
||||||
|
"unprefixed maps.lua is missing (mount hole)")
|
||||||
|
eq(love.filesystem.load("data/generated/maps.lua"), nil,
|
||||||
|
"love.filesystem.load misses unprefixed maps.lua")
|
||||||
|
|
||||||
|
local maps, mapsErr = CacheFs.loadActive("data/generated/maps.lua")
|
||||||
|
check(maps ~= nil, "loadActive finds gold/data/generated/maps.lua ("
|
||||||
|
.. tostring(mapsErr) .. ")")
|
||||||
|
eq(maps and maps.NEW_BARK_TOWN and maps.NEW_BARK_TOWN.id, 1,
|
||||||
|
"loadActive returns the Gold maps table")
|
||||||
|
|
||||||
|
local oak = CacheFs.loadActive("data/generated/oak_speech.lua")
|
||||||
|
eq(oak and oak.text and oak.text._OakText1, "Hello!",
|
||||||
|
"loadActive returns oak_speech.lua from gold/")
|
||||||
|
|
||||||
|
local font = CacheFs.loadActive("data/generated/font.lua")
|
||||||
|
eq(font and font.width, 8,
|
||||||
|
"loadActive returns font.lua from gold/")
|
||||||
|
|
||||||
|
love.filesystem.remove("gold/data/generated/maps.lua")
|
||||||
|
love.filesystem.remove("gold/data/generated/oak_speech.lua")
|
||||||
|
love.filesystem.remove("gold/data/generated/font.lua")
|
||||||
|
CacheFs.prefix = savedPrefix
|
||||||
|
GameVersion.set(savedVersion)
|
||||||
|
|
||||||
|
T.finish()
|
||||||
@@ -16,5 +16,8 @@ check(CacheFs.read("data/generated/audio.lua") == nil,
|
|||||||
"read is a nil miss headless, not a crash")
|
"read is a nil miss headless, not a crash")
|
||||||
check(CacheFs.readActive("data/generated/audio.lua") == nil,
|
check(CacheFs.readActive("data/generated/audio.lua") == nil,
|
||||||
"readActive is a nil miss headless, not a crash")
|
"readActive is a nil miss headless, not a crash")
|
||||||
|
local loaded, loadErr = CacheFs.loadActive("data/generated/audio.lua")
|
||||||
|
check(loaded == nil,
|
||||||
|
"loadActive is a nil miss headless, not a crash (" .. tostring(loadErr) .. ")")
|
||||||
|
|
||||||
T.finish()
|
T.finish()
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -43,6 +43,15 @@ local gold = LauncherSettings.open(hooks, "gold")
|
|||||||
check(has(gold, "TOUCH PAD"), "Gold's gear offers TOUCH PAD")
|
check(has(gold, "TOUCH PAD"), "Gold's gear offers TOUCH PAD")
|
||||||
check(has(gold, "VIBRATION"), "and VIBRATION")
|
check(has(gold, "VIBRATION"), "and VIBRATION")
|
||||||
check(has(gold, "TOUCH CONTROLS"), "and the layout editor")
|
check(has(gold, "TOUCH CONTROLS"), "and the layout editor")
|
||||||
|
check(has(gold, "VOID FILL"), "and VOID FILL")
|
||||||
|
|
||||||
|
local voidFill = findRow(gold, "VOID FILL")
|
||||||
|
eq(voidFill.value(), "FADE ", "VOID FILL defaults to FADE")
|
||||||
|
voidFill.step(1)
|
||||||
|
eq(gold.opts.gold.voidFill, "water", "right stores water in the gold block")
|
||||||
|
eq(voidFill.value(), "WATER", "and the row reads WATER")
|
||||||
|
voidFill.step(-1)
|
||||||
|
eq(gold.opts.gold.voidFill, "fade", "left restores fade")
|
||||||
|
|
||||||
-- Every write has to land in the gold block: the flat keys beside it are
|
-- Every write has to land in the gold block: the flat keys beside it are
|
||||||
-- Red's, and Gold's boot never reads them (src/core/gen2/Save.lua:299).
|
-- Red's, and Gold's boot never reads them (src/core/gen2/Save.lua:299).
|
||||||
|
|||||||
@@ -0,0 +1,125 @@
|
|||||||
|
-- FIND MODS download counts: the index feed publishes them per listing, so
|
||||||
|
-- the panel reads the one fetch it already made instead of asking GitHub per
|
||||||
|
-- mod (that fan-out is what blew the hourly API limit in _findStatsCached's
|
||||||
|
-- own comment). This pins the resolver's precedence, the unknown-is-not-zero
|
||||||
|
-- rule the card and the sorts both depend on, and the Trending wiring.
|
||||||
|
-- luajit tests/engine/launcher_mod_downloads.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
|
||||||
|
|
||||||
|
-- No launcher test may reach the network: beginFetchReleases is the only door
|
||||||
|
-- out of _requestFindStats, and counting the calls is the assertion.
|
||||||
|
local ModUpdate = require("src.mods.ModUpdate")
|
||||||
|
local fetched = {}
|
||||||
|
ModUpdate.beginFetchReleases = function(repo, id)
|
||||||
|
fetched[#fetched + 1] = id
|
||||||
|
return { stage = "done" }
|
||||||
|
end
|
||||||
|
|
||||||
|
local RomImporter = require("src.import.RomImporter")
|
||||||
|
local function launcher()
|
||||||
|
return setmetatable({ tab = "find" }, RomImporter)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function entry(id, downloads, github)
|
||||||
|
return { id = id, title = id, downloads = downloads, github = github }
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ------- the feed's counts win, and cost nothing
|
||||||
|
|
||||||
|
do
|
||||||
|
local ri = launcher()
|
||||||
|
local e = entry("counted", { total = 1578, recent = 388, window_days = 30,
|
||||||
|
as_of = "2026-08-18T05:17:00.000Z" },
|
||||||
|
"someone/counted")
|
||||||
|
local stats = ri:_findStats(e)
|
||||||
|
eq(stats.total, 1578, "the feed's total is what the card shows")
|
||||||
|
eq(stats.recent, 388, "the trailing-window count rides along")
|
||||||
|
eq(stats.windowDays, 30, "so does the window it covers")
|
||||||
|
eq(stats.asOf, "2026-08-18T05:17:00.000Z", "and when the index read it")
|
||||||
|
eq(#fetched, 0, "a listing with a published total asks GitHub for nothing")
|
||||||
|
end
|
||||||
|
|
||||||
|
-- A bare number is what a cache written before the object shipped holds; it
|
||||||
|
-- is read back through the same resolver rather than migrated.
|
||||||
|
do
|
||||||
|
local ri = launcher()
|
||||||
|
eq(ri:_findStats(entry("legacy", 4321, "someone/legacy")).total, 4321,
|
||||||
|
"a cached scalar count still resolves")
|
||||||
|
eq(#fetched, 0, "and still costs no request")
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Whole-list, not page-by-page: sorting calls the cached read for every entry
|
||||||
|
-- in the index, so all of them have to resolve from the feed alone. A
|
||||||
|
-- listing the feed dates but does not count still resolves, off `latest`.
|
||||||
|
do
|
||||||
|
local ri = launcher()
|
||||||
|
local dated = { id = "dated", title = "Dated", github = "someone/dated",
|
||||||
|
latest = { version = "1.0.0",
|
||||||
|
published_at = "2026-08-11T17:19:00Z" } }
|
||||||
|
local stats = ri:_findStats(dated)
|
||||||
|
eq(stats.latest, "2026-08-11", "the feed's own release blob dates the row")
|
||||||
|
eq(#fetched, 0, "with no repo fetch to wait on")
|
||||||
|
eq(ModUpdate.datesLine(stats.first, stats.latest), "Updated 2026-08-11",
|
||||||
|
"and the card states what it knows instead of a Released ?")
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ------- unknown is unknown: null falls through to the repo, as any other
|
||||||
|
-- missing feed stat does, and a listing with no repo resolves to no counts
|
||||||
|
-- rather than to zero.
|
||||||
|
|
||||||
|
do
|
||||||
|
local ri = launcher()
|
||||||
|
check(ri:_findStats(entry("nulled", nil, "someone/nulled")) == nil,
|
||||||
|
"a null count is not an answer; the repo is still consulted")
|
||||||
|
eq(#fetched, 1, "which is the fetch the panel already made for dates")
|
||||||
|
|
||||||
|
local bare = launcher()
|
||||||
|
local stats = bare:_findStats(entry("no-repo", nil, nil))
|
||||||
|
check(stats ~= nil and stats.total == nil,
|
||||||
|
"a listing with neither counts nor a repo is resolved-but-unknown")
|
||||||
|
check(stats.recent == nil, "and has nothing to trend on")
|
||||||
|
eq(#fetched, 1, "and queues nothing of its own")
|
||||||
|
end
|
||||||
|
|
||||||
|
-- A real zero is not unknown: the index has seen the releases and counted
|
||||||
|
-- none. The card prints it and the sort ranks it above the unknowns.
|
||||||
|
do
|
||||||
|
local ri = launcher()
|
||||||
|
local stats = ri:_findStats(entry("zero", { total = 0 }, "someone/zero"))
|
||||||
|
eq(stats.total, 0, "a counted zero survives as a number")
|
||||||
|
eq(ModUpdate.downloadsShort(stats.total), "0 downloads",
|
||||||
|
"and prints as zero rather than as a dash")
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ------- the panel wiring around those numbers
|
||||||
|
|
||||||
|
local view = (function()
|
||||||
|
local f = assert(io.open("src/import/LauncherView.lua", "r"))
|
||||||
|
local src = f:read("*a")
|
||||||
|
f:close()
|
||||||
|
return src
|
||||||
|
end)()
|
||||||
|
|
||||||
|
check(view:find('key = "trending"', 1, true) ~= nil,
|
||||||
|
"a Trending sort exists")
|
||||||
|
check(view:find('sortKey == "trending" then return stats and stats.recent or %-1'),
|
||||||
|
"Trending orders by the trailing-window count, unknown last")
|
||||||
|
check(view:find('sortKey == "popularity" then return stats and stats.total or %-1'),
|
||||||
|
"Most downloaded orders by the total, unknown last")
|
||||||
|
-- Trending has no meaning on the installed-mods tab, and the persisted key is
|
||||||
|
-- shared, so that panel has to degrade rather than sort on nothing.
|
||||||
|
check(view:find('if scope == "find" then', 1, true) ~= nil,
|
||||||
|
"Trending is offered only where the feed's counts exist")
|
||||||
|
check(view:find('sortKey == "trending" and scope ~= "find"', 1, true) ~= nil,
|
||||||
|
"the MODS tab falls back when the shared sort key is FIND-only")
|
||||||
|
check(view:find('sortKey or "popularity"', 1, true) ~= nil,
|
||||||
|
"the default sort is unchanged")
|
||||||
|
check(view:find("downloadsShort", 1, true) ~= nil,
|
||||||
|
"the browse card prints the abbreviated count")
|
||||||
|
|
||||||
|
T.finish("launcher_mod_downloads")
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -114,23 +114,110 @@ do
|
|||||||
"a feed without release stats parses them as absent")
|
"a feed without release stats parses them as absent")
|
||||||
end
|
end
|
||||||
|
|
||||||
-- release stats a feed can publish: total downloads and first/last dates
|
-- release stats a feed can publish: download counts and first/last dates
|
||||||
-- ride along additively, so a feed carrying them stays readable by every
|
-- ride along additively, so a feed carrying them stays readable by every
|
||||||
-- build that predates them
|
-- build that predates them
|
||||||
do
|
do
|
||||||
local withStats = {}
|
local withStats = {}
|
||||||
for k, v in pairs(NUZLOCKE) do withStats[k] = v end
|
for k, v in pairs(NUZLOCKE) do withStats[k] = v end
|
||||||
withStats.downloads = 1234
|
withStats.downloads = { total = 1578, recent = 388, window_days = 30,
|
||||||
|
as_of = "2026-08-18T05:17:00.000Z" }
|
||||||
withStats.first_release = "2024-05-31"
|
withStats.first_release = "2024-05-31"
|
||||||
withStats.last_release = "2026-07-01"
|
withStats.last_release = "2026-07-01"
|
||||||
local index = ModIndex.parse(feed({ withStats }))
|
local index = ModIndex.parse(feed({ withStats }))
|
||||||
local m = index.mods[1]
|
local m = index.mods[1]
|
||||||
eq(m.downloads, 1234, "total downloads are kept")
|
eq(m.downloads.total, 1578, "total downloads are kept")
|
||||||
|
eq(m.downloads.recent, 388, "the trailing-window count is kept")
|
||||||
|
eq(m.downloads.window_days, 30, "the window length is kept")
|
||||||
|
eq(m.downloads.as_of, "2026-08-18T05:17:00.000Z", "the read time is kept")
|
||||||
eq(m.first_release, "2024-05-31", "first release date is kept")
|
eq(m.first_release, "2024-05-31", "first release date is kept")
|
||||||
eq(m.last_release, "2026-07-01", "last release date is kept")
|
eq(m.last_release, "2026-07-01", "last release date is kept")
|
||||||
withStats.downloads = "9999"
|
end
|
||||||
m = ModIndex.parse(feed({ withStats })).mods[1]
|
|
||||||
eq(m.downloads, 9999, "numeric-string downloads are coerced")
|
-- ------- download counts: unknown is not zero
|
||||||
|
--
|
||||||
|
-- The feed's `downloads` object has three ways of saying "not known" -- the
|
||||||
|
-- field absent, the field null, and a null count inside it -- and every one
|
||||||
|
-- of them has to stay distinguishable from a real zero, because the browse
|
||||||
|
-- card prints one and sorts the other.
|
||||||
|
do
|
||||||
|
local function jsonWith(downloads)
|
||||||
|
local raw = {}
|
||||||
|
for k, v in pairs(NUZLOCKE) do raw[k] = v end
|
||||||
|
raw.downloads = downloads
|
||||||
|
return feed({ raw })
|
||||||
|
end
|
||||||
|
local function statsForJson(text)
|
||||||
|
return ModIndex.downloadStats(ModIndex.parse(text).mods[1])
|
||||||
|
end
|
||||||
|
local function statsFor(downloads)
|
||||||
|
return statsForJson(jsonWith(downloads))
|
||||||
|
end
|
||||||
|
|
||||||
|
check(statsFor(nil) == nil, "an absent downloads field is unknown")
|
||||||
|
-- Json.encode has no null of its own, so the literal the feed actually
|
||||||
|
-- sends is patched into the text.
|
||||||
|
local nulled = jsonWith({}):gsub('"downloads":%[%]', '"downloads":null', 1)
|
||||||
|
check(nulled:find('"downloads":null', 1, true) ~= nil,
|
||||||
|
"the null feed fixture really contains a null")
|
||||||
|
check(statsForJson(nulled) == nil, "a null downloads field is unknown")
|
||||||
|
check(statsFor({}) == nil, "an object with no counts is unknown")
|
||||||
|
eq(statsFor({ total = 0 }).total, 0, "a real zero total survives")
|
||||||
|
|
||||||
|
-- recent / window_days stay null until the index has more than a day of
|
||||||
|
-- history, even once total is a real number.
|
||||||
|
local young = statsFor({ total = 12, as_of = "2026-08-18T05:17:00.000Z" })
|
||||||
|
eq(young.total, 12, "a total with no window yet is still a total")
|
||||||
|
check(young.recent == nil and young.window_days == nil,
|
||||||
|
"no trailing window means no trending figure, not a zero one")
|
||||||
|
|
||||||
|
-- A cache written before the object shipped stored a bare number; it is
|
||||||
|
-- read back through the same door rather than migrated.
|
||||||
|
local legacy = ModIndex.downloadStats({ downloads = 4321 })
|
||||||
|
eq(legacy.total, 4321, "a bare number reads as the total")
|
||||||
|
check(legacy.recent == nil, "and carries no trending figure")
|
||||||
|
check(ModIndex.downloadStats({}) == nil, "a row with no counts is unknown")
|
||||||
|
check(ModIndex.downloadStats(nil) == nil, "no entry is unknown")
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ------- release dates: the feed already dates every listing it can install
|
||||||
|
--
|
||||||
|
-- Sorting must span the whole index, not the pages a reader happened to
|
||||||
|
-- visit, so the "last updated" date comes off the feed's own `latest` blob
|
||||||
|
-- rather than out of a per-mod repo fetch.
|
||||||
|
do
|
||||||
|
local raw = {}
|
||||||
|
for k, v in pairs(NUZLOCKE) do raw[k] = v end
|
||||||
|
local d = ModIndex.releaseDates(ModIndex.parse(feed({ raw })).mods[1])
|
||||||
|
eq(d.latest, "2026-07-31", "latest release date comes from latest.published_at")
|
||||||
|
check(d.first == nil, "the feed cannot date a first release from that alone")
|
||||||
|
|
||||||
|
raw.first_release = "2024-05-31"
|
||||||
|
raw.last_release = "2026-07-01"
|
||||||
|
d = ModIndex.releaseDates(ModIndex.parse(feed({ raw })).mods[1])
|
||||||
|
eq(d.first, "2024-05-31", "an explicit first_release wins")
|
||||||
|
eq(d.latest, "2026-07-01", "an explicit last_release beats the latest blob")
|
||||||
|
|
||||||
|
local bare = {}
|
||||||
|
for k, v in pairs(NUZLOCKE) do bare[k] = v end
|
||||||
|
bare.latest, bare.update_check = nil, "no installable release"
|
||||||
|
check(ModIndex.releaseDates(ModIndex.parse(feed({ bare })).mods[1]) == nil,
|
||||||
|
"a listing with no releases has no dates")
|
||||||
|
check(ModIndex.releaseDates(nil) == nil, "no entry has no dates")
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ------- cache version: a copy written before a field existed cannot answer
|
||||||
|
-- for it, and the TTL is a whole day
|
||||||
|
do
|
||||||
|
local now = os.time()
|
||||||
|
check(ModIndex.cacheFresh({ checkedAt = now,
|
||||||
|
version = ModIndex.CACHE_VERSION }), "a current cache is fresh")
|
||||||
|
check(not ModIndex.cacheFresh({ checkedAt = now }),
|
||||||
|
"an unstamped cache is refetched rather than trusted for a day")
|
||||||
|
check(not ModIndex.cacheFresh({ checkedAt = now,
|
||||||
|
version = ModIndex.CACHE_VERSION - 1 }), "so is an older stamp")
|
||||||
|
check(not ModIndex.cacheFresh({ checkedAt = now - ModIndex.CACHE_TTL - 1,
|
||||||
|
version = ModIndex.CACHE_VERSION }), "and an expired one")
|
||||||
end
|
end
|
||||||
|
|
||||||
-- schema_version is a contract, not a hint: an unknown one is refused rather
|
-- schema_version is a contract, not a hint: an unknown one is refused rather
|
||||||
|
|||||||
@@ -208,6 +208,32 @@ eq(ModUpdate.formatCount("12345"), "12,345", "numeric strings are accepted")
|
|||||||
eq(ModUpdate.formatCount(nil), "0", "nil formats as zero")
|
eq(ModUpdate.formatCount(nil), "0", "nil formats as zero")
|
||||||
eq(ModUpdate.formatCount("garbage"), "0", "garbage formats as zero")
|
eq(ModUpdate.formatCount("garbage"), "0", "garbage formats as zero")
|
||||||
|
|
||||||
|
-- abbrevCount: what a browse card has room for
|
||||||
|
eq(ModUpdate.abbrevCount(0), "0", "zero abbreviates to itself")
|
||||||
|
eq(ModUpdate.abbrevCount(999), "999", "below 1000 stays exact")
|
||||||
|
eq(ModUpdate.abbrevCount(1000), "1k", "a round thousand drops its decimal")
|
||||||
|
eq(ModUpdate.abbrevCount(1578), "1.6k", "thousands round to one decimal")
|
||||||
|
eq(ModUpdate.abbrevCount(24000), "24k", "a round figure keeps no .0")
|
||||||
|
eq(ModUpdate.abbrevCount(999999), "1M", "no count ever reads as 1000k")
|
||||||
|
eq(ModUpdate.abbrevCount(1234567), "1.2M", "millions abbreviate too")
|
||||||
|
eq(ModUpdate.abbrevCount("1578"), "1.6k", "numeric strings are accepted")
|
||||||
|
eq(ModUpdate.abbrevCount(nil), "?", "an unknown count is not a zero")
|
||||||
|
eq(ModUpdate.abbrevCount("garbage"), "?", "garbage is unknown, not a zero")
|
||||||
|
|
||||||
|
-- downloadsShort / trendingLine: the card and detail forms
|
||||||
|
eq(ModUpdate.downloadsShort(1578), "1.6k downloads", "the card form abbreviates")
|
||||||
|
eq(ModUpdate.downloadsShort(0), "0 downloads", "a real zero is still stated")
|
||||||
|
eq(ModUpdate.downloadsShort(nil), "? downloads",
|
||||||
|
"an unknown count says so rather than vanishing or reading as zero")
|
||||||
|
eq(ModUpdate.trendingLine(388, 30), "388 in the last 30 days",
|
||||||
|
"the trending line names its window")
|
||||||
|
eq(ModUpdate.trendingLine(388, 12), "388 in the last 12 days",
|
||||||
|
"a short window says so rather than claiming a month")
|
||||||
|
eq(ModUpdate.trendingLine(388, nil), "388 recently",
|
||||||
|
"a count with no window still reads")
|
||||||
|
check(ModUpdate.trendingLine(nil, 30) == nil,
|
||||||
|
"no trailing-window count means no trending line")
|
||||||
|
|
||||||
-- statsLine: the shared launcher row line, part by part
|
-- statsLine: the shared launcher row line, part by part
|
||||||
eq(ModUpdate.statsLine(1234567, "2024-05-31", "2026-07-01"),
|
eq(ModUpdate.statsLine(1234567, "2024-05-31", "2026-07-01"),
|
||||||
"1,234,567 downloads across all releases - Released 2024-05-31 - Updated 2026-07-01",
|
"1,234,567 downloads across all releases - Released 2024-05-31 - Updated 2026-07-01",
|
||||||
|
|||||||
@@ -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")
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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")
|
||||||
@@ -195,6 +195,55 @@ Studio.addControl()
|
|||||||
eq(#Studio.skin.pages[2].controls, 1, "controls land on the active page")
|
eq(#Studio.skin.pages[2].controls, 1, "controls land on the active page")
|
||||||
eq(#Studio.skin.pages[1].controls, 0, "and not on the other one")
|
eq(#Studio.skin.pages[1].controls, 0, "and not on the other one")
|
||||||
|
|
||||||
|
-- page orientation lock follows the canvas when Match canvas is on (#1503)
|
||||||
|
session()
|
||||||
|
Studio.canvasIndex = 1
|
||||||
|
Studio.matchOrient = true
|
||||||
|
eq(Studio.cyclePageOrient(1), "portrait", "cycle starts at portrait from unlocked")
|
||||||
|
eq(Studio.page().orient, "portrait", "and stores the lock on the page")
|
||||||
|
eq(Studio.page().name, "portrait", "a generic page is renamed so play can auto-rotate")
|
||||||
|
eq(Studio.canvas().id, "phone_portrait", "and the canvas stays portrait")
|
||||||
|
|
||||||
|
Studio.addPage()
|
||||||
|
eq(Studio.cyclePageOrient(1), "portrait", "the new page starts unlocked, first lock is portrait")
|
||||||
|
Studio.cyclePageOrient(1)
|
||||||
|
eq(Studio.page().orient, "landscape", "second cycle is landscape")
|
||||||
|
eq(Studio.canvas().id, "phone_landscape", "Match canvas flips the mock device with the page")
|
||||||
|
|
||||||
|
Studio.pageIndex = 1
|
||||||
|
Studio.syncCanvasToPage()
|
||||||
|
eq(Studio.canvas().id, "phone_portrait", "switching back to the portrait page restores portrait canvas")
|
||||||
|
|
||||||
|
Studio.setCanvas(2)
|
||||||
|
eq(Studio.pageIndex, 2, "picking a landscape canvas selects the landscape page")
|
||||||
|
|
||||||
|
Studio.matchOrient = false
|
||||||
|
Studio.pageIndex = 1
|
||||||
|
Studio.setCanvas(2)
|
||||||
|
eq(Studio.pageIndex, 1, "Match canvas off leaves the page when the device changes")
|
||||||
|
eq(Studio.canvas().id, "phone_landscape", "and still honours the canvas click")
|
||||||
|
|
||||||
|
-- a RetroArch overlay that already auto-rotates locks itself on open (#1503)
|
||||||
|
session()
|
||||||
|
Studio.matchOrient = false
|
||||||
|
Studio.canvasIndex = 2
|
||||||
|
Studio.skin = assert(TouchSkin.parse([[
|
||||||
|
overlays = 2
|
||||||
|
overlay0_name = "portrait"
|
||||||
|
overlay0_full_screen = true
|
||||||
|
overlay0_descs = 1
|
||||||
|
overlay0_desc0 = "a,0.5,0.5,radial,0.05,0.05"
|
||||||
|
overlay1_name = "landscape"
|
||||||
|
overlay1_full_screen = true
|
||||||
|
overlay1_descs = 1
|
||||||
|
overlay1_desc0 = "a,0.5,0.5,radial,0.05,0.05"
|
||||||
|
]]))
|
||||||
|
Studio.pageIndex = 1
|
||||||
|
check(Studio.applyImportedOrient(), "import of a portrait/landscape pair is automatic")
|
||||||
|
check(Studio.matchOrient, "and turns Match canvas on")
|
||||||
|
eq(Studio.page().name, "landscape", "keeping the landscape canvas already on screen")
|
||||||
|
eq(Studio.page().orient, "landscape", "with the page already locked")
|
||||||
|
|
||||||
-- --------------------------------------------------------------- clone
|
-- --------------------------------------------------------------- clone
|
||||||
|
|
||||||
local source = TouchSkin.load("assets/skins/gb_anim", "gb_anim")
|
local source = TouchSkin.load("assets/skins/gb_anim", "gb_anim")
|
||||||
|
|||||||
@@ -89,6 +89,23 @@ check(probe:find("resolve=yellow/assets/generated/fonts/font.png", 1, true) ~= n
|
|||||||
"probe records versioned font path visibility")
|
"probe records versioned font path visibility")
|
||||||
check(not probe:find(string.char(0xEA, 0x9B), 1, true),
|
check(not probe:find(string.char(0xEA, 0x9B), 1, true),
|
||||||
"probe log contains no ROM-like binary")
|
"probe log contains no ROM-like binary")
|
||||||
|
check(probe:find("data/generated/maps.lua", 1, true) ~= nil,
|
||||||
|
"probe records Gold/Gen2 maps.lua visibility")
|
||||||
|
check(probe:find("loadActive=", 1, true) ~= nil,
|
||||||
|
"probe uses loadActive for generated Lua instead of newImage")
|
||||||
|
|
||||||
|
-- Gold maps.lua / gold/ folders: fused NX intro/naming/overworld crash.
|
||||||
|
GameVersion.set("gold")
|
||||||
|
love.filesystem.write("gold/data/generated/maps.lua", "return { NEW_BARK_TOWN = true }")
|
||||||
|
love.filesystem.write("gold/data/generated/oak_speech.lua", "return {}")
|
||||||
|
SwitchDiagnostics.probeAssets("gold")
|
||||||
|
probe = love.filesystem.read("nx-asset-probe.log") or ""
|
||||||
|
check(probe:find("cachePrefix=gold/", 1, true) ~= nil, "probe records gold prefix")
|
||||||
|
check(probe:find("data/generated/maps.lua", 1, true) ~= nil,
|
||||||
|
"probe lists maps.lua (the Gold cache incomplete path)")
|
||||||
|
check(probe:find("list gold", 1, true) ~= nil, "probe lists gold/ folder")
|
||||||
|
check(probe:find("list gold/data/generated", 1, true) ~= nil,
|
||||||
|
"probe lists gold/data/generated/")
|
||||||
|
|
||||||
love.system = { getOS = function() return "OS X" end }
|
love.system = { getOS = function() return "OS X" end }
|
||||||
Platform._resetForTests()
|
Platform._resetForTests()
|
||||||
@@ -104,5 +121,7 @@ love.filesystem.remove("nx-asset-probe.log")
|
|||||||
love.filesystem.remove("yellow/assets/generated/fonts/font.png")
|
love.filesystem.remove("yellow/assets/generated/fonts/font.png")
|
||||||
love.filesystem.remove("yellow/assets/generated/tilesets/reds_house.png")
|
love.filesystem.remove("yellow/assets/generated/tilesets/reds_house.png")
|
||||||
love.filesystem.remove("yellow/assets/generated/sprites/red.png")
|
love.filesystem.remove("yellow/assets/generated/sprites/red.png")
|
||||||
|
love.filesystem.remove("gold/data/generated/maps.lua")
|
||||||
|
love.filesystem.remove("gold/data/generated/oak_speech.lua")
|
||||||
|
|
||||||
T.finish()
|
T.finish()
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ local page = skin.pages[1]
|
|||||||
eq(page.name, "shell", "page name")
|
eq(page.name, "shell", "page name")
|
||||||
eq(page.imagePath, "img/back.png", "page background path")
|
eq(page.imagePath, "img/back.png", "page background path")
|
||||||
check(page.fullScreen, "full_screen parsed")
|
check(page.fullScreen, "full_screen parsed")
|
||||||
|
check(not page.aspectFromCfg, "a cfg without aspect_ratio does not lock aspect")
|
||||||
eq(page.alphaMod, 0.001, "overlay alpha_mod parsed")
|
eq(page.alphaMod, 0.001, "overlay alpha_mod parsed")
|
||||||
eq(#page.controls, 7, "seven descs parsed")
|
eq(#page.controls, 7, "seven descs parsed")
|
||||||
|
|
||||||
@@ -109,7 +110,7 @@ local sx, sy, sw, sh, fill = TouchSkin.viewport(W, H)
|
|||||||
eq(sx, 0, "viewport x") eq(sy, 0, "viewport y")
|
eq(sx, 0, "viewport x") eq(sy, 0, "viewport y")
|
||||||
eq(sw, 400, "viewport w") eq(sh, 400, "viewport h")
|
eq(sw, 400, "viewport w") eq(sh, 400, "viewport h")
|
||||||
check(fill, "viewport fill flag returned")
|
check(fill, "viewport fill flag returned")
|
||||||
eq(TouchSkin.viewport(W, H), 0, "viewport is window-relative, not page-relative")
|
eq(TouchSkin.viewport(W, H), 0, "without a locked aspect, viewport tracks the window")
|
||||||
|
|
||||||
check(TouchControls:touchpressed("f1", at(0.80, 0.75)), "press on A is captured")
|
check(TouchControls:touchpressed("f1", at(0.80, 0.75)), "press on A is captured")
|
||||||
check(Input:isDown("a"), "skin A presses GB a")
|
check(Input:isDown("a"), "skin A presses GB a")
|
||||||
@@ -195,6 +196,8 @@ if bundled then
|
|||||||
for _, btn in ipairs({ "a", "b", "start", "select", "up", "down", "left", "right" }) do
|
for _, btn in ipairs({ "a", "b", "start", "select", "up", "down", "left", "right" }) do
|
||||||
check(named[btn], "gb_anim binds GB " .. btn)
|
check(named[btn], "gb_anim binds GB " .. btn)
|
||||||
end
|
end
|
||||||
|
check(not TouchSkin.hasOrientPair(bundled),
|
||||||
|
"gb_anim is not a portrait/landscape auto-rotate overlay")
|
||||||
end
|
end
|
||||||
|
|
||||||
local tv = TouchSkin.load("assets/skins/tv_crt", "tv_crt")
|
local tv = TouchSkin.load("assets/skins/tv_crt", "tv_crt")
|
||||||
@@ -322,4 +325,104 @@ end
|
|||||||
TouchSkin.setActive(nil)
|
TouchSkin.setActive(nil)
|
||||||
TouchControls:setHotkeyHandler(nil)
|
TouchControls:setHotkeyHandler(nil)
|
||||||
|
|
||||||
|
-- ------------------------------------------ auto-rotate (#1503)
|
||||||
|
|
||||||
|
-- RetroArch overlays name a portrait page and a landscape page and wire
|
||||||
|
-- overlay_next between them. Play has to pick the one that matches the
|
||||||
|
-- window, or landscape stretches the portrait ranges into wide ovals.
|
||||||
|
local ORIENT_CFG = [[
|
||||||
|
overlays = 2
|
||||||
|
overlay0_name = "portrait"
|
||||||
|
overlay0_full_screen = true
|
||||||
|
overlay0_normalized = true
|
||||||
|
overlay0_aspect_ratio = 0.45
|
||||||
|
overlay0_descs = 1
|
||||||
|
overlay0_desc0 = "a,0.84382,0.69563,radial,0.09722,0.04375"
|
||||||
|
|
||||||
|
overlay1_name = "landscape"
|
||||||
|
overlay1_full_screen = true
|
||||||
|
overlay1_normalized = true
|
||||||
|
overlay1_aspect_ratio = 2.22222222222222
|
||||||
|
overlay1_descs = 1
|
||||||
|
overlay1_desc0 = "a,0.8307031248,0.8614400000,radial,0.0364168752,0.0813300000"
|
||||||
|
]]
|
||||||
|
local orient = assert(TouchSkin.parse(ORIENT_CFG))
|
||||||
|
check(orient.pages[1].aspectFromCfg, "portrait page locks the cfg aspect_ratio")
|
||||||
|
check(orient.pages[2].aspectFromCfg, "landscape page locks the cfg aspect_ratio")
|
||||||
|
eq(orient.pages[1].orient, "portrait", "a portrait page name locks on import")
|
||||||
|
eq(orient.pages[2].orient, "landscape", "a landscape page name locks on import")
|
||||||
|
check(TouchSkin.hasOrientPair(orient), "the pair is an auto-rotate overlay")
|
||||||
|
TouchSkin.setActive(orient)
|
||||||
|
TouchSkin.autoOrient = true
|
||||||
|
|
||||||
|
local PW, PH = 720, 1600
|
||||||
|
TouchSkin.setSurface(0, 0, PW, PH)
|
||||||
|
eq(TouchSkin.page().name, "portrait", "a tall window picks the portrait page")
|
||||||
|
local _, _, pHalfW, pHalfH =
|
||||||
|
TouchSkin.controlGeometry(TouchSkin.page(), TouchSkin.page().controls[1], PW, PH)
|
||||||
|
eq(math.floor(pHalfW + 0.5), 70, "portrait A half-width follows range_x")
|
||||||
|
eq(math.floor(pHalfH + 0.5), 70, "portrait A half-height follows range_y")
|
||||||
|
|
||||||
|
local LW, LH = 1600, 720
|
||||||
|
TouchSkin.setSurface(0, 0, LW, LH)
|
||||||
|
eq(TouchSkin.page().name, "landscape", "a wide window picks the landscape page")
|
||||||
|
local _, _, lHalfW, lHalfH =
|
||||||
|
TouchSkin.controlGeometry(TouchSkin.page(), TouchSkin.page().controls[1], LW, LH)
|
||||||
|
eq(math.floor(lHalfW + 0.5), 58, "landscape A half-width follows the landscape range")
|
||||||
|
eq(math.floor(lHalfH + 0.5), 59, "landscape A half-height follows the landscape range")
|
||||||
|
check(math.abs(lHalfW - lHalfH) < 2, "landscape A stays round instead of stretching")
|
||||||
|
|
||||||
|
-- 16:9 is not the overlay's 20:9. Letterbox to aspect_ratio so A stays round
|
||||||
|
-- instead of filling the window and turning into a tall oval (#1503).
|
||||||
|
TouchSkin.setSurface(0, 0, 1920, 1080)
|
||||||
|
eq(TouchSkin.page().name, "landscape", "16:9 still picks landscape")
|
||||||
|
local _, _, boxW, boxH = TouchSkin.pageBox(TouchSkin.page(), 1920, 1080)
|
||||||
|
eq(math.floor(boxW + 0.5), 1920, "letterbox keeps the 16:9 width")
|
||||||
|
eq(math.floor(boxH + 0.5), 864, "and the overlay's 20:9 height")
|
||||||
|
local _, _, sHalfW, sHalfH =
|
||||||
|
TouchSkin.controlGeometry(TouchSkin.page(), TouchSkin.page().controls[1], 1920, 1080)
|
||||||
|
check(math.abs(sHalfW - sHalfH) < 2, "A stays round on a 16:9 window")
|
||||||
|
|
||||||
|
local nativeOrient = TouchSkin.parseNative(TouchSkin.serialize(orient))
|
||||||
|
check(nativeOrient and nativeOrient.pages[2].aspectFromCfg,
|
||||||
|
"native export keeps the aspect lock")
|
||||||
|
|
||||||
|
-- an explicit lock wins over the page name, so a studio-authored "main"
|
||||||
|
-- page can still auto-rotate in play
|
||||||
|
local LOCK_CFG = [[
|
||||||
|
overlays = 2
|
||||||
|
overlay0_name = "main"
|
||||||
|
overlay0_full_screen = true
|
||||||
|
overlay0_descs = 1
|
||||||
|
overlay0_desc0 = "a,0.5,0.5,radial,0.05,0.05"
|
||||||
|
overlay1_name = "wide"
|
||||||
|
overlay1_full_screen = true
|
||||||
|
overlay1_descs = 1
|
||||||
|
overlay1_desc0 = "a,0.5,0.5,radial,0.05,0.05"
|
||||||
|
]]
|
||||||
|
local locked = assert(TouchSkin.parse(LOCK_CFG))
|
||||||
|
locked.pages[1].orient = "portrait"
|
||||||
|
locked.pages[2].orient = "landscape"
|
||||||
|
TouchSkin.setActive(locked)
|
||||||
|
TouchSkin.setSurface(0, 0, 1600, 720)
|
||||||
|
eq(TouchSkin.page().name, "wide", "orient lock auto-rotates a page not named landscape")
|
||||||
|
local roundTrip = TouchSkin.parseNative(TouchSkin.serialize(locked))
|
||||||
|
eq(roundTrip.pages[1].orient, "portrait", "native export keeps a portrait lock")
|
||||||
|
eq(roundTrip.pages[2].orient, "landscape", "and a landscape lock")
|
||||||
|
|
||||||
|
TouchSkin.setActive(orient)
|
||||||
|
TouchSkin.autoOrient = false
|
||||||
|
TouchSkin.pageIndex = 1
|
||||||
|
eq(TouchSkin.page().name, "portrait",
|
||||||
|
"the studio can keep a portrait page on a landscape canvas")
|
||||||
|
TouchSkin.autoOrient = true
|
||||||
|
TouchSkin.setSurface(nil)
|
||||||
|
|
||||||
|
-- pages that are not a portrait/landscape pair (gb_anim) stay put
|
||||||
|
TouchSkin.setActive(skin)
|
||||||
|
TouchSkin.setSurface(0, 0, LW, LH)
|
||||||
|
eq(TouchSkin.page().name, "shell", "a non-oriented skin does not auto-rotate")
|
||||||
|
TouchSkin.setSurface(nil)
|
||||||
|
TouchSkin.setActive(nil)
|
||||||
|
|
||||||
T.finish("touch_skin")
|
T.finish("touch_skin")
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -84,6 +84,63 @@ end
|
|||||||
local _, _, vw0, vh0 = BorderFill.viewport(0, 0, 160, 144, 0)
|
local _, _, vw0, vh0 = BorderFill.viewport(0, 0, 160, 144, 0)
|
||||||
check(vw0 > 0 and vh0 > 0, "scale 0 falls back to 1 rather than collapsing")
|
check(vw0 > 0 and vh0 > 0, "scale 0 falls back to 1 rather than collapsing")
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- VOID FILL (#1418): FADE / WATER / TREES / BLACK
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
eq(BorderFill.voidFill, "fade", "the live fill defaults to FADE")
|
||||||
|
eq(BorderFill.voidFillLabel(), "FADE ", "and prints with a trailing space")
|
||||||
|
|
||||||
|
local johto = { id = "CHERRYGROVE_CITY", tileset = "TILESET_JOHTO",
|
||||||
|
borderBlock = 0x35 }
|
||||||
|
local route = { id = "ROUTE_30", tileset = "TILESET_JOHTO", borderBlock = 0x05 }
|
||||||
|
local kanto = { id = "PALLET_TOWN", tileset = "TILESET_KANTO",
|
||||||
|
borderBlock = 0x0f }
|
||||||
|
local cave = { id = "UNION_CAVE_1F", tileset = "TILESET_CAVE", borderBlock = 0x09 }
|
||||||
|
local house = { id = "PLAYERS_HOUSE_1F", tileset = "TILESET_PLAYERS_HOUSE",
|
||||||
|
borderBlock = 0x00 }
|
||||||
|
|
||||||
|
eq(BorderFill.fillBlock(johto), 0x35, "FADE keeps Cherrygrove's water")
|
||||||
|
eq(BorderFill.fillBlock(route), 0x05, "and Route 30's trees")
|
||||||
|
eq(BorderFill.fillKey(johto), "fade|CHERRYGROVE_CITY",
|
||||||
|
"FADE dissolves per map")
|
||||||
|
eq(BorderFill.fillKey(route) ~= BorderFill.fillKey(johto), true,
|
||||||
|
"so the two maps are different fills")
|
||||||
|
|
||||||
|
BorderFill.setVoidFill("water")
|
||||||
|
eq(BorderFill.fillBlock(johto), 0x35, "WATER uses Cherrygrove's water block")
|
||||||
|
eq(BorderFill.fillBlock(route), 0x35, "on Route 30 too")
|
||||||
|
eq(BorderFill.fillBlock(kanto), 0x43, "and Cinnabar's water on Kanto")
|
||||||
|
eq(BorderFill.fillBlock(cave), 0x09, "but a cave keeps its own border")
|
||||||
|
eq(BorderFill.fillBlock(house), 0x00, "and so does a house")
|
||||||
|
eq(BorderFill.fillKey(johto), BorderFill.fillKey(route),
|
||||||
|
"two Johto maps share one WATER fill")
|
||||||
|
eq(BorderFill.fillKey(johto) ~= BorderFill.fillKey(kanto), true,
|
||||||
|
"Kanto water is a different sheet")
|
||||||
|
|
||||||
|
BorderFill.setVoidFill("trees")
|
||||||
|
eq(BorderFill.fillBlock(johto), 0x05, "TREES uses New Bark's tree wall")
|
||||||
|
eq(BorderFill.fillBlock(kanto), 0x0f, "and Pallet's on Kanto")
|
||||||
|
eq(BorderFill.fillBlock(cave), 0x09, "caves still keep their own")
|
||||||
|
|
||||||
|
BorderFill.setVoidFill("black")
|
||||||
|
eq(BorderFill.fillBlock(johto), false, "BLACK skips the tiled bake")
|
||||||
|
eq(BorderFill.fillBlock(house), false, "indoors too")
|
||||||
|
eq(BorderFill.fillKey(johto), "black", "and is one sheet everywhere")
|
||||||
|
|
||||||
|
BorderFill.setVoidFill("nope")
|
||||||
|
eq(BorderFill.voidFill, "fade", "an unknown mode falls back to FADE")
|
||||||
|
BorderFill.setVoidFill("fade")
|
||||||
|
eq(BorderFill.cycle(1), "water", "cycle steps FADE to WATER")
|
||||||
|
eq(BorderFill.cycle(1), "trees", "then TREES")
|
||||||
|
eq(BorderFill.cycle(1), "black", "then BLACK")
|
||||||
|
eq(BorderFill.cycle(1), "fade", "and wraps to FADE")
|
||||||
|
eq(BorderFill.cycle(-1), "black", "left wraps the other way")
|
||||||
|
BorderFill.applyOptions({ voidFill = "trees" })
|
||||||
|
eq(BorderFill.voidFill, "trees", "applyOptions pushes the saved mode")
|
||||||
|
BorderFill.applyOptions({})
|
||||||
|
eq(BorderFill.voidFill, "fade", "and a missing key restores FADE")
|
||||||
|
|
||||||
-- ---------------------------------------------------------------------------
|
-- ---------------------------------------------------------------------------
|
||||||
-- The real cache: the map the bug was reported on
|
-- The real cache: the map the bug was reported on
|
||||||
-- ---------------------------------------------------------------------------
|
-- ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -213,6 +213,22 @@ previous, alpha = BorderFill.crossfade(owner, trees, "ROUTE_30")
|
|||||||
check("then it is over", previous, nil)
|
check("then it is over", previous, nil)
|
||||||
check("and the new block owns the void", alpha, 1)
|
check("and the new block owns the void", alpha, 1)
|
||||||
|
|
||||||
|
-- Forced WATER on two Johto maps shares a fill key, so the dissolve does
|
||||||
|
-- not run identical water against itself.
|
||||||
|
BorderFill.setVoidFill("water")
|
||||||
|
local johtoWater = { id = "CHERRYGROVE_CITY", tileset = "TILESET_JOHTO",
|
||||||
|
borderBlock = 0x35 }
|
||||||
|
local routeTrees = { id = "ROUTE_30", tileset = "TILESET_JOHTO",
|
||||||
|
borderBlock = 0x05 }
|
||||||
|
check("WATER fill keys match across Johto",
|
||||||
|
BorderFill.fillKey(johtoWater), BorderFill.fillKey(routeTrees))
|
||||||
|
local forced = {}
|
||||||
|
BorderFill.crossfade(forced, "water", BorderFill.fillKey(johtoWater))
|
||||||
|
local fromForced = BorderFill.crossfade(forced, "water",
|
||||||
|
BorderFill.fillKey(routeTrees))
|
||||||
|
check("so walking Route 30 does not start a fade", fromForced, nil)
|
||||||
|
BorderFill.setVoidFill("fade")
|
||||||
|
|
||||||
-- No key at all (an old caller) is the plain single draw.
|
-- No key at all (an old caller) is the plain single draw.
|
||||||
local bare = {}
|
local bare = {}
|
||||||
previous, alpha = BorderFill.crossfade(bare, trees, nil)
|
previous, alpha = BorderFill.crossfade(bare, trees, nil)
|
||||||
|
|||||||
@@ -358,7 +358,7 @@ local options = OptionsMenu.new(optionsGame, {
|
|||||||
})
|
})
|
||||||
-- The cart's seven rows, then the port's: CONTROLS, audio, speed, display,
|
-- The cart's seven rows, then the port's: CONTROLS, audio, speed, display,
|
||||||
-- video mode, the mobile-gated touch three (buildRows), MAX FPS and CANCEL.
|
-- video mode, the mobile-gated touch three (buildRows), MAX FPS and CANCEL.
|
||||||
check("twenty-two rows", #OptionsMenu.ROWS, 22)
|
check("twenty-three rows", #OptionsMenu.ROWS, 23)
|
||||||
check("the cart's rows come first", OptionsMenu.ROWS[7].key, "frame")
|
check("the cart's rows come first", OptionsMenu.ROWS[7].key, "frame")
|
||||||
check("then the rebind screen", OptionsMenu.ROWS[8].id, "controls")
|
check("then the rebind screen", OptionsMenu.ROWS[8].id, "controls")
|
||||||
check("then the port's audio group", OptionsMenu.ROWS[9].key, "musicVol")
|
check("then the port's audio group", OptionsMenu.ROWS[9].key, "musicVol")
|
||||||
@@ -861,10 +861,15 @@ check("GBC leaves a palette alone",
|
|||||||
1)[1], 1)
|
1)[1], 1)
|
||||||
check("and has no present pass", GbcPalette.presentColors(), nil)
|
check("and has no present pass", GbcPalette.presentColors(), nil)
|
||||||
|
|
||||||
local gbcfxIndex
|
local zoomIndex, gbcfxIndex
|
||||||
for i, row in ipairs(OptionsMenu.ROWS) do
|
for i, row in ipairs(OptionsMenu.ROWS) do
|
||||||
|
if row.label == "ZOOM" then zoomIndex = i end
|
||||||
if row.label == "GBC FX" then gbcfxIndex = i end
|
if row.label == "GBC FX" then gbcfxIndex = i end
|
||||||
end
|
end
|
||||||
|
check("VOID FILL follows ZOOM", OptionsMenu.ROWS[zoomIndex + 1].label,
|
||||||
|
"VOID FILL")
|
||||||
|
check("and TILT follows VOID FILL", OptionsMenu.ROWS[zoomIndex + 2].label,
|
||||||
|
"TILT")
|
||||||
check("VIDEO MODE follows GBC FX", OptionsMenu.ROWS[gbcfxIndex + 1].label,
|
check("VIDEO MODE follows GBC FX", OptionsMenu.ROWS[gbcfxIndex + 1].label,
|
||||||
"VIDEO MODE")
|
"VIDEO MODE")
|
||||||
check("and TOUCH PAD follows it", OptionsMenu.ROWS[gbcfxIndex + 2].label,
|
check("and TOUCH PAD follows it", OptionsMenu.ROWS[gbcfxIndex + 2].label,
|
||||||
@@ -897,6 +902,28 @@ scrollOptions:cycle(videoRow, -1)
|
|||||||
check("left toggles back to windowed",
|
check("left toggles back to windowed",
|
||||||
scrollOptions.options.videoMode, "windowed")
|
scrollOptions.options.videoMode, "windowed")
|
||||||
|
|
||||||
|
local voidRow = select(2, rowNamed("VOID FILL"))
|
||||||
|
check("VOID FILL is a row", voidRow ~= nil, true)
|
||||||
|
check("and it defaults to FADE", Save.DEFAULT_OPTIONS.voidFill, "fade")
|
||||||
|
scrollOptions.options.voidFill = "fade"
|
||||||
|
local BorderFill = require("src.world.gen2.BorderFill")
|
||||||
|
BorderFill.setVoidFill("fade")
|
||||||
|
scrollOptions:cycle(voidRow, 1)
|
||||||
|
check("right steps to WATER", scrollOptions.options.voidFill, "water")
|
||||||
|
check("and the live fill tracks it", BorderFill.voidFill, "water")
|
||||||
|
scrollOptions:cycle(voidRow, 1)
|
||||||
|
check("then TREES", scrollOptions.options.voidFill, "trees")
|
||||||
|
scrollOptions:cycle(voidRow, 1)
|
||||||
|
check("then BLACK", scrollOptions.options.voidFill, "black")
|
||||||
|
scrollOptions:cycle(voidRow, 1)
|
||||||
|
check("and wraps back to FADE", scrollOptions.options.voidFill, "fade")
|
||||||
|
check("FADE blanks the longer labels", voidRow.text(scrollOptions.options),
|
||||||
|
"FADE ")
|
||||||
|
scrollOptions.options.voidFill = "water"
|
||||||
|
check("WATER fits the value column", voidRow.text(scrollOptions.options),
|
||||||
|
"WATER")
|
||||||
|
BorderFill.setVoidFill("fade")
|
||||||
|
|
||||||
check("text reads WINDOWED", videoRow.text(scrollOptions.options), "WINDOWED")
|
check("text reads WINDOWED", videoRow.text(scrollOptions.options), "WINDOWED")
|
||||||
scrollOptions.options.videoMode = "borderless"
|
scrollOptions.options.videoMode = "borderless"
|
||||||
check("text reads FULL, not BORDERLESS", videoRow.text(scrollOptions.options),
|
check("text reads FULL, not BORDERLESS", videoRow.text(scrollOptions.options),
|
||||||
|
|||||||
@@ -179,6 +179,65 @@ check("the cursor is the column-1 arrow on the label row",
|
|||||||
check("the cursor starts on the first row", cursorAt and cursorAt.y, 2)
|
check("the cursor starts on the first row", cursorAt and cursorAt.y, 2)
|
||||||
check("FRAME still prints its literal TYPE", findPrint(":TYPE") ~= nil, true)
|
check("FRAME still prints its literal TYPE", findPrint(":TYPE") ~= nil, true)
|
||||||
|
|
||||||
|
-- ------------------------------------------------------- mart buy list
|
||||||
|
--
|
||||||
|
-- BuyMenu (engine/items/mart.asm) is FadeToMenu + BlankScreen, then
|
||||||
|
-- PlaceMoneyTopRight and ScrollingMenu_UpdateDisplay's ClearWholeMenuBox.
|
||||||
|
-- The list is not MenuBox'd; only the money box and UpdateItemDescription's
|
||||||
|
-- textbox are. The top menu still overlays the mart, so it must not blank.
|
||||||
|
local MartMenu = require("src.ui.gen2.MartMenu")
|
||||||
|
local Save = require("src.core.gen2.Save")
|
||||||
|
|
||||||
|
local martBoxes, martClears = {}, 0
|
||||||
|
local martClear, martBox = Chrome.clear, Chrome.box
|
||||||
|
local martPrint, martCursor = Chrome.print, Chrome.cursor
|
||||||
|
Chrome.clear = function() martClears = martClears + 1 end
|
||||||
|
Chrome.box = function(x, y, w, h)
|
||||||
|
martBoxes[#martBoxes + 1] = { x = x, y = y, w = w, h = h }
|
||||||
|
end
|
||||||
|
Chrome.print = function() end
|
||||||
|
Chrome.cursor = function() end
|
||||||
|
|
||||||
|
local function martHasBox(x, y, w, h)
|
||||||
|
for _, b in ipairs(martBoxes) do
|
||||||
|
if b.x == x and b.y == y and b.w == w and b.h == h then return true end
|
||||||
|
end
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
local martSave = Save.newGame()
|
||||||
|
local martItems = {
|
||||||
|
POTION = { id = "POTION", name = "POTION", pocket = "ITEM", price = 300,
|
||||||
|
description = "Restores HP\nby 20." },
|
||||||
|
}
|
||||||
|
local mart = MartMenu.new({ save = martSave, data = { items = martItems } }, {
|
||||||
|
save = martSave,
|
||||||
|
items = martItems,
|
||||||
|
marts = { lists = { { "POTION" } } },
|
||||||
|
})
|
||||||
|
mart:draw()
|
||||||
|
check("the top menu does not blank the tilemap", martClears, 0)
|
||||||
|
check("and frames BUY/SELL/QUIT", martHasBox(0, 0, 12, 9), true)
|
||||||
|
check("and the welcome speech box", martHasBox(0, 12, 20, 6), true)
|
||||||
|
check("the top menu is not opaque", mart.isOpaque, false)
|
||||||
|
|
||||||
|
martClears, martBoxes = 0, {}
|
||||||
|
mart:enterBuy()
|
||||||
|
mart:draw()
|
||||||
|
check("BUY blanks the screen (BlankScreen)", martClears > 0, true)
|
||||||
|
check("the money box is framed", martHasBox(11, 0, 9, 3), true)
|
||||||
|
check("the description box is framed", martHasBox(0, 12, 20, 6), true)
|
||||||
|
check("the item list is not (ClearWholeMenuBox, not MenuBox)",
|
||||||
|
martHasBox(1, 3, 19, 9), false)
|
||||||
|
check("BlankScreen shadows isOpaque so the letterbox is not the mart",
|
||||||
|
mart.isOpaque, true)
|
||||||
|
|
||||||
|
mart:leaveBuy()
|
||||||
|
check("leaving BUY unshadows isOpaque", mart.isOpaque, false)
|
||||||
|
|
||||||
|
Chrome.clear, Chrome.box = martClear, martBox
|
||||||
|
Chrome.print, Chrome.cursor = martPrint, martCursor
|
||||||
|
|
||||||
-- ------------------------------------------------------- one blit scale
|
-- ------------------------------------------------------- one blit scale
|
||||||
--
|
--
|
||||||
-- Every Gold screen paints its 160x144 panel through the same helper. A
|
-- Every Gold screen paints its 160x144 panel through the same helper. A
|
||||||
|
|||||||
@@ -66,13 +66,39 @@ table.sort(MOVES)
|
|||||||
-- in the peer's inbox, so one side is mid-queue when the other's action
|
-- in the peer's inbox, so one side is mid-queue when the other's action
|
||||||
-- arrives (Net.loopbackPair on its own delivers instantly, which is the one
|
-- arrives (Net.loopbackPair on its own delivers instantly, which is the one
|
||||||
-- thing the real relay never does)
|
-- thing the real relay never does)
|
||||||
local function laggyPair(delayA, delayB)
|
-- mutation mode: a peer whose messages arrive with a random field (or the
|
||||||
|
-- type itself) at the wrong Lua type. What is delivered is what Session
|
||||||
|
-- would deliver -- Wire.sanitize's output, or nothing at all -- so a run
|
||||||
|
-- exercises the real receive path rather than a hand-written stand-in.
|
||||||
|
local Wire = require("src.link.Wire")
|
||||||
|
local HOSTILE = { {}, { 1, 2, 3 }, 0, -1, 999, "s", "", true, false, math.huge }
|
||||||
|
|
||||||
|
local function mutate(rnd, msg)
|
||||||
|
local keys = {}
|
||||||
|
for k in pairs(msg) do
|
||||||
|
if k ~= "type" then keys[#keys + 1] = k end
|
||||||
|
end
|
||||||
|
table.sort(keys)
|
||||||
|
if #keys == 0 or rnd(1, 100) <= 20 then
|
||||||
|
msg.type = HOSTILE[rnd(1, #HOSTILE)]
|
||||||
|
else
|
||||||
|
msg[keys[rnd(1, #keys)]] = HOSTILE[rnd(1, #HOSTILE)]
|
||||||
|
end
|
||||||
|
return msg
|
||||||
|
end
|
||||||
|
|
||||||
|
local function laggyPair(delayA, delayB, rnd, mutateRate)
|
||||||
local a, b = Net.loopbackPair()
|
local a, b = Net.loopbackPair()
|
||||||
a.wire, b.wire = {}, {}
|
a.wire, b.wire = {}, {}
|
||||||
a.delay, b.delay = delayA or 0, delayB or 0
|
a.delay, b.delay = delayA or 0, delayB or 0
|
||||||
|
a.mutateRate, b.mutateRate = mutateRate or 0, mutateRate or 0
|
||||||
local function send(self, msg)
|
local function send(self, msg)
|
||||||
if self.closed then return end
|
if self.closed then return end
|
||||||
local decoded = Json.decode(Json.encode(msg)) -- same round trip as the wire
|
local decoded = Json.decode(Json.encode(msg)) -- same round trip as the wire
|
||||||
|
if decoded and self.mutateRate > 0 then
|
||||||
|
if rnd(1, 100) <= self.mutateRate then decoded = mutate(rnd, decoded) end
|
||||||
|
decoded = Wire.sanitize(decoded)
|
||||||
|
end
|
||||||
if decoded then table.insert(self.wire, { msg = decoded, at = self.delay }) end
|
if decoded then table.insert(self.wire, { msg = decoded, at = self.delay }) end
|
||||||
end
|
end
|
||||||
local function update(self)
|
local function update(self)
|
||||||
@@ -143,7 +169,7 @@ local function firstMismatch(a, b)
|
|||||||
end
|
end
|
||||||
|
|
||||||
-- Returns nil when the run agreed, or a description of how it split.
|
-- Returns nil when the run agreed, or a description of how it split.
|
||||||
local function runOne(seed)
|
local function runOne(seed, mutateRate)
|
||||||
local rnd = makeRandom(seed)
|
local rnd = makeRandom(seed)
|
||||||
-- the two sides are deliberately different clients
|
-- the two sides are deliberately different clients
|
||||||
local optsA = { animations = false, textSpeed = 1, battleStyle = "SET" }
|
local optsA = { animations = false, textSpeed = 1, battleStyle = "SET" }
|
||||||
@@ -155,7 +181,7 @@ local function runOne(seed)
|
|||||||
gameA.save.party = randomParty(rnd, rnd(1, 4))
|
gameA.save.party = randomParty(rnd, rnd(1, 4))
|
||||||
gameB.save.party = randomParty(rnd, rnd(1, 4))
|
gameB.save.party = randomParty(rnd, rnd(1, 4))
|
||||||
|
|
||||||
local netA, netB = laggyPair(lagA, lagB)
|
local netA, netB = laggyPair(lagA, lagB, rnd, mutateRate)
|
||||||
local battleSeed = rnd(1, 2 ^ 30)
|
local battleSeed = rnd(1, 2 ^ 30)
|
||||||
local battleA = LinkBattle.newHost(gameA, netA, {
|
local battleA = LinkBattle.newHost(gameA, netA, {
|
||||||
myParty = Protocol.packParty(gameA.save.party),
|
myParty = Protocol.packParty(gameA.save.party),
|
||||||
@@ -230,6 +256,12 @@ local function runOne(seed)
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
local turn, part = firstMismatch(battleA, battleB)
|
local turn, part = firstMismatch(battleA, battleB)
|
||||||
|
if turn and mutateRate then
|
||||||
|
-- a corrupted action IS a divergence; what matters here is that the
|
||||||
|
-- match ends by its own rules (desync draw, forfeit, disconnect)
|
||||||
|
-- rather than throwing
|
||||||
|
return nil, battleA.turnCount or 0
|
||||||
|
end
|
||||||
if turn then
|
if turn then
|
||||||
return ("seed %d: turn %d %s split (lag %d/%d, steps %d/%d)"):format(
|
return ("seed %d: turn %d %s split (lag %d/%d, steps %d/%d)"):format(
|
||||||
seed, turn, part, lagA, lagB, stepsA, stepsB), battleA.turnCount or 0
|
seed, turn, part, lagA, lagB, stepsA, stepsB), battleA.turnCount or 0
|
||||||
@@ -237,7 +269,7 @@ local function runOne(seed)
|
|||||||
end
|
end
|
||||||
-- a battle still running at the guard is a stalemate (two mons that cannot
|
-- a battle still running at the guard is a stalemate (two mons that cannot
|
||||||
-- KO each other), not a split; only a finished one can be checked mirrored
|
-- KO each other), not a split; only a finished one can be checked mirrored
|
||||||
if guard < 60000
|
if not mutateRate and guard < 60000
|
||||||
and (battleA.player.mon.hp ~= battleB.enemy.mon.hp
|
and (battleA.player.mon.hp ~= battleB.enemy.mon.hp
|
||||||
or battleA.enemy.mon.hp ~= battleB.player.mon.hp) then
|
or battleA.enemy.mon.hp ~= battleB.player.mon.hp) then
|
||||||
return ("seed %d: final HP not mirrored (%d/%d vs %d/%d)"):format(
|
return ("seed %d: final HP not mirrored (%d/%d vs %d/%d)"):format(
|
||||||
@@ -250,6 +282,8 @@ end
|
|||||||
local RUNS = tonumber(arg and arg[1]) or 40
|
local RUNS = tonumber(arg and arg[1]) or 40
|
||||||
local FIRST = tonumber(arg and arg[2]) or 1
|
local FIRST = tonumber(arg and arg[2]) or 1
|
||||||
|
|
||||||
|
local MUTATION_RUNS = tonumber(arg and arg[3]) or math.max(4, math.floor(RUNS / 4))
|
||||||
|
|
||||||
local failures, turns = 0, 0
|
local failures, turns = 0, 0
|
||||||
for seed = FIRST, FIRST + RUNS - 1 do
|
for seed = FIRST, FIRST + RUNS - 1 do
|
||||||
local ok, why, t = pcall(runOne, seed)
|
local ok, why, t = pcall(runOne, seed)
|
||||||
@@ -263,5 +297,21 @@ for seed = FIRST, FIRST + RUNS - 1 do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
print(("link desync fuzz: %d runs, %d turns, %d failures"):format(RUNS, turns, failures))
|
print(("link desync fuzz: %d runs, %d turns, %d failures"):format(RUNS, turns, failures))
|
||||||
|
|
||||||
|
local mutationFailures = 0
|
||||||
|
for seed = FIRST, FIRST + MUTATION_RUNS - 1 do
|
||||||
|
local ok, why = pcall(runOne, seed, 15)
|
||||||
|
if not ok then
|
||||||
|
mutationFailures = mutationFailures + 1
|
||||||
|
print("FAIL link mutation fuzz seed " .. seed .. ": " .. tostring(why))
|
||||||
|
elseif why then
|
||||||
|
mutationFailures = mutationFailures + 1
|
||||||
|
print("FAIL link mutation fuzz " .. why)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
print(("link mutation fuzz: %d runs, %d failures"):format(
|
||||||
|
MUTATION_RUNS, mutationFailures))
|
||||||
|
|
||||||
assert(failures == 0, failures .. " lockstep run(s) diverged")
|
assert(failures == 0, failures .. " lockstep run(s) diverged")
|
||||||
|
assert(mutationFailures == 0, mutationFailures .. " mutated run(s) threw")
|
||||||
return true
|
return true
|
||||||
|
|||||||
@@ -0,0 +1,396 @@
|
|||||||
|
-- Hostile link traffic: every message type this build reads, with every
|
||||||
|
-- field replaced by every wrong Lua type, driven through the real Session
|
||||||
|
-- choke point and then into the real consumers (trade session, link battle,
|
||||||
|
-- spectator battle, tournament screen -- including its draw).
|
||||||
|
--
|
||||||
|
-- The three payloads from the "How to Troll Pokemon Players" writeup are
|
||||||
|
-- rows in the table below: action.slot as a table, hash.parts as a number,
|
||||||
|
-- and pick.index out of range.
|
||||||
|
--
|
||||||
|
-- Self-contained; run directly or via run_link_tests.lua:
|
||||||
|
-- luajit tests/link_hostile.lua
|
||||||
|
|
||||||
|
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||||
|
love = love or require("tests.love_stub")
|
||||||
|
|
||||||
|
local Data = require("src.core.Data")
|
||||||
|
if not Data.pokemon then Data:load() end
|
||||||
|
local Font = require("src.render.Font")
|
||||||
|
Font.load(Data)
|
||||||
|
|
||||||
|
local Input = require("src.core.Input")
|
||||||
|
Input:init()
|
||||||
|
|
||||||
|
local Json = require("src.link.Json")
|
||||||
|
local LinkBattle = require("src.link.LinkBattle")
|
||||||
|
local Net = require("src.link.Net")
|
||||||
|
local Pokemon = require("src.pokemon.Pokemon")
|
||||||
|
local Protocol = require("src.link.Protocol")
|
||||||
|
local Session = require("src.link.Session")
|
||||||
|
local Tournament = require("src.link.Tournament")
|
||||||
|
local Wire = require("src.link.Wire")
|
||||||
|
|
||||||
|
local failures = 0
|
||||||
|
local function check(cond, msg)
|
||||||
|
if cond then
|
||||||
|
print("ok " .. msg)
|
||||||
|
else
|
||||||
|
failures = failures + 1
|
||||||
|
print("FAIL " .. msg)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------- corpus
|
||||||
|
|
||||||
|
local HOSTILE = { {}, { 1, 2, 3 }, { type = "x" }, 0, -1, 123, 1.5,
|
||||||
|
math.huge, "s", "", true, false }
|
||||||
|
|
||||||
|
local function copy(v)
|
||||||
|
if type(v) ~= "table" then return v end
|
||||||
|
local out = {}
|
||||||
|
for k, val in pairs(v) do out[k] = copy(val) end
|
||||||
|
return out
|
||||||
|
end
|
||||||
|
|
||||||
|
local packedMon = Protocol.packMon(Pokemon.new(Data, "PIKACHU", 12))
|
||||||
|
|
||||||
|
local TEMPLATES = {
|
||||||
|
{ type = "hello", protocol = 2, name = "RED", mode = "battle",
|
||||||
|
engineVersion = "1.0.0", apiVersion = "1", generation = 1,
|
||||||
|
fingerprint = "abc123", linkModified = false,
|
||||||
|
mods = { { id = "demo", version = "1.0", affectsLink = true } } },
|
||||||
|
{ type = "records", pokemon = { PIKACHU = "a" }, moves = { TACKLE = "b" },
|
||||||
|
heldItems = { BERRY = "c" } },
|
||||||
|
{ type = "party", mons = { copy(packedMon) }, seed = 1234, forceLevel = 50 },
|
||||||
|
{ type = "pick", index = 1 },
|
||||||
|
{ type = "confirm", ok = true },
|
||||||
|
{ type = "action", kind = "move", slot = 1, index = 1 },
|
||||||
|
{ type = "hash", turn = 1, value = "v",
|
||||||
|
parts = { actives = "a", volatile = "b", bench = "c" } },
|
||||||
|
{ type = "replace", index = 1 },
|
||||||
|
{ type = "bye" },
|
||||||
|
{ type = "forfeit" },
|
||||||
|
{ type = "spectate", side = "host",
|
||||||
|
msg = { type = "action", kind = "move", slot = 1 } },
|
||||||
|
{ type = "hosted", code = "ABCDEF" },
|
||||||
|
{ type = "paired" },
|
||||||
|
{ type = "peer_gone" },
|
||||||
|
{ type = "join_error", reason = "not_found" },
|
||||||
|
{ type = "tournament_hosted", code = "ABCDEF", turnLimit = 6,
|
||||||
|
requiredPartySize = 3, minLevel = 5, maxLevel = 50, forceLevel = 50,
|
||||||
|
participating = true },
|
||||||
|
{ type = "tournament_host_error", reason = "party_ineligible",
|
||||||
|
requiredPartySize = 3, minLevel = 5, maxLevel = 50 },
|
||||||
|
{ type = "tournament_join_error", reason = "party_ineligible",
|
||||||
|
requiredPartySize = 3, minLevel = 5, maxLevel = 50 },
|
||||||
|
{ type = "tournament_roster", players = { "RED", "BLUE" },
|
||||||
|
spectators = { "GREEN" }, turnLimit = 6, requiredPartySize = 3,
|
||||||
|
minLevel = 5, maxLevel = 50, forceLevel = 50 },
|
||||||
|
{ type = "bracket_update", tournament = { code = "ABCDEF", turnLimit = 6,
|
||||||
|
requiredPartySize = 3, minLevel = 5, maxLevel = 50, status = "active",
|
||||||
|
round = 1, champion = "RED",
|
||||||
|
rounds = { { round = 1, matches = { { a = "RED", b = "BLUE",
|
||||||
|
winner = "RED", bye = false, state = "live" } } } } } },
|
||||||
|
{ type = "match_start", opponent = "BLUE", round = 1, turnLimit = 6,
|
||||||
|
role = "host" },
|
||||||
|
{ type = "match_start_spectate", round = 1, playerHost = "RED",
|
||||||
|
playerGuest = "BLUE" },
|
||||||
|
{ type = "tournament_bye", round = 1 },
|
||||||
|
{ type = "tournament_over", champion = "RED" },
|
||||||
|
{ type = "a_type_this_build_has_never_heard_of", payload = { n = 1 } },
|
||||||
|
}
|
||||||
|
|
||||||
|
local NESTED = {
|
||||||
|
{ "hash", { "parts", "actives" } },
|
||||||
|
{ "party", { "mons", 1 } },
|
||||||
|
{ "party", { "mons", 1, "dvs" } },
|
||||||
|
{ "party", { "mons", 1, "moves" } },
|
||||||
|
{ "party", { "mons", 1, "moves", 1 } },
|
||||||
|
{ "party", { "mons", 1, "nickname" } },
|
||||||
|
{ "party", { "mons", 1, "level" } },
|
||||||
|
{ "party", { "mons", 1, "otId" } },
|
||||||
|
{ "hello", { "mods", 1 } },
|
||||||
|
{ "spectate", { "msg" } },
|
||||||
|
{ "spectate", { "msg", "slot" } },
|
||||||
|
{ "bracket_update", { "tournament", "rounds" } },
|
||||||
|
{ "bracket_update", { "tournament", "rounds", 1, "matches" } },
|
||||||
|
{ "bracket_update", { "tournament", "rounds", 1, "matches", 1, "a" } },
|
||||||
|
{ "tournament_roster", { "players", 1 } },
|
||||||
|
}
|
||||||
|
|
||||||
|
local function templateFor(kind)
|
||||||
|
for _, t in ipairs(TEMPLATES) do
|
||||||
|
if t.type == kind then return t end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function setPath(root, path, value)
|
||||||
|
local node = root
|
||||||
|
for i = 1, #path - 1 do
|
||||||
|
node = node[path[i]]
|
||||||
|
if type(node) ~= "table" then return false end
|
||||||
|
end
|
||||||
|
node[path[#path]] = value
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
local corpus = {}
|
||||||
|
local function add(msg) corpus[#corpus + 1] = msg end
|
||||||
|
|
||||||
|
add(false); add(true); add(123); add("string"); add({}); add({ 1, 2, 3 })
|
||||||
|
add({ type = 5 }); add({ type = {} }); add({ type = true })
|
||||||
|
add({ type = ("x"):rep(4096) })
|
||||||
|
|
||||||
|
for _, template in ipairs(TEMPLATES) do
|
||||||
|
add(copy(template))
|
||||||
|
for key in pairs(template) do
|
||||||
|
if key ~= "type" then
|
||||||
|
for _, bad in ipairs(HOSTILE) do
|
||||||
|
local m = copy(template)
|
||||||
|
m[key] = copy(bad)
|
||||||
|
add(m)
|
||||||
|
end
|
||||||
|
local missing = copy(template)
|
||||||
|
missing[key] = nil
|
||||||
|
add(missing)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
for _, row in ipairs(NESTED) do
|
||||||
|
local template = templateFor(row[1])
|
||||||
|
for _, bad in ipairs(HOSTILE) do
|
||||||
|
local m = copy(template)
|
||||||
|
if setPath(m, row[2], copy(bad)) then add(m) end
|
||||||
|
end
|
||||||
|
local m = copy(template)
|
||||||
|
if setPath(m, row[2], nil) then add(m) end
|
||||||
|
end
|
||||||
|
|
||||||
|
print(("hostile corpus: %d messages"):format(#corpus))
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------- session
|
||||||
|
|
||||||
|
local function fakeTransport(inbox)
|
||||||
|
local transport = { paired = true, closed = false, error = nil,
|
||||||
|
inbox = inbox, sent = {} }
|
||||||
|
function transport:update() end
|
||||||
|
function transport:poll()
|
||||||
|
local messages = self.inbox
|
||||||
|
self.inbox = {}
|
||||||
|
return messages
|
||||||
|
end
|
||||||
|
function transport:send(m) table.insert(self.sent, m) end
|
||||||
|
function transport:close() self.closed = true end
|
||||||
|
return transport
|
||||||
|
end
|
||||||
|
|
||||||
|
local transport = fakeTransport(copy(corpus))
|
||||||
|
local session = Session.new(transport, { role = "guest", kind = "link" })
|
||||||
|
local okUpdate, updateErr = pcall(session.update, session)
|
||||||
|
check(okUpdate, "the whole hostile corpus goes through Session without throwing"
|
||||||
|
.. (okUpdate and "" or (": " .. tostring(updateErr))))
|
||||||
|
check(session:getFailure() == nil,
|
||||||
|
"a hostile peer cannot latch a terminal failure on the session")
|
||||||
|
check(session:getStatus() == "paired", "the session is still usable afterwards")
|
||||||
|
local survivors = session:poll()
|
||||||
|
check(#survivors > 0, "well-formed messages still get through")
|
||||||
|
check(session.dropped > 0, "malformed messages are counted as dropped")
|
||||||
|
|
||||||
|
for _, msg in ipairs(survivors) do
|
||||||
|
if type(msg.type) ~= "string" then
|
||||||
|
check(false, "every delivered message has a string type")
|
||||||
|
break
|
||||||
|
end
|
||||||
|
end
|
||||||
|
check(true, "every delivered message has a string type")
|
||||||
|
|
||||||
|
for _, msg in ipairs(survivors) do
|
||||||
|
local ok = pcall(Json.encode, msg)
|
||||||
|
if not ok then
|
||||||
|
check(false, "every delivered message is still encodable (" .. msg.type .. ")")
|
||||||
|
break
|
||||||
|
end
|
||||||
|
end
|
||||||
|
check(true, "every delivered message is still encodable")
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------- consumers
|
||||||
|
|
||||||
|
local function makeFakeGame(species, name)
|
||||||
|
local save = require("src.core.SaveData").newGame()
|
||||||
|
save.player.name = name or "RED"
|
||||||
|
table.insert(save.party, Pokemon.new(Data, species, 20))
|
||||||
|
local stack = { list = {} }
|
||||||
|
function stack:push(s, ...)
|
||||||
|
table.insert(self.list, s)
|
||||||
|
if s.enter then s:enter(...) end
|
||||||
|
end
|
||||||
|
function stack:pop() return table.remove(self.list) end
|
||||||
|
function stack:top() return self.list[#self.list] end
|
||||||
|
function stack:update(dt)
|
||||||
|
local t = self:top()
|
||||||
|
if t and t.update then t:update(dt) end
|
||||||
|
end
|
||||||
|
return { data = Data, input = Input, stack = stack, save = save }
|
||||||
|
end
|
||||||
|
|
||||||
|
do
|
||||||
|
local crashed
|
||||||
|
for _, msg in ipairs(survivors) do
|
||||||
|
local party = { Pokemon.new(Data, "KADABRA", 30) }
|
||||||
|
local t = Protocol.TradeSession.new(Data, party)
|
||||||
|
local ok, err = pcall(function()
|
||||||
|
t:handle({ type = "party", mons = Protocol.packParty({
|
||||||
|
Pokemon.new(Data, "MACHOKE", 32) }) })
|
||||||
|
t:handle(msg)
|
||||||
|
t:pick(1)
|
||||||
|
t:handle(msg)
|
||||||
|
t:confirm(true)
|
||||||
|
t:handle(msg)
|
||||||
|
if t.stage == "done" then t:apply(nil) end
|
||||||
|
end)
|
||||||
|
if not ok then crashed = ("%s: %s"):format(tostring(msg.type), tostring(err)) end
|
||||||
|
if crashed then break end
|
||||||
|
end
|
||||||
|
check(not crashed, "the trade session survives every hostile message"
|
||||||
|
.. (crashed and (": " .. crashed) or ""))
|
||||||
|
end
|
||||||
|
|
||||||
|
do
|
||||||
|
local party = { Pokemon.new(Data, "KADABRA", 30) }
|
||||||
|
local t = Protocol.TradeSession.new(Data, party)
|
||||||
|
t:handle({ type = "party",
|
||||||
|
mons = Protocol.packParty({ Pokemon.new(Data, "MACHOKE", 32) }) })
|
||||||
|
t:pick(1)
|
||||||
|
t:handle(Wire.sanitize({ type = "pick", index = 0 }))
|
||||||
|
t:confirm(true)
|
||||||
|
t:handle(Wire.sanitize({ type = "confirm", ok = true }))
|
||||||
|
check(t.stage ~= "done", "an out-of-range pick never reaches a committed trade")
|
||||||
|
check(t.stage == "cancelled", "...it cancels the trade instead")
|
||||||
|
end
|
||||||
|
|
||||||
|
do
|
||||||
|
local gameA = makeFakeGame("CHARIZARD", "RED")
|
||||||
|
local gameB = makeFakeGame("BLASTOISE", "BLUE")
|
||||||
|
local netA, netB = Net.loopbackPair()
|
||||||
|
local battleA = LinkBattle.newHost(gameA, netA, {
|
||||||
|
myParty = Protocol.packParty(gameA.save.party),
|
||||||
|
theirParty = Protocol.packParty(gameB.save.party),
|
||||||
|
theirName = "BLUE", seed = 4242 })
|
||||||
|
gameA.stack:push(battleA)
|
||||||
|
local crashed
|
||||||
|
for _, msg in ipairs(survivors) do
|
||||||
|
table.insert(netA.inbox, copy(msg))
|
||||||
|
local ok, err = pcall(function()
|
||||||
|
Input.pressed = {}
|
||||||
|
gameA.stack:update(1 / 60)
|
||||||
|
end)
|
||||||
|
if not ok then
|
||||||
|
crashed = ("%s: %s"):format(tostring(msg.type), tostring(err))
|
||||||
|
break
|
||||||
|
end
|
||||||
|
end
|
||||||
|
check(not crashed, "a link battle survives every hostile message"
|
||||||
|
.. (crashed and (": " .. crashed) or ""))
|
||||||
|
end
|
||||||
|
|
||||||
|
do
|
||||||
|
local gameSpec = makeFakeGame("RATTATA", "WATCHER")
|
||||||
|
local specInbox = {}
|
||||||
|
local specNet = {
|
||||||
|
closed = false,
|
||||||
|
update = function() end,
|
||||||
|
poll = function()
|
||||||
|
local msgs = specInbox
|
||||||
|
specInbox = {}
|
||||||
|
return msgs
|
||||||
|
end,
|
||||||
|
send = function() end,
|
||||||
|
close = function() end,
|
||||||
|
}
|
||||||
|
local battle = LinkBattle.newSpectator(gameSpec, specNet, {
|
||||||
|
hostParty = Protocol.packParty(makeFakeGame("CHARIZARD").save.party),
|
||||||
|
guestParty = Protocol.packParty(makeFakeGame("BLASTOISE").save.party),
|
||||||
|
hostName = "RED", guestName = "BLUE", seed = 99 })
|
||||||
|
gameSpec.stack:push(battle)
|
||||||
|
local crashed
|
||||||
|
for _, msg in ipairs(corpus) do
|
||||||
|
for _, side in ipairs({ "host", "guest", 5, {} }) do
|
||||||
|
local wrapped = Wire.sanitize({ type = "spectate", side = side,
|
||||||
|
msg = copy(msg) })
|
||||||
|
if wrapped then table.insert(specInbox, wrapped) end
|
||||||
|
end
|
||||||
|
local ok, err = pcall(function()
|
||||||
|
Input.pressed = {}
|
||||||
|
gameSpec.stack:update(1 / 60)
|
||||||
|
end)
|
||||||
|
if not ok then
|
||||||
|
crashed = tostring(err)
|
||||||
|
break
|
||||||
|
end
|
||||||
|
end
|
||||||
|
check(not crashed, "a spectator battle survives every hostile envelope"
|
||||||
|
.. (crashed and (": " .. crashed) or ""))
|
||||||
|
end
|
||||||
|
|
||||||
|
do
|
||||||
|
local game = makeFakeGame("PIKACHU", "RED")
|
||||||
|
local exits = 0
|
||||||
|
local t = setmetatable({
|
||||||
|
game = game,
|
||||||
|
stage = "bracket",
|
||||||
|
index = 1,
|
||||||
|
settingsIndex = 1,
|
||||||
|
settings = { turnLimit = 6, requiredPartySize = 3, minLevel = "ANY",
|
||||||
|
maxLevel = "ANY", forceLevel = "ANY", participating = true },
|
||||||
|
roster = {},
|
||||||
|
spectatorRoster = {},
|
||||||
|
isCreator = false,
|
||||||
|
net = { send = function() end, close = function() end,
|
||||||
|
take = function() return nil end,
|
||||||
|
poll = function() return {} end,
|
||||||
|
hasPending = function() return false end },
|
||||||
|
}, Tournament)
|
||||||
|
t.exitWith = function(self) exits = exits + 1 end
|
||||||
|
local crashed
|
||||||
|
for _, msg in ipairs(survivors) do
|
||||||
|
local ok, err = pcall(function()
|
||||||
|
t:handleMessage(msg)
|
||||||
|
t:draw()
|
||||||
|
end)
|
||||||
|
if not ok then
|
||||||
|
crashed = ("%s: %s"):format(tostring(msg.type), tostring(err))
|
||||||
|
break
|
||||||
|
end
|
||||||
|
if type(t.roster) ~= "table" or type(t.spectatorRoster) ~= "table" then
|
||||||
|
crashed = ("%s left a non-table roster"):format(tostring(msg.type))
|
||||||
|
break
|
||||||
|
end
|
||||||
|
end
|
||||||
|
check(not crashed, "the tournament screen survives every hostile message"
|
||||||
|
.. (crashed and (": " .. crashed) or ""))
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------- json
|
||||||
|
do
|
||||||
|
local deep = ("["):rep(4096) .. ("]"):rep(4096)
|
||||||
|
local value, err = Json.decode(deep)
|
||||||
|
check(value == nil and err ~= nil, "a deeply nested document is refused")
|
||||||
|
local long = '{"type":"hello","name":"' .. ("x"):rep(1024) .. '"}'
|
||||||
|
check(Json.decode(long, 256) == nil, "a document past the caller's cap is refused")
|
||||||
|
check(Json.decode(long) ~= nil, "...and the cap is opt-in for other callers")
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------- net caps
|
||||||
|
do
|
||||||
|
local n = Net.new()
|
||||||
|
n.rxBuf = ("x"):rep(Net.MAX_LINE + 1)
|
||||||
|
n:drainLines()
|
||||||
|
check(n.closed and n.error ~= nil,
|
||||||
|
"a peer that never sends a newline closes the connection")
|
||||||
|
check(#n.rxBuf == 0, "...and the buffer is released")
|
||||||
|
end
|
||||||
|
|
||||||
|
print(("\nlink hostile: %d messages, %d failures"):format(#corpus, failures))
|
||||||
|
assert(failures == 0, failures .. " hostile-input failure(s)")
|
||||||
|
return true
|
||||||
@@ -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")
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
-- Parity test (#1519): wStepCounter is zeroed by ClearVariablesOnEnterMap
|
||||||
|
-- (engine/overworld/clear_variables.asm:7), reached from EnterMap on a warp
|
||||||
|
-- and from the post-battle tail jump (home/overworld.asm:353), but never from
|
||||||
|
-- CheckMapConnections (home/overworld.asm:675), so a seam crossing keeps phase.
|
||||||
|
-- Self-contained; run via `luajit tests/parity_field_poison_phase.lua`.
|
||||||
|
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||||
|
if not _G.love then _G.love = require("tests.love_stub") end
|
||||||
|
local Data = require("src.core.Data")
|
||||||
|
if not (Data.maps and Data.maps.PALLET_TOWN) then Data:load() end
|
||||||
|
local S = require("tests.harness").suite("parity field poison phase")
|
||||||
|
local check, eq = S.check, S.eq
|
||||||
|
|
||||||
|
require("src.render.Font").load(Data)
|
||||||
|
local Game = require("src.core.Game")
|
||||||
|
local Input = require("src.core.Input")
|
||||||
|
local StateStack = require("src.core.StateStack")
|
||||||
|
local Renderer = require("src.render.Renderer")
|
||||||
|
local SaveData = require("src.core.SaveData")
|
||||||
|
local Pokemon = require("src.pokemon.Pokemon")
|
||||||
|
local OW = require("src.world.OverworldController")
|
||||||
|
|
||||||
|
Game.data = Data
|
||||||
|
Game.input = Input; Input:init()
|
||||||
|
Game.renderer = Renderer; Renderer:init()
|
||||||
|
Game.stack = StateStack; StateStack:init()
|
||||||
|
Game.save = SaveData.newGame()
|
||||||
|
Game.save.party = { Pokemon.new(Data, "SQUIRTLE", 12) }
|
||||||
|
|
||||||
|
Game.stack:push(OW, "PALLET_TOWN", 5, 6, "down")
|
||||||
|
local ow = Game.stack:top()
|
||||||
|
Game.overworld = ow
|
||||||
|
|
||||||
|
local mon = Game.save.party[1]
|
||||||
|
mon.status = "PSN"
|
||||||
|
|
||||||
|
-- Walk until the poison bites, and report which step it was.
|
||||||
|
local function stepsToTick(limit)
|
||||||
|
for i = 1, limit or 12 do
|
||||||
|
local before = mon.hp
|
||||||
|
ow:applyFieldPoison()
|
||||||
|
if mon.hp < before then return i end
|
||||||
|
end
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local function reheal()
|
||||||
|
mon.hp = mon.stats.hp
|
||||||
|
mon.status = "PSN"
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ---- the free-running four-step cycle ------------------------------------
|
||||||
|
Game.save.poisonSteps = 0
|
||||||
|
eq(stepsToTick(), 4, "a cleared counter bites on the fourth step")
|
||||||
|
reheal()
|
||||||
|
eq(stepsToTick(), 4, "and every fourth step after that")
|
||||||
|
|
||||||
|
reheal()
|
||||||
|
Game.save.poisonSteps = 2
|
||||||
|
eq(stepsToTick(), 2, "a counter two steps into the cycle bites two steps later")
|
||||||
|
|
||||||
|
-- ---- a real map load re-phases it ----------------------------------------
|
||||||
|
reheal()
|
||||||
|
Game.save.poisonSteps = 2
|
||||||
|
ow:setMap("PALLET_TOWN", 5, 6, "down", { via = "warp" })
|
||||||
|
eq(Game.save.poisonSteps, 0, "setMap clears the counter")
|
||||||
|
eq(stepsToTick(), 4, "so the first tick after a warp is four steps out")
|
||||||
|
|
||||||
|
-- ---- a connection crossing does not --------------------------------------
|
||||||
|
reheal()
|
||||||
|
Game.save.poisonSteps = 2
|
||||||
|
ow:setMap("ROUTE_1", 9, 33, "up", { seamless = true, keepMusic = true })
|
||||||
|
eq(Game.save.poisonSteps, 2, "a seamless crossing leaves the counter alone")
|
||||||
|
eq(stepsToTick(), 2, "so the seam does not delay the tick")
|
||||||
|
|
||||||
|
-- ---- a battle return re-phases it ----------------------------------------
|
||||||
|
reheal()
|
||||||
|
Game.save.poisonSteps = 2
|
||||||
|
ow:afterBattle("win", { kind = "wild" })
|
||||||
|
eq(Game.save.poisonSteps, 0, "afterBattle clears the counter")
|
||||||
|
eq(stepsToTick(), 4, "so poison always bites four steps after a battle")
|
||||||
|
|
||||||
|
reheal()
|
||||||
|
Game.save.poisonSteps = 3
|
||||||
|
ow:afterBattle("win", { kind = "trainer", oppClass = "OPP_YOUNGSTER" })
|
||||||
|
eq(stepsToTick(), 4, "trainer battles re-phase it the same way")
|
||||||
|
|
||||||
|
S.finish()
|
||||||
@@ -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()
|
||||||
|
|||||||
@@ -645,6 +645,14 @@ check(fxAdvances("messages", "linkNext"),
|
|||||||
local fuzzOk, fuzzErr = pcall(dofile, "tests/link_desync_fuzz.lua")
|
local fuzzOk, fuzzErr = pcall(dofile, "tests/link_desync_fuzz.lua")
|
||||||
check(fuzzOk, "lockstep desync fuzz" .. (fuzzOk and "" or (": " .. tostring(fuzzErr))))
|
check(fuzzOk, "lockstep desync fuzz" .. (fuzzOk and "" or (": " .. tostring(fuzzErr))))
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------- hostile wire
|
||||||
|
-- Every message type crossed with every wrong Lua type, through the real
|
||||||
|
-- Session choke point and into the real consumers. The regression net for
|
||||||
|
-- the remote-crash payloads; self-contained like the fuzz above.
|
||||||
|
local hostileOk, hostileErr = pcall(dofile, "tests/link_hostile.lua")
|
||||||
|
check(hostileOk, "hostile wire suite"
|
||||||
|
.. (hostileOk and "" or (": " .. tostring(hostileErr))))
|
||||||
|
|
||||||
-- ---------------------------------------------------------------- mod link compat
|
-- ---------------------------------------------------------------- mod link compat
|
||||||
-- Self-contained like the tests/mod_*.lua suites: own bootstrap and
|
-- Self-contained like the tests/mod_*.lua suites: own bootstrap and
|
||||||
-- assert-based checks, so it lands here as a single pass/fail line.
|
-- assert-based checks, so it lands here as a single pass/fail line.
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ end
|
|||||||
-- Also gates the NX runtime modules and the NX engine suites so an NX
|
-- Also gates the NX runtime modules and the NX engine suites so an NX
|
||||||
-- runtime regression cannot slip past switch-selftest / switch-build.
|
-- runtime regression cannot slip past switch-selftest / switch-build.
|
||||||
local SWITCH_PATH_REGEX =
|
local SWITCH_PATH_REGEX =
|
||||||
[[^(scripts/build_switch\.sh$|scripts/switch/|docs/switch-.*\.md$|tests/switch_ci_workflows_test\.lua$|tests/switch_transfer_docs_test\.lua$|\.github/workflows/(ci|release|switch-artifact-comment)\.yml$|src/core/(NxAssetOverlay|Platform|GameVersion)\.lua$|src/import/CacheFs\.lua$|tests/engine/(assets_version_fallback|nx_generated_guard|nx_yellow_boot|switch_diagnostics)_test\.lua$|tests/engine/platform_nx)]]
|
[[^(scripts/build_switch\.sh$|scripts/switch/|docs/switch-.*\.md$|tests/switch_ci_workflows_test\.lua$|tests/switch_transfer_docs_test\.lua$|\.github/workflows/(ci|release|switch-artifact-comment)\.yml$|src/core/(NxAssetOverlay|Platform|GameVersion)\.lua$|src/import/CacheFs\.lua$|tests/engine/(assets_version_fallback|nx_generated_guard|nx_yellow_boot|switch_diagnostics|cache_fs_gold_nx_load)_test\.lua$|tests/engine/platform_nx)]]
|
||||||
|
|
||||||
local ci = read(".github/workflows/ci.yml")
|
local ci = read(".github/workflows/ci.yml")
|
||||||
local release = read(".github/workflows/release.yml")
|
local release = read(".github/workflows/release.yml")
|
||||||
@@ -50,6 +50,7 @@ for _, fragment in ipairs({
|
|||||||
"nx_generated_guard",
|
"nx_generated_guard",
|
||||||
"nx_yellow_boot",
|
"nx_yellow_boot",
|
||||||
"switch_diagnostics",
|
"switch_diagnostics",
|
||||||
|
"cache_fs_gold_nx_load",
|
||||||
"tests/engine/platform_nx",
|
"tests/engine/platform_nx",
|
||||||
}) do
|
}) do
|
||||||
mustContain(ci, fragment, "ci.yml path regex NX fragment")
|
mustContain(ci, fragment, "ci.yml path regex NX fragment")
|
||||||
@@ -79,6 +80,7 @@ do
|
|||||||
mustContain(block, "luajit tests/engine/assets_version_fallback_test.lua", "switch-selftest")
|
mustContain(block, "luajit tests/engine/assets_version_fallback_test.lua", "switch-selftest")
|
||||||
mustContain(block, "luajit tests/engine/nx_generated_guard_test.lua", "switch-selftest")
|
mustContain(block, "luajit tests/engine/nx_generated_guard_test.lua", "switch-selftest")
|
||||||
mustContain(block, "luajit tests/engine/nx_yellow_boot_test.lua", "switch-selftest")
|
mustContain(block, "luajit tests/engine/nx_yellow_boot_test.lua", "switch-selftest")
|
||||||
|
mustContain(block, "luajit tests/engine/cache_fs_gold_nx_load_test.lua", "switch-selftest")
|
||||||
mustNotContain(block, "continue-on-error:", "switch-selftest")
|
mustNotContain(block, "continue-on-error:", "switch-selftest")
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -185,6 +187,7 @@ mustContain(test_sh, "T0 switch transfer docs gate", "scripts/test.sh")
|
|||||||
mustContain(test_sh, "tests/engine/assets_version_fallback_test.lua", "scripts/test.sh")
|
mustContain(test_sh, "tests/engine/assets_version_fallback_test.lua", "scripts/test.sh")
|
||||||
mustContain(test_sh, "tests/engine/nx_generated_guard_test.lua", "scripts/test.sh")
|
mustContain(test_sh, "tests/engine/nx_generated_guard_test.lua", "scripts/test.sh")
|
||||||
mustContain(test_sh, "tests/engine/nx_yellow_boot_test.lua", "scripts/test.sh")
|
mustContain(test_sh, "tests/engine/nx_yellow_boot_test.lua", "scripts/test.sh")
|
||||||
|
mustContain(test_sh, "tests/engine/cache_fs_gold_nx_load_test.lua", "scripts/test.sh")
|
||||||
mustContain(build_doc, "tests/switch_ci_workflows_test.lua", "switch-build.md path list")
|
mustContain(build_doc, "tests/switch_ci_workflows_test.lua", "switch-build.md path list")
|
||||||
mustContain(build_doc, "tests/switch_transfer_docs_test.lua", "switch-build.md path list")
|
mustContain(build_doc, "tests/switch_transfer_docs_test.lua", "switch-build.md path list")
|
||||||
|
|
||||||
@@ -197,6 +200,7 @@ for _, path in ipairs({
|
|||||||
"tests/engine/assets_version_fallback_test.lua",
|
"tests/engine/assets_version_fallback_test.lua",
|
||||||
"tests/engine/nx_generated_guard_test.lua",
|
"tests/engine/nx_generated_guard_test.lua",
|
||||||
"tests/engine/nx_yellow_boot_test.lua",
|
"tests/engine/nx_yellow_boot_test.lua",
|
||||||
|
"tests/engine/cache_fs_gold_nx_load_test.lua",
|
||||||
"tests/engine/switch_diagnostics_test.lua",
|
"tests/engine/switch_diagnostics_test.lua",
|
||||||
"tests/engine/platform_nx_*",
|
"tests/engine/platform_nx_*",
|
||||||
}) do
|
}) do
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ mustContain(transfer, "FTP", "transfer")
|
|||||||
mustContain(transfer, "sdmc:/switch/gen1recomp/", "transfer")
|
mustContain(transfer, "sdmc:/switch/gen1recomp/", "transfer")
|
||||||
mustContain(transfer, "imports/", "transfer")
|
mustContain(transfer, "imports/", "transfer")
|
||||||
mustContain(transfer, "imports/mods/", "transfer")
|
mustContain(transfer, "imports/mods/", "transfer")
|
||||||
|
mustContain(transfer, "gold", "transfer")
|
||||||
mustContain(transfer, "1: SD Card", "transfer")
|
mustContain(transfer, "1: SD Card", "transfer")
|
||||||
mustContain(transfer, "Scan again", "transfer")
|
mustContain(transfer, "Scan again", "transfer")
|
||||||
mustContain(transfer, "documented example", "transfer")
|
mustContain(transfer, "documented example", "transfer")
|
||||||
@@ -70,6 +71,9 @@ mustContain(install, "PERFORMANCE", "install")
|
|||||||
mustContain(install, "Stock engine effect", "install")
|
mustContain(install, "Stock engine effect", "install")
|
||||||
mustContain(install, "## Limitations", "install")
|
mustContain(install, "## Limitations", "install")
|
||||||
mustContain(install, "Launch with title override", "install")
|
mustContain(install, "Launch with title override", "install")
|
||||||
|
mustContain(install, "Gold", "install")
|
||||||
|
mustContain(install, "imports/saves/gold/", "install")
|
||||||
|
mustContain(install, "exports/gold/", "install")
|
||||||
mustNotContain(install, "VoxelMod", "install")
|
mustNotContain(install, "VoxelMod", "install")
|
||||||
mustNotContain(install, "switch-development", "install")
|
mustNotContain(install, "switch-development", "install")
|
||||||
mustContain(build, "switch-transfer.md", "build")
|
mustContain(build, "switch-transfer.md", "build")
|
||||||
|
|||||||
Reference in New Issue
Block a user