mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-18 19:54:21 +02:00
Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c2e1db0f89 | |||
| 7b1e796c48 | |||
| 4c13770e70 | |||
| cf45cbbf92 | |||
| 4e1ab1879b | |||
| 917735a41c | |||
| c877ea80a7 | |||
| 7d1ddf9b7c | |||
| 5bc6036735 | |||
| 809628f0fa | |||
| 4817ff8bf9 | |||
| 4f8739c029 | |||
| c655217120 | |||
| fbdfc1c053 | |||
| 72c244b433 |
@@ -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.
|
||||||
+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
|
||||||
|
|
||||||
|
|||||||
+11
-4
@@ -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
|
||||||
@@ -159,8 +160,16 @@ 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.
|
|
||||||
|
**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 +184,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.
|
|
||||||
|
|||||||
@@ -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,20 @@
|
|||||||
"tintColor": "3b5ca8",
|
"tintColor": "3b5ca8",
|
||||||
"category": "games",
|
"category": "games",
|
||||||
"versions": [
|
"versions": [
|
||||||
|
{
|
||||||
|
"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",
|
"version": "0.2.1",
|
||||||
"date": "2026-08-17",
|
"date": "2026-08-17",
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
local Platform = require("src.core.Platform")
|
||||||
|
local HostShell = require("src.core.HostShell")
|
||||||
|
|
||||||
|
local FilePicker = {}
|
||||||
|
|
||||||
|
FilePicker.IMAGE = {
|
||||||
|
label = "Image",
|
||||||
|
exts = { "png", "jpg", "jpeg" },
|
||||||
|
tempName = "pokeport_image_pick",
|
||||||
|
}
|
||||||
|
|
||||||
|
local function trim(value)
|
||||||
|
return value and value:gsub("^%s+", ""):gsub("%s+$", "") or ""
|
||||||
|
end
|
||||||
|
|
||||||
|
local function shellSafe(s)
|
||||||
|
s = tostring(s):gsub("%%", "%%%%")
|
||||||
|
return s:gsub('"', '\\"'):gsub("'", "''")
|
||||||
|
end
|
||||||
|
|
||||||
|
local function commandOutput(command)
|
||||||
|
if not Platform.canSpawnProcess() then return nil end
|
||||||
|
local pipe = HostShell.popen(command)
|
||||||
|
if not pipe then return nil end
|
||||||
|
local result = pipe:read("*a")
|
||||||
|
HostShell.pclose(pipe)
|
||||||
|
result = trim(result)
|
||||||
|
return result ~= "" and result or nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local function appleTypes(exts)
|
||||||
|
local out = {}
|
||||||
|
for _, ext in ipairs(exts) do out[#out + 1] = '"' .. ext .. '"' end
|
||||||
|
return table.concat(out, ", ")
|
||||||
|
end
|
||||||
|
|
||||||
|
local function windowsPatterns(exts)
|
||||||
|
local out = {}
|
||||||
|
for _, ext in ipairs(exts) do out[#out + 1] = "*." .. ext end
|
||||||
|
return table.concat(out, ";")
|
||||||
|
end
|
||||||
|
|
||||||
|
local function globPatterns(exts)
|
||||||
|
local out = {}
|
||||||
|
for _, ext in ipairs(exts) do out[#out + 1] = "*." .. ext end
|
||||||
|
return table.concat(out, " ")
|
||||||
|
end
|
||||||
|
|
||||||
|
function FilePicker.available()
|
||||||
|
return Platform.canSpawnProcess()
|
||||||
|
end
|
||||||
|
|
||||||
|
function FilePicker.matches(name, kind)
|
||||||
|
local lower = tostring(name or ""):lower()
|
||||||
|
for _, ext in ipairs(kind.exts) do
|
||||||
|
if lower:match("%." .. ext .. "$") then return true end
|
||||||
|
end
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
function FilePicker.open(prompt, kind)
|
||||||
|
if not Platform.canSpawnProcess() then return nil end
|
||||||
|
local title = shellSafe(prompt)
|
||||||
|
local platform = love.system.getOS()
|
||||||
|
if platform == "OS X" then
|
||||||
|
return commandOutput(
|
||||||
|
([[osascript -e 'POSIX path of (choose file with prompt "%s" of type {%s})' 2>/dev/null]])
|
||||||
|
:format(title, appleTypes(kind.exts)))
|
||||||
|
elseif platform == "Windows" then
|
||||||
|
local script = table.concat({
|
||||||
|
"Add-Type -AssemblyName System.Windows.Forms;",
|
||||||
|
"$d=New-Object System.Windows.Forms.OpenFileDialog;",
|
||||||
|
"$d.Title='" .. title .. "';",
|
||||||
|
"$d.Filter='" .. kind.label .. " (" .. windowsPatterns(kind.exts) .. ")|"
|
||||||
|
.. windowsPatterns(kind.exts) .. "|All files (*.*)|*.*';",
|
||||||
|
"if($d.ShowDialog() -eq 'OK'){",
|
||||||
|
"$n=[IO.Path]::GetFileName($d.FileName) -replace '[^\\x20-\\x7E]','_';",
|
||||||
|
"$t=Join-Path $env:TEMP $n;",
|
||||||
|
"Copy-Item -LiteralPath $d.FileName -Destination $t -Force;",
|
||||||
|
"[Console]::OutputEncoding=[Text.Encoding]::UTF8;",
|
||||||
|
"[Console]::Write($t)}",
|
||||||
|
})
|
||||||
|
return commandOutput(
|
||||||
|
'powershell -NoProfile -STA -Command "' .. script .. '"')
|
||||||
|
elseif platform == "Linux" then
|
||||||
|
local path = commandOutput(
|
||||||
|
([[zenity --file-selection --title="%s" --file-filter="%s | %s" 2>/dev/null]])
|
||||||
|
:format(title, kind.label, globPatterns(kind.exts)))
|
||||||
|
if path then return path end
|
||||||
|
return commandOutput(([[kdialog --getopenfilename "$HOME" "%s|%s" 2>/dev/null]])
|
||||||
|
:format(globPatterns(kind.exts), kind.label))
|
||||||
|
end
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
function FilePicker.read(path)
|
||||||
|
local file, openError = io.open(path, "rb")
|
||||||
|
if not file then return nil, openError end
|
||||||
|
local data = file:read("*a")
|
||||||
|
file:close()
|
||||||
|
if not data or data == "" then return nil, "empty file" end
|
||||||
|
return data
|
||||||
|
end
|
||||||
|
|
||||||
|
function FilePicker.basename(path)
|
||||||
|
return tostring(path or ""):match("([^/\\]+)$") or tostring(path or "")
|
||||||
|
end
|
||||||
|
|
||||||
|
return FilePicker
|
||||||
+34
-1
@@ -222,6 +222,26 @@ function Game:touchSkinHotkey(action, pressed)
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
function Game:breakLink(err)
|
||||||
|
Logger.error("link: torn down after an error\n%s", tostring(err))
|
||||||
|
self.linkSession = nil
|
||||||
|
local net = self.linkNet
|
||||||
|
self.linkNet = nil
|
||||||
|
if net then pcall(net.close, net) end
|
||||||
|
pcall(ModRuntime.emit, "link.ended", { reason = "error" })
|
||||||
|
local stack = self.stack
|
||||||
|
local guard = 0
|
||||||
|
while #stack.states > 1 and stack:top() ~= self.overworld and guard < 64 do
|
||||||
|
guard = guard + 1
|
||||||
|
pcall(stack.pop, stack)
|
||||||
|
end
|
||||||
|
pcall(function()
|
||||||
|
local Strings = require("src.core.Strings")
|
||||||
|
local TextBox = require("src.render.TextBox")
|
||||||
|
stack:push(TextBox.new(self, Strings("The link was\nbroken.")))
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
function Game:step(dt)
|
function Game:step(dt)
|
||||||
-- Tool mods (autoplay, accessibility drivers, input visualizers) act on
|
-- Tool mods (autoplay, accessibility drivers, input visualizers) act on
|
||||||
-- the same fixed-step boundary as a physical controller. Run them before
|
-- the same fixed-step boundary as a physical controller. Run them before
|
||||||
@@ -249,9 +269,22 @@ function Game:step(dt)
|
|||||||
-- stall just because PartyMenu/ChoiceBox/NamingScreen is temporarily
|
-- stall just because PartyMenu/ChoiceBox/NamingScreen is temporarily
|
||||||
-- on top of BattleState (see LinkBattle.new)
|
-- on top of BattleState (see LinkBattle.new)
|
||||||
if self.linkNet and not self.linkNet.closed then
|
if self.linkNet and not self.linkNet.closed then
|
||||||
self.linkNet:update()
|
local ok, err = pcall(self.linkNet.update, self.linkNet)
|
||||||
|
if not ok then
|
||||||
|
self:breakLink(err)
|
||||||
|
return
|
||||||
end
|
end
|
||||||
|
end
|
||||||
|
if self.linkSession or self.linkNet then
|
||||||
|
local ok, err = xpcall(function() self.stack:update(dt) end,
|
||||||
|
function(e) return debug.traceback(tostring(e), 2) end)
|
||||||
|
if not ok then
|
||||||
|
self:breakLink(err)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
else
|
||||||
self.stack:update(dt)
|
self.stack:update(dt)
|
||||||
|
end
|
||||||
-- play time for the trainer card / save screen
|
-- play time for the trainer card / save screen
|
||||||
self.save.playTime = (self.save.playTime or 0) + dt
|
self.save.playTime = (self.save.playTime or 0) + dt
|
||||||
-- Music.update is NOT serviced here: it decrements fade counters and
|
-- Music.update is NOT serviced here: it decrements fade counters and
|
||||||
|
|||||||
+54
-14
@@ -2017,6 +2017,23 @@ local function buildSkinsPanel(imp, x, y, w, availH, m)
|
|||||||
local gap = m.gap
|
local gap = m.gap
|
||||||
local cy = y
|
local cy = y
|
||||||
|
|
||||||
|
local title = Strings("Skins/Borders")
|
||||||
|
local bh = m.btnH
|
||||||
|
local importLabel = imp:_skinsImportButtonLabel()
|
||||||
|
local importW = Kit.textWidth("small", importLabel) + math.floor(24 * m.s)
|
||||||
|
if Kit.textWidth("button", title) + importW + math.floor(24 * m.s) > w then
|
||||||
|
importLabel = Strings("Import")
|
||||||
|
importW = Kit.textWidth("small", importLabel) + math.floor(20 * m.s)
|
||||||
|
end
|
||||||
|
local place = Layout.rightCluster(x, w, math.floor(6 * m.s))
|
||||||
|
btn(imp, place(importW), cy, importW, bh, "skins-import", importLabel, {
|
||||||
|
kind = "accent", font = "small",
|
||||||
|
action = function() imp:chooseSkin() end })
|
||||||
|
Kit.text("button", Kit.ellipsize("button", title,
|
||||||
|
math.max(0, w - importW - math.floor(12 * m.s))), x,
|
||||||
|
cy + math.floor((bh - Kit.textHeight("button")) / 2), PAL.heading)
|
||||||
|
cy = cy + bh + math.floor(8 * m.s)
|
||||||
|
|
||||||
if imp._skinNotice then
|
if imp._skinNotice then
|
||||||
cy = cy + Kit.textWrapped("small", imp._skinNotice.text, x, cy, w,
|
cy = cy + Kit.textWrapped("small", imp._skinNotice.text, x, cy, w,
|
||||||
imp._skinNotice.ok and PAL.green or PAL.red, 2) + math.floor(8 * m.s)
|
imp._skinNotice.ok and PAL.green or PAL.red, 2) + math.floor(8 * m.s)
|
||||||
@@ -2075,13 +2092,39 @@ local function buildSkinsPanel(imp, x, y, w, availH, m)
|
|||||||
cy = cy + rowH + math.floor(4 * m.s)
|
cy = cy + rowH + math.floor(4 * m.s)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
local entries = { false }
|
||||||
|
for _, entry in ipairs(skins) do entries[#entries + 1] = entry end
|
||||||
|
|
||||||
|
local TouchSkin = require("src.core.TouchSkin")
|
||||||
|
local hint = Strings(
|
||||||
|
"You can also drop a skin .zip on this window, or put a folder in %s/ of your save directory. RetroArch overlay .cfg files work as-is.",
|
||||||
|
TouchSkin.USER_ROOT)
|
||||||
|
local hintH = Kit.wrapHeight("small", hint, w, 3)
|
||||||
|
local importH = math.floor(10 * m.s) + hintH
|
||||||
|
|
||||||
|
local rowGap = math.floor(4 * m.s)
|
||||||
|
local pagerH = math.max(Kit.tapMin(), math.floor(30 * m.s))
|
||||||
|
local listTop = cy
|
||||||
|
local listH = availH - (cy - y) - importH
|
||||||
|
local perPage = Kit.rowsThatFit(listH, rowH, rowGap, 1, 20)
|
||||||
|
if #entries > perPage then
|
||||||
|
perPage = Kit.rowsThatFit(listH - pagerH - gap, rowH, rowGap, 1, 20)
|
||||||
|
end
|
||||||
|
local first, last, cur, pages = Kit.pageBounds(page(imp, "skins"),
|
||||||
|
#entries, perPage)
|
||||||
|
setPage(imp, "skins", cur)
|
||||||
|
setPage(imp, "skins",
|
||||||
|
Kit.wheelPage(x, listTop, w, listH, cur, #entries, perPage))
|
||||||
|
|
||||||
|
for i = first, last do
|
||||||
|
local entry = entries[i]
|
||||||
|
if not entry then
|
||||||
skinRow("skin-none", nil, Strings("Built-in pad"),
|
skinRow("skin-none", nil, Strings("Built-in pad"),
|
||||||
Strings("The default on-screen buttons."), active == nil,
|
Strings("The default on-screen buttons."), active == nil,
|
||||||
imp.onEditTouchControls and function()
|
imp.onEditTouchControls and function()
|
||||||
imp.onEditTouchControls(imp.modScope or "red")
|
imp.onEditTouchControls(imp.modScope or "red")
|
||||||
end or nil)
|
end or nil)
|
||||||
|
else
|
||||||
for _, entry in ipairs(skins) do
|
|
||||||
local bits = {}
|
local bits = {}
|
||||||
bits[#bits + 1] = entry.source == "user" and Strings("installed")
|
bits[#bits + 1] = entry.source == "user" and Strings("installed")
|
||||||
or Strings("bundled")
|
or Strings("bundled")
|
||||||
@@ -2100,6 +2143,7 @@ local function buildSkinsPanel(imp, x, y, w, availH, m)
|
|||||||
skinRow("skin-" .. entry.id, entry.id, entry.id,
|
skinRow("skin-" .. entry.id, entry.id, entry.id,
|
||||||
table.concat(bits, " \194\183 "), active == entry.id, configure)
|
table.concat(bits, " \194\183 "), active == entry.id, configure)
|
||||||
end
|
end
|
||||||
|
end
|
||||||
|
|
||||||
if #skins == 0 then
|
if #skins == 0 then
|
||||||
Kit.emptyBox(x, cy, w, math.floor(72 * m.s),
|
Kit.emptyBox(x, cy, w, math.floor(72 * m.s),
|
||||||
@@ -2107,18 +2151,14 @@ local function buildSkinsPanel(imp, x, y, w, availH, m)
|
|||||||
cy = cy + math.floor(72 * m.s) + gap
|
cy = cy + math.floor(72 * m.s) + gap
|
||||||
end
|
end
|
||||||
|
|
||||||
cy = cy + math.floor(6 * m.s)
|
if pages > 1 then
|
||||||
local TouchSkin = require("src.core.TouchSkin")
|
setPage(imp, "skins",
|
||||||
Kit.caption(x, cy, Strings("IMPORT"))
|
Kit.pager(x, cy, w, cur, #entries, perPage, "skins"))
|
||||||
cy = cy + Kit.textHeight("small") + math.floor(6 * m.s)
|
cy = cy + pagerH + gap
|
||||||
local boxH = math.floor(76 * m.s)
|
end
|
||||||
Kit.card(x, cy, w, boxH, "muted")
|
|
||||||
Kit.textWrapped("small", Strings(
|
cy = cy + math.floor(10 * m.s)
|
||||||
"Drop a skin .zip on this window to install it, or put a folder in the skins folder of your save directory. RetroArch overlay .cfg files work as-is."),
|
Kit.textWrapped("small", hint, x, cy, w, PAL.muted, 3)
|
||||||
x + math.floor(14 * m.s), cy + math.floor(12 * m.s),
|
|
||||||
w - math.floor(28 * m.s), PAL.muted, 3)
|
|
||||||
Kit.text("small", TouchSkin.USER_ROOT .. "/", x + math.floor(14 * m.s),
|
|
||||||
cy + boxH - Kit.textHeight("small") - math.floor(10 * m.s), PAL.faint)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
local function buildFindPanel(imp, x, y, w, availH, m)
|
local function buildFindPanel(imp, x, y, w, availH, m)
|
||||||
|
|||||||
+147
-9
@@ -988,8 +988,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 +1099,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 +1514,23 @@ function RomImporter:focus(f)
|
|||||||
local text = "Could not read the picked file. Reopen the picker and choose "
|
local text = "Could not read the picked file. Reopen the picker and choose "
|
||||||
.. "it with the Files (Documents) app, or copy it into: "
|
.. "it with the Files (Documents) app, or copy it into: "
|
||||||
.. love.filesystem.getSaveDirectory()
|
.. love.filesystem.getSaveDirectory()
|
||||||
|
local legacyRequiredPick = self.requiredImportLegacyRomPick
|
||||||
|
and self.pickerPendingKind == "required_import"
|
||||||
if pickError:find("picked_required_import", 1, true)
|
if pickError:find("picked_required_import", 1, true)
|
||||||
or pickError:find("picked_stadium", 1, true) then
|
or pickError:find("picked_stadium", 1, true)
|
||||||
|
or (legacyRequiredPick and pickError:find("picked_rom", 1, true)) then
|
||||||
self.modNotice = { ok = false, text = text }
|
self.modNotice = { ok = false, text = text }
|
||||||
self.pickerPendingKind = nil
|
self.pickerPendingKind = nil
|
||||||
self.pickerPendingModId = nil
|
self.pickerPendingModId = nil
|
||||||
self.pickerPendingImportId = nil
|
self.pickerPendingImportId = nil
|
||||||
|
self.requiredImportLegacyRomPick = nil
|
||||||
elseif pickError:find("picked_mod", 1, true) then
|
elseif pickError:find("picked_mod", 1, true) then
|
||||||
|
if self.pickerPendingKind == "skin" then
|
||||||
|
self.pickerPendingKind = nil
|
||||||
|
self._skinNotice = { ok = false, text = text }
|
||||||
|
else
|
||||||
self.modNotice = { ok = false, text = text }
|
self.modNotice = { ok = false, text = text }
|
||||||
|
end
|
||||||
elseif pickError:find("picked_save", 1, true) then
|
elseif pickError:find("picked_save", 1, true) then
|
||||||
local version = self.androidPendingVersion or self:_savedropTarget()
|
local version = self.androidPendingVersion or self:_savedropTarget()
|
||||||
self.androidPendingVersion = nil
|
self.androidPendingVersion = nil
|
||||||
@@ -1488,11 +1540,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 +1557,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 +2084,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 +2103,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 +2570,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 +2597,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 +3126,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 +3158,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
|
||||||
|
|||||||
@@ -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"
|
|
||||||
failureDetail = ("message %d must be a table with string type")
|
|
||||||
:format(index)
|
|
||||||
end
|
|
||||||
break
|
|
||||||
end
|
|
||||||
self._inbox[#self._inbox + 1] = message
|
self._inbox[#self._inbox + 1] = message
|
||||||
|
else
|
||||||
|
self.dropped = (self.dropped or 0) + 1
|
||||||
|
local label = type(raw) == "table" and tostring(raw.type) or type(raw)
|
||||||
|
Logger.warn("link: dropped malformed message (%s)", label)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
+98
-18
@@ -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 = {}
|
||||||
|
|
||||||
@@ -214,12 +215,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 +236,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 +312,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()
|
||||||
@@ -617,10 +673,16 @@ local function inspectorBody(x, y, w)
|
|||||||
|
|
||||||
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 +763,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 +955,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
|
||||||
|
|||||||
+33
-30
@@ -2966,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
|
||||||
@@ -2995,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 /
|
||||||
@@ -3009,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
|
||||||
|
|||||||
@@ -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,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
|
||||||
@@ -70,12 +70,12 @@ end
|
|||||||
do
|
do
|
||||||
local host, guest = sessionPair()
|
local host, guest = sessionPair()
|
||||||
guest:send({ type = "before", sequence = 1 })
|
guest:send({ type = "before", sequence = 1 })
|
||||||
guest:send({ type = "hello", sequence = 2 })
|
guest:send({ type = "greeting", sequence = 2 })
|
||||||
guest:send({ type = "after", sequence = 3 })
|
guest:send({ type = "after", sequence = 3 })
|
||||||
guest:send({ type = "hello", sequence = 4 })
|
guest:send({ type = "greeting", sequence = 4 })
|
||||||
host:update()
|
host:update()
|
||||||
|
|
||||||
local hello = host:take("hello")
|
local hello = host:take("greeting")
|
||||||
T.eq(hello.sequence, 2, "take removes the first matching packet")
|
T.eq(hello.sequence, 2, "take removes the first matching packet")
|
||||||
T.eq(host:pollOne().sequence, 1, "pollOne removes only the FIFO head")
|
T.eq(host:pollOne().sequence, 1, "pollOne removes only the FIFO head")
|
||||||
|
|
||||||
@@ -122,14 +122,14 @@ end
|
|||||||
|
|
||||||
do
|
do
|
||||||
local transport = fakeTransport({ onUpdate = function(self)
|
local transport = fakeTransport({ onUpdate = function(self)
|
||||||
self.inbox[#self.inbox + 1] = { type = "bye", final = true }
|
self.inbox[#self.inbox + 1] = { type = "bye" }
|
||||||
self.closed = true
|
self.closed = true
|
||||||
end })
|
end })
|
||||||
local session = Session.new(transport, { role = "host", kind = "link" })
|
local session = Session.new(transport, { role = "host", kind = "link" })
|
||||||
session:update()
|
session:update()
|
||||||
T.eq(session:getStatus(), "draining", "normal close drains its final packet")
|
T.eq(session:getStatus(), "draining", "normal close drains its final packet")
|
||||||
T.eq(session.closed, false, "compatibility closed waits for the FIFO")
|
T.eq(session.closed, false, "compatibility closed waits for the FIFO")
|
||||||
T.eq(session:take("bye").final, true, "final close packet remains observable")
|
T.check(session:take("bye") ~= nil, "final close packet remains observable")
|
||||||
T.eq(session:getStatus(), "closed", "normal drain reaches closed")
|
T.eq(session:getStatus(), "closed", "normal drain reaches closed")
|
||||||
T.eq(transport.closeCount, 1, "transport cleanup runs once")
|
T.eq(transport.closeCount, 1, "transport cleanup runs once")
|
||||||
end
|
end
|
||||||
@@ -182,21 +182,22 @@ do
|
|||||||
} })
|
} })
|
||||||
local session = Session.new(transport, { role = "host", kind = "link" })
|
local session = Session.new(transport, { role = "host", kind = "link" })
|
||||||
session:update()
|
session:update()
|
||||||
local reason = session:getFailure()
|
T.eq(session:getFailure(), nil, "a malformed packet is not a terminal failure")
|
||||||
T.eq(reason, "protocol_error", "malformed packet fails as protocol_error")
|
T.eq(session:getStatus(), "paired", "the session stays usable after a bad packet")
|
||||||
T.eq(session:getStatus(), "draining", "malformed batch drains valid prefix")
|
|
||||||
local messages = session:poll()
|
local messages = session:poll()
|
||||||
T.eq(#messages, 1, "malformed value and untrusted tail are not exposed")
|
T.eq(#messages, 2, "the malformed value is dropped, the rest is delivered")
|
||||||
T.eq(messages[1].sequence, 1, "valid prefix survives malformed packet")
|
T.eq(messages[1].sequence, 1, "packets before the malformed one survive")
|
||||||
T.eq(session:getStatus(), "failed", "protocol drain reaches failed")
|
T.eq(messages[2].sequence, 3, "packets after the malformed one survive")
|
||||||
|
T.eq(session.dropped, 1, "the drop is counted")
|
||||||
end
|
end
|
||||||
|
|
||||||
do
|
do
|
||||||
local transport = fakeTransport({ inbox = { { type = 7 } } })
|
local transport = fakeTransport({ inbox = { { type = 7 } } })
|
||||||
local session = Session.new(transport, { role = "host", kind = "link" })
|
local session = Session.new(transport, { role = "host", kind = "link" })
|
||||||
session:update()
|
session:update()
|
||||||
T.eq(session:getFailure(), "protocol_error",
|
T.eq(session:getFailure(), nil,
|
||||||
"table without string type is a protocol error")
|
"a table without a string type is dropped, not a terminal failure")
|
||||||
|
T.eq(#session:poll(), 0, "...and never reaches the mode")
|
||||||
end
|
end
|
||||||
|
|
||||||
do
|
do
|
||||||
@@ -268,8 +269,9 @@ do
|
|||||||
local receiver = Session.new(receiverNet, { role = "guest", kind = "link" })
|
local receiver = Session.new(receiverNet, { role = "guest", kind = "link" })
|
||||||
senderNet:send(false)
|
senderNet:send(false)
|
||||||
receiver:update()
|
receiver:update()
|
||||||
T.eq(receiver:getFailure(), "protocol_error",
|
T.eq(receiver:getFailure(), nil,
|
||||||
"loopback forwards decoded false to session validation")
|
"a decoded scalar off the loopback is dropped, not fatal")
|
||||||
|
T.eq(#receiver:poll(), 0, "...and never reaches the mode")
|
||||||
end
|
end
|
||||||
|
|
||||||
do
|
do
|
||||||
@@ -284,8 +286,9 @@ do
|
|||||||
}
|
}
|
||||||
local session = Session.new(transport, { role = "guest", kind = "link" })
|
local session = Session.new(transport, { role = "guest", kind = "link" })
|
||||||
session:update()
|
session:update()
|
||||||
T.eq(session:getFailure(), "protocol_error",
|
T.eq(session:getFailure(), nil,
|
||||||
"ENet forwards decoded false to session validation")
|
"a decoded scalar off ENet is dropped, not fatal")
|
||||||
|
T.eq(#session:poll(), 0, "...and never reaches the mode")
|
||||||
end
|
end
|
||||||
|
|
||||||
do
|
do
|
||||||
@@ -294,8 +297,9 @@ do
|
|||||||
T.check(pcall(transport.handleTCPLine, transport, "42"),
|
T.check(pcall(transport.handleTCPLine, transport, "42"),
|
||||||
"TCP control handoff does not index a decoded scalar")
|
"TCP control handoff does not index a decoded scalar")
|
||||||
session:update()
|
session:update()
|
||||||
T.eq(session:getFailure(), "protocol_error",
|
T.eq(session:getFailure(), nil,
|
||||||
"decoded TCP scalar reaches session validation")
|
"a decoded TCP scalar is dropped, not fatal")
|
||||||
|
T.eq(#session:poll(), 0, "...and never reaches the mode")
|
||||||
end
|
end
|
||||||
|
|
||||||
do
|
do
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
-- The museum 1F ticket clerk's take-your-time/not-enough-money/come-again
|
||||||
|
-- lines used to be bare English literals, invisible to game.data.text no
|
||||||
|
-- matter what a translation mod put there. tests/engine/museum_money_box_
|
||||||
|
-- bug1335.lua only ever runs with an empty game.data.text, so it can't
|
||||||
|
-- tell a properly-wired t._Key or "..." fallback apart from a literal that
|
||||||
|
-- never looked at t at all -- every assertion there passes either way.
|
||||||
|
-- This test populates game.data.text with translated values and checks
|
||||||
|
-- they actually reach the pushed TextBox, for all three lines.
|
||||||
|
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||||
|
|
||||||
|
local T = require("tests.harness")
|
||||||
|
|
||||||
|
package.loaded["src.render.TextBox"] = {
|
||||||
|
new = function(_, text, onDone, opts)
|
||||||
|
return { text = text, onDone = onDone, opts = opts }
|
||||||
|
end,
|
||||||
|
}
|
||||||
|
|
||||||
|
local M = assert(loadfile("data/scripts/story2.lua"))()
|
||||||
|
local clerk = M.MUSEUM_1F.talk.TEXT_MUSEUM1F_SCIENTIST1
|
||||||
|
|
||||||
|
local TRANSLATED = {
|
||||||
|
_Museum1FScientist1TakePlentyOfTimeText = "Prends ton temps\net profite bien !",
|
||||||
|
_Museum1FScientist1DontHaveEnoughMoneyText = "Tu n'as pas assez\nd'argent.",
|
||||||
|
_Museum1FScientist1ComeAgainText = "Reviens vite !",
|
||||||
|
}
|
||||||
|
|
||||||
|
local pushed
|
||||||
|
local function mkGame(cash)
|
||||||
|
pushed = {}
|
||||||
|
return {
|
||||||
|
data = { text = TRANSLATED },
|
||||||
|
save = { money = cash, flags = {} },
|
||||||
|
stack = { push = function(_, box) pushed[#pushed + 1] = box end },
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
-- already ticketed: take-your-time line
|
||||||
|
local g = mkGame(3000)
|
||||||
|
g.save.flags.EVENT_BOUGHT_MUSEUM_TICKET = true
|
||||||
|
clerk(g, nil, nil, function() end)
|
||||||
|
T.eq(pushed[1].text, TRANSLATED._Museum1FScientist1TakePlentyOfTimeText,
|
||||||
|
"an existing ticket holder gets the translated take-your-time line")
|
||||||
|
|
||||||
|
-- YES but short on cash: not-enough-money line
|
||||||
|
g = mkGame(20)
|
||||||
|
clerk(g, nil, nil, function() end)
|
||||||
|
pushed[1].opts.choice(true)
|
||||||
|
T.eq(pushed[2].text, TRANSLATED._Museum1FScientist1DontHaveEnoughMoneyText,
|
||||||
|
"short on cash opens the translated not-enough-money box")
|
||||||
|
|
||||||
|
-- NO: come-again line
|
||||||
|
g = mkGame(3000)
|
||||||
|
clerk(g, nil, nil, function() end)
|
||||||
|
pushed[1].opts.choice(false)
|
||||||
|
T.eq(pushed[2].text, TRANSLATED._Museum1FScientist1ComeAgainText,
|
||||||
|
"declining opens the translated come-again box")
|
||||||
|
|
||||||
|
-- unpopulated game.data.text still falls back to the English literal
|
||||||
|
g = { data = { text = {} }, save = { money = 3000, flags = {} },
|
||||||
|
stack = { push = function(_, box) pushed[#pushed + 1] = box end } }
|
||||||
|
pushed = {}
|
||||||
|
g.save.flags.EVENT_BOUGHT_MUSEUM_TICKET = true
|
||||||
|
clerk(g, nil, nil, function() end)
|
||||||
|
T.check(tostring(pushed[1].text):find("Take your time", 1, true) ~= nil,
|
||||||
|
"an empty catalog still falls back to the English take-your-time line")
|
||||||
|
|
||||||
|
T.finish("museum_1f_clerk_translation_test")
|
||||||
@@ -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")
|
||||||
@@ -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()
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
Reference in New Issue
Block a user