Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e24f812475 | |||
| fddf619ed2 | |||
| bf83509ef2 | |||
| 6c05b854c4 | |||
| 2baafab027 | |||
| 813f9d959b | |||
| fba87f028c | |||
| cb4647daf0 | |||
| 93374fbbbb | |||
| abe176b26c | |||
| 085180992d | |||
| 9984958193 | |||
| 5871469002 | |||
| 302b2c9591 | |||
| 67a170fd6e | |||
| 66079686fc | |||
| cc5ff987ac | |||
| f8ba51636b |
@@ -512,8 +512,9 @@ gains a field instead of the name gaining a prefix.
|
|||||||
id under Gen 1's `name` key, which is the one payload difference the
|
id under Gen 1's `name` key, which is the one payload difference the
|
||||||
numeric flag space forces.
|
numeric flag space forces.
|
||||||
- *Menus (`src/ui/gen2/`):* `ui.start_menu.items`, `ui.title_menu.items`,
|
- *Menus (`src/ui/gen2/`):* `ui.start_menu.items`, `ui.title_menu.items`,
|
||||||
`ui.options.rows`, `ui.party.submenu`, `ui.naming.grid`, `ui.pc.items`,
|
`ui.options.rows`, `ui.party.submenu`, `ui.party.grid_navigation`,
|
||||||
`ui.list_menu`, `transition.style`. `ui.list_menu` covers Gold's script
|
`ui.naming.grid`, `ui.pc.items`, `ui.list_menu`, `transition.style`.
|
||||||
|
`ui.list_menu` covers Gold's script
|
||||||
menus (`ScriptMenu.lua`); the `Chrome.List` widget the START and title
|
menus (`ScriptMenu.lua`); the `Chrome.List` widget the START and title
|
||||||
menus draw with does not raise it yet, so those two are composed through
|
menus draw with does not raise it yet, so those two are composed through
|
||||||
their own hooks only.
|
their own hooks only.
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ Features intentionally added beyond the original Pokémon Red, Blue, and Yellow
|
|||||||
* **Mobile touch controls** with editable layouts, vibration, and orientation settings
|
* **Mobile touch controls** with editable layouts, vibration, and orientation settings
|
||||||
* **Touch skins** in RetroArch overlay format, with bezel art, per-button press states, and Super Game Boy borders
|
* **Touch skins** in RetroArch overlay format, with bezel art, per-button press states, and Super Game Boy borders
|
||||||
* **Pokédex diploma and printer image exports**
|
* **Pokédex diploma and printer image exports**
|
||||||
* **Mod download counts** from the index feed, with Most-downloaded and Trending sorts
|
|
||||||
|
|
||||||
## Gen 2 Specifics
|
## Gen 2 Specifics
|
||||||
|
|
||||||
|
|||||||
@@ -4,17 +4,23 @@ A **skin** replaces the on-screen controls wholesale: a bezel image, a
|
|||||||
control layout, and the rectangle the Game Boy screen is drawn into. Engine:
|
control layout, and the rectangle the Game Boy screen is drawn into. Engine:
|
||||||
`src/core/TouchSkin.lua` (model, parsers, zip export), `src/core/TouchControls.lua`
|
`src/core/TouchSkin.lua` (model, parsers, zip export), `src/core/TouchControls.lua`
|
||||||
(draw and input), `src/render/Renderer.lua` (the screen viewport),
|
(draw and input), `src/render/Renderer.lua` (the screen viewport),
|
||||||
|
`src/core/DeltaSkin.lua` (Delta `.deltaskin` import and export),
|
||||||
`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_ux.lua`,
|
||||||
`tests/engine/skin_studio_image_import.lua`,
|
`tests/engine/skin_studio_image_import.lua`,
|
||||||
`tests/engine/launcher_skins_tab.lua`.
|
`tests/engine/skin_format_import_test.lua`,
|
||||||
|
`tests/engine/launcher_skins_tab.lua`,
|
||||||
|
`tests/engine/launcher_skins_ux.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
|
||||||
opens the studio. `options.touchControls.skin` holds the folder name.
|
opens the studio. `options.touchControls.skin` holds the folder name.
|
||||||
|
|
||||||
## Formats
|
## Formats
|
||||||
|
|
||||||
Two load. `skin.lua` wins when a folder has both.
|
Three load: the native `skin.lua`, a RetroArch overlay `.cfg`, and a Delta
|
||||||
|
`.deltaskin`. `skin.lua` wins when a folder has more than one. The launcher
|
||||||
|
badges each installed skin with the format it was read from.
|
||||||
|
|
||||||
**RetroArch overlay `.cfg`.** The libretro `common-overlays` collection loads
|
**RetroArch overlay `.cfg`.** The libretro `common-overlays` collection loads
|
||||||
as-is. Supported keys:
|
as-is. Supported keys:
|
||||||
@@ -41,6 +47,15 @@ Hitboxes are `radial` or `rect`. Pipe-separated binds (`left|down`) are one
|
|||||||
control that holds both. A `nul` desc is decoration: it draws and never
|
control that holds both. A `nul` desc is decoration: it draws and never
|
||||||
captures a touch.
|
captures a touch.
|
||||||
|
|
||||||
|
The area desc types are expanded rather than ignored: `dpad_area`,
|
||||||
|
`abxy_area`, `analog_left` and `analog_right` each become eight hitboxes over
|
||||||
|
the same area, one per 45 degree sector measured from its centre, the way
|
||||||
|
RetroArch resolves them: there is no neutral middle, and the four corner
|
||||||
|
sectors fire two inputs. Any `_up` / `_down` / `_left` / `_right` override and
|
||||||
|
the per-side reach are honoured, and the desc's own art is kept as decoration
|
||||||
|
over the top. Exporting a cfg folds the eight back into the one area desc they
|
||||||
|
came from. `retrok_<key>` is a keyboard bind.
|
||||||
|
|
||||||
Alpha follows RetroArch (`input_driver.c`, `input_overlay_post_poll`): every
|
Alpha follows RetroArch (`input_driver.c`, `input_overlay_post_poll`): every
|
||||||
image sits at the overlay opacity, and a pressed control's image swaps to
|
image sits at the overlay opacity, and a pressed control's image swaps to
|
||||||
`opacity * alpha_mod`. So `alpha_mod` above 1 lights a control up and below 1
|
`opacity * alpha_mod`. So `alpha_mod` above 1 lights a control up and below 1
|
||||||
@@ -71,6 +86,28 @@ return {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Delta `.deltaskin`.** A zip (any wrapping folder is stripped) holding an
|
||||||
|
`info.json` plus its art. The `representations` tree is walked
|
||||||
|
device / display type / orientation, and every orientation that exists becomes
|
||||||
|
a page; `page.orient` is the orientation key, so a portrait/landscape pair
|
||||||
|
auto-rotates like a RetroArch one. Item `frame` rects are top-left plus size in
|
||||||
|
`mappingSize` points and are converted to the native centre plus half extent;
|
||||||
|
`extendedEdges` merge per key into the reach fields; `mask: "circle"` becomes a
|
||||||
|
radial hitbox. A `dpad` or `thumbstick` item expands into the 3x3 grid, so the
|
||||||
|
corners fire two directions. `screens[1].outputFrame` (or the legacy
|
||||||
|
`gameScreenFrame`) becomes the screen cutout, and the skin stretches to the
|
||||||
|
window the way Delta does rather than letterboxing. Host functions map to
|
||||||
|
engine hotkeys: `menu` to `menu_toggle`, `fastForward` to
|
||||||
|
`hold_fast_forward`, `toggleFastForward` to `toggle_fast_forward`;
|
||||||
|
`quickSave` and `quickLoad` have nothing to bind to and drop to decoration.
|
||||||
|
Both `com.rileytestut.delta.game.*` and Manic's `public.aoshuang.game.*`
|
||||||
|
identifiers are accepted, and a non Game Boy system warns instead of failing.
|
||||||
|
|
||||||
|
PDF artwork is the one thing that does not come across: Delta's own templates
|
||||||
|
are all-PDF and this engine has no rasterizer, so such a skin is refused with
|
||||||
|
the message asking for a PNG version. GBA4iOS `.gbcskin` / `.gbaskin` files are
|
||||||
|
an older, incompatible schema and are refused by name.
|
||||||
|
|
||||||
## Bindable actions
|
## Bindable actions
|
||||||
|
|
||||||
The eight Game Boy buttons: `a`, `b`, `start`, `select`, `up`, `down`,
|
The eight Game Boy buttons: `a`, `b`, `start`, `select`, `up`, `down`,
|
||||||
@@ -119,10 +156,22 @@ them. Anything that binds a button still follows the usual mobile /
|
|||||||
|
|
||||||
## Installing
|
## Installing
|
||||||
|
|
||||||
Drop a folder or a `.zip` into `skins/` in the save directory, or drop a zip on
|
Four roads, all of them landing in `skins/` in the save directory:
|
||||||
the launcher window while the Skins tab is open. A zip is mounted in place, so
|
|
||||||
there is nothing to unpack. The folder needs one `skin.lua` or `.cfg`
|
* **Import** on the Skins tab opens the host file picker for a `.zip` or a
|
||||||
(`overlay.cfg` is preferred when there are several) and the images it names.
|
`.deltaskin`.
|
||||||
|
* **Paste a skin link** in the tab's URL row, then **Add**. The download runs
|
||||||
|
on the fetch pool (`src/net/Fetch.lua`), so the launcher stays live, and the
|
||||||
|
row shows a spinner until it lands. A link to a bare `overlay.cfg` is wrapped
|
||||||
|
into an archive on the way in. This is the road that works on a phone, where
|
||||||
|
there is no file picker to speak of.
|
||||||
|
* Drop a `.zip` or `.deltaskin` on the launcher window while the Skins tab is
|
||||||
|
open.
|
||||||
|
* Copy a folder or archive into `skins/` by hand.
|
||||||
|
|
||||||
|
An archive is mounted in place, so there is nothing to unpack. It needs one
|
||||||
|
`skin.lua`, `.cfg` (`overlay.cfg` is preferred when there are several) or
|
||||||
|
`info.json`, plus the images it names.
|
||||||
|
|
||||||
Two ship bundled, both from libretro's `common-overlays` under CC-BY-4.0:
|
Two ship bundled, both from libretro's `common-overlays` under CC-BY-4.0:
|
||||||
|
|
||||||
@@ -157,23 +206,40 @@ The Super Game Boy preset locks the viewport to the real screen window,
|
|||||||
160x144 at (48,40), so an SGB border cannot be drawn out of register.
|
160x144 at (48,40), so an SGB border cannot be drawn out of register.
|
||||||
|
|
||||||
**Editing.** Click a control to select it, drag to move, eight handles to
|
**Editing.** Click a control to select it, drag to move, eight handles to
|
||||||
resize. X / Y / W / H are in canvas pixels, so a control can be typed to the
|
resize. Arrow keys nudge the selection one canvas pixel, shift-arrow ten. While
|
||||||
coordinate its art was drawn at. Bind, hitbox shape, hit reach and idle and
|
a control is dragged it snaps to the centres and edges of the other controls
|
||||||
|
and of the page itself when it comes within a few pixels, and the guide it
|
||||||
|
snapped to is drawn. X / Y / W / H are in canvas pixels, so a control can be
|
||||||
|
typed to the coordinate its art was drawn at. **Back** and **Front** move the
|
||||||
|
selection through the draw order. Bind, hitbox shape, hit reach and idle and
|
||||||
pressed images are per control; the bezel, the pages and the screen cutout are
|
pressed images are per control; the bezel, the pages and the screen cutout are
|
||||||
per page. The cutout is itself a draggable element with a 10:9 lock.
|
per page. The cutout is itself a draggable element with a 10:9 lock.
|
||||||
|
|
||||||
|
**Bind** opens a grid of every bind the engine understands: the eight Game Boy
|
||||||
|
buttons, the diagonal pairs, every hotkey, a few `key:` entries, and
|
||||||
|
decoration. The COMBINE chips at the top toggle one part at a time, which is
|
||||||
|
how a pipe bind like `left|down` is built without typing it.
|
||||||
|
|
||||||
|
**Undo** and **Redo** in the top bar cover every edit (ctrl+Z / ctrl+Y, or
|
||||||
|
`u` / shift+`u` without a keyboard modifier). The stack holds the last 50
|
||||||
|
actions. `L` toggles the bind captions drawn on the canvas.
|
||||||
|
|
||||||
Each page can **Lock** to portrait or landscape. With **Match canvas** on
|
Each page can **Lock** to portrait or landscape. With **Match canvas** on
|
||||||
(the default), Next page picks a matching mock device and the canvas preset
|
(the default), the page list picks a matching mock device and the canvas preset
|
||||||
picks a matching page. Turn Match canvas off to look at a portrait page on a
|
picks a matching page. Turn Match canvas off to look at a portrait page on a
|
||||||
landscape device.
|
landscape device. **Pages** opens the page list, where a page is selected,
|
||||||
|
renamed or deleted.
|
||||||
|
|
||||||
|
Starting a new skin, opening another one or closing the studio with unsaved
|
||||||
|
edits prompts first, with Save first / Discard / Cancel.
|
||||||
|
|
||||||
A RetroArch overlay whose pages are already named portrait / landscape
|
A RetroArch overlay whose pages are already named portrait / landscape
|
||||||
(the auto-rotate convention) locks those pages and turns Match canvas on
|
(the auto-rotate convention) locks those pages and turns Match canvas on
|
||||||
when you open it. You do not have to click Lock first.
|
when you open it. You do not have to click Lock first.
|
||||||
|
|
||||||
**Art.** The **Bezel**, **Idle art** and **Pressed art** rows cycle through the
|
**Art.** The **Bezel**, **Idle art** and **Pressed art** rows open a
|
||||||
images already in the skin folder; the **Import** button beside each one opens
|
thumbnail grid of the images already in the skin folder, with `(none)` first;
|
||||||
the host file picker (`src/core/FilePicker.lua`: osascript, PowerShell,
|
the **Import** button there and beside each row 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
|
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
|
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
|
window does the same for whichever slot was last touched. A new bezel does not
|
||||||
@@ -185,11 +251,23 @@ buttons and the footer reports what is held. **Play** saves the skin, selects
|
|||||||
it, and boots the game with it.
|
it, and boots the game with it.
|
||||||
|
|
||||||
**Saving.** **Save** writes `skins/<name>/skin.lua` and copies every image the
|
**Saving.** **Save** writes `skins/<name>/skin.lua` and copies every image the
|
||||||
skin names, so the folder stands alone. **Export** packs it as one zip
|
skin names, so the folder stands alone. **Export** offers three formats, and
|
||||||
(`src/core/SkinZip.lua`, store-only) carrying the native `skin.lua`, the
|
the Skins tab's gear offers the same three for any installed skin:
|
||||||
images, and the original `.cfg` when it came from one. An exported skin drops
|
|
||||||
straight back into `skins/` and still opens in RetroArch.
|
| Export | Contents |
|
||||||
|
| --- | --- |
|
||||||
|
| gen1recomp `.zip` | the native `skin.lua`, the images, and the original `.cfg` when it came from one |
|
||||||
|
| RetroArch `.zip` | an `overlay.cfg` generated from the model, plus the images |
|
||||||
|
| Delta `.deltaskin` | an `info.json` generated from the model, plus the images |
|
||||||
|
|
||||||
|
All three are written store-only (`src/core/SkinZip.lua`) into `skins/_export/`
|
||||||
|
in the save directory, which is outside the folder the skin list scans, so an
|
||||||
|
export can never shadow the skin it came from. The notice names the full path
|
||||||
|
so a phone can find the file in its own file manager. On desktop **Show the
|
||||||
|
exported file** opens that folder.
|
||||||
|
|
||||||
## Not implemented
|
## Not implemented
|
||||||
|
|
||||||
RetroArch's `analog_*`, `dpad_area`, `abxy_area` and `retrok_*` desc types.
|
Delta skins whose art is PDF only. Rasterizing them needs a PDF renderer this
|
||||||
|
engine does not carry, so they are refused with a message rather than imported
|
||||||
|
half-drawn.
|
||||||
|
|||||||
@@ -291,6 +291,78 @@ function closeSkinStudio()
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
local function makeLauncher()
|
||||||
|
local RomImporter = require("src.import.RomImporter")
|
||||||
|
local forceImport = os.getenv("POKEPORT_FORCE_IMPORT") == "1"
|
||||||
|
return RomImporter.new(function(version)
|
||||||
|
Importer = nil
|
||||||
|
bootGame(version)
|
||||||
|
end, {
|
||||||
|
launcher = true,
|
||||||
|
forceImport = forceImport,
|
||||||
|
onEditSave = openEditor,
|
||||||
|
onEditTouchControls = openTouchControlsEditor,
|
||||||
|
onOpenSkinStudio = require("src.ui.SkinStudio").available_desktop()
|
||||||
|
and openSkinStudio or nil,
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
|
local function returnToLauncher()
|
||||||
|
if not Game then return end
|
||||||
|
|
||||||
|
pcall(function() require("src.core.Music").stop() end)
|
||||||
|
pcall(function() require("src.core.Sound").stop() end)
|
||||||
|
if package.loaded["src.core.ChipAudio"] then
|
||||||
|
pcall(package.loaded["src.core.ChipAudio"].shutdown)
|
||||||
|
end
|
||||||
|
if package.loaded["src.core.DiscordPresence"] then
|
||||||
|
pcall(package.loaded["src.core.DiscordPresence"].shutdown)
|
||||||
|
end
|
||||||
|
if package.loaded["src.core.gen2.Clock"] then
|
||||||
|
pcall(package.loaded["src.core.gen2.Clock"].shutdown)
|
||||||
|
end
|
||||||
|
if package.loaded["src.net.Gen1Tls"] then
|
||||||
|
pcall(package.loaded["src.net.Gen1Tls"].shutdown)
|
||||||
|
end
|
||||||
|
if love.audio and love.audio.stop then
|
||||||
|
pcall(love.audio.stop)
|
||||||
|
end
|
||||||
|
|
||||||
|
local GameVersion = require("src.core.GameVersion")
|
||||||
|
local currentVersion = GameVersion.get()
|
||||||
|
if currentVersion then
|
||||||
|
require("src.import.CacheFs").unmountVersion(currentVersion)
|
||||||
|
end
|
||||||
|
require("src.core.Data"):unloadGenerated()
|
||||||
|
|
||||||
|
local Runtime = require("src.mods.Runtime")
|
||||||
|
if Runtime.reset then
|
||||||
|
Runtime.reset()
|
||||||
|
end
|
||||||
|
|
||||||
|
Game = nil
|
||||||
|
autopilot = nil
|
||||||
|
driverCo = nil
|
||||||
|
|
||||||
|
local Input = require("src.core.Input")
|
||||||
|
local TouchControls = require("src.core.TouchControls")
|
||||||
|
Input:reset()
|
||||||
|
TouchControls:reset()
|
||||||
|
|
||||||
|
require("src.core.Orientation").applyOptions(
|
||||||
|
require("src.core.SaveData").loadOptions())
|
||||||
|
|
||||||
|
local preload = require("src.mods.LauncherMods").translationStrings()
|
||||||
|
if preload then require("src.core.Strings").load({ strings = preload }) end
|
||||||
|
|
||||||
|
if love.window and love.window.setTitle then
|
||||||
|
local Version = require("src.core.Version")
|
||||||
|
love.window.setTitle(Version.title("Gen 1 Recompilation Project"))
|
||||||
|
end
|
||||||
|
|
||||||
|
Importer = makeLauncher()
|
||||||
|
end
|
||||||
|
|
||||||
function bootGame(version)
|
function bootGame(version)
|
||||||
-- The launcher hands us the chosen game (Red / Blue / Yellow / Gold);
|
-- The launcher hands us the chosen game (Red / Blue / Yellow / Gold);
|
||||||
-- scripted and headless runs fall back to POKEPORT_VERSION, then Red.
|
-- scripted and headless runs fall back to POKEPORT_VERSION, then Red.
|
||||||
@@ -382,7 +454,7 @@ function love.load(args)
|
|||||||
|
|
||||||
-- Apply the persisted Android orientation lock (#592) before the launcher
|
-- Apply the persisted Android orientation lock (#592) before the launcher
|
||||||
-- shows: SDL created the window with no orientation hint, so without this
|
-- shows: SDL created the window with no orientation hint, so without this
|
||||||
-- the launcher would rotate freely until Game:applyOptions runs at boot.
|
-- the launcher would rotate freely until options are applied at boot.
|
||||||
-- No-op on desktop / iOS / when options.lua does not exist yet.
|
-- No-op on desktop / iOS / when options.lua does not exist yet.
|
||||||
require("src.core.Orientation").applyOptions(
|
require("src.core.Orientation").applyOptions(
|
||||||
require("src.core.SaveData").loadOptions())
|
require("src.core.SaveData").loadOptions())
|
||||||
@@ -442,8 +514,8 @@ function love.load(args)
|
|||||||
-- (#767) only pays off if something fills that catalog this early, and no
|
-- (#767) only pays off if something fills that catalog this early, and no
|
||||||
-- restart could: the ordering is the same on every launch. Read the
|
-- restart could: the ordering is the same on every launch. Read the
|
||||||
-- enabled mods' string catalogs -- data only, no entry chunk -- so a
|
-- enabled mods' string catalogs -- data only, no entry chunk -- so a
|
||||||
-- translation reaches the launcher too. Game:load replaces this with the
|
-- translation reaches the launcher too. The active game's loader replaces
|
||||||
-- real merged catalog once a version boots.
|
-- this with the real merged catalog once a version boots.
|
||||||
do
|
do
|
||||||
local preload = require("src.mods.LauncherMods").translationStrings()
|
local preload = require("src.mods.LauncherMods").translationStrings()
|
||||||
if preload then require("src.core.Strings").load({ strings = preload }) end
|
if preload then require("src.core.Strings").load({ strings = preload }) end
|
||||||
@@ -484,17 +556,7 @@ function love.load(args)
|
|||||||
-- by its SHA-1 (GameVersion.forSha1); pressing Play boots that game (Gold
|
-- by its SHA-1 (GameVersion.forSha1); pressing Play boots that game (Gold
|
||||||
-- goes to its own service owner, src/core/Game2.lua -- docs/gold-phase1.md).
|
-- goes to its own service owner, src/core/Game2.lua -- docs/gold-phase1.md).
|
||||||
-- Edit on a save row opens the bundled editor on that slot (openEditor).
|
-- Edit on a save row opens the bundled editor on that slot (openEditor).
|
||||||
Importer = RomImporter.new(function(version)
|
Importer = makeLauncher()
|
||||||
Importer = nil
|
|
||||||
bootGame(version)
|
|
||||||
end, {
|
|
||||||
launcher = true,
|
|
||||||
forceImport = forceImport,
|
|
||||||
onEditSave = openEditor,
|
|
||||||
onEditTouchControls = openTouchControlsEditor,
|
|
||||||
onOpenSkinStudio = require("src.ui.SkinStudio").available_desktop()
|
|
||||||
and openSkinStudio or nil,
|
|
||||||
})
|
|
||||||
end
|
end
|
||||||
|
|
||||||
function love.update(dt)
|
function love.update(dt)
|
||||||
@@ -827,6 +889,27 @@ function love.handlers.audioreset()
|
|||||||
if Sound then pcall(Sound.onDeviceReset) end
|
if Sound then pcall(Sound.onDeviceReset) end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
function love.handlers.intent_game(version)
|
||||||
|
if type(version) ~= "string" or version == "" then return end
|
||||||
|
version = version:lower():gsub("^%s+", ""):gsub("%s+$", "")
|
||||||
|
local GameVersion = require("src.core.GameVersion")
|
||||||
|
if GameVersion.VERSIONS and not GameVersion.VERSIONS[version] then return end
|
||||||
|
|
||||||
|
local RomImporter = require("src.import.RomImporter")
|
||||||
|
if not RomImporter.isReady(version) then return end
|
||||||
|
|
||||||
|
local currentVersion = GameVersion.get()
|
||||||
|
if Game and currentVersion == version then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
if Game then
|
||||||
|
returnToLauncher()
|
||||||
|
end
|
||||||
|
Importer = nil
|
||||||
|
bootGame(version)
|
||||||
|
end
|
||||||
|
|
||||||
function love.touchpressed(id, x, y, dx, dy, pressure)
|
function love.touchpressed(id, x, y, dx, dy, pressure)
|
||||||
if editorMode then
|
if editorMode then
|
||||||
-- iOS synthesizes mousepressed for the primary touch; forwarding here
|
-- iOS synthesizes mousepressed for the primary touch; forwarding here
|
||||||
@@ -1032,11 +1115,16 @@ function love.quit()
|
|||||||
-- docs/modding.md's core.quit_to_launcher entry) may veto returning to
|
-- docs/modding.md's core.quit_to_launcher entry) may veto returning to
|
||||||
-- this Lua launcher via that hook. Vanilla behavior (used when no mod
|
-- this Lua launcher via that hook. Vanilla behavior (used when no mod
|
||||||
-- claims the hook) is exactly the condition below.
|
-- claims the hook) is exactly the condition below.
|
||||||
|
local isAndroid = (love.system and love.system.getOS and love.system.getOS() == "Android")
|
||||||
local wouldReturnToLauncher = PlatformHooks.quitToLauncher(function()
|
local wouldReturnToLauncher = PlatformHooks.quitToLauncher(function()
|
||||||
return Game and not Importer and not quitToLauncher and not scripted
|
return Game and not Importer and not quitToLauncher and not scripted
|
||||||
and not launchedIntoGame
|
and (isAndroid or not launchedIntoGame)
|
||||||
end)
|
end)
|
||||||
if wouldReturnToLauncher then
|
if wouldReturnToLauncher then
|
||||||
|
if isAndroid then
|
||||||
|
returnToLauncher()
|
||||||
|
return true -- abort this quit; the restart lands back in the launcher
|
||||||
|
end
|
||||||
quitToLauncher = true
|
quitToLauncher = true
|
||||||
-- Tell the fresh boot to ignore any boot-straight-into-a-game option this
|
-- Tell the fresh boot to ignore any boot-straight-into-a-game option this
|
||||||
-- once, so the restart really does land in the launcher (#887). A failed
|
-- once, so the restart really does land in the launcher (#887). A failed
|
||||||
|
|||||||
@@ -29,7 +29,8 @@
|
|||||||
|
|
||||||
<application
|
<application
|
||||||
android:allowBackup="true"
|
android:allowBackup="true"
|
||||||
android:icon="@drawable/love"
|
android:icon="@mipmap/ic_launcher"
|
||||||
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
android:label="${NAME}" >
|
android:label="${NAME}" >
|
||||||
<meta-data
|
<meta-data
|
||||||
android:name="android.allow_multiple_resumed_activities"
|
android:name="android.allow_multiple_resumed_activities"
|
||||||
@@ -39,7 +40,7 @@
|
|||||||
android:exported="true"
|
android:exported="true"
|
||||||
android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout|keyboard|keyboardHidden|navigation"
|
android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout|keyboard|keyboardHidden|navigation"
|
||||||
android:label="${NAME}"
|
android:label="${NAME}"
|
||||||
android:launchMode="singleInstance"
|
android:launchMode="singleTask"
|
||||||
android:screenOrientation="${ORIENTATION}"
|
android:screenOrientation="${ORIENTATION}"
|
||||||
android:resizeableActivity="false"
|
android:resizeableActivity="false"
|
||||||
android:theme="@android:style/Theme.NoTitleBar.Fullscreen" >
|
android:theme="@android:style/Theme.NoTitleBar.Fullscreen" >
|
||||||
|
|||||||
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 6.5 KiB |
|
After Width: | Height: | Size: 6.3 KiB |
|
After Width: | Height: | Size: 6.4 KiB |
|
After Width: | Height: | Size: 6.4 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 3.5 KiB |
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 3.5 KiB |
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 9.9 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 32 KiB |
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@color/ic_launcher_background" />
|
||||||
|
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||||
|
</adaptive-icon>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@color/ic_launcher_background" />
|
||||||
|
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||||
|
</adaptive-icon>
|
||||||
@@ -3,4 +3,9 @@
|
|||||||
<color name="colorPrimary">#3F51B5</color>
|
<color name="colorPrimary">#3F51B5</color>
|
||||||
<color name="colorPrimaryDark">#303F9F</color>
|
<color name="colorPrimaryDark">#303F9F</color>
|
||||||
<color name="colorAccent">#FF4081</color>
|
<color name="colorAccent">#FF4081</color>
|
||||||
|
<color name="ic_launcher_background">#FFFFFF</color>
|
||||||
|
<color name="shortcut_red">#E53935</color>
|
||||||
|
<color name="shortcut_blue">#1E88E5</color>
|
||||||
|
<color name="shortcut_yellow">#FDD835</color>
|
||||||
|
<color name="shortcut_gold">#D4AF37</color>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -48,6 +48,7 @@
|
|||||||
#include "common/Module.h"
|
#include "common/Module.h"
|
||||||
#include "audio/Audio.h"
|
#include "audio/Audio.h"
|
||||||
#include "audio/openal/Audio.h"
|
#include "audio/openal/Audio.h"
|
||||||
|
#include "event/Event.h"
|
||||||
|
|
||||||
namespace love
|
namespace love
|
||||||
{
|
{
|
||||||
@@ -282,6 +283,70 @@ bool restartApp()
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool updateAppShortcuts(const std::vector<std::string> &versions)
|
||||||
|
{
|
||||||
|
JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv();
|
||||||
|
jclass activity = env->FindClass("org/love2d/android/GameActivity");
|
||||||
|
if (activity == nullptr)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
jmethodID method = env->GetStaticMethodID(activity, "updateAppShortcuts", "([Ljava/lang/String;)Z");
|
||||||
|
if (method == nullptr)
|
||||||
|
{
|
||||||
|
env->ExceptionClear();
|
||||||
|
env->DeleteLocalRef(activity);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
jclass stringClass = env->FindClass("java/lang/String");
|
||||||
|
jobjectArray array = env->NewObjectArray((jsize) versions.size(), stringClass, nullptr);
|
||||||
|
for (size_t i = 0; i < versions.size(); ++i)
|
||||||
|
{
|
||||||
|
jstring jstr = env->NewStringUTF(versions[i].c_str());
|
||||||
|
env->SetObjectArrayElement(array, (jsize) i, jstr);
|
||||||
|
env->DeleteLocalRef(jstr);
|
||||||
|
}
|
||||||
|
|
||||||
|
jboolean result = env->CallStaticBooleanMethod(activity, method, array);
|
||||||
|
|
||||||
|
env->DeleteLocalRef(array);
|
||||||
|
env->DeleteLocalRef(stringClass);
|
||||||
|
env->DeleteLocalRef(activity);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string getLaunchGame()
|
||||||
|
{
|
||||||
|
JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv();
|
||||||
|
jclass activity = env->FindClass("org/love2d/android/GameActivity");
|
||||||
|
if (activity == nullptr)
|
||||||
|
return "";
|
||||||
|
|
||||||
|
jmethodID method = env->GetStaticMethodID(activity, "getLaunchGame", "()Ljava/lang/String;");
|
||||||
|
if (method == nullptr)
|
||||||
|
{
|
||||||
|
env->ExceptionClear();
|
||||||
|
env->DeleteLocalRef(activity);
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
jstring jgame = (jstring) env->CallStaticObjectMethod(activity, method);
|
||||||
|
if (jgame == nullptr)
|
||||||
|
{
|
||||||
|
env->DeleteLocalRef(activity);
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
const char *str = env->GetStringUTFChars(jgame, nullptr);
|
||||||
|
std::string result = (str != nullptr) ? str : "";
|
||||||
|
if (str != nullptr)
|
||||||
|
env->ReleaseStringUTFChars(jgame, str);
|
||||||
|
|
||||||
|
env->DeleteLocalRef(jgame);
|
||||||
|
env->DeleteLocalRef(activity);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
bool httpDownload(const char *url, const char *destPath, const char *userAgent, const char *accept)
|
bool httpDownload(const char *url, const char *destPath, const char *userAgent, const char *accept)
|
||||||
{
|
{
|
||||||
if (url == nullptr || destPath == nullptr)
|
if (url == nullptr || destPath == nullptr)
|
||||||
@@ -378,6 +443,104 @@ bool httpPost(const char *url, const char *body, int bodyLen, const char *conten
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool httpRequest(const char *url, const char *method,
|
||||||
|
const char *const *headerPairs, int headerPairCount,
|
||||||
|
const char *body, int bodyLen, const char *userAgent, std::string &out)
|
||||||
|
{
|
||||||
|
out.clear();
|
||||||
|
if (url == nullptr)
|
||||||
|
return false;
|
||||||
|
if (headerPairCount < 0 || (headerPairCount > 0 && headerPairs == nullptr))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv();
|
||||||
|
// Same resolution rule as httpDownload: the activity's own class via
|
||||||
|
// SDL_AndroidGetActivity, never FindClass for an app class -- save sync
|
||||||
|
// runs on a love.thread worker, whose class loader cannot see them.
|
||||||
|
jobject activityObj = (jobject) SDL_AndroidGetActivity();
|
||||||
|
if (activityObj == nullptr)
|
||||||
|
return false;
|
||||||
|
jclass activity = env->GetObjectClass(activityObj);
|
||||||
|
env->DeleteLocalRef(activityObj);
|
||||||
|
|
||||||
|
// Old APK / new liblove skew: report "no transport" instead of aborting
|
||||||
|
// on a missing method (#597).
|
||||||
|
jmethodID method_id = env->GetStaticMethodID(activity, "httpRequest",
|
||||||
|
"(Ljava/lang/String;Ljava/lang/String;[Ljava/lang/String;[BLjava/lang/String;)[B");
|
||||||
|
if (method_id == nullptr)
|
||||||
|
{
|
||||||
|
env->ExceptionClear();
|
||||||
|
env->DeleteLocalRef(activity);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
jobjectArray jheaders = nullptr;
|
||||||
|
if (headerPairCount > 0)
|
||||||
|
{
|
||||||
|
// java/lang/String, unlike an app class, resolves from any thread.
|
||||||
|
jclass stringClass = env->FindClass("java/lang/String");
|
||||||
|
if (stringClass == nullptr)
|
||||||
|
{
|
||||||
|
env->ExceptionClear();
|
||||||
|
env->DeleteLocalRef(activity);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
jheaders = env->NewObjectArray((jsize) headerPairCount, stringClass, nullptr);
|
||||||
|
env->DeleteLocalRef(stringClass);
|
||||||
|
if (jheaders == nullptr)
|
||||||
|
{
|
||||||
|
env->ExceptionClear();
|
||||||
|
env->DeleteLocalRef(activity);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
for (int i = 0; i < headerPairCount; i++)
|
||||||
|
{
|
||||||
|
jstring field = env->NewStringUTF(headerPairs[i] != nullptr ? headerPairs[i] : "");
|
||||||
|
env->SetObjectArrayElement(jheaders, (jsize) i, field);
|
||||||
|
if (field != nullptr)
|
||||||
|
env->DeleteLocalRef(field);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
jstring jurl = env->NewStringUTF(url);
|
||||||
|
jstring jmethod = env->NewStringUTF(method != nullptr ? method : "GET");
|
||||||
|
// raw bytes across the bridge, as httpPost does: a request body is JSON
|
||||||
|
// carrying a base64 save, and a jstring would run it through modified UTF-8
|
||||||
|
jbyteArray jbody = nullptr;
|
||||||
|
if (body != nullptr && bodyLen >= 0)
|
||||||
|
{
|
||||||
|
jbody = env->NewByteArray((jsize) bodyLen);
|
||||||
|
if (jbody != nullptr && bodyLen > 0)
|
||||||
|
env->SetByteArrayRegion(jbody, 0, (jsize) bodyLen, (const jbyte*) body);
|
||||||
|
}
|
||||||
|
jstring jua = env->NewStringUTF(userAgent != nullptr ? userAgent : "gen1recomp");
|
||||||
|
|
||||||
|
jobject result = env->CallStaticObjectMethod(activity, method_id, jurl, jmethod,
|
||||||
|
jheaders, jbody, jua);
|
||||||
|
|
||||||
|
env->DeleteLocalRef(jurl);
|
||||||
|
env->DeleteLocalRef(jmethod);
|
||||||
|
if (jheaders != nullptr)
|
||||||
|
env->DeleteLocalRef(jheaders);
|
||||||
|
if (jbody != nullptr)
|
||||||
|
env->DeleteLocalRef(jbody);
|
||||||
|
env->DeleteLocalRef(jua);
|
||||||
|
env->DeleteLocalRef(activity);
|
||||||
|
|
||||||
|
if (result == nullptr)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
jbyteArray bytes = (jbyteArray) result;
|
||||||
|
jsize length = env->GetArrayLength(bytes);
|
||||||
|
if (length > 0)
|
||||||
|
{
|
||||||
|
out.resize((size_t) length);
|
||||||
|
env->GetByteArrayRegion(bytes, 0, length, (jbyte*) &out[0]);
|
||||||
|
}
|
||||||
|
env->DeleteLocalRef(result);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* TLS sockets. Same resolution rule as httpDownload above -- the activity's
|
* TLS sockets. Same resolution rule as httpDownload above -- the activity's
|
||||||
* own class, never FindClass -- and the same tolerance for an old APK: a
|
* own class, never FindClass -- and the same tolerance for an old APK: a
|
||||||
@@ -1390,4 +1553,32 @@ Java_org_love2d_android_GameActivity_nativeAudioDeviceChanged(JNIEnv *env, jclas
|
|||||||
love::audio::openal::pushAudioResetEvent();
|
love::audio::openal::pushAudioResetEvent();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static void pushGameIntentEvent(const char *game)
|
||||||
|
{
|
||||||
|
auto eventmodule = love::Module::getInstance<love::event::Event>(love::Module::M_EVENT);
|
||||||
|
if (eventmodule == nullptr || game == nullptr)
|
||||||
|
return;
|
||||||
|
|
||||||
|
std::vector<love::Variant> args;
|
||||||
|
args.push_back(love::Variant(std::string(game)));
|
||||||
|
|
||||||
|
love::event::Message *msg = new love::event::Message("intent_game", args);
|
||||||
|
eventmodule->push(msg);
|
||||||
|
msg->release();
|
||||||
|
}
|
||||||
|
|
||||||
|
extern "C" JNIEXPORT void JNICALL
|
||||||
|
Java_org_love2d_android_GameActivity_nativeOnGameIntent(JNIEnv *env, jclass cls, jstring game)
|
||||||
|
{
|
||||||
|
(void) cls;
|
||||||
|
if (game == nullptr)
|
||||||
|
return;
|
||||||
|
const char *str = env->GetStringUTFChars(game, nullptr);
|
||||||
|
if (str != nullptr)
|
||||||
|
{
|
||||||
|
pushGameIntentEvent(str);
|
||||||
|
env->ReleaseStringUTFChars(game, str);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#endif // LOVE_ANDROID
|
#endif // LOVE_ANDROID
|
||||||
|
|||||||
@@ -90,6 +90,16 @@ bool syncHealthSteps();
|
|||||||
**/
|
**/
|
||||||
bool restartApp();
|
bool restartApp();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dynamic App Shortcuts: updates Android ShortcutManager with ready game versions.
|
||||||
|
**/
|
||||||
|
bool updateAppShortcuts(const std::vector<std::string> &versions);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the game version requested via initial launch Intent (if any).
|
||||||
|
**/
|
||||||
|
std::string getLaunchGame();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Blocking HTTPS GET into destPath (GameActivity.httpDownload). Android has
|
* Blocking HTTPS GET into destPath (GameActivity.httpDownload). Android has
|
||||||
* no curl binary, so this is the transport src/core/HostShell.lua uses there
|
* no curl binary, so this is the transport src/core/HostShell.lua uses there
|
||||||
@@ -106,6 +116,21 @@ bool httpDownload(const char *url, const char *destPath, const char *userAgent,
|
|||||||
**/
|
**/
|
||||||
bool httpPost(const char *url, const char *body, int bodyLen, const char *contentType, const char *userAgent);
|
bool httpPost(const char *url, const char *body, int bodyLen, const char *contentType, const char *userAgent);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Blocking HTTPS request with a method, headers and a byte body
|
||||||
|
* (GameActivity.httpRequest). What save sync needs and neither of the two
|
||||||
|
* above can give it: PUT, per-request auth headers, and the response body of
|
||||||
|
* a 4xx as well as a 2xx. headerPairs is a flat name, value array of
|
||||||
|
* headerPairCount entries; body/userAgent may be null. `out` receives the
|
||||||
|
* Java side's envelope -- a head line of "STATUS <code>" or "ERROR <text>",
|
||||||
|
* a newline, then the raw response bytes. False means the platform has no
|
||||||
|
* such bridge at all (an old APK under a newer liblove), which the Lua side
|
||||||
|
* reports as "update the app" rather than as a failed request.
|
||||||
|
**/
|
||||||
|
bool httpRequest(const char *url, const char *method,
|
||||||
|
const char *const *headerPairs, int headerPairCount,
|
||||||
|
const char *body, int bodyLen, const char *userAgent, std::string &out);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* TLS client sockets (GameActivity.tls*, implemented by TlsSocket.java).
|
* TLS client sockets (GameActivity.tls*, implemented by TlsSocket.java).
|
||||||
* LuaSocket, which is what LOVE ships, does TCP only, so wss:// is otherwise
|
* LuaSocket, which is what LOVE ships, does TCP only, so wss:// is otherwise
|
||||||
|
|||||||
@@ -245,6 +245,25 @@ bool System::restartApp() const
|
|||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool System::updateShortcuts(const std::vector<std::string> &versions) const
|
||||||
|
{
|
||||||
|
#ifdef LOVE_ANDROID
|
||||||
|
return love::android::updateAppShortcuts(versions);
|
||||||
|
#else
|
||||||
|
LOVE_UNUSED(versions);
|
||||||
|
return false;
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string System::getLaunchGame() const
|
||||||
|
{
|
||||||
|
#ifdef LOVE_ANDROID
|
||||||
|
return love::android::getLaunchGame();
|
||||||
|
#else
|
||||||
|
return "";
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
bool System::httpDownload(const char *url, const char *destPath,
|
bool System::httpDownload(const char *url, const char *destPath,
|
||||||
const char *userAgent, const char *accept) const
|
const char *userAgent, const char *accept) const
|
||||||
{
|
{
|
||||||
@@ -274,6 +293,27 @@ bool System::httpPost(const char *url, const char *body, int bodyLen,
|
|||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool System::httpRequest(const char *url, const char *method,
|
||||||
|
const char *const *headerPairs, int headerPairCount,
|
||||||
|
const char *body, int bodyLen, const char *userAgent,
|
||||||
|
std::string &out) const
|
||||||
|
{
|
||||||
|
#ifdef LOVE_ANDROID
|
||||||
|
return love::android::httpRequest(url, method, headerPairs, headerPairCount,
|
||||||
|
body, bodyLen, userAgent, out);
|
||||||
|
#else
|
||||||
|
LOVE_UNUSED(url);
|
||||||
|
LOVE_UNUSED(method);
|
||||||
|
LOVE_UNUSED(headerPairs);
|
||||||
|
LOVE_UNUSED(headerPairCount);
|
||||||
|
LOVE_UNUSED(body);
|
||||||
|
LOVE_UNUSED(bodyLen);
|
||||||
|
LOVE_UNUSED(userAgent);
|
||||||
|
out.clear();
|
||||||
|
return false;
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
int System::tlsOpen(const char *host, int port) const
|
int System::tlsOpen(const char *host, int port) const
|
||||||
{
|
{
|
||||||
#ifdef LOVE_ANDROID
|
#ifdef LOVE_ANDROID
|
||||||
|
|||||||
@@ -143,6 +143,9 @@ public:
|
|||||||
**/
|
**/
|
||||||
virtual bool restartApp() const;
|
virtual bool restartApp() const;
|
||||||
|
|
||||||
|
virtual bool updateShortcuts(const std::vector<std::string> &versions) const;
|
||||||
|
virtual std::string getLaunchGame() const;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Blocking HTTPS GET into an absolute host path (Android only; false
|
* Blocking HTTPS GET into an absolute host path (Android only; false
|
||||||
* elsewhere). Android has no curl, which is what every other platform
|
* elsewhere). Android has no curl, which is what every other platform
|
||||||
@@ -159,6 +162,18 @@ public:
|
|||||||
virtual bool httpPost(const char *url, const char *body, int bodyLen,
|
virtual bool httpPost(const char *url, const char *body, int bodyLen,
|
||||||
const char *contentType = nullptr, const char *userAgent = nullptr) const;
|
const char *contentType = nullptr, const char *userAgent = nullptr) const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Blocking HTTPS request with a method, headers and a byte body (Android
|
||||||
|
* only; false elsewhere). Save sync needs PUT, auth headers and the body
|
||||||
|
* of a 4xx, none of which the two bridges above can express. headerPairs
|
||||||
|
* is a flat name, value array; `out` receives the response envelope
|
||||||
|
* ("STATUS <code>" or "ERROR <text>", a newline, then the raw body).
|
||||||
|
**/
|
||||||
|
virtual bool httpRequest(const char *url, const char *method,
|
||||||
|
const char *const *headerPairs, int headerPairCount,
|
||||||
|
const char *body, int bodyLen, const char *userAgent,
|
||||||
|
std::string &out) const;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* TLS client sockets (Android only; every call fails elsewhere, where
|
* TLS client sockets (Android only; every call fails elsewhere, where
|
||||||
* LuaSec or another provider is the answer). Non-blocking by contract:
|
* LuaSec or another provider is the answer). Non-blocking by contract:
|
||||||
|
|||||||
@@ -22,6 +22,9 @@
|
|||||||
#include "wrap_System.h"
|
#include "wrap_System.h"
|
||||||
#include "sdl/System.h"
|
#include "sdl/System.h"
|
||||||
|
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
namespace love
|
namespace love
|
||||||
{
|
{
|
||||||
namespace system
|
namespace system
|
||||||
@@ -150,6 +153,57 @@ int w_httpPost(lua_State *L)
|
|||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* love.system.httpRequest(url, method, headers, body, userAgent) -> envelope
|
||||||
|
*
|
||||||
|
* `headers` is a flat array of alternating header name and value strings, so
|
||||||
|
* it maps straight onto the Java bridge's String[] without any parsing here.
|
||||||
|
* The single return is the response envelope -- a head line of
|
||||||
|
* "STATUS <code>" or "ERROR <text>", a newline, then the raw body -- or nil
|
||||||
|
* where the build has no bridge, which src/core/HostShell.lua turns into an
|
||||||
|
* "update the app" notice rather than a failed request.
|
||||||
|
*/
|
||||||
|
int w_httpRequest(lua_State *L)
|
||||||
|
{
|
||||||
|
const char *url = luaL_checkstring(L, 1);
|
||||||
|
const char *method = luaL_optstring(L, 2, "GET");
|
||||||
|
|
||||||
|
std::vector<std::string> fields;
|
||||||
|
if (!lua_isnoneornil(L, 3))
|
||||||
|
{
|
||||||
|
luaL_checktype(L, 3, LUA_TTABLE);
|
||||||
|
size_t count = luax_objlen(L, 3);
|
||||||
|
for (size_t i = 1; i <= count; i++)
|
||||||
|
{
|
||||||
|
lua_rawgeti(L, 3, (int) i);
|
||||||
|
const char *field = lua_tostring(L, -1);
|
||||||
|
fields.push_back(field != nullptr ? field : "");
|
||||||
|
lua_pop(L, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
std::vector<const char *> pairs;
|
||||||
|
for (size_t i = 0; i < fields.size(); i++)
|
||||||
|
pairs.push_back(fields[i].c_str());
|
||||||
|
|
||||||
|
size_t bodyLen = 0;
|
||||||
|
const char *body = nullptr;
|
||||||
|
if (!lua_isnoneornil(L, 4))
|
||||||
|
body = luaL_checklstring(L, 4, &bodyLen);
|
||||||
|
const char *ua = luaL_optstring(L, 5, nullptr);
|
||||||
|
|
||||||
|
std::string out;
|
||||||
|
bool ok = instance()->httpRequest(url, method,
|
||||||
|
pairs.empty() ? nullptr : &pairs[0], (int) pairs.size(),
|
||||||
|
body, (int) bodyLen, ua, out);
|
||||||
|
if (!ok)
|
||||||
|
{
|
||||||
|
lua_pushnil(L);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
lua_pushlstring(L, out.data(), out.size());
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
int w_hasBackgroundMusic(lua_State *L)
|
int w_hasBackgroundMusic(lua_State *L)
|
||||||
{
|
{
|
||||||
lua_pushboolean(L, instance()->hasBackgroundMusic());
|
lua_pushboolean(L, instance()->hasBackgroundMusic());
|
||||||
@@ -229,6 +283,34 @@ int w_tlsClose(lua_State *L)
|
|||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
int w_updateShortcuts(lua_State *L)
|
||||||
|
{
|
||||||
|
if (!lua_istable(L, 1))
|
||||||
|
return luaL_error(L, "Expected table of game version strings");
|
||||||
|
|
||||||
|
std::vector<std::string> versions;
|
||||||
|
int len = (int) luax_objlen(L, 1);
|
||||||
|
for (int i = 1; i <= len; ++i)
|
||||||
|
{
|
||||||
|
lua_rawgeti(L, 1, i);
|
||||||
|
if (lua_isstring(L, -1))
|
||||||
|
versions.push_back(lua_tostring(L, -1));
|
||||||
|
lua_pop(L, 1);
|
||||||
|
}
|
||||||
|
luax_pushboolean(L, instance()->updateShortcuts(versions));
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
int w_getLaunchGame(lua_State *L)
|
||||||
|
{
|
||||||
|
std::string game = instance()->getLaunchGame();
|
||||||
|
if (game.empty())
|
||||||
|
lua_pushnil(L);
|
||||||
|
else
|
||||||
|
luax_pushstring(L, game);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
static const luaL_Reg functions[] =
|
static const luaL_Reg functions[] =
|
||||||
{
|
{
|
||||||
{ "getOS", w_getOS },
|
{ "getOS", w_getOS },
|
||||||
@@ -243,8 +325,11 @@ static const luaL_Reg functions[] =
|
|||||||
{ "createFile", w_createFile },
|
{ "createFile", w_createFile },
|
||||||
{ "syncHealthSteps", w_syncHealthSteps },
|
{ "syncHealthSteps", w_syncHealthSteps },
|
||||||
{ "restartApp", w_restartApp },
|
{ "restartApp", w_restartApp },
|
||||||
|
{ "updateShortcuts", w_updateShortcuts },
|
||||||
|
{ "getLaunchGame", w_getLaunchGame },
|
||||||
{ "httpDownload", w_httpDownload },
|
{ "httpDownload", w_httpDownload },
|
||||||
{ "httpPost", w_httpPost },
|
{ "httpPost", w_httpPost },
|
||||||
|
{ "httpRequest", w_httpRequest },
|
||||||
{ "tlsOpen", w_tlsOpen },
|
{ "tlsOpen", w_tlsOpen },
|
||||||
{ "tlsStatus", w_tlsStatus },
|
{ "tlsStatus", w_tlsStatus },
|
||||||
{ "tlsSend", w_tlsSend },
|
{ "tlsSend", w_tlsSend },
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import org.libsdl.app.SDLActivity;
|
|||||||
|
|
||||||
import java.io.BufferedInputStream;
|
import java.io.BufferedInputStream;
|
||||||
import java.io.BufferedOutputStream;
|
import java.io.BufferedOutputStream;
|
||||||
|
import java.io.ByteArrayOutputStream;
|
||||||
import java.io.File;
|
import java.io.File;
|
||||||
import java.io.FileInputStream;
|
import java.io.FileInputStream;
|
||||||
import java.io.FileNotFoundException;
|
import java.io.FileNotFoundException;
|
||||||
@@ -36,6 +37,7 @@ import java.net.URL;
|
|||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
import android.Manifest;
|
import android.Manifest;
|
||||||
@@ -67,8 +69,11 @@ import android.os.Vibrator;
|
|||||||
import android.provider.Settings;
|
import android.provider.Settings;
|
||||||
import android.util.Log;
|
import android.util.Log;
|
||||||
import android.util.DisplayMetrics;
|
import android.util.DisplayMetrics;
|
||||||
import android.view.*;
|
import android.content.pm.ShortcutInfo;
|
||||||
|
import android.content.pm.ShortcutManager;
|
||||||
import android.content.pm.PackageManager;
|
import android.content.pm.PackageManager;
|
||||||
|
import android.graphics.drawable.Icon;
|
||||||
|
import android.view.*;
|
||||||
|
|
||||||
import androidx.annotation.Keep;
|
import androidx.annotation.Keep;
|
||||||
import androidx.core.app.ActivityCompat;
|
import androidx.core.app.ActivityCompat;
|
||||||
@@ -157,6 +162,10 @@ public class GameActivity extends SDLActivity {
|
|||||||
|
|
||||||
private static native void nativeAudioDeviceChanged();
|
private static native void nativeAudioDeviceChanged();
|
||||||
|
|
||||||
|
private static native void nativeOnGameIntent(String game);
|
||||||
|
|
||||||
|
private static String initialGame = "";
|
||||||
|
|
||||||
private AudioManager.OnAudioFocusChangeListener audioFocusListener = null;
|
private AudioManager.OnAudioFocusChangeListener audioFocusListener = null;
|
||||||
private Object audioFocusRequest = null;
|
private Object audioFocusRequest = null;
|
||||||
private Object audioDeviceCallback = null;
|
private Object audioDeviceCallback = null;
|
||||||
@@ -226,6 +235,10 @@ public class GameActivity extends SDLActivity {
|
|||||||
embed = getResources().getBoolean(R.bool.embed);
|
embed = getResources().getBoolean(R.bool.embed);
|
||||||
needToCopyGameInArchive = embed;
|
needToCopyGameInArchive = embed;
|
||||||
|
|
||||||
|
Intent startIntent = getIntent();
|
||||||
|
if (startIntent != null && startIntent.hasExtra("game")) {
|
||||||
|
initialGame = startIntent.getStringExtra("game");
|
||||||
|
}
|
||||||
if (!embed) {
|
if (!embed) {
|
||||||
Intent intent = getIntent();
|
Intent intent = getIntent();
|
||||||
handleIntent(intent);
|
handleIntent(intent);
|
||||||
@@ -259,6 +272,12 @@ public class GameActivity extends SDLActivity {
|
|||||||
@Override
|
@Override
|
||||||
protected void onNewIntent(Intent intent) {
|
protected void onNewIntent(Intent intent) {
|
||||||
Log.d("GameActivity", "onNewIntent() with " + intent);
|
Log.d("GameActivity", "onNewIntent() with " + intent);
|
||||||
|
if (intent != null && intent.hasExtra("game")) {
|
||||||
|
String game = intent.getStringExtra("game");
|
||||||
|
if (game != null && !game.isEmpty()) {
|
||||||
|
nativeOnGameIntent(game);
|
||||||
|
}
|
||||||
|
}
|
||||||
if (!embed) {
|
if (!embed) {
|
||||||
handleIntent(intent);
|
handleIntent(intent);
|
||||||
resetNative();
|
resetNative();
|
||||||
@@ -671,6 +690,95 @@ public class GameActivity extends SDLActivity {
|
|||||||
return true; // unreachable, but keeps the JNI signature honest
|
return true; // unreachable, but keeps the JNI signature honest
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Keep
|
||||||
|
public static String getLaunchGame() {
|
||||||
|
return initialGame != null ? initialGame : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Keep
|
||||||
|
public static boolean updateAppShortcuts(String[] readyVersions) {
|
||||||
|
GameActivity self = (GameActivity) mSingleton;
|
||||||
|
if (self == null) return false;
|
||||||
|
if (android.os.Build.VERSION.SDK_INT < 25) return false;
|
||||||
|
try {
|
||||||
|
Context context = self.getApplicationContext();
|
||||||
|
ShortcutManager shortcutManager = context.getSystemService(ShortcutManager.class);
|
||||||
|
if (shortcutManager == null) return false;
|
||||||
|
|
||||||
|
if (readyVersions == null || readyVersions.length == 0) {
|
||||||
|
shortcutManager.removeAllDynamicShortcuts();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<ShortcutInfo> shortcuts = new ArrayList<>();
|
||||||
|
int maxShortcuts = Math.min(readyVersions.length, 4);
|
||||||
|
|
||||||
|
for (int i = 0; i < maxShortcuts; i++) {
|
||||||
|
String ver = readyVersions[i];
|
||||||
|
if (ver == null || ver.isEmpty()) continue;
|
||||||
|
String lower = ver.toLowerCase();
|
||||||
|
String shortLabel;
|
||||||
|
String longLabel;
|
||||||
|
int iconResId;
|
||||||
|
|
||||||
|
switch (lower) {
|
||||||
|
case "red":
|
||||||
|
shortLabel = "Play Red";
|
||||||
|
longLabel = "Play Red";
|
||||||
|
iconResId = context.getResources().getIdentifier("ic_shortcut_red", "drawable", context.getPackageName());
|
||||||
|
break;
|
||||||
|
case "blue":
|
||||||
|
shortLabel = "Play Blue";
|
||||||
|
longLabel = "Play Blue";
|
||||||
|
iconResId = context.getResources().getIdentifier("ic_shortcut_blue", "drawable", context.getPackageName());
|
||||||
|
break;
|
||||||
|
case "yellow":
|
||||||
|
shortLabel = "Play Yellow";
|
||||||
|
longLabel = "Play Yellow";
|
||||||
|
iconResId = context.getResources().getIdentifier("ic_shortcut_yellow", "drawable", context.getPackageName());
|
||||||
|
break;
|
||||||
|
case "gold":
|
||||||
|
shortLabel = "Play Gold";
|
||||||
|
longLabel = "Play Gold";
|
||||||
|
iconResId = context.getResources().getIdentifier("ic_shortcut_gold", "drawable", context.getPackageName());
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
String capitalized = lower.substring(0, 1).toUpperCase() + lower.substring(1);
|
||||||
|
shortLabel = "Play " + capitalized;
|
||||||
|
longLabel = "Play " + capitalized;
|
||||||
|
iconResId = context.getResources().getIdentifier("ic_shortcut_" + lower, "drawable", context.getPackageName());
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (iconResId == 0) {
|
||||||
|
iconResId = context.getResources().getIdentifier("ic_launcher_foreground", "drawable", context.getPackageName());
|
||||||
|
}
|
||||||
|
|
||||||
|
Intent intent = new Intent(context, GameActivity.class);
|
||||||
|
intent.setAction(Intent.ACTION_VIEW);
|
||||||
|
intent.putExtra("game", lower);
|
||||||
|
intent.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP);
|
||||||
|
|
||||||
|
ShortcutInfo.Builder builder = new ShortcutInfo.Builder(context, "shortcut_" + lower)
|
||||||
|
.setShortLabel(shortLabel)
|
||||||
|
.setLongLabel(longLabel)
|
||||||
|
.setIntent(intent);
|
||||||
|
|
||||||
|
if (iconResId != 0) {
|
||||||
|
builder.setIcon(Icon.createWithResource(context, iconResId));
|
||||||
|
}
|
||||||
|
|
||||||
|
shortcuts.add(builder.build());
|
||||||
|
}
|
||||||
|
|
||||||
|
shortcutManager.setDynamicShortcuts(shortcuts);
|
||||||
|
return true;
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.d("GameActivity", "could not update shortcuts: " + e.getMessage());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Blocking HTTPS GET into destPath, exposed as love.system.httpDownload
|
* Blocking HTTPS GET into destPath, exposed as love.system.httpDownload
|
||||||
* and used by src/core/HostShell.lua. Android ships no curl binary, so
|
* and used by src/core/HostShell.lua. Android ships no curl binary, so
|
||||||
@@ -847,6 +955,149 @@ public class GameActivity extends SDLActivity {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Response ceiling for httpRequest; anything larger is refused, not buffered. */
|
||||||
|
private static final int HTTP_REQUEST_MAX_RESPONSE = 4 * 1024 * 1024;
|
||||||
|
|
||||||
|
/** Builds an httpRequest envelope: one head line, a newline, then the body. */
|
||||||
|
private static byte[] httpEnvelope(String head, byte[] payload) {
|
||||||
|
byte[] prefix;
|
||||||
|
try {
|
||||||
|
prefix = (head + "\n").getBytes("UTF-8");
|
||||||
|
} catch (Exception e) {
|
||||||
|
prefix = (head + "\n").getBytes();
|
||||||
|
}
|
||||||
|
if (payload == null || payload.length == 0) return prefix;
|
||||||
|
byte[] out = new byte[prefix.length + payload.length];
|
||||||
|
System.arraycopy(prefix, 0, out, 0, prefix.length);
|
||||||
|
System.arraycopy(payload, 0, out, prefix.length, payload.length);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One-line, CR/LF-free failure text, so an envelope head stays one line. */
|
||||||
|
private static String httpErrorText(Exception e) {
|
||||||
|
String text = e.getMessage();
|
||||||
|
if (text == null || text.length() == 0) text = e.getClass().getSimpleName();
|
||||||
|
text = text.replace('\r', ' ').replace('\n', ' ');
|
||||||
|
if (text.length() > 160) text = text.substring(0, 160);
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Blocking HTTPS request with a chosen method, headers and byte body,
|
||||||
|
* exposed as love.system.httpRequest and used by src/core/HostShell.lua
|
||||||
|
* for save sync. Sync needs PUT, per-request auth headers and the response
|
||||||
|
* body of a 4xx as well as a 2xx (a conflict answers 409 with the save
|
||||||
|
* that won), none of which httpDownload or httpPost above can express.
|
||||||
|
*
|
||||||
|
* Same rules as those two: https only, redirects followed by hand
|
||||||
|
* (re-sending method and body on each hop), 15s connect / 60s read, and
|
||||||
|
* blocking on the Lua/worker thread -- never the UI thread. Headers arrive
|
||||||
|
* as a flat name, value array; a field carrying CR or LF is refused rather
|
||||||
|
* than sent, so a header value can never inject a second header.
|
||||||
|
*
|
||||||
|
* The reply is an envelope: a head line of "STATUS <code>" or
|
||||||
|
* "ERROR <text>", a newline, then the raw response bytes.
|
||||||
|
*/
|
||||||
|
@Keep
|
||||||
|
public static byte[] httpRequest(String url, String method, String[] headerPairs,
|
||||||
|
byte[] body, String userAgent) {
|
||||||
|
if (url == null) return httpEnvelope("ERROR missing url", null);
|
||||||
|
String verb = method == null ? "GET" : method.toUpperCase(Locale.US);
|
||||||
|
if (!"GET".equals(verb) && !"POST".equals(verb)
|
||||||
|
&& !"PUT".equals(verb) && !"DELETE".equals(verb)) {
|
||||||
|
return httpEnvelope("ERROR unsupported request method", null);
|
||||||
|
}
|
||||||
|
if (headerPairs != null) {
|
||||||
|
if ((headerPairs.length % 2) != 0) {
|
||||||
|
return httpEnvelope("ERROR bad request header", null);
|
||||||
|
}
|
||||||
|
for (int i = 0; i < headerPairs.length; i++) {
|
||||||
|
String field = headerPairs[i];
|
||||||
|
if (field == null) return httpEnvelope("ERROR bad request header", null);
|
||||||
|
if (field.indexOf('\r') >= 0 || field.indexOf('\n') >= 0) {
|
||||||
|
return httpEnvelope("ERROR bad request header", null);
|
||||||
|
}
|
||||||
|
if ((i % 2) == 0 && field.length() == 0) {
|
||||||
|
return httpEnvelope("ERROR bad request header", null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
HttpURLConnection conn = null;
|
||||||
|
try {
|
||||||
|
String current = url;
|
||||||
|
for (int hop = 0; hop < 5; hop++) {
|
||||||
|
URL parsed = new URL(current);
|
||||||
|
if (!"https".equalsIgnoreCase(parsed.getProtocol())) {
|
||||||
|
return httpEnvelope("ERROR https only", null);
|
||||||
|
}
|
||||||
|
conn = (HttpURLConnection) parsed.openConnection();
|
||||||
|
conn.setInstanceFollowRedirects(false);
|
||||||
|
conn.setConnectTimeout(15000);
|
||||||
|
conn.setReadTimeout(60000);
|
||||||
|
conn.setRequestMethod(verb);
|
||||||
|
conn.setRequestProperty("User-Agent",
|
||||||
|
userAgent == null ? "gen1recomp" : userAgent);
|
||||||
|
if (headerPairs != null) {
|
||||||
|
for (int i = 0; i + 1 < headerPairs.length; i += 2) {
|
||||||
|
conn.setRequestProperty(headerPairs[i], headerPairs[i + 1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (body != null && !"GET".equals(verb)) {
|
||||||
|
conn.setDoOutput(true);
|
||||||
|
conn.setFixedLengthStreamingMode(body.length);
|
||||||
|
OutputStream out = new BufferedOutputStream(conn.getOutputStream());
|
||||||
|
try {
|
||||||
|
out.write(body);
|
||||||
|
} finally {
|
||||||
|
try { out.close(); } catch (IOException ignored) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
int code = conn.getResponseCode();
|
||||||
|
if (code == 301 || code == 302 || code == 303 || code == 307 || code == 308) {
|
||||||
|
String next = conn.getHeaderField("Location");
|
||||||
|
conn.disconnect();
|
||||||
|
conn = null;
|
||||||
|
if (next == null) {
|
||||||
|
return httpEnvelope("ERROR redirect without a location", null);
|
||||||
|
}
|
||||||
|
current = new URL(parsed, next).toString();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// A rejection's body is the diagnosis the caller wants, so 4xx
|
||||||
|
// and 5xx are read through getErrorStream rather than dropped.
|
||||||
|
InputStream in;
|
||||||
|
try {
|
||||||
|
in = conn.getInputStream();
|
||||||
|
} catch (IOException e) {
|
||||||
|
in = conn.getErrorStream();
|
||||||
|
}
|
||||||
|
ByteArrayOutputStream sink = new ByteArrayOutputStream();
|
||||||
|
if (in != null) {
|
||||||
|
InputStream reader = new BufferedInputStream(in);
|
||||||
|
try {
|
||||||
|
byte[] buf = new byte[16384];
|
||||||
|
int n;
|
||||||
|
while ((n = reader.read(buf)) > 0) {
|
||||||
|
if (sink.size() + n > HTTP_REQUEST_MAX_RESPONSE) {
|
||||||
|
return httpEnvelope("ERROR the reply was too large", null);
|
||||||
|
}
|
||||||
|
sink.write(buf, 0, n);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
try { reader.close(); } catch (IOException ignored) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return httpEnvelope("STATUS " + code, sink.toByteArray());
|
||||||
|
}
|
||||||
|
return httpEnvelope("ERROR too many redirects", null);
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.d("GameActivity", "httpRequest failed: " + e.getMessage());
|
||||||
|
return httpEnvelope("ERROR " + httpErrorText(e), null);
|
||||||
|
} finally {
|
||||||
|
if (conn != null) conn.disconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Shows ACTION_CREATE_DOCUMENT so the player can save a staged export
|
* Shows ACTION_CREATE_DOCUMENT so the player can save a staged export
|
||||||
* (pending_export.sav in the app save identity) to Downloads / Drive /
|
* (pending_export.sav in the app save identity) to Downloads / Drive /
|
||||||
|
|||||||
@@ -12,6 +12,13 @@
|
|||||||
"tintColor": "3b5ca8",
|
"tintColor": "3b5ca8",
|
||||||
"category": "games",
|
"category": "games",
|
||||||
"versions": [
|
"versions": [
|
||||||
|
{
|
||||||
|
"version": "0.2.7",
|
||||||
|
"date": "2026-08-18",
|
||||||
|
"size": 13597177,
|
||||||
|
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.7/gen1recomp++-0.2.7-ios.ipa",
|
||||||
|
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #1005 (Android) Screen record mutes the game\n- #1291 Audio Crash\n- #1310 Incoming call crashes G1R\n- #1471 [Gold] #1117 still not fixed\n- #1528 Surfing Minigame doesn't play as intended\n- #1537 Shellder and Corsola missing from Rod encounter tables\n\n## Contributors\n\n- @1Jamie\n- @bryanthaboi\n- @castdrian"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"version": "0.2.6",
|
"version": "0.2.6",
|
||||||
"date": "2026-08-18",
|
"date": "2026-08-18",
|
||||||
|
|||||||
@@ -67,6 +67,124 @@ public final class GRPickerBridge: NSObject {
|
|||||||
return succeeded
|
return succeeded
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - General HTTP request (love.system.httpRequest)
|
||||||
|
|
||||||
|
private static let httpMaxResponse = 4 * 1024 * 1024
|
||||||
|
|
||||||
|
// URLSession turns a 301/302/303 POST into a GET on its own. Save sync
|
||||||
|
// signs a method and a body, so every hop re-sends the original request
|
||||||
|
// against the new URL instead, and only over https.
|
||||||
|
private final class GRRedirectKeeper: NSObject, URLSessionTaskDelegate {
|
||||||
|
func urlSession(_ session: URLSession, task: URLSessionTask,
|
||||||
|
willPerformHTTPRedirection response: HTTPURLResponse,
|
||||||
|
newRequest request: URLRequest,
|
||||||
|
completionHandler: @escaping (URLRequest?) -> Void) {
|
||||||
|
guard let original = task.originalRequest,
|
||||||
|
let target = request.url,
|
||||||
|
target.scheme?.lowercased() == "https" else {
|
||||||
|
completionHandler(nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var next = original
|
||||||
|
next.url = target
|
||||||
|
completionHandler(next)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static let httpSession = URLSession(configuration: .ephemeral,
|
||||||
|
delegate: GRRedirectKeeper(),
|
||||||
|
delegateQueue: nil)
|
||||||
|
|
||||||
|
private static func httpEnvelope(_ head: String, _ payload: Data?) -> NSData {
|
||||||
|
var out = Data((head + "\n").utf8)
|
||||||
|
if let payload { out.append(payload) }
|
||||||
|
return out as NSData
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func httpErrorText(_ error: Error) -> String {
|
||||||
|
var text = error.localizedDescription
|
||||||
|
.replacingOccurrences(of: "\r", with: " ")
|
||||||
|
.replacingOccurrences(of: "\n", with: " ")
|
||||||
|
if text.isEmpty { text = "the request failed" }
|
||||||
|
if text.count > 160 { text = String(text.prefix(160)) }
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Blocking HTTPS request with a chosen method, headers and byte body, the
|
||||||
|
/// iOS half of love.system.httpRequest (see the Android GameActivity one).
|
||||||
|
/// Headers arrive as "name: value" lines joined by newlines. The reply is
|
||||||
|
/// an envelope: a head line of "STATUS <code>" or "ERROR <text>", a
|
||||||
|
/// newline, then the raw response bytes -- read for 4xx and 5xx as well,
|
||||||
|
/// because a sync conflict answers 409 with the save that won.
|
||||||
|
@objc(httpRequestWithUrl:method:headers:body:bodyLength:userAgent:)
|
||||||
|
public static func httpRequest(url: UnsafePointer<CChar>?,
|
||||||
|
method: UnsafePointer<CChar>?,
|
||||||
|
headers: UnsafePointer<CChar>?,
|
||||||
|
body: UnsafePointer<UInt8>?,
|
||||||
|
bodyLength: Int32,
|
||||||
|
userAgent: UnsafePointer<CChar>?) -> NSData? {
|
||||||
|
guard let url, let requestURL = URL(string: String(cString: url)) else {
|
||||||
|
return httpEnvelope("ERROR missing url", nil)
|
||||||
|
}
|
||||||
|
guard requestURL.scheme?.lowercased() == "https" else {
|
||||||
|
return httpEnvelope("ERROR https only", nil)
|
||||||
|
}
|
||||||
|
let verb = (method.map { String(cString: $0) } ?? "GET").uppercased()
|
||||||
|
guard ["GET", "POST", "PUT", "DELETE"].contains(verb) else {
|
||||||
|
return httpEnvelope("ERROR unsupported request method", nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
var request = URLRequest(url: requestURL)
|
||||||
|
request.httpMethod = verb
|
||||||
|
request.timeoutInterval = 60
|
||||||
|
request.setValue(userAgent.map { String(cString: $0) } ?? "gen1recomp",
|
||||||
|
forHTTPHeaderField: "User-Agent")
|
||||||
|
if let headers, headers.pointee != 0 {
|
||||||
|
for line in String(cString: headers).split(separator: "\n") {
|
||||||
|
guard let colon = line.firstIndex(of: ":") else {
|
||||||
|
return httpEnvelope("ERROR bad request header", nil)
|
||||||
|
}
|
||||||
|
let name = line[line.startIndex..<colon]
|
||||||
|
.trimmingCharacters(in: .whitespaces)
|
||||||
|
let value = line[line.index(after: colon)...]
|
||||||
|
.trimmingCharacters(in: .whitespaces)
|
||||||
|
if name.isEmpty {
|
||||||
|
return httpEnvelope("ERROR bad request header", nil)
|
||||||
|
}
|
||||||
|
request.setValue(value, forHTTPHeaderField: name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if verb != "GET", let body, bodyLength > 0 {
|
||||||
|
request.httpBody = Data(bytes: body, count: Int(bodyLength))
|
||||||
|
}
|
||||||
|
|
||||||
|
let semaphore = DispatchSemaphore(value: 0)
|
||||||
|
var envelope = httpEnvelope("ERROR no response", nil)
|
||||||
|
let task = httpSession.dataTask(with: request) { data, response, error in
|
||||||
|
defer { semaphore.signal() }
|
||||||
|
if let error {
|
||||||
|
envelope = httpEnvelope("ERROR " + httpErrorText(error), nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard let http = response as? HTTPURLResponse else {
|
||||||
|
envelope = httpEnvelope("ERROR no response", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let payload = data ?? Data()
|
||||||
|
if payload.count > httpMaxResponse {
|
||||||
|
envelope = httpEnvelope("ERROR the reply was too large", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
envelope = httpEnvelope("STATUS \(http.statusCode)", payload)
|
||||||
|
}
|
||||||
|
task.resume()
|
||||||
|
guard semaphore.wait(timeout: .now() + 65) == .success else {
|
||||||
|
task.cancel()
|
||||||
|
return httpEnvelope("ERROR the request timed out", nil)
|
||||||
|
}
|
||||||
|
return envelope
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Entry points called from liblove (C strings on purpose)
|
// MARK: - Entry points called from liblove (C strings on purpose)
|
||||||
|
|
||||||
@objc(presentPickerWithKind:saveDir:)
|
@objc(presentPickerWithKind:saveDir:)
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ What it does:
|
|||||||
1. Copies mobile/ios/native/ (GRPickerBridge.swift, GRHealthBridge.swift,
|
1. Copies mobile/ios/native/ (GRPickerBridge.swift, GRHealthBridge.swift,
|
||||||
GRBootstrap.m) and the HealthKit entitlements into the LÖVE tree.
|
GRBootstrap.m) and the HealthKit entitlements into the LÖVE tree.
|
||||||
2. Patches liblove's wrap_System.cpp to expose love.system.pickFile,
|
2. Patches liblove's wrap_System.cpp to expose love.system.pickFile,
|
||||||
love.system.createFile, and love.system.syncHealthSteps on iOS (each
|
love.system.createFile, love.system.syncHealthSteps,
|
||||||
|
love.system.httpDownload and love.system.httpRequest on iOS (each
|
||||||
calls a GR*Bridge Swift class through the Objective-C runtime, so
|
calls a GR*Bridge Swift class through the Objective-C runtime, so
|
||||||
liblove never links against Swift directly).
|
liblove never links against Swift directly).
|
||||||
3. Patches love.xcodeproj so the love-ios app target compiles the native
|
3. Patches love.xcodeproj so the love-ios app target compiles the native
|
||||||
@@ -50,6 +51,7 @@ WRAP_INCLUDES = """
|
|||||||
#include <objc/runtime.h>
|
#include <objc/runtime.h>
|
||||||
#include <objc/message.h>
|
#include <objc/message.h>
|
||||||
#include <string>
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
#include "filesystem/Filesystem.h"
|
#include "filesystem/Filesystem.h"
|
||||||
#endif
|
#endif
|
||||||
""" % MARKER
|
""" % MARKER
|
||||||
@@ -159,6 +161,7 @@ WRAP_REGISTRATION = """#ifdef LOVE_IOS
|
|||||||
{ "createFile", w_createFile },
|
{ "createFile", w_createFile },
|
||||||
{ "syncHealthSteps", w_syncHealthSteps },
|
{ "syncHealthSteps", w_syncHealthSteps },
|
||||||
{ "httpDownload", w_httpDownload },
|
{ "httpDownload", w_httpDownload },
|
||||||
|
{ "httpRequest", w_httpRequest },
|
||||||
#endif
|
#endif
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -201,6 +204,7 @@ int w_syncHealthSteps(lua_State *L)
|
|||||||
WRAP_SYNC_REGISTRATION = """#ifdef LOVE_IOS
|
WRAP_SYNC_REGISTRATION = """#ifdef LOVE_IOS
|
||||||
{ "syncHealthSteps", w_syncHealthSteps },
|
{ "syncHealthSteps", w_syncHealthSteps },
|
||||||
{ "httpDownload", w_httpDownload },
|
{ "httpDownload", w_httpDownload },
|
||||||
|
{ "httpRequest", w_httpRequest },
|
||||||
#endif
|
#endif
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -226,6 +230,80 @@ int w_httpDownload(lua_State *L)
|
|||||||
lua_pushboolean(L, ok != 0);
|
lua_pushboolean(L, ok != 0);
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// love.system.httpRequest(url, method, headers, body, userAgent) -> envelope
|
||||||
|
//
|
||||||
|
// The transport save sync needs: a chosen method, per-request auth headers,
|
||||||
|
// and the response body of a 4xx as well as a 2xx. `headers` is a flat array
|
||||||
|
// of alternating name and value strings, joined into "name: value" lines here
|
||||||
|
// because the Swift bridge takes C strings and no Foundation type may be
|
||||||
|
// NAMED in this translation unit (see w_pickFileKinds above).
|
||||||
|
//
|
||||||
|
// The single return is the response envelope -- a head line of
|
||||||
|
// "STATUS <code>" or "ERROR <text>", a newline, then the raw body -- or nil
|
||||||
|
// where the build carries no bridge at all, which src/core/HostShell.lua
|
||||||
|
// turns into an "update the app" notice rather than a failed request.
|
||||||
|
int w_httpRequest(lua_State *L)
|
||||||
|
{
|
||||||
|
const char *url = luaL_checkstring(L, 1);
|
||||||
|
const char *method = luaL_optstring(L, 2, "GET");
|
||||||
|
|
||||||
|
std::string headerBlob;
|
||||||
|
if (!lua_isnoneornil(L, 3))
|
||||||
|
{
|
||||||
|
luaL_checktype(L, 3, LUA_TTABLE);
|
||||||
|
std::vector<std::string> fields;
|
||||||
|
size_t count = luax_objlen(L, 3);
|
||||||
|
for (size_t i = 1; i <= count; i++)
|
||||||
|
{
|
||||||
|
lua_rawgeti(L, 3, (int) i);
|
||||||
|
const char *field = lua_tostring(L, -1);
|
||||||
|
fields.push_back(field != nullptr ? field : "");
|
||||||
|
lua_pop(L, 1);
|
||||||
|
}
|
||||||
|
for (size_t i = 0; i + 1 < fields.size(); i += 2)
|
||||||
|
headerBlob += fields[i] + ": " + fields[i + 1] + "\\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
size_t bodyLen = 0;
|
||||||
|
const char *body = nullptr;
|
||||||
|
if (!lua_isnoneornil(L, 4))
|
||||||
|
body = luaL_checklstring(L, 4, &bodyLen);
|
||||||
|
const char *ua = luaL_optstring(L, 5, "gen1recomp");
|
||||||
|
|
||||||
|
Class cls = objc_getClass("GRPickerBridge");
|
||||||
|
if (cls == nullptr)
|
||||||
|
{
|
||||||
|
lua_pushnil(L);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
typedef id (*GRRequest)(Class, SEL, const char *, const char *,
|
||||||
|
const char *, const unsigned char *, int,
|
||||||
|
const char *);
|
||||||
|
id reply = ((GRRequest)objc_msgSend)(
|
||||||
|
cls,
|
||||||
|
sel_registerName("httpRequestWithUrl:method:headers:body:bodyLength:userAgent:"),
|
||||||
|
url, method, headerBlob.c_str(), (const unsigned char *) body,
|
||||||
|
(int) bodyLen, ua);
|
||||||
|
if (reply == nullptr)
|
||||||
|
{
|
||||||
|
lua_pushnil(L);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
// NSData read through the runtime, for the same reason as above: the
|
||||||
|
// bytes are copied out immediately, before any autorelease pool drains.
|
||||||
|
typedef const void *(*GRBytes)(id, SEL);
|
||||||
|
typedef unsigned long (*GRLength)(id, SEL);
|
||||||
|
const void *bytes = ((GRBytes)objc_msgSend)(reply, sel_registerName("bytes"));
|
||||||
|
unsigned long length = ((GRLength)objc_msgSend)(reply, sel_registerName("length"));
|
||||||
|
if (bytes == nullptr || length == 0)
|
||||||
|
{
|
||||||
|
lua_pushnil(L);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
lua_pushlstring(L, (const char *) bytes, (size_t) length);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
#endif
|
#endif
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -308,7 +386,7 @@ def patch_wrap_system():
|
|||||||
text = text.replace(reg_anchor, reg_anchor + registration, 1)
|
text = text.replace(reg_anchor, reg_anchor + registration, 1)
|
||||||
WRAP_SYSTEM.write_text(text)
|
WRAP_SYSTEM.write_text(text)
|
||||||
print("patch_love_src: wrap_System.cpp patched "
|
print("patch_love_src: wrap_System.cpp patched "
|
||||||
"(pickFile/createFile/syncHealthSteps/httpDownload)")
|
"(pickFile/createFile/syncHealthSteps/httpDownload/httpRequest)")
|
||||||
|
|
||||||
|
|
||||||
def patch_public_documents():
|
def patch_public_documents():
|
||||||
|
|||||||
@@ -2527,11 +2527,7 @@ end
|
|||||||
|
|
||||||
-- the HUD label drawn in place of the level for a statused mon
|
-- the HUD label drawn in place of the level for a statused mon
|
||||||
function BattleState:statusLabel(mon)
|
function BattleState:statusLabel(mon)
|
||||||
local record = Status.recordFor(self.data.statuses, mon.status)
|
return Status.hudLabelFor(self.data.statuses, mon.status)
|
||||||
if record then
|
|
||||||
return record.hudLabel or record.label or mon.status
|
|
||||||
end
|
|
||||||
return mon.status
|
|
||||||
end
|
end
|
||||||
|
|
||||||
-- the one accuracy roll (MoveHitTest), hooked as battle.accuracy
|
-- the one accuracy roll (MoveHitTest), hooked as battle.accuracy
|
||||||
|
|||||||
@@ -54,6 +54,13 @@ end
|
|||||||
-- freeze the English. They are already translatable through the
|
-- freeze the English. They are already translatable through the
|
||||||
-- statuses registry (mod.content.statuses:patch(id, { label = ... })).
|
-- statuses registry (mod.content.statuses:patch(id, { label = ... })).
|
||||||
--
|
--
|
||||||
|
-- Do not add a matching hudLabel = "..." below: Status.hudLabelFor reads
|
||||||
|
-- hudLabel before label, and Registry:patch only overrides the fields a
|
||||||
|
-- mod actually passes, so a label-only translation patch would be
|
||||||
|
-- shadowed by this hudLabel forever. Nothing in this codebase gives
|
||||||
|
-- hudLabel a value different from label -- setting it here only recreates
|
||||||
|
-- that trap for no observed benefit.
|
||||||
|
--
|
||||||
-- The five persistent conditions as records: the beforeMove gauntlet, the
|
-- The five persistent conditions as records: the beforeMove gauntlet, the
|
||||||
-- residual sweep, the inflict text/immunities (StatusRegistry.inflict),
|
-- residual sweep, the inflict text/immunities (StatusRegistry.inflict),
|
||||||
-- the catch/wobble bonuses (Catching.attempt), the HUD label, and the
|
-- the catch/wobble bonuses (Catching.attempt), the HUD label, and the
|
||||||
@@ -61,7 +68,7 @@ end
|
|||||||
-- read these fields, so a mod's sixth status plugs into every consumer.
|
-- read these fields, so a mod's sixth status plugs into every consumer.
|
||||||
Status.RECORDS = {
|
Status.RECORDS = {
|
||||||
SLP = {
|
SLP = {
|
||||||
id = "SLP", label = "SLP", hudLabel = "SLP",
|
id = "SLP", label = "SLP",
|
||||||
catchBonus = 25, shakeBonus = 10,
|
catchBonus = 25, shakeBonus = 10,
|
||||||
beforeMovePriority = 40,
|
beforeMovePriority = 40,
|
||||||
beforeMove = function(battler, _, battle)
|
beforeMove = function(battler, _, battle)
|
||||||
@@ -82,7 +89,7 @@ Status.RECORDS = {
|
|||||||
end,
|
end,
|
||||||
},
|
},
|
||||||
FRZ = {
|
FRZ = {
|
||||||
id = "FRZ", label = "FRZ", hudLabel = "FRZ",
|
id = "FRZ", label = "FRZ",
|
||||||
catchBonus = 25, shakeBonus = 10,
|
catchBonus = 25, shakeBonus = 10,
|
||||||
beforeMovePriority = 30,
|
beforeMovePriority = 30,
|
||||||
beforeMove = function(battler, _, battle)
|
beforeMove = function(battler, _, battle)
|
||||||
@@ -96,7 +103,7 @@ Status.RECORDS = {
|
|||||||
end,
|
end,
|
||||||
},
|
},
|
||||||
PSN = {
|
PSN = {
|
||||||
id = "PSN", label = "PSN", hudLabel = "PSN",
|
id = "PSN", label = "PSN",
|
||||||
catchBonus = 12, shakeBonus = 5,
|
catchBonus = 12, shakeBonus = 5,
|
||||||
residual = damageOverTime("_HurtByPoisonText",
|
residual = damageOverTime("_HurtByPoisonText",
|
||||||
Strings.source("%s's\nhurt by poison!")),
|
Strings.source("%s's\nhurt by poison!")),
|
||||||
@@ -112,7 +119,7 @@ Status.RECORDS = {
|
|||||||
end,
|
end,
|
||||||
},
|
},
|
||||||
BRN = {
|
BRN = {
|
||||||
id = "BRN", label = "BRN", hudLabel = "BRN",
|
id = "BRN", label = "BRN",
|
||||||
catchBonus = 12, shakeBonus = 5,
|
catchBonus = 12, shakeBonus = 5,
|
||||||
statPenalty = { stat = "attack", div = 2 },
|
statPenalty = { stat = "attack", div = 2 },
|
||||||
residual = damageOverTime("_HurtByBurnText",
|
residual = damageOverTime("_HurtByBurnText",
|
||||||
@@ -124,7 +131,7 @@ Status.RECORDS = {
|
|||||||
end,
|
end,
|
||||||
},
|
},
|
||||||
PAR = {
|
PAR = {
|
||||||
id = "PAR", label = "PAR", hudLabel = "PAR",
|
id = "PAR", label = "PAR",
|
||||||
catchBonus = 12, shakeBonus = 5,
|
catchBonus = 12, shakeBonus = 5,
|
||||||
statPenalty = { stat = "speed", div = 4 },
|
statPenalty = { stat = "speed", div = 4 },
|
||||||
beforeMovePriority = 10,
|
beforeMovePriority = 10,
|
||||||
@@ -160,6 +167,14 @@ function Status.recordFor(statuses, id)
|
|||||||
return (statuses or Status.RECORDS)[id]
|
return (statuses or Status.RECORDS)[id]
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- the HUD label for a status id: a mod's patched hudLabel/label if the
|
||||||
|
-- merged registry has one, the raw id otherwise (BattleState.statusLabel,
|
||||||
|
-- SummaryMenu.draw and PartyMenu.draw all read this the same way)
|
||||||
|
function Status.hudLabelFor(statuses, id)
|
||||||
|
local record = Status.recordFor(statuses, id)
|
||||||
|
return record and (record.hudLabel or record.label) or id
|
||||||
|
end
|
||||||
|
|
||||||
local function battleStatuses(battle)
|
local function battleStatuses(battle)
|
||||||
return battle and battle.data and battle.data.statuses
|
return battle and battle.data and battle.data.statuses
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -0,0 +1,523 @@
|
|||||||
|
local Json = require("src.link.Json")
|
||||||
|
local TouchSkin = require("src.core.TouchSkin")
|
||||||
|
|
||||||
|
local DeltaSkin = {}
|
||||||
|
|
||||||
|
DeltaSkin.INFO_NAME = "info.json"
|
||||||
|
DeltaSkin.MAX_INFO_BYTES = 4 * 1024 * 1024
|
||||||
|
|
||||||
|
DeltaSkin.GAME_TYPE_PREFIXES = {
|
||||||
|
"com.rileytestut.delta.game.",
|
||||||
|
"public.aoshuang.game.",
|
||||||
|
}
|
||||||
|
|
||||||
|
DeltaSkin.SYSTEMS = { gb = true, gbc = true }
|
||||||
|
|
||||||
|
DeltaSkin.LEGACY_EXTS = { gbcskin = true, gbaskin = true, gbskin = true }
|
||||||
|
|
||||||
|
DeltaSkin.DEVICE_ORDER = { "iphone", "ipad", "tv" }
|
||||||
|
DeltaSkin.DISPLAY_ORDER = { "edgeToEdge", "standard", "splitView" }
|
||||||
|
DeltaSkin.ORIENTATIONS = { "portrait", "landscape" }
|
||||||
|
DeltaSkin.SIDES = { "up", "down", "left", "right" }
|
||||||
|
|
||||||
|
DeltaSkin.ASSET_LADDER = { "small", "medium", "large" }
|
||||||
|
DeltaSkin.ASSET_WIDTHS = { small = 640, medium = 750, large = 1080 }
|
||||||
|
DeltaSkin.DEFAULT_TARGET_WIDTH = 1080
|
||||||
|
|
||||||
|
DeltaSkin.INPUTS = {
|
||||||
|
a = "a", b = "b", start = "start", select = "select",
|
||||||
|
up = "up", down = "down", left = "left", right = "right",
|
||||||
|
menu = "menu_toggle",
|
||||||
|
fastforward = "hold_fast_forward",
|
||||||
|
togglefastforward = "toggle_fast_forward",
|
||||||
|
}
|
||||||
|
|
||||||
|
DeltaSkin.OUTPUT_HOTKEYS = {
|
||||||
|
menu = "menu",
|
||||||
|
fast_forward_hold = "fastForward",
|
||||||
|
fast_forward_toggle = "toggleFastForward",
|
||||||
|
}
|
||||||
|
|
||||||
|
DeltaSkin.MAPPING = {
|
||||||
|
portrait = { width = 1080, height = 1920 },
|
||||||
|
landscape = { width = 1920, height = 1080 },
|
||||||
|
}
|
||||||
|
|
||||||
|
DeltaSkin.SCREEN_WIDTH = 160
|
||||||
|
DeltaSkin.SCREEN_HEIGHT = 144
|
||||||
|
|
||||||
|
local function pick(t, key)
|
||||||
|
if type(t) ~= "table" then return nil end
|
||||||
|
local direct = t[key]
|
||||||
|
if direct ~= nil then return direct end
|
||||||
|
local want = tostring(key):lower()
|
||||||
|
for k, v in pairs(t) do
|
||||||
|
if tostring(k):lower() == want then return v end
|
||||||
|
end
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local function numOr(v, fallback)
|
||||||
|
local n = tonumber(v)
|
||||||
|
if not n or n ~= n then return fallback end
|
||||||
|
return n
|
||||||
|
end
|
||||||
|
|
||||||
|
local function round(n)
|
||||||
|
return math.floor(numOr(n, 0) + 0.5)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function isArray(t)
|
||||||
|
return type(t) == "table" and t[1] ~= nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local function addWarning(list, text)
|
||||||
|
if type(list) ~= "table" then return end
|
||||||
|
for _, existing in ipairs(list) do
|
||||||
|
if existing == text then return end
|
||||||
|
end
|
||||||
|
list[#list + 1] = text
|
||||||
|
end
|
||||||
|
|
||||||
|
function DeltaSkin.findInfo(root)
|
||||||
|
local direct = root .. "/" .. DeltaSkin.INFO_NAME
|
||||||
|
if TouchSkin.readFile(direct) then return direct, "" end
|
||||||
|
local items = TouchSkin.listDir(root)
|
||||||
|
for _, name in ipairs(items) do
|
||||||
|
if tostring(name):lower() == DeltaSkin.INFO_NAME then
|
||||||
|
return root .. "/" .. name, ""
|
||||||
|
end
|
||||||
|
end
|
||||||
|
table.sort(items)
|
||||||
|
for _, name in ipairs(items) do
|
||||||
|
local nested = root .. "/" .. name .. "/" .. DeltaSkin.INFO_NAME
|
||||||
|
if TouchSkin.readFile(nested) then return nested, name .. "/" end
|
||||||
|
end
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
function DeltaSkin.resolveName(name, opts)
|
||||||
|
name = tostring(name or ""):gsub("\\", "/"):gsub("^%./", "")
|
||||||
|
if name == "" then return nil end
|
||||||
|
local names = opts and opts.names
|
||||||
|
if type(names) == "table" then
|
||||||
|
local want = name:lower()
|
||||||
|
for _, entry in ipairs(names) do
|
||||||
|
if tostring(entry):lower() == want then
|
||||||
|
name = tostring(entry)
|
||||||
|
break
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return ((opts and opts.prefix) or "") .. name
|
||||||
|
end
|
||||||
|
|
||||||
|
function DeltaSkin.pickAsset(assets, opts, pdfFiles)
|
||||||
|
if type(assets) ~= "table" then return nil end
|
||||||
|
pdfFiles = pdfFiles or {}
|
||||||
|
local raster = {}
|
||||||
|
for _, key in ipairs(DeltaSkin.ASSET_LADDER) do
|
||||||
|
local name = pick(assets, key)
|
||||||
|
if key == "medium" and type(name) ~= "string" then name = pick(assets, "normal") end
|
||||||
|
if type(name) == "string" and name ~= "" then
|
||||||
|
if name:lower():match("%.pdf$") then
|
||||||
|
pdfFiles[#pdfFiles + 1] = name
|
||||||
|
else
|
||||||
|
raster[#raster + 1] = { key = key, name = name }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
local resizable = pick(assets, "resizable")
|
||||||
|
if type(resizable) == "string" and resizable ~= "" then
|
||||||
|
if resizable:lower():match("%.pdf$") then
|
||||||
|
pdfFiles[#pdfFiles + 1] = resizable
|
||||||
|
else
|
||||||
|
raster[#raster + 1] = { key = "large", name = resizable }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if #raster == 0 then return nil end
|
||||||
|
|
||||||
|
local target = numOr(opts and opts.targetWidth, DeltaSkin.DEFAULT_TARGET_WIDTH)
|
||||||
|
local chosen
|
||||||
|
for _, cand in ipairs(raster) do
|
||||||
|
if not chosen and (DeltaSkin.ASSET_WIDTHS[cand.key] or 0) >= target then
|
||||||
|
chosen = cand.name
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if not chosen then chosen = raster[#raster].name end
|
||||||
|
return DeltaSkin.resolveName(chosen, opts)
|
||||||
|
end
|
||||||
|
|
||||||
|
function DeltaSkin.mergeEdges(base, item)
|
||||||
|
local out = { top = 0, bottom = 0, left = 0, right = 0 }
|
||||||
|
for _, side in ipairs({ "top", "bottom", "left", "right" }) do
|
||||||
|
local v = pick(item, side)
|
||||||
|
if v == nil then v = pick(base, side) end
|
||||||
|
out[side] = numOr(v, 0)
|
||||||
|
end
|
||||||
|
return out
|
||||||
|
end
|
||||||
|
|
||||||
|
function DeltaSkin.representation(reps, orient)
|
||||||
|
for _, device in ipairs(DeltaSkin.DEVICE_ORDER) do
|
||||||
|
local dev = pick(reps, device)
|
||||||
|
if type(dev) == "table" then
|
||||||
|
for _, display in ipairs(DeltaSkin.DISPLAY_ORDER) do
|
||||||
|
local shown = pick(dev, display)
|
||||||
|
if type(shown) == "table" then
|
||||||
|
local obj = pick(shown, orient)
|
||||||
|
if type(obj) == "table" then return obj, device, display end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
local flat = pick(dev, orient)
|
||||||
|
if type(flat) == "table" and (pick(flat, "items") or pick(flat, "mappingSize")) then
|
||||||
|
return flat, device, nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
function DeltaSkin.directionalInputs(inputs)
|
||||||
|
if type(inputs) ~= "table" or isArray(inputs) then return nil end
|
||||||
|
local out, found = {}, 0
|
||||||
|
for _, side in ipairs(DeltaSkin.SIDES) do
|
||||||
|
local v = pick(inputs, side)
|
||||||
|
if type(v) == "string" then
|
||||||
|
local lower = v:lower()
|
||||||
|
local mapped = DeltaSkin.INPUTS[lower]
|
||||||
|
if not mapped and lower:find(side, 1, true) then mapped = side end
|
||||||
|
if mapped then
|
||||||
|
out[side] = mapped
|
||||||
|
found = found + 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if found >= 2 then return out end
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
function DeltaSkin.specFor(inputs)
|
||||||
|
local parts = {}
|
||||||
|
local function add(v)
|
||||||
|
if type(v) ~= "string" then return end
|
||||||
|
local mapped = DeltaSkin.INPUTS[v:lower()]
|
||||||
|
if mapped then parts[#parts + 1] = mapped end
|
||||||
|
end
|
||||||
|
if type(inputs) == "string" then
|
||||||
|
add(inputs)
|
||||||
|
elseif type(inputs) == "table" then
|
||||||
|
if isArray(inputs) then
|
||||||
|
for _, v in ipairs(inputs) do add(v) end
|
||||||
|
else
|
||||||
|
local keys = {}
|
||||||
|
for k in pairs(inputs) do keys[#keys + 1] = tostring(k) end
|
||||||
|
table.sort(keys)
|
||||||
|
for _, k in ipairs(keys) do add(inputs[k]) end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if #parts == 0 then return "nul" end
|
||||||
|
return table.concat(parts, "|")
|
||||||
|
end
|
||||||
|
|
||||||
|
function DeltaSkin.screenRect(obj, mapW, mapH)
|
||||||
|
local frame
|
||||||
|
local screens = pick(obj, "screens")
|
||||||
|
if type(screens) == "table" and type(screens[1]) == "table" then
|
||||||
|
frame = pick(screens[1], "outputFrame")
|
||||||
|
end
|
||||||
|
if type(frame) ~= "table" then frame = pick(obj, "gameScreenFrame") end
|
||||||
|
if type(frame) ~= "table" then return nil end
|
||||||
|
local w = numOr(pick(frame, "width"), 0)
|
||||||
|
local h = numOr(pick(frame, "height"), 0)
|
||||||
|
if w <= 0 or h <= 0 then return nil end
|
||||||
|
return {
|
||||||
|
x = numOr(pick(frame, "x"), 0) / mapW,
|
||||||
|
y = numOr(pick(frame, "y"), 0) / mapH,
|
||||||
|
w = w / mapW, h = h / mapH,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
function DeltaSkin.addItem(page, item, baseEdges, mapW, mapH)
|
||||||
|
if type(item) ~= "table" then return end
|
||||||
|
local frame = pick(item, "frame")
|
||||||
|
if type(frame) ~= "table" then return end
|
||||||
|
local fw = numOr(pick(frame, "width"), 0)
|
||||||
|
local fh = numOr(pick(frame, "height"), 0)
|
||||||
|
if fw <= 0 or fh <= 0 then return end
|
||||||
|
local fx = numOr(pick(frame, "x"), 0)
|
||||||
|
local fy = numOr(pick(frame, "y"), 0)
|
||||||
|
|
||||||
|
local edges = DeltaSkin.mergeEdges(baseEdges, pick(item, "extendedEdges"))
|
||||||
|
local cx, cy = (fx + fw * 0.5) / mapW, (fy + fh * 0.5) / mapH
|
||||||
|
local w, h = fw / mapW, fh / mapH
|
||||||
|
local reachLeft = 1 + edges.left / (fw * 0.5)
|
||||||
|
local reachRight = 1 + edges.right / (fw * 0.5)
|
||||||
|
local reachUp = 1 + edges.top / (fh * 0.5)
|
||||||
|
local reachDown = 1 + edges.bottom / (fh * 0.5)
|
||||||
|
|
||||||
|
local inputs = pick(item, "inputs")
|
||||||
|
local dirs = DeltaSkin.directionalInputs(inputs)
|
||||||
|
if dirs then
|
||||||
|
local base = {
|
||||||
|
x = cx, y = cy, rangeX = w * 0.5, rangeY = h * 0.5,
|
||||||
|
rangeMod = 1, alphaMod = page.alphaMod, shape = "rect",
|
||||||
|
reachLeft = reachLeft, reachRight = reachRight,
|
||||||
|
reachUp = reachUp, reachDown = reachDown,
|
||||||
|
}
|
||||||
|
for _, ctl in ipairs(TouchSkin.expandDirectional(base, dirs)) do
|
||||||
|
page.controls[#page.controls + 1] = ctl
|
||||||
|
end
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
local shape = tostring(pick(item, "mask") or ""):lower() == "circle" and "radial" or "rect"
|
||||||
|
local ctl = TouchSkin.newControl(DeltaSkin.specFor(inputs), cx, cy, w, h, shape)
|
||||||
|
ctl.alphaMod = page.alphaMod
|
||||||
|
ctl.reachLeft, ctl.reachRight = reachLeft, reachRight
|
||||||
|
ctl.reachUp, ctl.reachDown = reachUp, reachDown
|
||||||
|
page.controls[#page.controls + 1] = ctl
|
||||||
|
end
|
||||||
|
|
||||||
|
function DeltaSkin.buildPage(obj, orient, opts, warnings, pdfFiles)
|
||||||
|
local mapping = pick(obj, "mappingSize")
|
||||||
|
local mapW = numOr(pick(mapping, "width"), 0)
|
||||||
|
local mapH = numOr(pick(mapping, "height"), 0)
|
||||||
|
if mapW <= 0 or mapH <= 0 then
|
||||||
|
mapW, mapH = 320, 240
|
||||||
|
addWarning(warnings, orient .. " has no mappingSize; assuming 320x240")
|
||||||
|
end
|
||||||
|
|
||||||
|
local page = {
|
||||||
|
name = orient,
|
||||||
|
orient = orient,
|
||||||
|
imagePath = DeltaSkin.pickAsset(pick(obj, "assets"), opts, pdfFiles),
|
||||||
|
fullScreen = true,
|
||||||
|
normalized = true,
|
||||||
|
pixelCoords = false,
|
||||||
|
rangeMod = 1,
|
||||||
|
alphaMod = pick(obj, "translucent") == true and 0.7 or 1,
|
||||||
|
aspect = mapW / mapH,
|
||||||
|
aspectFromCfg = false,
|
||||||
|
rect = { x = 0, y = 0, w = 1, h = 1 },
|
||||||
|
mappingWidth = mapW,
|
||||||
|
mappingHeight = mapH,
|
||||||
|
controls = {},
|
||||||
|
}
|
||||||
|
|
||||||
|
local screen = DeltaSkin.screenRect(obj, mapW, mapH)
|
||||||
|
if screen then
|
||||||
|
page.viewport = screen
|
||||||
|
page.viewportFill = false
|
||||||
|
end
|
||||||
|
|
||||||
|
local baseEdges = pick(obj, "extendedEdges")
|
||||||
|
local items = pick(obj, "items")
|
||||||
|
if type(items) == "table" then
|
||||||
|
for _, item in ipairs(items) do
|
||||||
|
DeltaSkin.addItem(page, item, baseEdges, mapW, mapH)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return page
|
||||||
|
end
|
||||||
|
|
||||||
|
function DeltaSkin.systemOf(gameType)
|
||||||
|
if type(gameType) ~= "string" or gameType == "" then return nil end
|
||||||
|
for _, prefix in ipairs(DeltaSkin.GAME_TYPE_PREFIXES) do
|
||||||
|
if gameType:sub(1, #prefix) == prefix then
|
||||||
|
return gameType:sub(#prefix + 1):lower()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
function DeltaSkin.parse(text, opts)
|
||||||
|
opts = opts or {}
|
||||||
|
local info, err = Json.decode(tostring(text or ""), DeltaSkin.MAX_INFO_BYTES)
|
||||||
|
if type(info) ~= "table" then
|
||||||
|
return nil, "info.json does not parse: " .. tostring(err)
|
||||||
|
end
|
||||||
|
|
||||||
|
local gameType = info.gameTypeIdentifier
|
||||||
|
if type(gameType) ~= "string" or gameType == "" then
|
||||||
|
return nil, "old GBA4iOS skin, not supported: info.json has no gameTypeIdentifier"
|
||||||
|
end
|
||||||
|
if gameType:lower():find("gba4ios", 1, true) then
|
||||||
|
return nil, "old GBA4iOS skin, not supported"
|
||||||
|
end
|
||||||
|
local system = DeltaSkin.systemOf(gameType)
|
||||||
|
if not system then
|
||||||
|
return nil, "not a Delta skin: unknown gameTypeIdentifier " .. gameType
|
||||||
|
end
|
||||||
|
|
||||||
|
local warnings = {}
|
||||||
|
if not DeltaSkin.SYSTEMS[system] then
|
||||||
|
addWarning(warnings, "this skin is for " .. system .. ", not Game Boy")
|
||||||
|
end
|
||||||
|
|
||||||
|
local reps = info.representations
|
||||||
|
if type(reps) ~= "table" then return nil, "info.json has no representations" end
|
||||||
|
|
||||||
|
local pdfFiles, pages = {}, {}
|
||||||
|
for _, orient in ipairs(DeltaSkin.ORIENTATIONS) do
|
||||||
|
local obj = DeltaSkin.representation(reps, orient)
|
||||||
|
if obj then
|
||||||
|
local page = DeltaSkin.buildPage(obj, orient, opts, warnings, pdfFiles)
|
||||||
|
page.index = #pages + 1
|
||||||
|
pages[#pages + 1] = page
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if #pages == 0 then return nil, "info.json has no usable representation" end
|
||||||
|
if #pdfFiles > 0 then
|
||||||
|
addWarning(warnings, "PDF artwork cannot be imported yet")
|
||||||
|
end
|
||||||
|
|
||||||
|
return {
|
||||||
|
pages = pages,
|
||||||
|
name = info.name,
|
||||||
|
author = info.author,
|
||||||
|
notes = info.notes,
|
||||||
|
format = "delta",
|
||||||
|
system = system,
|
||||||
|
identifier = info.identifier,
|
||||||
|
warnings = warnings,
|
||||||
|
pdfFiles = pdfFiles,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
function DeltaSkin.needsConversion(skin)
|
||||||
|
if type(skin) ~= "table" then return nil end
|
||||||
|
local files = skin.pdfFiles
|
||||||
|
if type(files) ~= "table" or #files == 0 then return nil end
|
||||||
|
for _, page in ipairs(skin.pages or {}) do
|
||||||
|
if page.imagePath then return nil end
|
||||||
|
end
|
||||||
|
return { pdfOnly = true, files = files }
|
||||||
|
end
|
||||||
|
|
||||||
|
function DeltaSkin.outputInputs(ctl)
|
||||||
|
local out = {}
|
||||||
|
for _, b in ipairs(ctl.buttons or {}) do out[#out + 1] = b end
|
||||||
|
for _, h in ipairs(ctl.hotkeys or {}) do
|
||||||
|
local mapped = DeltaSkin.OUTPUT_HOTKEYS[h]
|
||||||
|
if mapped then out[#out + 1] = mapped end
|
||||||
|
end
|
||||||
|
return out
|
||||||
|
end
|
||||||
|
|
||||||
|
function DeltaSkin.buildRepresentation(page, orient, warnings)
|
||||||
|
local map = DeltaSkin.MAPPING[orient] or DeltaSkin.MAPPING.portrait
|
||||||
|
local mapW, mapH = map.width, map.height
|
||||||
|
local items, files = {}, {}
|
||||||
|
|
||||||
|
for _, ctl in ipairs(page.controls or {}) do
|
||||||
|
local names = DeltaSkin.outputInputs(ctl)
|
||||||
|
if ctl.sector and ctl.sector ~= 1 then
|
||||||
|
names = {}
|
||||||
|
elseif ctl.sector and ctl.areaNames then
|
||||||
|
local TouchSkin = require("src.core.TouchSkin")
|
||||||
|
local dirs = {}
|
||||||
|
for _, side in ipairs({ "up", "down", "left", "right" }) do
|
||||||
|
local mapped = TouchSkin.GB_BUTTONS[tostring(ctl.areaNames[side]):lower()]
|
||||||
|
if mapped then dirs[side] = mapped end
|
||||||
|
end
|
||||||
|
names = next(dirs) and dirs or {}
|
||||||
|
end
|
||||||
|
if names.up or names.down or names.left or names.right or #names > 0 then
|
||||||
|
local item = {
|
||||||
|
inputs = names,
|
||||||
|
frame = {
|
||||||
|
x = round((ctl.x - ctl.rangeX) * mapW),
|
||||||
|
y = round((ctl.y - ctl.rangeY) * mapH),
|
||||||
|
width = round(ctl.rangeX * 2 * mapW),
|
||||||
|
height = round(ctl.rangeY * 2 * mapH),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if ctl.shape == "radial" then item.mask = "circle" end
|
||||||
|
local edges, any = {}, false
|
||||||
|
local pairsList = {
|
||||||
|
{ key = "left", reach = ctl.reachLeft, half = ctl.rangeX * mapW },
|
||||||
|
{ key = "right", reach = ctl.reachRight, half = ctl.rangeX * mapW },
|
||||||
|
{ key = "top", reach = ctl.reachUp, half = ctl.rangeY * mapH },
|
||||||
|
{ key = "bottom", reach = ctl.reachDown, half = ctl.rangeY * mapH },
|
||||||
|
}
|
||||||
|
for _, side in ipairs(pairsList) do
|
||||||
|
local reach = numOr(side.reach, 1)
|
||||||
|
if reach ~= 1 then
|
||||||
|
edges[side.key] = round((reach - 1) * side.half)
|
||||||
|
any = true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if any then item.extendedEdges = edges end
|
||||||
|
items[#items + 1] = item
|
||||||
|
elseif ctl.imagePath then
|
||||||
|
addWarning(warnings, "per-button art is dropped: Delta keeps all art in one image")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local obj = {
|
||||||
|
items = items,
|
||||||
|
mappingSize = { width = mapW, height = mapH },
|
||||||
|
extendedEdges = { top = 0, bottom = 0, left = 0, right = 0 },
|
||||||
|
translucent = false,
|
||||||
|
}
|
||||||
|
if page.imagePath then
|
||||||
|
obj.assets = {
|
||||||
|
small = page.imagePath, medium = page.imagePath, large = page.imagePath,
|
||||||
|
}
|
||||||
|
files[#files + 1] = page.imagePath
|
||||||
|
end
|
||||||
|
if page.viewport then
|
||||||
|
obj.screens = { {
|
||||||
|
inputFrame = { x = 0, y = 0,
|
||||||
|
width = DeltaSkin.SCREEN_WIDTH, height = DeltaSkin.SCREEN_HEIGHT },
|
||||||
|
outputFrame = {
|
||||||
|
x = round(page.viewport.x * mapW), y = round(page.viewport.y * mapH),
|
||||||
|
width = round(page.viewport.w * mapW), height = round(page.viewport.h * mapH),
|
||||||
|
},
|
||||||
|
} }
|
||||||
|
end
|
||||||
|
return obj, files
|
||||||
|
end
|
||||||
|
|
||||||
|
function DeltaSkin.build(skin, opts)
|
||||||
|
if type(skin) ~= "table" or not skin.pages or not skin.pages[1] then
|
||||||
|
return nil, "skin has no pages"
|
||||||
|
end
|
||||||
|
opts = opts or {}
|
||||||
|
local standard, edgeToEdge = {}, {}
|
||||||
|
local assets, warnings, used = {}, {}, {}
|
||||||
|
|
||||||
|
for _, page in ipairs(skin.pages) do
|
||||||
|
local orient = TouchSkin.pageOrient(page)
|
||||||
|
if orient ~= "portrait" and orient ~= "landscape" then
|
||||||
|
orient = (numOr(page.aspect, 1) < 1) and "portrait" or "landscape"
|
||||||
|
end
|
||||||
|
if not used[orient] then
|
||||||
|
used[orient] = true
|
||||||
|
local obj, files = DeltaSkin.buildRepresentation(page, orient, warnings)
|
||||||
|
standard[orient] = obj
|
||||||
|
edgeToEdge[orient] = obj
|
||||||
|
for _, rel in ipairs(files) do assets[#assets + 1] = rel end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local system = tostring(opts.system or "gbc")
|
||||||
|
local info = {
|
||||||
|
name = skin.name or skin.id or "skin",
|
||||||
|
identifier = opts.identifier
|
||||||
|
or ("com.gen1recomp.skin." .. tostring(skin.id or "skin")),
|
||||||
|
gameTypeIdentifier = DeltaSkin.GAME_TYPE_PREFIXES[1] .. system,
|
||||||
|
debug = false,
|
||||||
|
representations = { iphone = { standard = standard, edgeToEdge = edgeToEdge } },
|
||||||
|
}
|
||||||
|
return info, assets, warnings
|
||||||
|
end
|
||||||
|
|
||||||
|
function DeltaSkin.encodeInfo(skin, opts)
|
||||||
|
local info, assets, warnings = DeltaSkin.build(skin, opts)
|
||||||
|
if not info then return nil, assets end
|
||||||
|
return Json.encode(info), assets, warnings
|
||||||
|
end
|
||||||
|
|
||||||
|
return DeltaSkin
|
||||||
@@ -34,6 +34,7 @@ end
|
|||||||
|
|
||||||
function Game:load()
|
function Game:load()
|
||||||
self.data = Data
|
self.data = Data
|
||||||
|
self.sessionStartedAt = os.time()
|
||||||
Data:load()
|
Data:load()
|
||||||
|
|
||||||
-- Mods are a native engine subsystem. They load after the verified ROM
|
-- Mods are a native engine subsystem. They load after the verified ROM
|
||||||
@@ -155,6 +156,7 @@ function Game:makeTitleState()
|
|||||||
onNewGame = function()
|
onNewGame = function()
|
||||||
while self.stack:top() do self.stack:pop() end
|
while self.stack:top() do self.stack:pop() end
|
||||||
-- New Game keeps the standalone options.lua preferences
|
-- New Game keeps the standalone options.lua preferences
|
||||||
|
self.sessionStartedAt = os.time()
|
||||||
self.save = SaveData.newGame(self:bootConfig())
|
self.save = SaveData.newGame(self:bootConfig())
|
||||||
-- no bucket carry-over: mod state from an abandoned session must
|
-- no bucket carry-over: mod state from an abandoned session must
|
||||||
-- not leak into a fresh slot; mods seed via save.created instead
|
-- not leak into a fresh slot; mods seed via save.created instead
|
||||||
@@ -174,6 +176,7 @@ function Game:makeTitleState()
|
|||||||
self:restoreSave(loaded, recovered, { freshBoot = true })
|
self:restoreSave(loaded, recovered, { freshBoot = true })
|
||||||
end
|
end
|
||||||
end,
|
end,
|
||||||
|
onExit = self.onExit,
|
||||||
})
|
})
|
||||||
title.screenId = title.screenId or "TitleState"
|
title.screenId = title.screenId or "TitleState"
|
||||||
return title
|
return title
|
||||||
@@ -356,6 +359,7 @@ function Game:update(dt)
|
|||||||
-- reason: they are presentational, so fast-forward must not speed them up
|
-- reason: they are presentational, so fast-forward must not speed them up
|
||||||
require("src.render.Pipelines").update(dt)
|
require("src.render.Pipelines").update(dt)
|
||||||
pcall(function() require("src.core.DiscordPresence").update(dt) end)
|
pcall(function() require("src.core.DiscordPresence").update(dt) end)
|
||||||
|
self:updateSync(dt)
|
||||||
-- Steady-state memory backstop: advance the incremental collector one
|
-- Steady-state memory backstop: advance the incremental collector one
|
||||||
-- small step every rendered frame. The heavy GPU objects are now freed
|
-- small step every rendered frame. The heavy GPU objects are now freed
|
||||||
-- explicitly (map eviction, battle exit, canvas/renderer swaps), so this
|
-- explicitly (map eviction, battle exit, canvas/renderer swaps), so this
|
||||||
@@ -1178,11 +1182,41 @@ function Game:writeSave()
|
|||||||
-- stamp here so the save.writing payload carries the exact meta the
|
-- stamp here so the save.writing payload carries the exact meta the
|
||||||
-- file gets; mods snapshot runtime state into their namespace now
|
-- file gets; mods snapshot runtime state into their namespace now
|
||||||
self.save.meta = SaveData.buildMeta(
|
self.save.meta = SaveData.buildMeta(
|
||||||
self.modStatus and self.modStatus.loaded, self.save.meta)
|
self.modStatus and self.modStatus.loaded, self.save.meta,
|
||||||
|
self.sessionStartedAt)
|
||||||
if ModRuntime.wants("save.writing") then
|
if ModRuntime.wants("save.writing") then
|
||||||
ModRuntime.emit("save.writing", { save = self.save, meta = self.save.meta })
|
ModRuntime.emit("save.writing", { save = self.save, meta = self.save.meta })
|
||||||
end
|
end
|
||||||
return SaveData.save(self.save)
|
local written = SaveData.save(self.save)
|
||||||
|
if written then
|
||||||
|
local eng = self:syncEngine()
|
||||||
|
if eng then pcall(eng.noteSaveWritten, eng) end
|
||||||
|
end
|
||||||
|
return written
|
||||||
|
end
|
||||||
|
|
||||||
|
function Game:syncEngine()
|
||||||
|
if self._syncOff then return nil end
|
||||||
|
if self._syncEngineRef then return self._syncEngineRef end
|
||||||
|
local ok, SyncEngine = pcall(require, "src.sync.SyncEngine")
|
||||||
|
if not ok or type(SyncEngine) ~= "table" then
|
||||||
|
self._syncOff = true
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
local eng = SyncEngine.shared()
|
||||||
|
if not eng then
|
||||||
|
self._syncOff = true
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
self._syncEngineRef = eng
|
||||||
|
return eng
|
||||||
|
end
|
||||||
|
|
||||||
|
function Game:updateSync(dt)
|
||||||
|
local eng = self:syncEngine()
|
||||||
|
if not eng then return end
|
||||||
|
if not (eng.state.enabled and eng:linked()) and not eng:busy() then return end
|
||||||
|
pcall(eng.update, eng, dt)
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Persist options.lua only (Options menu / hotkeys 2-5). Keeps settings
|
-- Persist options.lua only (Options menu / hotkeys 2-5). Keeps settings
|
||||||
@@ -1241,6 +1275,7 @@ function Game:applyOptions(opts)
|
|||||||
end
|
end
|
||||||
|
|
||||||
function Game:restoreSave(loaded, recovered, opts)
|
function Game:restoreSave(loaded, recovered, opts)
|
||||||
|
self.sessionStartedAt = os.time()
|
||||||
if ModRuntime.wants("save.loading") then
|
if ModRuntime.wants("save.loading") then
|
||||||
ModRuntime.emit("save.loading", { raw = loaded })
|
ModRuntime.emit("save.loading", { raw = loaded })
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ local World = require("src.world.gen2.World")
|
|||||||
-- other engine file, so a call site here is the same call site Gen 1 has.
|
-- other engine file, so a call site here is the same call site Gen 1 has.
|
||||||
local ModRuntime = require("src.mods.Runtime")
|
local ModRuntime = require("src.mods.Runtime")
|
||||||
local GameViewport = require("src.render.GameViewport")
|
local GameViewport = require("src.render.GameViewport")
|
||||||
|
local Playfield = require("src.render.Playfield")
|
||||||
-- Only for the mod-supplied save migrations and the mods-changed report, which
|
-- Only for the mod-supplied save migrations and the mods-changed report, which
|
||||||
-- are keyed off save.meta and know nothing about a generation; Gold's own save
|
-- are keyed off save.meta and know nothing about a generation; Gold's own save
|
||||||
-- IO is src/core/gen2/Save.lua.
|
-- IO is src/core/gen2/Save.lua.
|
||||||
@@ -195,6 +196,7 @@ end
|
|||||||
function Game2:persistOptions()
|
function Game2:persistOptions()
|
||||||
pcall(Save.saveOptions, self.options)
|
pcall(Save.saveOptions, self.options)
|
||||||
end
|
end
|
||||||
|
Game2.writeOptions = Game2.persistOptions
|
||||||
|
|
||||||
-- Point the loader's mod.save backing at this save's modData so per-mod state
|
-- Point the loader's mod.save backing at this save's modData so per-mod state
|
||||||
-- persists with the slot. Same contract and same three call sites as Gen 1
|
-- persists with the slot. Same contract and same three call sites as Gen 1
|
||||||
@@ -318,6 +320,7 @@ function Game2:showMainMenu()
|
|||||||
onNewGame = function() self:newGame() end,
|
onNewGame = function() self:newGame() end,
|
||||||
onContinue = function(save) self:continueGame(save) end,
|
onContinue = function(save) self:continueGame(save) end,
|
||||||
onOption = function() self:showOptions(function() self:showMainMenu() end) end,
|
onOption = function() self:showOptions(function() self:showMainMenu() end) end,
|
||||||
|
onExit = self.onExit,
|
||||||
})
|
})
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -1160,7 +1163,8 @@ end
|
|||||||
-- (Renderer:endFrame's Sp). Following the zoom used to shrink the LCD grid to
|
-- (Renderer:endFrame's Sp). Following the zoom used to shrink the LCD grid to
|
||||||
-- one screen pixel a cell out at survey range.
|
-- one screen pixel a cell out at survey range.
|
||||||
function Game2:pixelScale(w, h)
|
function Game2:pixelScale(w, h)
|
||||||
return math.max(1, math.floor(math.min(w / 160, h / 144)))
|
local _, _, pw, ph = Playfield.rect(w, h)
|
||||||
|
return math.max(1, math.floor(math.min(pw / 160, ph / 144)))
|
||||||
end
|
end
|
||||||
|
|
||||||
-- A window-sized canvas the whole frame is composed into, so the post passes
|
-- A window-sized canvas the whole frame is composed into, so the post passes
|
||||||
@@ -1277,7 +1281,8 @@ end
|
|||||||
function Game2:blitZones(canvas, zones, w, h)
|
function Game2:blitZones(canvas, zones, w, h)
|
||||||
local G = love.graphics
|
local G = love.graphics
|
||||||
local GbcPalette = require("src.render.GbcPalette")
|
local GbcPalette = require("src.render.GbcPalette")
|
||||||
local sx, sy = w / 160, h / 144
|
local px, py, pw, ph = Playfield.rect(w, h)
|
||||||
|
local sx, sy = pw / 160, ph / 144
|
||||||
G.setColor(1, 1, 1, 1)
|
G.setColor(1, 1, 1, 1)
|
||||||
for _, z in ipairs(zones) do
|
for _, z in ipairs(zones) do
|
||||||
-- a colors == false zone is the true-colour opt-out; anything the shader
|
-- a colors == false zone is the true-colour opt-out; anything the shader
|
||||||
@@ -1296,11 +1301,11 @@ function Game2:blitZones(canvas, zones, w, h)
|
|||||||
-- whose contract differs from Gen 1's. Whole-screen and half-screen zones
|
-- whose contract differs from Gen 1's. Whole-screen and half-screen zones
|
||||||
-- come out of this at exactly the pixels the plain floor/ceil pair gave
|
-- come out of this at exactly the pixels the plain floor/ceil pair gave
|
||||||
-- them, so the vanilla picture is untouched.
|
-- them, so the vanilla picture is untouched.
|
||||||
local zx, zy = (z.x or 0) * sx, (z.y or 0) * sy
|
local zx, zy = px + (z.x or 0) * sx, py + (z.y or 0) * sy
|
||||||
local x1 = math.floor(math.max(zx, 0))
|
local x1 = math.floor(math.max(zx, px))
|
||||||
local y1 = math.floor(math.max(zy, 0))
|
local y1 = math.floor(math.max(zy, py))
|
||||||
local x2 = math.ceil(math.min(zx + (z.w or 160) * sx, w))
|
local x2 = math.ceil(math.min(zx + (z.w or 160) * sx, px + pw))
|
||||||
local y2 = math.ceil(math.min(zy + (z.h or 144) * sy, h))
|
local y2 = math.ceil(math.min(zy + (z.h or 144) * sy, py + ph))
|
||||||
if x2 > x1 and y2 > y1 then
|
if x2 > x1 and y2 > y1 then
|
||||||
G.setScissor(x1, y1, x2 - x1, y2 - y1)
|
G.setScissor(x1, y1, x2 - x1, y2 - y1)
|
||||||
G.draw(canvas, 0, 0)
|
G.draw(canvas, 0, 0)
|
||||||
@@ -1402,7 +1407,7 @@ function Game2:drawViewportFrame()
|
|||||||
scene = self:presentCanvas(1, w, h)
|
scene = self:presentCanvas(1, w, h)
|
||||||
end
|
end
|
||||||
if not scene then
|
if not scene then
|
||||||
self:drawScene(w, h)
|
self:drawContained(w, h)
|
||||||
self:drawHud(w, h)
|
self:drawHud(w, h)
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
@@ -1413,7 +1418,7 @@ function Game2:drawViewportFrame()
|
|||||||
G.origin()
|
G.origin()
|
||||||
G.setCanvas(scene)
|
G.setCanvas(scene)
|
||||||
G.clear(0, 0, 0, 1)
|
G.clear(0, 0, 0, 1)
|
||||||
self:drawScene(w, h)
|
self:drawContained(w, h)
|
||||||
G.setCanvas(previous)
|
G.setCanvas(previous)
|
||||||
|
|
||||||
if composing and self:compose(scene, zones, w, h) then
|
if composing and self:compose(scene, zones, w, h) then
|
||||||
@@ -1462,6 +1467,8 @@ function Game2:drawViewportFrame()
|
|||||||
generation = 2,
|
generation = 2,
|
||||||
}) == true
|
}) == true
|
||||||
if not outputHandled then
|
if not outputHandled then
|
||||||
|
local cx, cy, cw, ch = Playfield.cutout(w, h)
|
||||||
|
if cx then G.setScissor(cx, cy, cw, ch) end
|
||||||
if fx then
|
if fx then
|
||||||
GBCFX.present(source, self:pixelScale(w, h))
|
GBCFX.present(source, self:pixelScale(w, h))
|
||||||
else
|
else
|
||||||
@@ -1469,6 +1476,7 @@ function Game2:drawViewportFrame()
|
|||||||
G.draw(source, 0, 0)
|
G.draw(source, 0, 0)
|
||||||
G.setShader()
|
G.setShader()
|
||||||
end
|
end
|
||||||
|
if cx then G.setScissor() end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
G.pop()
|
G.pop()
|
||||||
@@ -1499,6 +1507,13 @@ function Game2:textboxPaper()
|
|||||||
return nil
|
return nil
|
||||||
end
|
end
|
||||||
|
|
||||||
|
function Game2:drawContained(w, h)
|
||||||
|
local pw, ph = Playfield.push(w, h)
|
||||||
|
local ok, err = pcall(self.drawScene, self, pw, ph)
|
||||||
|
Playfield.pop()
|
||||||
|
if not ok then error(err, 0) end
|
||||||
|
end
|
||||||
|
|
||||||
function Game2:drawScene(w, h)
|
function Game2:drawScene(w, h)
|
||||||
local G = love.graphics
|
local G = love.graphics
|
||||||
-- render.compose reads this after the scene is drawn; the plain overworld
|
-- render.compose reads this after the scene is drawn; the plain overworld
|
||||||
|
|||||||
@@ -350,11 +350,23 @@ local function haveBridge()
|
|||||||
return osName == "Android" or osName == "iOS" or osName == "UWP"
|
return osName == "Android" or osName == "iOS" or osName == "UWP"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
local function haveRequestBridge()
|
||||||
|
if not (love and love.system and type(love.system.httpRequest) == "function") then
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
local osName = love.system.getOS and love.system.getOS()
|
||||||
|
return osName == "Android" or osName == "iOS" or osName == "UWP"
|
||||||
|
end
|
||||||
|
|
||||||
-- Is any transport available at all? Callers gate on this, never on curl.
|
-- Is any transport available at all? Callers gate on this, never on curl.
|
||||||
function HostShell.canFetch()
|
function HostShell.canFetch()
|
||||||
return HostShell.haveCurl() or haveBridge()
|
return HostShell.haveCurl() or haveBridge()
|
||||||
end
|
end
|
||||||
|
|
||||||
|
function HostShell.canHttpRequest()
|
||||||
|
return (HostShell.haveCurl() or haveRequestBridge()) and true or false
|
||||||
|
end
|
||||||
|
|
||||||
-- Download url to an absolute host path. Returns true, or nil plus an error.
|
-- Download url to an absolute host path. Returns true, or nil plus an error.
|
||||||
-- The curl branch deliberately ignores curl's exit code, as the download paths
|
-- The curl branch deliberately ignores curl's exit code, as the download paths
|
||||||
-- always did: callers judge the result by the file they got.
|
-- always did: callers judge the result by the file they got.
|
||||||
@@ -557,4 +569,173 @@ function HostShell.httpPost(url, body, contentType, userAgent, maxTime)
|
|||||||
return nil, "no POST transport on this platform"
|
return nil, "no POST transport on this platform"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
local function requestHeaderList(headers)
|
||||||
|
local out = {}
|
||||||
|
if type(headers) == "table" then
|
||||||
|
if #headers > 0 then
|
||||||
|
for _, line in ipairs(headers) do
|
||||||
|
if type(line) == "string" then out[#out + 1] = line end
|
||||||
|
end
|
||||||
|
else
|
||||||
|
local names = {}
|
||||||
|
for name in pairs(headers) do names[#names + 1] = tostring(name) end
|
||||||
|
table.sort(names)
|
||||||
|
for _, name in ipairs(names) do
|
||||||
|
out[#out + 1] = name .. ": " .. tostring(headers[name])
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
for _, line in ipairs(out) do
|
||||||
|
if line:find("[\r\n]") or not line:find(":", 1, true) then return nil end
|
||||||
|
end
|
||||||
|
return out
|
||||||
|
end
|
||||||
|
|
||||||
|
local BRIDGE_METHODS = { GET = true, POST = true, PUT = true, DELETE = true }
|
||||||
|
|
||||||
|
local function requestHeaderPairs(lines)
|
||||||
|
local out = {}
|
||||||
|
for _, line in ipairs(lines) do
|
||||||
|
local name, value = line:match("^%s*([^:]-)%s*:%s*(.-)%s*$")
|
||||||
|
if not name or name == "" then return nil end
|
||||||
|
if name:find("[\r\n]") or value:find("[\r\n]") then return nil end
|
||||||
|
out[#out + 1] = name
|
||||||
|
out[#out + 1] = value
|
||||||
|
end
|
||||||
|
return out
|
||||||
|
end
|
||||||
|
|
||||||
|
local function bridgeRequest(url, method, headers, body, userAgent)
|
||||||
|
if not BRIDGE_METHODS[method] then
|
||||||
|
return nil, "no request transport for " .. method .. " on this platform"
|
||||||
|
end
|
||||||
|
local fields = requestHeaderPairs(headers)
|
||||||
|
if not fields then return nil, "bad request header" end
|
||||||
|
local ok, envelope = pcall(love.system.httpRequest, url, method, fields,
|
||||||
|
body, userAgent)
|
||||||
|
if not ok or type(envelope) ~= "string" or envelope == "" then
|
||||||
|
return nil, "this app build cannot make signed requests: update the app to use save sync"
|
||||||
|
end
|
||||||
|
local head, rest = envelope:match("^([^\n]*)\n(.*)$")
|
||||||
|
if not head then
|
||||||
|
return nil, fetchError(url, nil, "unreadable reply from the network bridge")
|
||||||
|
end
|
||||||
|
local status = tonumber(head:match("^STATUS (%d+)$"))
|
||||||
|
if status then return rest or "", nil, status end
|
||||||
|
return nil, fetchError(url, nil, head:match("^ERROR (.*)$") or head)
|
||||||
|
end
|
||||||
|
|
||||||
|
local requestSeq = 0
|
||||||
|
|
||||||
|
local function requestStagingPath(kind)
|
||||||
|
local dir
|
||||||
|
if love and love.filesystem and love.filesystem.getSaveDirectory then
|
||||||
|
local ok, saveDir = pcall(love.filesystem.getSaveDirectory)
|
||||||
|
if ok and type(saveDir) == "string" and saveDir ~= "" then dir = saveDir end
|
||||||
|
end
|
||||||
|
if not dir then
|
||||||
|
dir = os.getenv("TEMP") or os.getenv("TMP")
|
||||||
|
if not dir or dir == "" then dir = os.getenv("TMPDIR") or "/tmp" end
|
||||||
|
end
|
||||||
|
local sep = dir:find("\\") and "\\" or "/"
|
||||||
|
requestSeq = requestSeq + 1
|
||||||
|
return dir .. sep .. ("gen1recomp-req-%s-%d-%d-%d.tmp"):format(
|
||||||
|
kind, os.time() % 1000000, requestSeq, math.random(0, 999999))
|
||||||
|
end
|
||||||
|
|
||||||
|
local function writeStagingFile(kind, text)
|
||||||
|
local path = requestStagingPath(kind)
|
||||||
|
local file, openErr = io.open(path, "wb")
|
||||||
|
if not file then
|
||||||
|
return nil, "could not create the request " .. kind .. ": " .. tostring(openErr)
|
||||||
|
end
|
||||||
|
local wrote, writeErr = pcall(function()
|
||||||
|
assert(file:write(text))
|
||||||
|
assert(file:close())
|
||||||
|
end)
|
||||||
|
if not wrote then
|
||||||
|
pcall(function() file:close() end)
|
||||||
|
pcall(os.remove, path)
|
||||||
|
return nil, "could not write the request " .. kind .. ": " .. tostring(writeErr)
|
||||||
|
end
|
||||||
|
return path
|
||||||
|
end
|
||||||
|
|
||||||
|
function HostShell.httpRequest(url, opts)
|
||||||
|
opts = type(opts) == "table" and opts or {}
|
||||||
|
if type(url) ~= "string" or url == "" then return nil, "missing url" end
|
||||||
|
local method = tostring(opts.method or "GET"):upper()
|
||||||
|
if not method:match("^%u+$") then return nil, "bad request method" end
|
||||||
|
local headers = requestHeaderList(opts.headers)
|
||||||
|
if not headers then return nil, "bad request header" end
|
||||||
|
local body = opts.body
|
||||||
|
if body ~= nil and type(body) ~= "string" then return nil, "bad request body" end
|
||||||
|
local userAgent = opts.userAgent or "gen1recomp"
|
||||||
|
local maxTime = tonumber(opts.maxTime) or 30
|
||||||
|
|
||||||
|
if not HostShell.haveCurl() then
|
||||||
|
if haveRequestBridge() then
|
||||||
|
return bridgeRequest(url, method, headers, body, userAgent)
|
||||||
|
end
|
||||||
|
if method == "GET" and #headers == 0 then
|
||||||
|
local got, err = HostShell.httpGet(url, userAgent, opts.accept, maxTime)
|
||||||
|
if not got then return nil, err end
|
||||||
|
return got, nil, 200
|
||||||
|
end
|
||||||
|
if haveBridge() then
|
||||||
|
return nil, "this app build cannot make signed requests: update the app to use save sync"
|
||||||
|
end
|
||||||
|
return nil, "no request transport on this platform"
|
||||||
|
end
|
||||||
|
|
||||||
|
local bodyPath, stageErr
|
||||||
|
if body then
|
||||||
|
bodyPath, stageErr = writeStagingFile("body", body)
|
||||||
|
if not bodyPath then return nil, stageErr end
|
||||||
|
end
|
||||||
|
|
||||||
|
local lines = { "User-Agent: " .. userAgent }
|
||||||
|
for _, line in ipairs(headers) do lines[#lines + 1] = line end
|
||||||
|
if body then
|
||||||
|
lines[#lines + 1] = "Content-Length: " .. tostring(#body)
|
||||||
|
end
|
||||||
|
local headerPath
|
||||||
|
headerPath, stageErr = writeStagingFile("head",
|
||||||
|
table.concat(lines, "\n") .. "\n")
|
||||||
|
if not headerPath then
|
||||||
|
if bodyPath then pcall(os.remove, bodyPath) end
|
||||||
|
return nil, stageErr
|
||||||
|
end
|
||||||
|
|
||||||
|
local function cleanup()
|
||||||
|
if bodyPath then pcall(os.remove, bodyPath) end
|
||||||
|
pcall(os.remove, headerPath)
|
||||||
|
end
|
||||||
|
|
||||||
|
local cmd = ("curl -sSL --proto =http,https --proto-redir =http,https "
|
||||||
|
.. "--connect-timeout 10 --max-time %d "):format(maxTime)
|
||||||
|
.. "-X " .. HostShell.quote(method) .. " "
|
||||||
|
.. "-H " .. HostShell.quote("@" .. headerPath) .. " "
|
||||||
|
if body then
|
||||||
|
cmd = cmd .. "--data-binary " .. HostShell.quote("@" .. bodyPath) .. " "
|
||||||
|
end
|
||||||
|
cmd = cmd .. "-w " .. HostShell.quote(HTTP_MARK_FMT) .. " "
|
||||||
|
.. HostShell.quote(url) .. " 2>&1"
|
||||||
|
|
||||||
|
local pipe = HostShell.popen(cmd)
|
||||||
|
if not pipe then
|
||||||
|
cleanup()
|
||||||
|
return nil, "could not run curl"
|
||||||
|
end
|
||||||
|
local readOk, out = pcall(function() return pipe:read("*a") end)
|
||||||
|
HostShell.pclose(pipe)
|
||||||
|
cleanup()
|
||||||
|
if not readOk then
|
||||||
|
return nil, fetchError(url, nil, tostring(out))
|
||||||
|
end
|
||||||
|
local respBody, status, noise = splitCurlOutput(out)
|
||||||
|
if not status then return nil, fetchError(url, nil, noise) end
|
||||||
|
return respBody or "", nil, status
|
||||||
|
end
|
||||||
|
|
||||||
return HostShell
|
return HostShell
|
||||||
|
|||||||
@@ -0,0 +1,219 @@
|
|||||||
|
local SaveData = require("src.core.SaveData")
|
||||||
|
local Version = require("src.core.Version")
|
||||||
|
|
||||||
|
local IssueReport = {}
|
||||||
|
|
||||||
|
local FORM_URL = "https://github.com/bryanthaboi/gen1recomp/issues/new"
|
||||||
|
local TEMPLATE = "bug_report.yml"
|
||||||
|
|
||||||
|
local function clean(value)
|
||||||
|
if value == nil then return nil end
|
||||||
|
local text = tostring(value):gsub("^%s+", ""):gsub("%s+$", "")
|
||||||
|
if text == "" or text == "unknown" or text == "Unknown" then return nil end
|
||||||
|
return text
|
||||||
|
end
|
||||||
|
|
||||||
|
local function call(fn, ...)
|
||||||
|
if type(fn) ~= "function" then return nil end
|
||||||
|
local ok, a, b, c, d, e = pcall(fn, ...)
|
||||||
|
if not ok then return nil end
|
||||||
|
return a, b, c, d, e
|
||||||
|
end
|
||||||
|
|
||||||
|
local function invoke(fn, ...)
|
||||||
|
if type(fn) ~= "function" then return false end
|
||||||
|
local ok, result = pcall(fn, ...)
|
||||||
|
return ok, result
|
||||||
|
end
|
||||||
|
|
||||||
|
local function commandValue(command)
|
||||||
|
if not io or type(io.popen) ~= "function" then return nil end
|
||||||
|
local ok, pipe = pcall(io.popen, command, "r")
|
||||||
|
if not ok or not pipe then return nil end
|
||||||
|
local readOK, value = pcall(pipe.read, pipe, "*l")
|
||||||
|
pcall(pipe.close, pipe)
|
||||||
|
if not readOK then return nil end
|
||||||
|
return clean(value)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function percentEncode(value)
|
||||||
|
local text = tostring(value or "")
|
||||||
|
return (text:gsub("([^%w%-_%.~])", function(char)
|
||||||
|
return ("%%%02X"):format(char:byte())
|
||||||
|
end))
|
||||||
|
end
|
||||||
|
|
||||||
|
local function formOS(raw)
|
||||||
|
local values = {
|
||||||
|
["OS X"] = "macOS",
|
||||||
|
macOS = "macOS",
|
||||||
|
Windows = "Windows",
|
||||||
|
Linux = "Linux",
|
||||||
|
Android = "Android",
|
||||||
|
iOS = "iOS",
|
||||||
|
NX = "Nintendo Switch",
|
||||||
|
UWP = "Xbox",
|
||||||
|
Xbox = "Xbox",
|
||||||
|
}
|
||||||
|
return values[raw] or ""
|
||||||
|
end
|
||||||
|
|
||||||
|
local function loveVersion()
|
||||||
|
local major, minor, revision, codename = call(love and love.getVersion)
|
||||||
|
if not major then return "" end
|
||||||
|
local result = tostring(major) .. "." .. tostring(minor) .. "." .. tostring(revision)
|
||||||
|
if codename and codename ~= "" then result = result .. " (" .. tostring(codename) .. ")" end
|
||||||
|
return result
|
||||||
|
end
|
||||||
|
|
||||||
|
local function appVersion()
|
||||||
|
local version = clean(Version.engine)
|
||||||
|
if not version or version == "0.0.0" or version == "0.0.0-dev" then return "" end
|
||||||
|
return version
|
||||||
|
end
|
||||||
|
|
||||||
|
local function deviceModel(rawOS, system)
|
||||||
|
local model = clean(call(system.getModel))
|
||||||
|
if model then return model end
|
||||||
|
if rawOS == "OS X" or rawOS == "macOS" then
|
||||||
|
return commandValue("sysctl -n hw.model 2>/dev/null")
|
||||||
|
end
|
||||||
|
if rawOS == "Windows" then
|
||||||
|
return commandValue("powershell.exe -NoProfile -NonInteractive -Command \"(Get-CimInstance Win32_ComputerSystem).Model\" 2>NUL")
|
||||||
|
end
|
||||||
|
if rawOS == "Linux" then
|
||||||
|
return commandValue("cat /sys/devices/virtual/dmi/id/product_name 2>/dev/null")
|
||||||
|
or commandValue("cat /sys/devices/virtual/dmi/id/model 2>/dev/null")
|
||||||
|
end
|
||||||
|
if rawOS == "Android" then
|
||||||
|
return commandValue("getprop ro.product.model 2>/dev/null")
|
||||||
|
end
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local function modRows(context)
|
||||||
|
if context and type(context.mods) == "table" then return context.mods end
|
||||||
|
local ok, LauncherMods = pcall(require, "src.mods.LauncherMods")
|
||||||
|
if ok and LauncherMods and LauncherMods.list then
|
||||||
|
local listed = call(LauncherMods.list)
|
||||||
|
if type(listed) == "table" then return listed end
|
||||||
|
end
|
||||||
|
return {}
|
||||||
|
end
|
||||||
|
|
||||||
|
local function modNames(rows, safeMode)
|
||||||
|
local enabled = {}
|
||||||
|
for _, mod in ipairs(rows or {}) do
|
||||||
|
if type(mod) == "table" then
|
||||||
|
local name = clean(mod.name or mod.id)
|
||||||
|
if name and not safeMode and mod.enabled == true then
|
||||||
|
enabled[#enabled + 1] = name
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
table.sort(enabled)
|
||||||
|
return enabled
|
||||||
|
end
|
||||||
|
|
||||||
|
local function metadata(options, context)
|
||||||
|
local system = love and love.system or {}
|
||||||
|
local graphics = love and love.graphics or {}
|
||||||
|
local window = love and love.window or {}
|
||||||
|
local rawOS = clean(call(system.getOS))
|
||||||
|
local model = deviceModel(rawOS, system)
|
||||||
|
local renderer, rendererVersion, _, rendererDevice = call(graphics.getRendererInfo)
|
||||||
|
local width, height = call(graphics.getDimensions)
|
||||||
|
local pixelWidth, pixelHeight = call(graphics.getPixelDimensions)
|
||||||
|
local modeWidth, modeHeight, flags = call(window.getMode)
|
||||||
|
local safeMode = SaveData.isSafeMode(options)
|
||||||
|
local rows = modRows(context or {})
|
||||||
|
local enabledMods = modNames(rows, safeMode)
|
||||||
|
local lines = { "Diagnostics:" }
|
||||||
|
local function add(label, value)
|
||||||
|
value = clean(value)
|
||||||
|
if value then lines[#lines + 1] = "- " .. label .. ": " .. value end
|
||||||
|
end
|
||||||
|
add("Platform", formOS(rawOS))
|
||||||
|
local hardware = model
|
||||||
|
if rendererDevice and rendererDevice ~= model then
|
||||||
|
hardware = hardware and (hardware .. " (" .. rendererDevice .. ")") or rendererDevice
|
||||||
|
end
|
||||||
|
add("Device", hardware)
|
||||||
|
local rendererDetails = clean(renderer)
|
||||||
|
if rendererDetails and clean(rendererVersion) then
|
||||||
|
rendererDetails = rendererDetails .. " " .. clean(rendererVersion)
|
||||||
|
end
|
||||||
|
add("Renderer", rendererDetails)
|
||||||
|
local displayWidth, displayHeight = width or modeWidth or pixelWidth, height or modeHeight or pixelHeight
|
||||||
|
if displayWidth and displayHeight then
|
||||||
|
add("Display", tostring(displayWidth) .. "x" .. tostring(displayHeight))
|
||||||
|
end
|
||||||
|
if pixelWidth and pixelHeight
|
||||||
|
and (pixelWidth ~= displayWidth or pixelHeight ~= displayHeight) then
|
||||||
|
add("Pixel display", tostring(pixelWidth) .. "x" .. tostring(pixelHeight))
|
||||||
|
end
|
||||||
|
if flags and flags.fullscreen == true then add("Fullscreen", "yes") end
|
||||||
|
local version = appVersion()
|
||||||
|
add("App", version ~= "" and Version.title() or "gen1recomp")
|
||||||
|
add("LÖVE", loveVersion())
|
||||||
|
if safeMode then add("Safe mode", "on") end
|
||||||
|
return {
|
||||||
|
rawOS = rawOS,
|
||||||
|
os = formOS(rawOS),
|
||||||
|
device = model,
|
||||||
|
version = version,
|
||||||
|
safeMode = safeMode,
|
||||||
|
enabledMods = enabledMods,
|
||||||
|
metadata = table.concat(lines, "\n"),
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
function IssueReport.build(options, context)
|
||||||
|
options = options or SaveData.loadOptions()
|
||||||
|
context = context or {}
|
||||||
|
local info = metadata(options, context)
|
||||||
|
local fields = {
|
||||||
|
summary = "",
|
||||||
|
mods_which = #info.enabledMods > 0 and table.concat(info.enabledMods, ", ") or "",
|
||||||
|
version = info.version or "",
|
||||||
|
location = "",
|
||||||
|
screenshot = "",
|
||||||
|
steps = "",
|
||||||
|
expected = "",
|
||||||
|
extra = info.metadata,
|
||||||
|
}
|
||||||
|
local params = {
|
||||||
|
"template=" .. percentEncode(TEMPLATE),
|
||||||
|
"title=" .. percentEncode("bug: replace this with a meaningful title"),
|
||||||
|
}
|
||||||
|
local order = { "summary", "mods_which",
|
||||||
|
"version", "location", "screenshot", "steps", "expected", "extra" }
|
||||||
|
for _, key in ipairs(order) do
|
||||||
|
params[#params + 1] = key .. "=" .. percentEncode(fields[key])
|
||||||
|
end
|
||||||
|
return FORM_URL .. "?" .. table.concat(params, "&"), fields, info
|
||||||
|
end
|
||||||
|
|
||||||
|
function IssueReport.open(options, context)
|
||||||
|
local url = IssueReport.build(options, context)
|
||||||
|
local system = love and love.system or {}
|
||||||
|
local opened, openResult = invoke(system.openURL, url)
|
||||||
|
if opened and openResult ~= false then
|
||||||
|
return true, url
|
||||||
|
end
|
||||||
|
local copied, copyResult = invoke(system.setClipboardText, url)
|
||||||
|
if copied and copyResult ~= false then
|
||||||
|
return true, url, "Issue URL copied to the clipboard."
|
||||||
|
end
|
||||||
|
local filesystem = love and love.filesystem or {}
|
||||||
|
local written, writeResult = invoke(filesystem.write, "issue-report-url.txt", url)
|
||||||
|
if written and writeResult ~= false then
|
||||||
|
return true, url, "Issue URL saved to issue-report-url.txt."
|
||||||
|
end
|
||||||
|
return false, url, "No browser, clipboard, or writable save directory is available for the issue report."
|
||||||
|
end
|
||||||
|
|
||||||
|
IssueReport.percentEncode = percentEncode
|
||||||
|
IssueReport.metadata = metadata
|
||||||
|
|
||||||
|
return IssueReport
|
||||||
@@ -67,10 +67,23 @@ local function argFlag(argv, name)
|
|||||||
return false
|
return false
|
||||||
end
|
end
|
||||||
|
|
||||||
|
local cachedIntentGame = nil
|
||||||
|
|
||||||
-- Returns version, slotId (either may be nil). Command line wins over env,
|
-- Returns version, slotId (either may be nil). Command line wins over env,
|
||||||
-- so a shortcut can override a machine-wide default.
|
-- so a shortcut can override a machine-wide default.
|
||||||
function LaunchOptions.resolve(argv)
|
function LaunchOptions.resolve(argv)
|
||||||
|
if cachedIntentGame == nil then
|
||||||
|
if love.system and love.system.getOS and love.system.getOS() == "Android"
|
||||||
|
and love.system.getLaunchGame then
|
||||||
|
cachedIntentGame = normalizeVersion(love.system.getLaunchGame()) or false
|
||||||
|
else
|
||||||
|
cachedIntentGame = false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
local intentGame = cachedIntentGame or nil
|
||||||
|
|
||||||
local game = normalizeVersion(argValue(argv, "game"))
|
local game = normalizeVersion(argValue(argv, "game"))
|
||||||
|
or intentGame
|
||||||
or normalizeVersion(os.getenv("POKEPORT_GAME"))
|
or normalizeVersion(os.getenv("POKEPORT_GAME"))
|
||||||
or normalizeVersion(os.getenv("POKEPORT_LAUNCH"))
|
or normalizeVersion(os.getenv("POKEPORT_LAUNCH"))
|
||||||
local slot = argValue(argv, "slot") or os.getenv("POKEPORT_SLOT")
|
local slot = argValue(argv, "slot") or os.getenv("POKEPORT_SLOT")
|
||||||
|
|||||||
@@ -300,6 +300,7 @@ function SaveData.defaultOptions()
|
|||||||
-- Native mod enablement is an installation option, not save-slot data.
|
-- Native mod enablement is an installation option, not save-slot data.
|
||||||
-- Missing entries mean enabled so newly installed mods work by default.
|
-- Missing entries mean enabled so newly installed mods work by default.
|
||||||
mods = {},
|
mods = {},
|
||||||
|
safeMode = false,
|
||||||
-- Mods the player forced past the target gate (Loader:_gateGeneration).
|
-- Mods the player forced past the target gate (Loader:_gateGeneration).
|
||||||
-- modsGen2[id][version] = true, one answer per game; a bare `true` is the
|
-- modsGen2[id][version] = true, one answer per game; a bare `true` is the
|
||||||
-- pre-per-game shape and means the Gen 2 games only (see modForced).
|
-- pre-per-game shape and means the Gen 2 games only (see modForced).
|
||||||
@@ -356,6 +357,8 @@ function SaveData.defaultOptions()
|
|||||||
-- rewind presentation preferences.
|
-- rewind presentation preferences.
|
||||||
dateFormat = "device", -- device | dmy | mdy | ymd
|
dateFormat = "device", -- device | dmy | mdy | ymd
|
||||||
timeFormat = "device", -- device | 24h | 12h
|
timeFormat = "device", -- device | 24h | 12h
|
||||||
|
saveSync = { enabled = false, lastSyncAt = 0, revs = {}, stamps = {},
|
||||||
|
pendingConflicts = {} },
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -384,6 +387,16 @@ function SaveData.mergeOptions(loaded)
|
|||||||
return opts
|
return opts
|
||||||
end
|
end
|
||||||
|
|
||||||
|
function SaveData.isSafeMode(options)
|
||||||
|
return type(options) == "table" and options.safeMode == true
|
||||||
|
end
|
||||||
|
|
||||||
|
function SaveData.setSafeMode(options, enabled)
|
||||||
|
if type(options) ~= "table" then return false end
|
||||||
|
options.safeMode = enabled == true
|
||||||
|
return options.safeMode
|
||||||
|
end
|
||||||
|
|
||||||
function SaveData.encode(data)
|
function SaveData.encode(data)
|
||||||
return SaveSerializer.encode(data)
|
return SaveSerializer.encode(data)
|
||||||
end
|
end
|
||||||
@@ -1013,6 +1026,22 @@ function SaveData.listSlots(version)
|
|||||||
return out
|
return out
|
||||||
end
|
end
|
||||||
|
|
||||||
|
function SaveData.readSlotSource(version, slotId, injectedFs)
|
||||||
|
version = version or GameVersion.get()
|
||||||
|
if not knownVersion(version) or type(slotId) ~= "string" then return nil end
|
||||||
|
local fs = persistFs(injectedFs)
|
||||||
|
local main, bak, tmp = slotNames(version, slotId)
|
||||||
|
for _, name in ipairs({ main, tmp, bak }) do
|
||||||
|
if fs.getInfo(name) then
|
||||||
|
local body = fs.read(name)
|
||||||
|
if type(body) == "string" and body ~= "" then
|
||||||
|
if SaveSerializer.decode(body) then return body end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
-- Give a registered slot a custom label (#205: "a way to name save slots so
|
-- Give a registered slot a custom label (#205: "a way to name save slots so
|
||||||
-- you can see that in the launcher"). The label lives in the options
|
-- you can see that in the launcher"). The label lives in the options
|
||||||
-- registry next to list/active, never in the save file itself, so renaming
|
-- registry next to list/active, never in the save file itself, so renaming
|
||||||
@@ -1327,7 +1356,7 @@ end
|
|||||||
-- loaded list sorted by id and is the ground truth for the load-time
|
-- loaded list sorted by id and is the ground truth for the load-time
|
||||||
-- mod-set diff. A nil mods list keeps the previous stamp's set so a
|
-- mod-set diff. A nil mods list keeps the previous stamp's set so a
|
||||||
-- headless writer (the save editor) never wipes it.
|
-- headless writer (the save editor) never wipes it.
|
||||||
function SaveData.buildMeta(mods, previous)
|
function SaveData.buildMeta(mods, previous, sessionStart)
|
||||||
local list
|
local list
|
||||||
if mods ~= nil then
|
if mods ~= nil then
|
||||||
list = {}
|
list = {}
|
||||||
@@ -1338,10 +1367,18 @@ function SaveData.buildMeta(mods, previous)
|
|||||||
else
|
else
|
||||||
list = (type(previous) == "table" and previous.mods) or {}
|
list = (type(previous) == "table" and previous.mods) or {}
|
||||||
end
|
end
|
||||||
|
local started = tonumber(sessionStart)
|
||||||
|
if not started or started ~= started or started <= 0
|
||||||
|
or started == math.huge then
|
||||||
|
started = type(previous) == "table" and tonumber(previous.sessionStart) or nil
|
||||||
|
end
|
||||||
|
local savedAt = os.time()
|
||||||
|
if started and started > savedAt then started = savedAt end
|
||||||
return {
|
return {
|
||||||
format = Version.saveFormat,
|
format = Version.saveFormat,
|
||||||
engine = Version.engine,
|
engine = Version.engine,
|
||||||
savedAt = os.time(),
|
savedAt = savedAt,
|
||||||
|
sessionStart = started,
|
||||||
playthroughId = type(previous) == "table" and previous.playthroughId or nil,
|
playthroughId = type(previous) == "table" and previous.playthroughId or nil,
|
||||||
mods = list,
|
mods = list,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -616,13 +616,15 @@ local function exitControl(self, ctl)
|
|||||||
for _, action in ipairs(ctl.hotkeys) do fireHotkey(self, action, false, ctl) end
|
for _, action in ipairs(ctl.hotkeys) do fireHotkey(self, action, false, ctl) end
|
||||||
end
|
end
|
||||||
|
|
||||||
function skinHitSet(self, x, y)
|
function skinHitSet(self, x, y, prev)
|
||||||
local page = TouchSkin.page()
|
local page = TouchSkin.page()
|
||||||
if not page then return nil end
|
if not page then return nil end
|
||||||
local ww, wh, ox, oy = surfaceRect()
|
local ww, wh, ox, oy = surfaceRect()
|
||||||
local set = nil
|
local set = nil
|
||||||
for _, ctl in ipairs(page.controls) do
|
for _, ctl in ipairs(page.controls) do
|
||||||
if not ctl.decorative and TouchSkin.hits(page, ctl, ww, wh, x, y, ox, oy) then
|
local held = (prev and prev[ctl]) == true
|
||||||
|
if not ctl.decorative
|
||||||
|
and TouchSkin.hits(page, ctl, ww, wh, x, y, ox, oy, held) then
|
||||||
set = set or {}
|
set = set or {}
|
||||||
set[ctl] = true
|
set[ctl] = true
|
||||||
end
|
end
|
||||||
@@ -665,7 +667,7 @@ function TouchControls:touchpressed(id, x, y)
|
|||||||
return
|
return
|
||||||
end
|
end
|
||||||
if TouchSkin.active then
|
if TouchSkin.active then
|
||||||
local set = skinHitSet(self, x, y)
|
local set = skinHitSet(self, x, y, nil)
|
||||||
if not set then return end
|
if not set then return end
|
||||||
local touch = { control = "skin" }
|
local touch = { control = "skin" }
|
||||||
self.touches[id] = touch
|
self.touches[id] = touch
|
||||||
@@ -698,7 +700,7 @@ function TouchControls:touchmoved(id, x, y)
|
|||||||
local touch = self.touches[id]
|
local touch = self.touches[id]
|
||||||
if not touch then return end
|
if not touch then return end
|
||||||
if touch.control == "skin" then
|
if touch.control == "skin" then
|
||||||
applySkinSet(self, touch, skinHitSet(self, x, y))
|
applySkinSet(self, touch, skinHitSet(self, x, y, touch.set))
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
-- only the d-pad tracks movement (slide between directions without
|
-- only the d-pad tracks movement (slide between directions without
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ local TouchSkin = {}
|
|||||||
|
|
||||||
TouchSkin.BUNDLED_ROOT = "assets/skins"
|
TouchSkin.BUNDLED_ROOT = "assets/skins"
|
||||||
TouchSkin.USER_ROOT = "skins"
|
TouchSkin.USER_ROOT = "skins"
|
||||||
|
TouchSkin.EXPORT_ROOT = "skins/_export"
|
||||||
|
|
||||||
TouchSkin.GB_BUTTONS = {
|
TouchSkin.GB_BUTTONS = {
|
||||||
a = "a", b = "b", start = "start", select = "select",
|
a = "a", b = "b", start = "start", select = "select",
|
||||||
@@ -83,6 +84,110 @@ local function parseBinds(spec)
|
|||||||
return buttons, hotkeys, keys, decorative
|
return buttons, hotkeys, keys, decorative
|
||||||
end
|
end
|
||||||
|
|
||||||
|
TouchSkin.AREA_DEFAULTS = {
|
||||||
|
dpad_area = { up = "up", down = "down", left = "left", right = "right" },
|
||||||
|
abxy_area = { up = "x", down = "b", left = "y", right = "a" },
|
||||||
|
analog_left = { up = "up", down = "down", left = "left", right = "right" },
|
||||||
|
analog_right = { up = "up", down = "down", left = "left", right = "right" },
|
||||||
|
}
|
||||||
|
|
||||||
|
local DIRECTIONAL_CELLS = {
|
||||||
|
{ col = 1, row = 1, h = "left", v = "up" },
|
||||||
|
{ col = 2, row = 1, v = "up" },
|
||||||
|
{ col = 3, row = 1, h = "right", v = "up" },
|
||||||
|
{ col = 1, row = 2, h = "left" },
|
||||||
|
{ col = 3, row = 2, h = "right" },
|
||||||
|
{ col = 1, row = 3, h = "left", v = "down" },
|
||||||
|
{ col = 2, row = 3, v = "down" },
|
||||||
|
{ col = 3, row = 3, h = "right", v = "down" },
|
||||||
|
}
|
||||||
|
|
||||||
|
local function outwardReach(reach)
|
||||||
|
return 1 + 3 * ((num(reach, 1)) - 1)
|
||||||
|
end
|
||||||
|
|
||||||
|
function TouchSkin.expandDirectional(base, names)
|
||||||
|
names = names or {}
|
||||||
|
local cellX = math.abs(num(base.rangeX, 0.05)) / 3
|
||||||
|
local cellY = math.abs(num(base.rangeY, 0.05)) / 3
|
||||||
|
local out = {}
|
||||||
|
for _, cell in ipairs(DIRECTIONAL_CELLS) do
|
||||||
|
local parts = {}
|
||||||
|
if cell.h and names[cell.h] then parts[#parts + 1] = names[cell.h] end
|
||||||
|
if cell.v and names[cell.v] then parts[#parts + 1] = names[cell.v] end
|
||||||
|
local spec = #parts > 0 and table.concat(parts, "|") or "nul"
|
||||||
|
local ctl = TouchSkin.newControl(spec,
|
||||||
|
num(base.x, 0.5) + (cell.col - 2) * cellX * 2,
|
||||||
|
num(base.y, 0.5) + (cell.row - 2) * cellY * 2,
|
||||||
|
cellX * 2, cellY * 2, "rect")
|
||||||
|
ctl.rangeMod = num(base.rangeMod, 1)
|
||||||
|
ctl.alphaMod = num(base.alphaMod, 1)
|
||||||
|
ctl.reachLeft = cell.col == 1 and outwardReach(base.reachLeft) or 1
|
||||||
|
ctl.reachRight = cell.col == 3 and outwardReach(base.reachRight) or 1
|
||||||
|
ctl.reachUp = cell.row == 1 and outwardReach(base.reachUp) or 1
|
||||||
|
ctl.reachDown = cell.row == 3 and outwardReach(base.reachDown) or 1
|
||||||
|
ctl.pixelCoords = base.pixelCoords
|
||||||
|
ctl.movable = base.movable
|
||||||
|
ctl.exclusive = base.exclusive
|
||||||
|
out[#out + 1] = ctl
|
||||||
|
end
|
||||||
|
return out
|
||||||
|
end
|
||||||
|
|
||||||
|
local SECTOR_CELLS = {
|
||||||
|
{ h = "right" },
|
||||||
|
{ h = "right", v = "down" },
|
||||||
|
{ v = "down" },
|
||||||
|
{ h = "left", v = "down" },
|
||||||
|
{ h = "left" },
|
||||||
|
{ h = "left", v = "up" },
|
||||||
|
{ v = "up" },
|
||||||
|
{ h = "right", v = "up" },
|
||||||
|
}
|
||||||
|
|
||||||
|
TouchSkin.SECTOR_SPAN = math.pi / 4
|
||||||
|
|
||||||
|
function TouchSkin.sectorHit(sector, dx, dy)
|
||||||
|
local span = TouchSkin.SECTOR_SPAN
|
||||||
|
local start = (sector - 1) * span - span * 0.5
|
||||||
|
local a = (math.atan2(dy, dx) - start) % (math.pi * 2)
|
||||||
|
return a < span
|
||||||
|
end
|
||||||
|
|
||||||
|
function TouchSkin.expandSectors(base, names)
|
||||||
|
names = names or {}
|
||||||
|
local out = {}
|
||||||
|
for i, cell in ipairs(SECTOR_CELLS) do
|
||||||
|
local parts = {}
|
||||||
|
if cell.h and names[cell.h] then parts[#parts + 1] = names[cell.h] end
|
||||||
|
if cell.v and names[cell.v] then parts[#parts + 1] = names[cell.v] end
|
||||||
|
local spec = #parts > 0 and table.concat(parts, "|") or "nul"
|
||||||
|
local ctl = TouchSkin.newControl(spec, num(base.x, 0.5), num(base.y, 0.5),
|
||||||
|
math.abs(num(base.rangeX, 0.05)) * 2, math.abs(num(base.rangeY, 0.05)) * 2,
|
||||||
|
base.shape)
|
||||||
|
ctl.sector = i
|
||||||
|
ctl.areaKind = base.areaKind
|
||||||
|
ctl.areaNames = base.areaNames
|
||||||
|
ctl.rangeMod = num(base.rangeMod, 1)
|
||||||
|
ctl.alphaMod = num(base.alphaMod, 1)
|
||||||
|
ctl.reachLeft = num(base.reachLeft, 1)
|
||||||
|
ctl.reachRight = num(base.reachRight, 1)
|
||||||
|
ctl.reachUp = num(base.reachUp, 1)
|
||||||
|
ctl.reachDown = num(base.reachDown, 1)
|
||||||
|
ctl.pixelCoords = base.pixelCoords
|
||||||
|
ctl.movable = base.movable
|
||||||
|
ctl.exclusive = base.exclusive
|
||||||
|
out[#out + 1] = ctl
|
||||||
|
end
|
||||||
|
return out
|
||||||
|
end
|
||||||
|
|
||||||
|
local function areaSide(kv, prefix, side, fallback)
|
||||||
|
local v = kv[prefix .. "_" .. side]
|
||||||
|
if v == nil or trim(v) == "" then return fallback end
|
||||||
|
return trim(v)
|
||||||
|
end
|
||||||
|
|
||||||
local function parseDesc(kv, prefix, page)
|
local function parseDesc(kv, prefix, page)
|
||||||
local spec = kv[prefix]
|
local spec = kv[prefix]
|
||||||
if not spec then return nil end
|
if not spec then return nil end
|
||||||
@@ -116,9 +221,28 @@ local function parseDesc(kv, prefix, page)
|
|||||||
imagePath = kv[prefix .. "_overlay"],
|
imagePath = kv[prefix .. "_overlay"],
|
||||||
pressedImagePath = kv[prefix .. "_overlay_pressed"],
|
pressedImagePath = kv[prefix .. "_overlay_pressed"],
|
||||||
nextTarget = kv[prefix .. "_next_target"],
|
nextTarget = kv[prefix .. "_next_target"],
|
||||||
|
movable = toBool(kv[prefix .. "_movable"]) or nil,
|
||||||
|
exclusive = (toBool(kv[prefix .. "_exclusive"])
|
||||||
|
or toBool(kv[prefix .. "_range_mod_exclusive"])) or nil,
|
||||||
|
saturatePct = num(kv[prefix .. "_saturate_pct"], nil),
|
||||||
}
|
}
|
||||||
if ctl.imagePath == "" then ctl.imagePath = nil end
|
if ctl.imagePath == "" then ctl.imagePath = nil end
|
||||||
if ctl.pressedImagePath == "" then ctl.pressedImagePath = nil end
|
if ctl.pressedImagePath == "" then ctl.pressedImagePath = nil end
|
||||||
|
|
||||||
|
local normalized = kv[prefix .. "_normalized"]
|
||||||
|
if normalized ~= nil then ctl.pixelCoords = not toBool(normalized) end
|
||||||
|
|
||||||
|
local areaKind = trim(t[1]):lower()
|
||||||
|
local defaults = TouchSkin.AREA_DEFAULTS[areaKind]
|
||||||
|
if defaults then
|
||||||
|
ctl.areaKind = areaKind
|
||||||
|
ctl.areaNames = {
|
||||||
|
up = areaSide(kv, prefix, "up", defaults.up),
|
||||||
|
down = areaSide(kv, prefix, "down", defaults.down),
|
||||||
|
left = areaSide(kv, prefix, "left", defaults.left),
|
||||||
|
right = areaSide(kv, prefix, "right", defaults.right),
|
||||||
|
}
|
||||||
|
end
|
||||||
return ctl
|
return ctl
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -127,7 +251,14 @@ function TouchSkin.parse(text)
|
|||||||
local count = math.floor(num(kv.overlays, 0))
|
local count = math.floor(num(kv.overlays, 0))
|
||||||
if count <= 0 then return nil, "no overlays" end
|
if count <= 0 then return nil, "no overlays" end
|
||||||
|
|
||||||
local pages = {}
|
local pages, warnings = {}, {}
|
||||||
|
local function warn(text)
|
||||||
|
for _, existing in ipairs(warnings) do
|
||||||
|
if existing == text then return end
|
||||||
|
end
|
||||||
|
warnings[#warnings + 1] = text
|
||||||
|
end
|
||||||
|
|
||||||
for i = 0, count - 1 do
|
for i = 0, count - 1 do
|
||||||
local p = "overlay" .. i
|
local p = "overlay" .. i
|
||||||
local page = {
|
local page = {
|
||||||
@@ -166,10 +297,34 @@ function TouchSkin.parse(text)
|
|||||||
page.viewportExpand = toBool(kv[p .. "_viewport_expand"])
|
page.viewportExpand = toBool(kv[p .. "_viewport_expand"])
|
||||||
end
|
end
|
||||||
|
|
||||||
|
page.pixelCoords = not page.normalized
|
||||||
|
if page.pixelCoords and not page.imagePath then
|
||||||
|
page.pixelCoords = false
|
||||||
|
warn(page.name .. " has no base image: desc coordinates read as normalized")
|
||||||
|
end
|
||||||
|
|
||||||
local descs = math.floor(num(kv[p .. "_descs"], 0))
|
local descs = math.floor(num(kv[p .. "_descs"], 0))
|
||||||
for d = 0, descs - 1 do
|
for d = 0, descs - 1 do
|
||||||
local ctl = parseDesc(kv, p .. "_desc" .. d, page)
|
local ctl = parseDesc(kv, p .. "_desc" .. d, page)
|
||||||
if ctl then page.controls[#page.controls + 1] = ctl end
|
if not ctl then
|
||||||
|
warn(page.name .. " is missing desc " .. d)
|
||||||
|
elseif ctl.areaKind then
|
||||||
|
if ctl.imagePath or ctl.pressedImagePath then
|
||||||
|
local art = TouchSkin.newControl("nul", ctl.x, ctl.y,
|
||||||
|
ctl.rangeX * 2, ctl.rangeY * 2, ctl.shape)
|
||||||
|
art.imagePath = ctl.imagePath
|
||||||
|
art.pressedImagePath = ctl.pressedImagePath
|
||||||
|
art.rangeMod, art.alphaMod = ctl.rangeMod, ctl.alphaMod
|
||||||
|
art.pixelCoords = ctl.pixelCoords
|
||||||
|
art.movable, art.exclusive = ctl.movable, ctl.exclusive
|
||||||
|
page.controls[#page.controls + 1] = art
|
||||||
|
end
|
||||||
|
for _, cell in ipairs(TouchSkin.expandSectors(ctl, ctl.areaNames)) do
|
||||||
|
page.controls[#page.controls + 1] = cell
|
||||||
|
end
|
||||||
|
else
|
||||||
|
page.controls[#page.controls + 1] = ctl
|
||||||
|
end
|
||||||
end
|
end
|
||||||
pages[#pages + 1] = page
|
pages[#pages + 1] = page
|
||||||
end
|
end
|
||||||
@@ -180,7 +335,7 @@ function TouchSkin.parse(text)
|
|||||||
if not page.orient then page.orient = TouchSkin.pageOrient(page) end
|
if not page.orient then page.orient = TouchSkin.pageOrient(page) end
|
||||||
end
|
end
|
||||||
|
|
||||||
return { pages = pages }
|
return { pages = pages, warnings = warnings }
|
||||||
end
|
end
|
||||||
|
|
||||||
local function readFile(path)
|
local function readFile(path)
|
||||||
@@ -219,18 +374,26 @@ end
|
|||||||
|
|
||||||
TouchSkin.NATIVE_NAME = "skin.lua"
|
TouchSkin.NATIVE_NAME = "skin.lua"
|
||||||
|
|
||||||
|
TouchSkin.readFile = readFile
|
||||||
|
TouchSkin.listDir = listDir
|
||||||
|
TouchSkin.isDir = isDir
|
||||||
|
|
||||||
local function findConfig(root)
|
local function findConfig(root)
|
||||||
if readFile(root .. "/" .. TouchSkin.NATIVE_NAME) then
|
if readFile(root .. "/" .. TouchSkin.NATIVE_NAME) then
|
||||||
return root .. "/" .. TouchSkin.NATIVE_NAME, "native"
|
return root .. "/" .. TouchSkin.NATIVE_NAME, "native", ""
|
||||||
end
|
end
|
||||||
local named = { "overlay.cfg", "skin.cfg", "layout.cfg" }
|
local named = { "overlay.cfg", "skin.cfg", "layout.cfg" }
|
||||||
for _, name in ipairs(named) do
|
for _, name in ipairs(named) do
|
||||||
if readFile(root .. "/" .. name) then return root .. "/" .. name, "retroarch" end
|
if readFile(root .. "/" .. name) then
|
||||||
|
return root .. "/" .. name, "retroarch", ""
|
||||||
end
|
end
|
||||||
|
end
|
||||||
|
local infoPath, prefix = require("src.core.DeltaSkin").findInfo(root)
|
||||||
|
if infoPath then return infoPath, "delta", prefix end
|
||||||
local items = listDir(root)
|
local items = listDir(root)
|
||||||
table.sort(items)
|
table.sort(items)
|
||||||
for _, name in ipairs(items) do
|
for _, name in ipairs(items) do
|
||||||
if name:match("%.cfg$") then return root .. "/" .. name, "retroarch" end
|
if name:match("%.cfg$") then return root .. "/" .. name, "retroarch", "" end
|
||||||
end
|
end
|
||||||
return nil
|
return nil
|
||||||
end
|
end
|
||||||
@@ -262,6 +425,7 @@ function TouchSkin.parseNative(text)
|
|||||||
imagePath = raw.image,
|
imagePath = raw.image,
|
||||||
fullScreen = raw.fullScreen ~= false,
|
fullScreen = raw.fullScreen ~= false,
|
||||||
normalized = true,
|
normalized = true,
|
||||||
|
pixelCoords = false,
|
||||||
rangeMod = num(raw.rangeMod, 1),
|
rangeMod = num(raw.rangeMod, 1),
|
||||||
alphaMod = num(raw.alphaMod, 1),
|
alphaMod = num(raw.alphaMod, 1),
|
||||||
aspect = num(raw.aspect, DEFAULT_ASPECT),
|
aspect = num(raw.aspect, DEFAULT_ASPECT),
|
||||||
@@ -283,7 +447,24 @@ function TouchSkin.parseNative(text)
|
|||||||
end
|
end
|
||||||
for _, c in ipairs(raw.controls or {}) do
|
for _, c in ipairs(raw.controls or {}) do
|
||||||
local buttons, hotkeys, keys, decorative = parseBinds(c.bind or "nul")
|
local buttons, hotkeys, keys, decorative = parseBinds(c.bind or "nul")
|
||||||
|
local sector = tonumber(c.sector)
|
||||||
|
if sector then
|
||||||
|
sector = math.floor(sector)
|
||||||
|
if sector < 1 or sector > #SECTOR_CELLS then sector = nil end
|
||||||
|
end
|
||||||
|
local areaNames
|
||||||
|
if type(c.areaNames) == "table" then
|
||||||
|
areaNames = {}
|
||||||
|
for _, side in ipairs({ "up", "down", "left", "right" }) do
|
||||||
|
if type(c.areaNames[side]) == "string" then
|
||||||
|
areaNames[side] = c.areaNames[side]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
page.controls[#page.controls + 1] = {
|
page.controls[#page.controls + 1] = {
|
||||||
|
sector = sector,
|
||||||
|
areaKind = type(c.areaKind) == "string" and c.areaKind or nil,
|
||||||
|
areaNames = areaNames,
|
||||||
spec = tostring(c.bind or "nul"),
|
spec = tostring(c.bind or "nul"),
|
||||||
buttons = buttons, hotkeys = hotkeys, keys = keys,
|
buttons = buttons, hotkeys = hotkeys, keys = keys,
|
||||||
decorative = decorative,
|
decorative = decorative,
|
||||||
@@ -298,6 +479,8 @@ function TouchSkin.parseNative(text)
|
|||||||
imagePath = c.image,
|
imagePath = c.image,
|
||||||
pressedImagePath = c.imagePressed,
|
pressedImagePath = c.imagePressed,
|
||||||
nextTarget = c.nextTarget,
|
nextTarget = c.nextTarget,
|
||||||
|
movable = c.movable == true or nil,
|
||||||
|
exclusive = c.exclusive == true or nil,
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
if not page.orient then page.orient = TouchSkin.pageOrient(page) end
|
if not page.orient then page.orient = TouchSkin.pageOrient(page) end
|
||||||
@@ -355,6 +538,14 @@ function TouchSkin.toNative(skin)
|
|||||||
image = ctl.imagePath,
|
image = ctl.imagePath,
|
||||||
imagePressed = ctl.pressedImagePath,
|
imagePressed = ctl.pressedImagePath,
|
||||||
nextTarget = ctl.nextTarget,
|
nextTarget = ctl.nextTarget,
|
||||||
|
movable = ctl.movable or nil,
|
||||||
|
exclusive = ctl.exclusive or nil,
|
||||||
|
sector = ctl.sector,
|
||||||
|
areaKind = ctl.areaKind,
|
||||||
|
areaNames = ctl.areaNames and {
|
||||||
|
up = ctl.areaNames.up, down = ctl.areaNames.down,
|
||||||
|
left = ctl.areaNames.left, right = ctl.areaNames.right,
|
||||||
|
} or nil,
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
out.pages[#out.pages + 1] = p
|
out.pages[#out.pages + 1] = p
|
||||||
@@ -379,14 +570,44 @@ local function loadImage(path)
|
|||||||
return img
|
return img
|
||||||
end
|
end
|
||||||
|
|
||||||
|
local function pixelScalePending(page)
|
||||||
|
if page.pixelCoords then return true end
|
||||||
|
for _, ctl in ipairs(page.controls or {}) do
|
||||||
|
if ctl.pixelCoords then return true end
|
||||||
|
end
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
local function applyPixelScale(page)
|
||||||
|
if not pixelScalePending(page) then return true end
|
||||||
|
if not page.image or not page.image.getDimensions then return false end
|
||||||
|
local iw, ih = page.image:getDimensions()
|
||||||
|
if not iw or not ih or iw <= 0 or ih <= 0 then return false end
|
||||||
|
for _, ctl in ipairs(page.controls or {}) do
|
||||||
|
local pixel = ctl.pixelCoords
|
||||||
|
if pixel == nil then pixel = page.pixelCoords end
|
||||||
|
if pixel then
|
||||||
|
ctl.x, ctl.y = ctl.x / iw, ctl.y / ih
|
||||||
|
ctl.rangeX, ctl.rangeY = ctl.rangeX / iw, ctl.rangeY / ih
|
||||||
|
ctl.pixelCoords = false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
page.pixelCoords = false
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
function TouchSkin.load(root, id)
|
function TouchSkin.load(root, id)
|
||||||
local cfgPath, format = findConfig(root)
|
local cfgPath, format, prefix = findConfig(root)
|
||||||
if not cfgPath then return nil, "no skin.lua or .cfg in " .. root end
|
if not cfgPath then return nil, "no skin.lua, .cfg or info.json in " .. root end
|
||||||
local text = readFile(cfgPath)
|
local text = readFile(cfgPath)
|
||||||
if not text then return nil, "unreadable " .. cfgPath end
|
if not text then return nil, "unreadable " .. cfgPath end
|
||||||
local skin, err
|
local skin, err
|
||||||
if format == "native" then
|
if format == "native" then
|
||||||
skin, err = TouchSkin.parseNative(text)
|
skin, err = TouchSkin.parseNative(text)
|
||||||
|
elseif format == "delta" then
|
||||||
|
local dir = cfgPath:match("^(.*)/[^/]+$") or root
|
||||||
|
skin, err = require("src.core.DeltaSkin").parse(text,
|
||||||
|
{ prefix = prefix or "", names = listDir(dir) })
|
||||||
else
|
else
|
||||||
skin, err = TouchSkin.parse(text)
|
skin, err = TouchSkin.parse(text)
|
||||||
end
|
end
|
||||||
@@ -402,6 +623,10 @@ function TouchSkin.load(root, id)
|
|||||||
if page.imagePath then
|
if page.imagePath then
|
||||||
page.image = loadImage(joinPath(root, page.imagePath))
|
page.image = loadImage(joinPath(root, page.imagePath))
|
||||||
end
|
end
|
||||||
|
if not applyPixelScale(page) then
|
||||||
|
return nil, "could not read " .. tostring(page.imagePath)
|
||||||
|
.. ", which " .. page.name .. " measures its coordinates against"
|
||||||
|
end
|
||||||
for _, ctl in ipairs(page.controls) do
|
for _, ctl in ipairs(page.controls) do
|
||||||
if ctl.imagePath then ctl.image = loadImage(joinPath(root, ctl.imagePath)) end
|
if ctl.imagePath then ctl.image = loadImage(joinPath(root, ctl.imagePath)) end
|
||||||
if ctl.pressedImagePath then
|
if ctl.pressedImagePath then
|
||||||
@@ -418,14 +643,30 @@ local function mountZip(archive, point)
|
|||||||
return ok and mounted == true
|
return ok and mounted == true
|
||||||
end
|
end
|
||||||
|
|
||||||
|
TouchSkin.ARCHIVE_EXTS = { zip = true, deltaskin = true }
|
||||||
|
TouchSkin.LEGACY_EXTS = { gbcskin = true, gbaskin = true, gbskin = true }
|
||||||
|
TouchSkin.PDF_ONLY_MESSAGE =
|
||||||
|
"This skin uses PDF artwork, which cannot be imported yet. "
|
||||||
|
.. "Ask the author for a PNG version."
|
||||||
|
|
||||||
|
function TouchSkin.archiveId(name)
|
||||||
|
name = tostring(name or "")
|
||||||
|
local ext = name:match("%.([%w]+)$")
|
||||||
|
if not ext or not TouchSkin.ARCHIVE_EXTS[ext:lower()] then return nil end
|
||||||
|
local id = name:sub(1, #name - #ext - 1)
|
||||||
|
if id == "" then return nil end
|
||||||
|
return id, ext:lower()
|
||||||
|
end
|
||||||
|
|
||||||
function TouchSkin.list()
|
function TouchSkin.list()
|
||||||
local out, seen = {}, {}
|
local out, seen = {}, {}
|
||||||
local function scan(root, source)
|
local function scan(root, source)
|
||||||
for _, name in ipairs(listDir(root)) do
|
for _, name in ipairs(listDir(root)) do
|
||||||
local id = name:gsub("%.zip$", "")
|
local archiveId = TouchSkin.archiveId(name)
|
||||||
if not seen[id] then
|
local id = archiveId or name
|
||||||
|
if not seen[id] and name:sub(1, 1) ~= "_" then
|
||||||
local path = root .. "/" .. name
|
local path = root .. "/" .. name
|
||||||
if name:match("%.zip$") then
|
if archiveId then
|
||||||
local point = TouchSkin.USER_ROOT .. "/_mounted/" .. id
|
local point = TouchSkin.USER_ROOT .. "/_mounted/" .. id
|
||||||
if mountZip(path, point) and findConfig(point) then
|
if mountZip(path, point) and findConfig(point) then
|
||||||
seen[id] = true
|
seen[id] = true
|
||||||
@@ -447,17 +688,20 @@ function TouchSkin.list()
|
|||||||
return out
|
return out
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Drop a .zip into <save>/skins and report the id it will list under.
|
-- Drop a .zip or .deltaskin into <save>/skins and report the id it lists under.
|
||||||
function TouchSkin.installArchive(name, data)
|
function TouchSkin.installArchive(name, data)
|
||||||
if not data or data == "" then return nil, "empty archive" end
|
if not data or data == "" then return nil, "empty archive" end
|
||||||
if not (love and love.filesystem and love.filesystem.write) then
|
if not (love and love.filesystem and love.filesystem.write) then
|
||||||
return nil, "no writable filesystem"
|
return nil, "no writable filesystem"
|
||||||
end
|
end
|
||||||
name = tostring(name or ""):match("([^/\\]+)$") or ""
|
name = tostring(name or ""):match("([^/\\]+)$") or ""
|
||||||
name = name:gsub("[^%w%._%-]", "_")
|
name = name:gsub("[^%w%._%-]", "_"):gsub("^_+", "")
|
||||||
if not name:lower():match("%.zip$") then return nil, "not a .zip" end
|
local legacy = name:match("%.([%w]+)$")
|
||||||
local id = name:gsub("%.[Zz][Ii][Pp]$", "")
|
if legacy and TouchSkin.LEGACY_EXTS[legacy:lower()] then
|
||||||
if id == "" then return nil, "bad archive name" end
|
return nil, "old GBA4iOS skin, not supported"
|
||||||
|
end
|
||||||
|
local id = TouchSkin.archiveId(name)
|
||||||
|
if not id then return nil, "not a .zip or .deltaskin" end
|
||||||
|
|
||||||
pcall(love.filesystem.createDirectory, TouchSkin.USER_ROOT)
|
pcall(love.filesystem.createDirectory, TouchSkin.USER_ROOT)
|
||||||
local dest = TouchSkin.USER_ROOT .. "/" .. name
|
local dest = TouchSkin.USER_ROOT .. "/" .. name
|
||||||
@@ -467,9 +711,14 @@ function TouchSkin.installArchive(name, data)
|
|||||||
local entry = TouchSkin.find(id)
|
local entry = TouchSkin.find(id)
|
||||||
if not entry then
|
if not entry then
|
||||||
love.filesystem.remove(dest)
|
love.filesystem.remove(dest)
|
||||||
return nil, "no skin.lua or .cfg inside " .. name
|
return nil, "no skin.lua, .cfg or info.json inside " .. name
|
||||||
end
|
end
|
||||||
return id
|
local skin = TouchSkin.load(entry.root, entry.id)
|
||||||
|
if skin and require("src.core.DeltaSkin").needsConversion(skin) then
|
||||||
|
love.filesystem.remove(dest)
|
||||||
|
return nil, TouchSkin.PDF_ONLY_MESSAGE
|
||||||
|
end
|
||||||
|
return id, skin and skin.warnings or nil
|
||||||
end
|
end
|
||||||
|
|
||||||
function TouchSkin.find(id)
|
function TouchSkin.find(id)
|
||||||
@@ -498,29 +747,13 @@ function TouchSkin.assetPaths(skin)
|
|||||||
return out
|
return out
|
||||||
end
|
end
|
||||||
|
|
||||||
function TouchSkin.export(skin, destPath)
|
local function writeArchive(entries, destPath)
|
||||||
if not skin then return nil, "no skin" end
|
local blob = require("src.core.SkinZip").encode(entries)
|
||||||
local SkinZip = require("src.core.SkinZip")
|
|
||||||
local entries = { { name = TouchSkin.NATIVE_NAME, data = TouchSkin.serialize(skin) } }
|
|
||||||
local missing = {}
|
|
||||||
for _, rel in ipairs(TouchSkin.assetPaths(skin)) do
|
|
||||||
local data = readFile(joinPath(skin.root, rel))
|
|
||||||
if data then
|
|
||||||
entries[#entries + 1] = { name = rel, data = data }
|
|
||||||
else
|
|
||||||
missing[#missing + 1] = rel
|
|
||||||
end
|
|
||||||
end
|
|
||||||
if skin.configPath and skin.format == "retroarch" then
|
|
||||||
local cfg = readFile(skin.configPath)
|
|
||||||
if cfg then
|
|
||||||
entries[#entries + 1] =
|
|
||||||
{ name = skin.configPath:match("([^/]+)$") or "overlay.cfg", data = cfg }
|
|
||||||
end
|
|
||||||
end
|
|
||||||
local blob = SkinZip.encode(entries)
|
|
||||||
destPath = destPath or (TouchSkin.USER_ROOT .. "/" .. skin.id .. "-export.zip")
|
|
||||||
local absolute = destPath:sub(1, 1) == "/" or destPath:match("^%a:[/\\]") ~= nil
|
local absolute = destPath:sub(1, 1) == "/" or destPath:match("^%a:[/\\]") ~= nil
|
||||||
|
if not absolute and love and love.filesystem and love.filesystem.createDirectory then
|
||||||
|
local dir = destPath:match("^(.*)/[^/]+$")
|
||||||
|
if dir then pcall(love.filesystem.createDirectory, dir) end
|
||||||
|
end
|
||||||
if not absolute and love and love.filesystem and love.filesystem.write then
|
if not absolute and love and love.filesystem and love.filesystem.write then
|
||||||
local ok, err = love.filesystem.write(destPath, blob)
|
local ok, err = love.filesystem.write(destPath, blob)
|
||||||
if not ok then return nil, tostring(err) end
|
if not ok then return nil, tostring(err) end
|
||||||
@@ -530,9 +763,167 @@ function TouchSkin.export(skin, destPath)
|
|||||||
handle:write(blob)
|
handle:write(blob)
|
||||||
handle:close()
|
handle:close()
|
||||||
end
|
end
|
||||||
|
return destPath
|
||||||
|
end
|
||||||
|
|
||||||
|
local function collectAssets(skin, rels)
|
||||||
|
local entries, missing = {}, {}
|
||||||
|
for _, rel in ipairs(rels) do
|
||||||
|
local data = readFile(joinPath(skin.root, rel))
|
||||||
|
if data then
|
||||||
|
entries[#entries + 1] = { name = rel, data = data }
|
||||||
|
else
|
||||||
|
missing[#missing + 1] = rel
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return entries, missing
|
||||||
|
end
|
||||||
|
|
||||||
|
function TouchSkin.export(skin, destPath)
|
||||||
|
if not skin then return nil, "no skin" end
|
||||||
|
local entries = { { name = TouchSkin.NATIVE_NAME, data = TouchSkin.serialize(skin) } }
|
||||||
|
local assets, missing = collectAssets(skin, TouchSkin.assetPaths(skin))
|
||||||
|
for _, entry in ipairs(assets) do entries[#entries + 1] = entry end
|
||||||
|
if skin.configPath and skin.format == "retroarch" then
|
||||||
|
local cfg = readFile(skin.configPath)
|
||||||
|
if cfg then
|
||||||
|
entries[#entries + 1] =
|
||||||
|
{ name = skin.configPath:match("([^/]+)$") or "overlay.cfg", data = cfg }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
destPath = destPath or (TouchSkin.EXPORT_ROOT .. "/" .. skin.id .. "-export.zip")
|
||||||
|
local written, err = writeArchive(entries, destPath)
|
||||||
|
if not written then return nil, err end
|
||||||
return destPath, missing
|
return destPath, missing
|
||||||
end
|
end
|
||||||
|
|
||||||
|
local function fmtNum(n)
|
||||||
|
n = tonumber(n) or 0
|
||||||
|
if n == math.floor(n) then return string.format("%d", n) end
|
||||||
|
local s = string.format("%.6f", n):gsub("0+$", ""):gsub("%.$", "")
|
||||||
|
return s
|
||||||
|
end
|
||||||
|
|
||||||
|
local function fmtRect(r)
|
||||||
|
return ('"%s,%s,%s,%s"'):format(fmtNum(r.x), fmtNum(r.y), fmtNum(r.w), fmtNum(r.h))
|
||||||
|
end
|
||||||
|
|
||||||
|
local function cfgSpec(spec)
|
||||||
|
local parts = {}
|
||||||
|
for raw in tostring(spec or ""):gmatch("[^|]+") do
|
||||||
|
local name = trim(raw)
|
||||||
|
local key = name:lower():match("^key:(.+)$")
|
||||||
|
parts[#parts + 1] = key and ("retrok_" .. key) or name
|
||||||
|
end
|
||||||
|
return table.concat(parts, "|")
|
||||||
|
end
|
||||||
|
|
||||||
|
function TouchSkin.toRetroArchConfig(skin)
|
||||||
|
local pages = (skin and skin.pages) or {}
|
||||||
|
local out = { "overlays = " .. #pages }
|
||||||
|
for i, page in ipairs(pages) do
|
||||||
|
local p = "overlay" .. (i - 1)
|
||||||
|
out[#out + 1] = ""
|
||||||
|
out[#out + 1] = p .. '_name = "' .. tostring(page.name or ("overlay" .. (i - 1))) .. '"'
|
||||||
|
if page.imagePath then out[#out + 1] = p .. "_overlay = " .. page.imagePath end
|
||||||
|
out[#out + 1] = p .. "_full_screen = " .. (page.fullScreen ~= false and "true" or "false")
|
||||||
|
out[#out + 1] = p .. "_normalized = true"
|
||||||
|
if num(page.rangeMod, 1) ~= 1 then
|
||||||
|
out[#out + 1] = p .. "_range_mod = " .. fmtNum(page.rangeMod)
|
||||||
|
end
|
||||||
|
if num(page.alphaMod, 1) ~= 1 then
|
||||||
|
out[#out + 1] = p .. "_alpha_mod = " .. fmtNum(page.alphaMod)
|
||||||
|
end
|
||||||
|
if page.aspectFromCfg and page.aspect and page.aspect > 0 then
|
||||||
|
out[#out + 1] = p .. "_aspect_ratio = " .. fmtNum(page.aspect)
|
||||||
|
end
|
||||||
|
local r = page.rect
|
||||||
|
if r and (r.x ~= 0 or r.y ~= 0 or r.w ~= 1 or r.h ~= 1) then
|
||||||
|
out[#out + 1] = p .. "_rect = " .. fmtRect(r)
|
||||||
|
end
|
||||||
|
if page.viewport then
|
||||||
|
out[#out + 1] = p .. "_viewport = " .. fmtRect(page.viewport)
|
||||||
|
if page.viewportFill then out[#out + 1] = p .. "_viewport_fill = true" end
|
||||||
|
if page.viewportExpand then out[#out + 1] = p .. "_viewport_expand = true" end
|
||||||
|
end
|
||||||
|
local controls = {}
|
||||||
|
for _, ctl in ipairs(page.controls or {}) do
|
||||||
|
if not ctl.sector or ctl.sector == 1 then controls[#controls + 1] = ctl end
|
||||||
|
end
|
||||||
|
out[#out + 1] = p .. "_descs = " .. #controls
|
||||||
|
for j, ctl in ipairs(controls) do
|
||||||
|
local d = p .. "_desc" .. (j - 1)
|
||||||
|
local spec = ctl.areaKind and ctl.sector and ctl.areaKind
|
||||||
|
or cfgSpec(ctl.spec)
|
||||||
|
if spec == "" then spec = "nul" end
|
||||||
|
out[#out + 1] = ('%s = "%s,%s,%s,%s,%s,%s"'):format(d, spec,
|
||||||
|
fmtNum(ctl.x), fmtNum(ctl.y),
|
||||||
|
ctl.shape == "radial" and "radial" or "rect",
|
||||||
|
fmtNum(ctl.rangeX), fmtNum(ctl.rangeY))
|
||||||
|
if ctl.imagePath then out[#out + 1] = d .. "_overlay = " .. ctl.imagePath end
|
||||||
|
if ctl.pressedImagePath then
|
||||||
|
out[#out + 1] = d .. "_overlay_pressed = " .. ctl.pressedImagePath
|
||||||
|
end
|
||||||
|
if num(ctl.rangeMod, 1) ~= num(page.rangeMod, 1) then
|
||||||
|
out[#out + 1] = d .. "_range_mod = " .. fmtNum(ctl.rangeMod)
|
||||||
|
end
|
||||||
|
if num(ctl.alphaMod, 1) ~= num(page.alphaMod, 1) then
|
||||||
|
out[#out + 1] = d .. "_alpha_mod = " .. fmtNum(ctl.alphaMod)
|
||||||
|
end
|
||||||
|
for key, value in pairs({ up = ctl.reachUp, down = ctl.reachDown,
|
||||||
|
left = ctl.reachLeft, right = ctl.reachRight }) do
|
||||||
|
if num(value, 1) ~= 1 then
|
||||||
|
out[#out + 1] = d .. "_reach_" .. key .. " = " .. fmtNum(value)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if ctl.movable then out[#out + 1] = d .. "_movable = true" end
|
||||||
|
if ctl.exclusive then out[#out + 1] = d .. "_exclusive = true" end
|
||||||
|
if ctl.nextTarget then
|
||||||
|
out[#out + 1] = d .. '_next_target = "' .. tostring(ctl.nextTarget) .. '"'
|
||||||
|
end
|
||||||
|
if ctl.areaKind and ctl.sector and ctl.areaNames then
|
||||||
|
local defaults = TouchSkin.AREA_DEFAULTS[ctl.areaKind] or {}
|
||||||
|
for _, side in ipairs({ "up", "down", "left", "right" }) do
|
||||||
|
local name = ctl.areaNames[side]
|
||||||
|
if name and name ~= defaults[side] then
|
||||||
|
out[#out + 1] = d .. "_" .. side .. ' = "' .. name .. '"'
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return table.concat(out, "\n") .. "\n"
|
||||||
|
end
|
||||||
|
|
||||||
|
function TouchSkin.exportRetroArch(skin, destPath)
|
||||||
|
if not skin then return nil, "no skin" end
|
||||||
|
local entries = { { name = "overlay.cfg", data = TouchSkin.toRetroArchConfig(skin) } }
|
||||||
|
local assets, missing = collectAssets(skin, TouchSkin.assetPaths(skin))
|
||||||
|
for _, entry in ipairs(assets) do entries[#entries + 1] = entry end
|
||||||
|
destPath = destPath or (TouchSkin.EXPORT_ROOT .. "/" .. skin.id .. "-retroarch.zip")
|
||||||
|
local written, err = writeArchive(entries, destPath)
|
||||||
|
if not written then return nil, err end
|
||||||
|
return destPath, missing
|
||||||
|
end
|
||||||
|
|
||||||
|
function TouchSkin.exportDelta(skin, opts)
|
||||||
|
if not skin then return nil, "no skin" end
|
||||||
|
opts = opts or {}
|
||||||
|
local DeltaSkin = require("src.core.DeltaSkin")
|
||||||
|
local info, assetRels, warnings = DeltaSkin.build(skin, opts)
|
||||||
|
if not info then return nil, assetRels end
|
||||||
|
local entries = {
|
||||||
|
{ name = DeltaSkin.INFO_NAME, data = require("src.link.Json").encode(info) },
|
||||||
|
}
|
||||||
|
local assets, missing = collectAssets(skin, assetRels)
|
||||||
|
for _, entry in ipairs(assets) do entries[#entries + 1] = entry end
|
||||||
|
local destPath = opts.path
|
||||||
|
or (TouchSkin.EXPORT_ROOT .. "/" .. skin.id .. ".deltaskin")
|
||||||
|
local written, err = writeArchive(entries, destPath)
|
||||||
|
if not written then return nil, err end
|
||||||
|
return destPath, missing, warnings
|
||||||
|
end
|
||||||
|
|
||||||
TouchSkin.BINDS = {
|
TouchSkin.BINDS = {
|
||||||
"nul",
|
"nul",
|
||||||
"up", "down", "left", "right",
|
"up", "down", "left", "right",
|
||||||
@@ -597,6 +988,7 @@ function TouchSkin.clone(skin)
|
|||||||
id = skin.id, name = skin.name, root = skin.root, format = skin.format,
|
id = skin.id, name = skin.name, root = skin.root, format = skin.format,
|
||||||
author = skin.author, notes = skin.notes, configPath = skin.configPath,
|
author = skin.author, notes = skin.notes, configPath = skin.configPath,
|
||||||
source = skin.source, pages = {},
|
source = skin.source, pages = {},
|
||||||
|
warnings = skin.warnings and copyTable(skin.warnings) or nil,
|
||||||
}
|
}
|
||||||
for i, page in ipairs(skin.pages or {}) do
|
for i, page in ipairs(skin.pages or {}) do
|
||||||
local p = copyTable(page)
|
local p = copyTable(page)
|
||||||
@@ -676,7 +1068,8 @@ function TouchSkin.listImages(root)
|
|||||||
local function scan(dir, prefix)
|
local function scan(dir, prefix)
|
||||||
for _, name in ipairs(listDir(dir)) do
|
for _, name in ipairs(listDir(dir)) do
|
||||||
local path = dir .. "/" .. name
|
local path = dir .. "/" .. name
|
||||||
if name:lower():match("%.png$") or name:lower():match("%.jpg$") then
|
local lower = name:lower()
|
||||||
|
if lower:match("%.png$") or lower:match("%.jpg$") or lower:match("%.jpeg$") then
|
||||||
out[#out + 1] = prefix .. name
|
out[#out + 1] = prefix .. name
|
||||||
elseif isDir(path) and prefix == "" then
|
elseif isDir(path) and prefix == "" then
|
||||||
scan(path, name .. "/")
|
scan(path, name .. "/")
|
||||||
@@ -872,17 +1265,21 @@ function TouchSkin.controlGeometry(page, ctl, w, h, ox, oy)
|
|||||||
return cx, cy, halfW, halfH
|
return cx, cy, halfW, halfH
|
||||||
end
|
end
|
||||||
|
|
||||||
function TouchSkin.hits(page, ctl, w, h, px, py, ox, oy)
|
function TouchSkin.hits(page, ctl, w, h, px, py, ox, oy, held)
|
||||||
local cx, cy, halfW, halfH = TouchSkin.controlGeometry(page, ctl, w, h, ox, oy)
|
local cx, cy, halfW, halfH = TouchSkin.controlGeometry(page, ctl, w, h, ox, oy)
|
||||||
local left = halfW * ctl.reachLeft * ctl.rangeMod
|
local mod = held == false and 1 or ctl.rangeMod
|
||||||
local right = halfW * ctl.reachRight * ctl.rangeMod
|
local left = halfW * ctl.reachLeft * mod
|
||||||
local up = halfH * ctl.reachUp * ctl.rangeMod
|
local right = halfW * ctl.reachRight * mod
|
||||||
local down = halfH * ctl.reachDown * ctl.rangeMod
|
local up = halfH * ctl.reachUp * mod
|
||||||
|
local down = halfH * ctl.reachDown * mod
|
||||||
local dx = px - cx
|
local dx = px - cx
|
||||||
local dy = py - cy
|
local dy = py - cy
|
||||||
local rx = dx < 0 and left or right
|
local rx = dx < 0 and left or right
|
||||||
local ry = dy < 0 and up or down
|
local ry = dy < 0 and up or down
|
||||||
if rx <= 0 or ry <= 0 then return false end
|
if rx <= 0 or ry <= 0 then return false end
|
||||||
|
if ctl.sector and not TouchSkin.sectorHit(ctl.sector, dx, dy) then
|
||||||
|
return false
|
||||||
|
end
|
||||||
if ctl.shape == "radial" then
|
if ctl.shape == "radial" then
|
||||||
return (dx * dx) / (rx * rx) + (dy * dy) / (ry * ry) <= 1
|
return (dx * dx) / (rx * rx) + (dy * dy) / (ry * ry) <= 1
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -301,6 +301,13 @@ end
|
|||||||
-- gear edit these before the game starts (src/import/LauncherSettings.lua).
|
-- gear edit these before the game starts (src/import/LauncherSettings.lua).
|
||||||
Save.OPTIONS_KEY = "gold"
|
Save.OPTIONS_KEY = "gold"
|
||||||
|
|
||||||
|
local SHARED_KEYS = {
|
||||||
|
touchControls = true, haptics = true,
|
||||||
|
mods = true, modsByVersion = true, modsGen2 = true,
|
||||||
|
modOptions = true, modProfiles = true, modProfilesSeeded = true,
|
||||||
|
activeProfile = true,
|
||||||
|
}
|
||||||
|
|
||||||
function Save.loadOptions(fs)
|
function Save.loadOptions(fs)
|
||||||
local options = Save.defaultOptions()
|
local options = Save.defaultOptions()
|
||||||
local ok, SaveData = pcall(require, "src.core.SaveData")
|
local ok, SaveData = pcall(require, "src.core.SaveData")
|
||||||
@@ -308,7 +315,21 @@ function Save.loadOptions(fs)
|
|||||||
local loaded = SaveData.loadOptions(fs)
|
local loaded = SaveData.loadOptions(fs)
|
||||||
local stored = loaded and loaded[Save.OPTIONS_KEY]
|
local stored = loaded and loaded[Save.OPTIONS_KEY]
|
||||||
if type(stored) == "table" then
|
if type(stored) == "table" then
|
||||||
for key, value in pairs(stored) do options[key] = value end
|
for key, value in pairs(stored) do
|
||||||
|
if not SHARED_KEYS[key] then options[key] = value end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if type(loaded) == "table" then
|
||||||
|
for key in pairs(SHARED_KEYS) do
|
||||||
|
if loaded[key] ~= nil then options[key] = loaded[key] end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if type(stored) == "table" then
|
||||||
|
for key in pairs(SHARED_KEYS) do
|
||||||
|
if options[key] == nil and stored[key] ~= nil then
|
||||||
|
options[key] = stored[key]
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
return options
|
return options
|
||||||
end
|
end
|
||||||
@@ -321,7 +342,13 @@ function Save.saveOptions(options, fs)
|
|||||||
if not ok then return false end
|
if not ok then return false end
|
||||||
local file = SaveData.loadOptions(fs) or {}
|
local file = SaveData.loadOptions(fs) or {}
|
||||||
local block = {}
|
local block = {}
|
||||||
for key, value in pairs(options) do block[key] = value end
|
for key, value in pairs(options) do
|
||||||
|
if SHARED_KEYS[key] then
|
||||||
|
file[key] = value
|
||||||
|
else
|
||||||
|
block[key] = value
|
||||||
|
end
|
||||||
|
end
|
||||||
file[Save.OPTIONS_KEY] = block
|
file[Save.OPTIONS_KEY] = block
|
||||||
SaveData.saveOptions(file, fs)
|
SaveData.saveOptions(file, fs)
|
||||||
return true
|
return true
|
||||||
|
|||||||
@@ -422,7 +422,7 @@ local function discoverModSchemas(opts)
|
|||||||
-- except experimental mods, which stay off until opted in.
|
-- except experimental mods, which stay off until opted in.
|
||||||
local flag = require("src.core.SaveData").modEnabled(opts, m.id)
|
local flag = require("src.core.SaveData").modEnabled(opts, m.id)
|
||||||
local enabled = flag == true or (flag == nil and not m.experimental)
|
local enabled = flag == true or (flag == nil and not m.experimental)
|
||||||
if enabled then
|
if enabled and not SaveData.isSafeMode(opts) then
|
||||||
local chunk = fs.load(path .. "/" .. m.options_schema)
|
local chunk = fs.load(path .. "/" .. m.options_schema)
|
||||||
if chunk then
|
if chunk then
|
||||||
local okR, schema = pcall(chunk)
|
local okR, schema = pcall(chunk)
|
||||||
@@ -521,9 +521,49 @@ local function modRows(opts, mod)
|
|||||||
return true
|
return true
|
||||||
end }
|
end }
|
||||||
end
|
end
|
||||||
|
for _, row in ipairs(rows) do
|
||||||
|
row.safeModeBlocked = true
|
||||||
|
if row.step then
|
||||||
|
local step = row.step
|
||||||
|
row.step = function(dir)
|
||||||
|
if SaveData.isSafeMode(opts) then return false end
|
||||||
|
return step(dir)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if row.setText then
|
||||||
|
local setText = row.setText
|
||||||
|
row.setText = function(text)
|
||||||
|
if SaveData.isSafeMode(opts) then return false end
|
||||||
|
return setText(text)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
return rows
|
return rows
|
||||||
end
|
end
|
||||||
|
|
||||||
|
local function troubleshootingRows(opts, hooks)
|
||||||
|
return {
|
||||||
|
{
|
||||||
|
label = Strings("SAFE MODE"),
|
||||||
|
actionLabel = function()
|
||||||
|
return SaveData.isSafeMode(opts) and Strings("Turn off") or Strings("Turn on")
|
||||||
|
end,
|
||||||
|
action = function()
|
||||||
|
SaveData.setSafeMode(opts, not SaveData.isSafeMode(opts))
|
||||||
|
return true
|
||||||
|
end,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label = Strings("REPORT ISSUE"),
|
||||||
|
actionLabel = Strings("Report bug"),
|
||||||
|
action = function()
|
||||||
|
if hooks and hooks.reportIssue then hooks.reportIssue(opts) end
|
||||||
|
return false
|
||||||
|
end,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
-- ------- Gen 2 (Gold)
|
-- ------- Gen 2 (Gold)
|
||||||
--
|
--
|
||||||
-- Gold reads NONE of the rows above. Its OPTION screen writes a different
|
-- Gold reads NONE of the rows above. Its OPTION screen writes a different
|
||||||
@@ -704,6 +744,10 @@ function LauncherSettings.open(hooks, version)
|
|||||||
sections[#sections + 1] = { title = mod.name, rows = rows }
|
sections[#sections + 1] = { title = mod.name, rows = rows }
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
sections[#sections + 1] = {
|
||||||
|
title = Strings("TROUBLESHOOTING"),
|
||||||
|
rows = troubleshootingRows(opts, hooks),
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
opts = opts,
|
opts = opts,
|
||||||
version = version,
|
version = version,
|
||||||
|
|||||||
@@ -136,6 +136,9 @@ local VERSION_REQUIRED_FILES_OVERRIDE = {
|
|||||||
-- costs nothing on a current cache and is the difference between every
|
-- costs nothing on a current cache and is the difference between every
|
||||||
-- trainer battle opening with a picture and opening with none.
|
-- trainer battle opening with a picture and opening with none.
|
||||||
"assets/generated/battle/trainers/falkner.png",
|
"assets/generated/battle/trainers/falkner.png",
|
||||||
|
-- BattleStart_TrainerHuds cannot draw its party rows from a cache made
|
||||||
|
-- before the four ball tiles were extracted (#1502).
|
||||||
|
"assets/generated/battle/hud/balls.png",
|
||||||
"assets/generated/audio/programs.bin",
|
"assets/generated/audio/programs.bin",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -326,6 +329,32 @@ function RomImporter.isReady(version)
|
|||||||
return marker == markerFor(version) and allRequiredFilesExist(version)
|
return marker == markerFor(version) and allRequiredFilesExist(version)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
function RomImporter.syncAndroidShortcuts(activeVersion)
|
||||||
|
if not (love.system and love.system.getOS and love.system.getOS() == "Android"
|
||||||
|
and love.system.updateShortcuts) then
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
local allVersions = { "red", "blue", "yellow", "gold" }
|
||||||
|
local ready = {}
|
||||||
|
local seen = {}
|
||||||
|
|
||||||
|
if activeVersion and RomImporter.isReady(activeVersion) then
|
||||||
|
table.insert(ready, activeVersion)
|
||||||
|
seen[activeVersion] = true
|
||||||
|
end
|
||||||
|
|
||||||
|
for _, v in ipairs(allVersions) do
|
||||||
|
if not seen[v] and RomImporter.isReady(v) then
|
||||||
|
table.insert(ready, v)
|
||||||
|
seen[v] = true
|
||||||
|
if #ready >= 4 then break end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
return love.system.updateShortcuts(ready)
|
||||||
|
end
|
||||||
|
|
||||||
-- Load the import manifest for a version and confirm it matches that ROM.
|
-- Load the import manifest for a version and confirm it matches that ROM.
|
||||||
local function sha1(data)
|
local function sha1(data)
|
||||||
local digest = love.data.hash("sha1", data)
|
local digest = love.data.hash("sha1", data)
|
||||||
@@ -1111,18 +1140,18 @@ local function chooseZip()
|
|||||||
end
|
end
|
||||||
|
|
||||||
local function chooseSkinZip()
|
local function chooseSkinZip()
|
||||||
local prompt = shellSafe(Strings("Choose a skin .zip"))
|
local prompt = shellSafe(Strings("Choose a skin .zip or .deltaskin"))
|
||||||
local platform = love.system.getOS()
|
local platform = love.system.getOS()
|
||||||
if platform == "OS X" then
|
if platform == "OS X" then
|
||||||
return commandOutput(
|
return commandOutput(
|
||||||
([[osascript -e 'POSIX path of (choose file with prompt "%s" of type {"zip"})' 2>/dev/null]])
|
([[osascript -e 'POSIX path of (choose file with prompt "%s" of type {"zip", "deltaskin"})' 2>/dev/null]])
|
||||||
:format(prompt))
|
:format(prompt))
|
||||||
elseif platform == "Windows" then
|
elseif platform == "Windows" then
|
||||||
local script = table.concat({
|
local script = table.concat({
|
||||||
"Add-Type -AssemblyName System.Windows.Forms;",
|
"Add-Type -AssemblyName System.Windows.Forms;",
|
||||||
"$d=New-Object System.Windows.Forms.OpenFileDialog;",
|
"$d=New-Object System.Windows.Forms.OpenFileDialog;",
|
||||||
"$d.Title='" .. prompt .. "';",
|
"$d.Title='" .. prompt .. "';",
|
||||||
"$d.Filter='Skin archive (*.zip)|*.zip|All files (*.*)|*.*';",
|
"$d.Filter='Skin archive (*.zip;*.deltaskin)|*.zip;*.deltaskin|All files (*.*)|*.*';",
|
||||||
"if($d.ShowDialog() -eq 'OK'){",
|
"if($d.ShowDialog() -eq 'OK'){",
|
||||||
"$n=[IO.Path]::GetFileName($d.FileName) -replace '[^\\x20-\\x7E]','_';",
|
"$n=[IO.Path]::GetFileName($d.FileName) -replace '[^\\x20-\\x7E]','_';",
|
||||||
"$t=Join-Path $env:TEMP $n;",
|
"$t=Join-Path $env:TEMP $n;",
|
||||||
@@ -1134,11 +1163,11 @@ local function chooseSkinZip()
|
|||||||
'powershell -NoProfile -STA -Command "' .. script .. '"')
|
'powershell -NoProfile -STA -Command "' .. script .. '"')
|
||||||
elseif platform == "Linux" then
|
elseif platform == "Linux" then
|
||||||
local path = commandOutput(
|
local path = commandOutput(
|
||||||
([[zenity --file-selection --title="%s" --file-filter="Skin archive | *.zip" 2>/dev/null]])
|
([[zenity --file-selection --title="%s" --file-filter="Skin archive | *.zip *.deltaskin" 2>/dev/null]])
|
||||||
:format(prompt))
|
:format(prompt))
|
||||||
if path then return path end
|
if path then return path end
|
||||||
return commandOutput(
|
return commandOutput(
|
||||||
[[kdialog --getopenfilename "$HOME" "*.zip|Skin archive" 2>/dev/null]])
|
[[kdialog --getopenfilename "$HOME" "*.zip *.deltaskin|Skin archive" 2>/dev/null]])
|
||||||
end
|
end
|
||||||
return nil
|
return nil
|
||||||
end
|
end
|
||||||
@@ -1349,6 +1378,7 @@ function RomImporter.new(onComplete, opts)
|
|||||||
findLoaded = false, findSources = nil, findIndex = nil,
|
findLoaded = false, findSources = nil, findIndex = nil,
|
||||||
findScroll = 0, findNotice = nil, findQuery = "", findCategory = nil,
|
findScroll = 0, findNotice = nil, findQuery = "", findCategory = nil,
|
||||||
_findSearchFocus = false, _findThumbs = nil,
|
_findSearchFocus = false, _findThumbs = nil,
|
||||||
|
skinUrl = "", _skinUrlFocus = false,
|
||||||
-- Page scroll offset (px) for the column under the tab bar -- panel, updater
|
-- Page scroll offset (px) for the column under the tab bar -- panel, updater
|
||||||
-- banner and footer -- used only while that column is taller than the window
|
-- banner and footer -- used only while that column is taller than the window
|
||||||
-- (see draw()). Clamped against content in draw, reset on a tab change.
|
-- (see draw()). Clamped against content in draw, reset on a tab change.
|
||||||
@@ -1392,6 +1422,7 @@ function RomImporter.new(onComplete, opts)
|
|||||||
self.romName[version] = "pokemon_" .. info.id
|
self.romName[version] = "pokemon_" .. info.id
|
||||||
.. ((info.id == "yellow" or info.id == "gold") and ".gbc" or ".gb")
|
.. ((info.id == "yellow" or info.id == "gold") and ".gbc" or ".gb")
|
||||||
end
|
end
|
||||||
|
RomImporter.syncAndroidShortcuts()
|
||||||
self:_applyLastVersionTab()
|
self:_applyLastVersionTab()
|
||||||
self:_queueBaseRomScan()
|
self:_queueBaseRomScan()
|
||||||
|
|
||||||
@@ -1786,6 +1817,7 @@ function RomImporter:_completeImport(version, prefix, displayName)
|
|||||||
self.workState = "complete"
|
self.workState = "complete"
|
||||||
self.completeVersion = version
|
self.completeVersion = version
|
||||||
self.status = "Ready"
|
self.status = "Ready"
|
||||||
|
RomImporter.syncAndroidShortcuts(version)
|
||||||
-- NX launcher stays put: keep the imports/ cleanup hint instead of
|
-- NX launcher stays put: keep the imports/ cleanup hint instead of
|
||||||
-- overwriting it with a "Starting…" line that never boots from here.
|
-- overwriting it with a "Starting…" line that never boots from here.
|
||||||
if self.launcher and self.isNX and type(displayName) == "string" then
|
if self.launcher and self.isNX and type(displayName) == "string" then
|
||||||
@@ -1851,10 +1883,14 @@ end
|
|||||||
function RomImporter:filedropped(file)
|
function RomImporter:filedropped(file)
|
||||||
if self.workState == "working" then return end
|
if self.workState == "working" then return end
|
||||||
-- A dropped .zip is a mod archive: hand it straight to the mods installer
|
-- A dropped .zip is a mod archive: hand it straight to the mods installer
|
||||||
-- (which mounts + validates it). Everything else is treated as a ROM. The
|
-- (which mounts + validates it). A .deltaskin is only ever a skin, and
|
||||||
-- dropped file itself is passed through -- installZip opens it the same way
|
-- everything else is treated as a ROM. The dropped file itself is passed
|
||||||
-- readDroppedFile does here.
|
-- through -- installZip opens it the same way readDroppedFile does here.
|
||||||
local name = file:getFilename() or ""
|
local name = file:getFilename() or ""
|
||||||
|
if name:lower():match("%.deltaskin$") then
|
||||||
|
self:_installSkinZip(file)
|
||||||
|
return
|
||||||
|
end
|
||||||
if name:lower():match("%.zip$") then
|
if name:lower():match("%.zip$") then
|
||||||
-- On the SKINS tab a zip is a skin; everywhere else it is a mod archive.
|
-- On the SKINS tab a zip is a skin; everywhere else it is a mod archive.
|
||||||
if self.tab == "skins" then
|
if self.tab == "skins" then
|
||||||
@@ -2481,6 +2517,8 @@ function RomImporter:update(dt)
|
|||||||
self:_pumpModInfoFetch()
|
self:_pumpModInfoFetch()
|
||||||
self:_pumpFindStats()
|
self:_pumpFindStats()
|
||||||
self:_pumpFindThumbs()
|
self:_pumpFindThumbs()
|
||||||
|
self:_pumpSkinFetch()
|
||||||
|
self:_pumpSync(dt)
|
||||||
self:_pumpModCheck()
|
self:_pumpModCheck()
|
||||||
self:_pumpModInstall()
|
self:_pumpModInstall()
|
||||||
self:_pumpExtract()
|
self:_pumpExtract()
|
||||||
@@ -3082,6 +3120,8 @@ end
|
|||||||
function RomImporter:_switchTab(id)
|
function RomImporter:_switchTab(id)
|
||||||
self.tab = id
|
self.tab = id
|
||||||
self._findSearchFocus = false
|
self._findSearchFocus = false
|
||||||
|
self._skinUrlFocus = false
|
||||||
|
self._modScrollMax, self._modListRect = 0, nil
|
||||||
self:_disarmTextInput()
|
self:_disarmTextInput()
|
||||||
-- the skins list is cheap and can change behind the launcher's back
|
-- the skins list is cheap and can change behind the launcher's back
|
||||||
-- (an export, a hand-dropped folder), so re-read it on every visit
|
-- (an export, a hand-dropped folder), so re-read it on every visit
|
||||||
@@ -3107,6 +3147,7 @@ function RomImporter:_ensureSkins(force)
|
|||||||
out[#out + 1] = {
|
out[#out + 1] = {
|
||||||
id = entry.id,
|
id = entry.id,
|
||||||
source = entry.source,
|
source = entry.source,
|
||||||
|
format = skin and skin.format or nil,
|
||||||
pages = skin and #skin.pages or 0,
|
pages = skin and #skin.pages or 0,
|
||||||
controls = controls,
|
controls = controls,
|
||||||
screen = page ~= nil and page.viewport ~= nil,
|
screen = page ~= nil and page.viewport ~= nil,
|
||||||
@@ -3140,7 +3181,6 @@ end
|
|||||||
function RomImporter:_installSkinZip(source)
|
function RomImporter:_installSkinZip(source)
|
||||||
if self.workState == "working" then return end
|
if self.workState == "working" then return end
|
||||||
self.tab = "skins"
|
self.tab = "skins"
|
||||||
local TouchSkin = require("src.core.TouchSkin")
|
|
||||||
local name, data, readError
|
local name, data, readError
|
||||||
if type(source) == "string" then
|
if type(source) == "string" then
|
||||||
name = source
|
name = source
|
||||||
@@ -3160,13 +3200,368 @@ function RomImporter:_installSkinZip(source)
|
|||||||
.. tostring(readError or name) }
|
.. tostring(readError or name) }
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
local id, err = TouchSkin.installArchive(name, data)
|
self:_installSkinData(name, data)
|
||||||
|
end
|
||||||
|
|
||||||
|
local MAX_SKIN_URL = 300
|
||||||
|
local SKIN_TEMP_DIR = "skins/_download"
|
||||||
|
|
||||||
|
function RomImporter.skinUrlName(url)
|
||||||
|
local path = tostring(url or ""):gsub("[?#].*$", "")
|
||||||
|
local base = (path:match("([^/\\]+)$") or ""):gsub("[^%w%._%-]", "_")
|
||||||
|
local ext = base:match("%.([%w]+)$")
|
||||||
|
if not ext then
|
||||||
|
return (base ~= "" and base or "skin") .. ".zip"
|
||||||
|
end
|
||||||
|
ext = ext:lower()
|
||||||
|
local TouchSkin = require("src.core.TouchSkin")
|
||||||
|
if TouchSkin.ARCHIVE_EXTS[ext] or ext == "cfg" then return base end
|
||||||
|
return (base:gsub("%.[%w]+$", "")) .. ".zip"
|
||||||
|
end
|
||||||
|
|
||||||
|
function RomImporter.wrapSkinPayload(name, data)
|
||||||
|
name = tostring(name or "")
|
||||||
|
if not name:lower():match("%.cfg$") then return name, data end
|
||||||
|
if not data then return name, data end
|
||||||
|
if data:sub(1, 2) == "PK" then
|
||||||
|
return (name:gsub("%.[Cc][Ff][Gg]$", "")) .. ".zip", data
|
||||||
|
end
|
||||||
|
local blob = require("src.core.SkinZip").encode({
|
||||||
|
{ name = "overlay.cfg", data = data },
|
||||||
|
})
|
||||||
|
return (name:gsub("%.[Cc][Ff][Gg]$", "")) .. ".zip", blob
|
||||||
|
end
|
||||||
|
|
||||||
|
function RomImporter:_installSkinData(name, data)
|
||||||
|
local TouchSkin = require("src.core.TouchSkin")
|
||||||
|
if not data or data == "" then
|
||||||
|
self._skinNotice = { ok = false, text = Strings("The skin file was empty.") }
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
local wrappedName, payload = RomImporter.wrapSkinPayload(name, data)
|
||||||
|
local id, note = TouchSkin.installArchive(wrappedName, payload)
|
||||||
self:_ensureSkins(true)
|
self:_ensureSkins(true)
|
||||||
if not id then
|
if not id then
|
||||||
self._skinNotice = { ok = false, text = "Import failed: " .. tostring(err) }
|
self._skinNotice = { ok = false, text = "Import failed: " .. tostring(note) }
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
local text = "Imported " .. id
|
||||||
|
if type(note) == "table" and note[1] then
|
||||||
|
text = text .. ": " .. tostring(note[1])
|
||||||
|
end
|
||||||
|
self._skinNotice = { ok = true, text = text }
|
||||||
|
return id
|
||||||
|
end
|
||||||
|
|
||||||
|
function RomImporter:_toggleSkinUrlFocus()
|
||||||
|
self._skinUrlFocus = not self._skinUrlFocus
|
||||||
|
if self._skinUrlFocus then
|
||||||
|
self:_armTextInput()
|
||||||
|
else
|
||||||
|
self:_disarmTextInput()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
function RomImporter:_pasteSkinUrl()
|
||||||
|
local ok, text = pcall(love.system.getClipboardText)
|
||||||
|
if ok and type(text) == "string" then
|
||||||
|
self.skinUrl = utf8Cap((self.skinUrl or "") .. text:gsub("%s", ""),
|
||||||
|
MAX_SKIN_URL)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
function RomImporter:_addSkinFromUrl(url)
|
||||||
|
if self._skinFetch then return false end
|
||||||
|
url = tostring(url or self.skinUrl or ""):gsub("%s", "")
|
||||||
|
if url == "" then
|
||||||
|
self._skinNotice = { ok = false,
|
||||||
|
text = Strings("Paste a link to a skin archive first.") }
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
if not url:match("^https?://") then
|
||||||
|
self._skinNotice = { ok = false,
|
||||||
|
text = Strings("A skin link has to start with http:// or https://") }
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
if not require("src.core.Platform").canFetchRemote() then
|
||||||
|
self._skinNotice = { ok = false,
|
||||||
|
text = Strings("Downloading needs a network transport this build has not got.") }
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
local name = RomImporter.skinUrlName(url)
|
||||||
|
local Fetch = require("src.net.Fetch")
|
||||||
|
self._skinFetch = {
|
||||||
|
url = url, name = name, dest = SKIN_TEMP_DIR .. "/" .. name,
|
||||||
|
job = Fetch.download(url, SKIN_TEMP_DIR .. "/" .. name,
|
||||||
|
{ userAgent = "gen1recomp-skin", maxSeconds = 90 }),
|
||||||
|
}
|
||||||
|
self._skinNotice = { ok = true, text = Strings("Downloading %s...", name) }
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
function RomImporter:_pumpSkinFetch()
|
||||||
|
local f = self._skinFetch
|
||||||
|
if not f then return end
|
||||||
|
local Fetch = require("src.net.Fetch")
|
||||||
|
local st = Fetch.poll(f.job)
|
||||||
|
if st.status == "pending" then
|
||||||
|
self._skinFetchProgress = st.progress
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
self._skinNotice = { ok = true, text = "Imported " .. id }
|
Fetch.release(f.job)
|
||||||
|
self._skinFetch, self._skinFetchProgress = nil, nil
|
||||||
|
if st.status ~= "ok" or not st.path then
|
||||||
|
self._skinNotice = { ok = false,
|
||||||
|
text = "Download failed: " .. tostring(st.err or "no data") }
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local data = love.filesystem.read(st.path)
|
||||||
|
love.filesystem.remove(st.path)
|
||||||
|
if self:_installSkinData(f.name, data) then
|
||||||
|
self.skinUrl = ""
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
function RomImporter:_exportSkin(id, kind)
|
||||||
|
local TouchSkin = require("src.core.TouchSkin")
|
||||||
|
local entry = id and TouchSkin.find(id)
|
||||||
|
if not entry then
|
||||||
|
self._skinNotice = { ok = false, text = Strings("That skin is gone.") }
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
local skin = TouchSkin.load(entry.root, entry.id)
|
||||||
|
if not skin then
|
||||||
|
self._skinNotice = { ok = false,
|
||||||
|
text = Strings("Could not read %s", tostring(id)) }
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
local path, missing, warnings
|
||||||
|
if kind == "retroarch" then
|
||||||
|
path, missing = TouchSkin.exportRetroArch(skin)
|
||||||
|
elseif kind == "delta" then
|
||||||
|
path, missing, warnings = TouchSkin.exportDelta(skin)
|
||||||
|
else
|
||||||
|
path, missing = TouchSkin.export(skin)
|
||||||
|
end
|
||||||
|
if not path then
|
||||||
|
self._skinNotice = { ok = false,
|
||||||
|
text = "Export failed: " .. tostring(missing) }
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
local dir = love.filesystem.getSaveDirectory
|
||||||
|
and love.filesystem.getSaveDirectory() or nil
|
||||||
|
self._skinExport = { path = path, dir = dir }
|
||||||
|
local text = Strings("Exported to %s", (dir and (dir .. "/") or "") .. path)
|
||||||
|
if type(missing) == "table" and missing[1] then
|
||||||
|
text = text .. " (" .. #missing .. " image(s) missing)"
|
||||||
|
end
|
||||||
|
if type(warnings) == "table" and warnings[1] then
|
||||||
|
text = text .. " " .. tostring(warnings[1])
|
||||||
|
end
|
||||||
|
self._skinNotice = { ok = true, text = text }
|
||||||
|
return path
|
||||||
|
end
|
||||||
|
|
||||||
|
function RomImporter:_revealSkinExport()
|
||||||
|
local e = self._skinExport
|
||||||
|
if not e or not e.dir then return false end
|
||||||
|
if love.system and love.system.openURL then
|
||||||
|
pcall(love.system.openURL, fileUrl(e.dir))
|
||||||
|
end
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
local MAX_SYNC_CODE = 8
|
||||||
|
local MAX_SHARE_CODE = 6
|
||||||
|
|
||||||
|
function RomImporter.syncDigits(text)
|
||||||
|
local digits = tostring(text or ""):gsub("[^%d]", "")
|
||||||
|
return digits:sub(1, MAX_SYNC_CODE)
|
||||||
|
end
|
||||||
|
|
||||||
|
function RomImporter.syncShareCode(text)
|
||||||
|
local out = tostring(text or ""):upper():gsub("[^A-Z2-9]", "")
|
||||||
|
return out:sub(1, MAX_SHARE_CODE)
|
||||||
|
end
|
||||||
|
|
||||||
|
function RomImporter:_syncDeviceLabel()
|
||||||
|
local name = love.system and love.system.getOS and love.system.getOS()
|
||||||
|
if type(name) ~= "string" or name == "" then return "device" end
|
||||||
|
return name
|
||||||
|
end
|
||||||
|
|
||||||
|
function RomImporter:_syncEngine()
|
||||||
|
if self._sync ~= nil then return self._sync or nil end
|
||||||
|
local ok, SyncEngine = pcall(require, "src.sync.SyncEngine")
|
||||||
|
if not ok or type(SyncEngine) ~= "table" then
|
||||||
|
self._sync = false
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
local made, eng = pcall(SyncEngine.shared)
|
||||||
|
if not made or type(eng) ~= "table" then
|
||||||
|
self._sync = false
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
self._sync = eng
|
||||||
|
return eng
|
||||||
|
end
|
||||||
|
|
||||||
|
function RomImporter:_syncSupported()
|
||||||
|
if self._syncTransportOk ~= nil then return self._syncTransportOk end
|
||||||
|
local ok, HostShell = pcall(require, "src.core.HostShell")
|
||||||
|
if not ok or type(HostShell) ~= "table"
|
||||||
|
or type(HostShell.canHttpRequest) ~= "function" then
|
||||||
|
self._syncTransportOk = true
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
local asked, can = pcall(HostShell.canHttpRequest)
|
||||||
|
self._syncTransportOk = (not asked) or (can and true or false)
|
||||||
|
return self._syncTransportOk
|
||||||
|
end
|
||||||
|
|
||||||
|
function RomImporter:_pumpSync(dt)
|
||||||
|
if self._sync == nil then
|
||||||
|
if not self.launcher or self._syncBooted then return end
|
||||||
|
if not self:_syncSupported() then return end
|
||||||
|
self._syncBooted = true
|
||||||
|
local booted = self:_syncEngine()
|
||||||
|
if booted and booted.state.enabled and booted:linked() then
|
||||||
|
pcall(booted.syncNow, booted)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
local eng = self._sync
|
||||||
|
if not eng then return end
|
||||||
|
pcall(eng.update, eng, dt)
|
||||||
|
if eng.phase == "conflict" and eng.conflicts and #eng.conflicts > 0 then
|
||||||
|
if not self._syncModal and not self._syncConflictShown then
|
||||||
|
self._syncConflictShown = true
|
||||||
|
self:_openSync()
|
||||||
|
end
|
||||||
|
else
|
||||||
|
self._syncConflictShown = nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
function RomImporter:_openSync()
|
||||||
|
self:_syncEngine()
|
||||||
|
self._syncModal = self._syncModal
|
||||||
|
or { view = "home", code1 = "", code2 = "", share = "" }
|
||||||
|
self._syncFocus = nil
|
||||||
|
self:_disarmTextInput()
|
||||||
|
end
|
||||||
|
|
||||||
|
function RomImporter:_closeSync()
|
||||||
|
self._syncModal = nil
|
||||||
|
self._syncFocus = nil
|
||||||
|
self:_disarmTextInput()
|
||||||
|
end
|
||||||
|
|
||||||
|
function RomImporter:_syncView(view)
|
||||||
|
if not self._syncModal then return end
|
||||||
|
self._syncModal.view = view
|
||||||
|
self._syncFocus = nil
|
||||||
|
self:_disarmTextInput()
|
||||||
|
end
|
||||||
|
|
||||||
|
function RomImporter:_syncFocusField(field)
|
||||||
|
if not self._syncModal then return end
|
||||||
|
if self._syncFocus == field then
|
||||||
|
self._syncFocus = nil
|
||||||
|
self:_disarmTextInput()
|
||||||
|
return
|
||||||
|
end
|
||||||
|
self._syncFocus = field
|
||||||
|
self:_armTextInput()
|
||||||
|
end
|
||||||
|
|
||||||
|
function RomImporter:_syncTypeInto(field, text)
|
||||||
|
local mo = self._syncModal
|
||||||
|
if not mo or not field then return end
|
||||||
|
if field == "share" then
|
||||||
|
mo.share = RomImporter.syncShareCode((mo.share or "") .. tostring(text or ""))
|
||||||
|
else
|
||||||
|
mo[field] = RomImporter.syncDigits((mo[field] or "") .. tostring(text or ""))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
function RomImporter:_syncPaste()
|
||||||
|
local field = self._syncFocus
|
||||||
|
if not field then return end
|
||||||
|
local ok, text = pcall(love.system.getClipboardText)
|
||||||
|
if ok and type(text) == "string" then self:_syncTypeInto(field, text) end
|
||||||
|
end
|
||||||
|
|
||||||
|
function RomImporter:_syncCreate()
|
||||||
|
local eng = self:_syncEngine()
|
||||||
|
if not eng then return false end
|
||||||
|
return eng:createAccount(self:_syncDeviceLabel())
|
||||||
|
end
|
||||||
|
|
||||||
|
function RomImporter:_syncLink()
|
||||||
|
local eng, mo = self:_syncEngine(), self._syncModal
|
||||||
|
if not eng or not mo then return false end
|
||||||
|
local ok = eng:linkDevice(mo.code1, mo.code2, self:_syncDeviceLabel())
|
||||||
|
if ok then
|
||||||
|
mo.code1, mo.code2, mo.view = "", "", "home"
|
||||||
|
self._syncFocus = nil
|
||||||
|
self:_disarmTextInput()
|
||||||
|
end
|
||||||
|
return ok
|
||||||
|
end
|
||||||
|
|
||||||
|
function RomImporter:_syncNow()
|
||||||
|
local eng = self:_syncEngine()
|
||||||
|
if not eng then return false end
|
||||||
|
return eng:syncNow()
|
||||||
|
end
|
||||||
|
|
||||||
|
function RomImporter:_syncUnlink()
|
||||||
|
local eng = self:_syncEngine()
|
||||||
|
if not eng then return false end
|
||||||
|
eng:unlink()
|
||||||
|
if self._syncModal then self._syncModal.view = "home" end
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
function RomImporter:_syncUnlinkDevice(deviceId)
|
||||||
|
local eng = self:_syncEngine()
|
||||||
|
if not eng or type(eng.unlinkDevice) ~= "function" then return false end
|
||||||
|
return eng:unlinkDevice(deviceId)
|
||||||
|
end
|
||||||
|
|
||||||
|
function RomImporter:_syncShareMods()
|
||||||
|
local eng = self:_syncEngine()
|
||||||
|
if not eng then return false end
|
||||||
|
return eng:shareMods()
|
||||||
|
end
|
||||||
|
|
||||||
|
function RomImporter:_syncGetShare()
|
||||||
|
local eng, mo = self:_syncEngine(), self._syncModal
|
||||||
|
if not eng or not mo then return false end
|
||||||
|
return eng:fetchShare(mo.share or "")
|
||||||
|
end
|
||||||
|
|
||||||
|
function RomImporter:_syncApplyMods()
|
||||||
|
local eng, mo = self:_syncEngine(), self._syncModal
|
||||||
|
if not eng then return false end
|
||||||
|
local ok, err = eng:applyModPlan(function(done, total, label, finished)
|
||||||
|
if not mo then return end
|
||||||
|
if finished then
|
||||||
|
mo.progress = nil
|
||||||
|
if self._refreshMods then self:_refreshMods() end
|
||||||
|
else
|
||||||
|
mo.progress = { done = done, total = total, label = label }
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
if mo then mo.progress = nil end
|
||||||
|
if ok and self._refreshMods then self:_refreshMods() end
|
||||||
|
return ok, err
|
||||||
|
end
|
||||||
|
|
||||||
|
function RomImporter:_syncResolve(key, choice)
|
||||||
|
local eng = self:_syncEngine()
|
||||||
|
if not eng then return false end
|
||||||
|
return eng:resolveConflict(key, choice)
|
||||||
end
|
end
|
||||||
|
|
||||||
function RomImporter:_skinsImportButtonLabel()
|
function RomImporter:_skinsImportButtonLabel()
|
||||||
@@ -3233,6 +3628,10 @@ function RomImporter:_openSettings()
|
|||||||
-- The tab rides along: the editor persists the layout into that game's own
|
-- The tab rides along: the editor persists the layout into that game's own
|
||||||
-- option block, and Gold's is not the flat Gen 1 one (#1100).
|
-- option block, and Gold's is not the flat Gen 1 one (#1100).
|
||||||
local hooks = {}
|
local hooks = {}
|
||||||
|
local version = self.tab
|
||||||
|
hooks.reportIssue = function(opts)
|
||||||
|
return self:_reportIssue(opts, version)
|
||||||
|
end
|
||||||
if self.onEditTouchControls then
|
if self.onEditTouchControls then
|
||||||
local version = self.tab
|
local version = self.tab
|
||||||
hooks.editTouchControls = function()
|
hooks.editTouchControls = function()
|
||||||
@@ -3250,11 +3649,13 @@ function RomImporter:_openSettings()
|
|||||||
-- The tab the gear was opened on decides the row set: Gold reads a
|
-- The tab the gear was opened on decides the row set: Gold reads a
|
||||||
-- different option block entirely, and offering it Gen 1's rows meant a
|
-- different option block entirely, and offering it Gen 1's rows meant a
|
||||||
-- dozen controls that changed nothing (see LauncherSettings.gen2Rows).
|
-- dozen controls that changed nothing (see LauncherSettings.gen2Rows).
|
||||||
local version = self.tab
|
|
||||||
local ok, model = pcall(function()
|
local ok, model = pcall(function()
|
||||||
return require("src.import.LauncherSettings").open(hooks, version)
|
return require("src.import.LauncherSettings").open(hooks, version)
|
||||||
end)
|
end)
|
||||||
if ok and model then self._settings = model end
|
if ok and model then
|
||||||
|
self._settings = model
|
||||||
|
self._settingsSafeModeAtOpen = require("src.core.SaveData").isSafeMode(model.opts)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Quit from the launcher's own X. It goes through love.event.quit so main.lua's
|
-- Quit from the launcher's own X. It goes through love.event.quit so main.lua's
|
||||||
@@ -3265,8 +3666,38 @@ function RomImporter:_quitApp()
|
|||||||
end
|
end
|
||||||
|
|
||||||
function RomImporter:_closeSettings()
|
function RomImporter:_closeSettings()
|
||||||
if self._settings then self._settings.save() end
|
local model = self._settings
|
||||||
|
if model then
|
||||||
|
model.save()
|
||||||
|
local safeMode = require("src.core.SaveData").isSafeMode(model.opts)
|
||||||
|
if safeMode ~= self._settingsSafeModeAtOpen then
|
||||||
|
self.mods = nil
|
||||||
|
self.safeMode = safeMode
|
||||||
|
self._modSortCache = nil
|
||||||
|
self._modInfoFetch = nil
|
||||||
|
end
|
||||||
|
end
|
||||||
self._settings = nil
|
self._settings = nil
|
||||||
|
self._settingsSafeModeAtOpen = nil
|
||||||
|
end
|
||||||
|
|
||||||
|
function RomImporter:_reportIssue(options, version)
|
||||||
|
local ok, IssueReport = pcall(require, "src.core.IssueReport")
|
||||||
|
if not ok then
|
||||||
|
self.modNotice = { ok = false, text = "Could not prepare the issue report." }
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
local opened, url, reason = IssueReport.open(options, {
|
||||||
|
version = version,
|
||||||
|
mods = self.mods,
|
||||||
|
})
|
||||||
|
if not opened then
|
||||||
|
self.modNotice = { ok = false, text = reason or "Could not open the issue report." }
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
self._lastIssueReportURL = url
|
||||||
|
if reason then self.modNotice = { ok = true, text = reason } end
|
||||||
|
return true
|
||||||
end
|
end
|
||||||
|
|
||||||
function RomImporter:_commitSettingsText()
|
function RomImporter:_commitSettingsText()
|
||||||
@@ -3338,6 +3769,27 @@ function RomImporter:keypressed(key)
|
|||||||
if key == "escape" then self:_closeSettings() end
|
if key == "escape" then self:_closeSettings() end
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
if self._syncModal then
|
||||||
|
local field = self._syncFocus
|
||||||
|
if field then
|
||||||
|
local mo = self._syncModal
|
||||||
|
if key == "backspace" then
|
||||||
|
mo[field] = tostring(mo[field] or ""):sub(1, -2)
|
||||||
|
elseif key == "return" or key == "kpenter" or key == "escape" then
|
||||||
|
self._syncFocus = nil
|
||||||
|
self:_disarmTextInput()
|
||||||
|
elseif key == "v"
|
||||||
|
and love.keyboard.isDown("lctrl", "rctrl", "lgui", "rgui") then
|
||||||
|
self:_syncPaste()
|
||||||
|
end
|
||||||
|
return
|
||||||
|
end
|
||||||
|
if self._flex and require("src.import.LauncherView").keypressed(self, key) then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
if key == "escape" then self:_closeSync() end
|
||||||
|
return
|
||||||
|
end
|
||||||
if self._rename then
|
if self._rename then
|
||||||
if key == "backspace" then
|
if key == "backspace" then
|
||||||
self._rename.text = utf8Back(self._rename.text)
|
self._rename.text = utf8Back(self._rename.text)
|
||||||
@@ -3387,6 +3839,21 @@ function RomImporter:keypressed(key)
|
|||||||
end
|
end
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
if self._skinUrlFocus then
|
||||||
|
if key == "backspace" then
|
||||||
|
self.skinUrl = utf8Back(self.skinUrl or "")
|
||||||
|
elseif key == "return" or key == "kpenter" then
|
||||||
|
self._skinUrlFocus = false
|
||||||
|
self:_disarmTextInput()
|
||||||
|
self:_addSkinFromUrl()
|
||||||
|
elseif key == "escape" then
|
||||||
|
self._skinUrlFocus = false
|
||||||
|
self:_disarmTextInput()
|
||||||
|
elseif key == "v" and love.keyboard.isDown("lctrl", "rctrl", "lgui", "rgui") then
|
||||||
|
self:_pasteSkinUrl()
|
||||||
|
end
|
||||||
|
return
|
||||||
|
end
|
||||||
if self._findSearchFocus then
|
if self._findSearchFocus then
|
||||||
if key == "backspace" then
|
if key == "backspace" then
|
||||||
self.findQuery = utf8Back(self.findQuery or "")
|
self.findQuery = utf8Back(self.findQuery or "")
|
||||||
@@ -3494,6 +3961,10 @@ function RomImporter:_commitRename()
|
|||||||
end
|
end
|
||||||
|
|
||||||
function RomImporter:textinput(text)
|
function RomImporter:textinput(text)
|
||||||
|
if self._syncModal and self._syncFocus then
|
||||||
|
self:_syncTypeInto(self._syncFocus, text)
|
||||||
|
return
|
||||||
|
end
|
||||||
if self._profileSavePrompt then
|
if self._profileSavePrompt then
|
||||||
self._profileSavePrompt.text = utf8Cap((self._profileSavePrompt.text or "") .. text, MAX_SLOT_LABEL)
|
self._profileSavePrompt.text = utf8Cap((self._profileSavePrompt.text or "") .. text, MAX_SLOT_LABEL)
|
||||||
return
|
return
|
||||||
@@ -3514,6 +3985,11 @@ function RomImporter:textinput(text)
|
|||||||
utf8Cap(self._indexPrompt.text .. text:gsub("%s", ""), MAX_INDEX_URL)
|
utf8Cap(self._indexPrompt.text .. text:gsub("%s", ""), MAX_INDEX_URL)
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
if self._skinUrlFocus then
|
||||||
|
self.skinUrl = utf8Cap((self.skinUrl or "") .. text:gsub("%s", ""),
|
||||||
|
MAX_SKIN_URL)
|
||||||
|
return
|
||||||
|
end
|
||||||
if self._findSearchFocus then
|
if self._findSearchFocus then
|
||||||
self.findQuery = utf8Cap((self.findQuery or "") .. text, MAX_FIND_QUERY)
|
self.findQuery = utf8Cap((self.findQuery or "") .. text, MAX_FIND_QUERY)
|
||||||
self.findScroll = 0
|
self.findScroll = 0
|
||||||
@@ -3561,6 +4037,8 @@ end
|
|||||||
-- so a still list costs nothing after the first paint.
|
-- so a still list costs nothing after the first paint.
|
||||||
function RomImporter:_refreshMods()
|
function RomImporter:_refreshMods()
|
||||||
local LauncherMods = require("src.mods.LauncherMods")
|
local LauncherMods = require("src.mods.LauncherMods")
|
||||||
|
local SaveData = require("src.core.SaveData")
|
||||||
|
self.safeMode = SaveData.isSafeMode(SaveData.loadOptions())
|
||||||
-- Once per session, ahead of the first listing: pull in any mod the player
|
-- Once per session, ahead of the first listing: pull in any mod the player
|
||||||
-- unzipped beside the executable, which an ordinary (non-portable) install
|
-- unzipped beside the executable, which an ordinary (non-portable) install
|
||||||
-- has no way to read. It happens here rather than behind a button because
|
-- has no way to read. It happens here rather than behind a button because
|
||||||
@@ -3700,6 +4178,10 @@ end
|
|||||||
-- so that game's checkbox and status chips reflect the new resolution.
|
-- so that game's checkbox and status chips reflect the new resolution.
|
||||||
-- Enabling an experimental mod arms a confirmation for that same game.
|
-- Enabling an experimental mod arms a confirmation for that same game.
|
||||||
function RomImporter:_toggleMod(id, confirmed, version)
|
function RomImporter:_toggleMod(id, confirmed, version)
|
||||||
|
if self.safeMode then
|
||||||
|
self.modNotice = { ok = false, text = "Safe mode is active. Turn it off in Settings to change mods." }
|
||||||
|
return
|
||||||
|
end
|
||||||
local LauncherMods = require("src.mods.LauncherMods")
|
local LauncherMods = require("src.mods.LauncherMods")
|
||||||
local cur, experimental = false, false
|
local cur, experimental = false, false
|
||||||
for _, m in ipairs(self.mods or {}) do
|
for _, m in ipairs(self.mods or {}) do
|
||||||
@@ -3740,6 +4222,10 @@ end
|
|||||||
-- must not be the way around it. Disabling needs no confirm -- it is the
|
-- must not be the way around it. Disabling needs no confirm -- it is the
|
||||||
-- recovery action, and Delete is the only destructive one on this panel.
|
-- recovery action, and Delete is the only destructive one on this panel.
|
||||||
function RomImporter:_setAllMods(want, confirmed)
|
function RomImporter:_setAllMods(want, confirmed)
|
||||||
|
if self.safeMode then
|
||||||
|
self.modNotice = { ok = false, text = "Safe mode is active. Turn it off in Settings to change mods." }
|
||||||
|
return
|
||||||
|
end
|
||||||
local LauncherMods = require("src.mods.LauncherMods")
|
local LauncherMods = require("src.mods.LauncherMods")
|
||||||
local ids, experimental = {}, false
|
local ids, experimental = {}, false
|
||||||
for _, m in ipairs(self.mods or {}) do
|
for _, m in ipairs(self.mods or {}) do
|
||||||
|
|||||||
@@ -291,6 +291,7 @@ function LauncherMods.deriveList(manifests, options, version)
|
|||||||
local ordered = {}
|
local ordered = {}
|
||||||
for _, m in ipairs(manifests) do ordered[#ordered + 1] = m end
|
for _, m in ipairs(manifests) do ordered[#ordered + 1] = m end
|
||||||
table.sort(ordered, function(a, b) return a.id < b.id end)
|
table.sort(ordered, function(a, b) return a.id < b.id end)
|
||||||
|
local safeMode = SaveData.isSafeMode(options)
|
||||||
|
|
||||||
-- the override is one answer per game (SaveData.modForced), the same scope
|
-- the override is one answer per game (SaveData.modForced), the same scope
|
||||||
-- the loader resolves it under
|
-- the loader resolves it under
|
||||||
@@ -304,17 +305,24 @@ function LauncherMods.deriveList(manifests, options, version)
|
|||||||
-- matching the loader -- except experimental mods, which stay off until
|
-- matching the loader -- except experimental mods, which stay off until
|
||||||
-- the player opts in. Scoped through modScope, so this reads exactly what
|
-- the player opts in. Scoped through modScope, so this reads exactly what
|
||||||
-- setEnabled writes and the loader loads for the selected game.
|
-- setEnabled writes and the loader loads for the selected game.
|
||||||
|
if not safeMode then
|
||||||
local decided = SaveData.modEnabled(options, m.id, SaveData.modScope(version))
|
local decided = SaveData.modEnabled(options, m.id, SaveData.modScope(version))
|
||||||
if decided == nil then decided = not m.experimental end
|
if decided == nil then decided = not m.experimental end
|
||||||
if decided then enabledSet[m.id] = true end
|
if decided then enabledSet[m.id] = true end
|
||||||
end
|
end
|
||||||
|
end
|
||||||
|
|
||||||
local out = {}
|
local out = {}
|
||||||
for _, m in ipairs(ordered) do
|
for _, m in ipairs(ordered) do
|
||||||
local enabled = enabledSet[m.id] == true
|
local enabled = not safeMode and enabledSet[m.id] == true
|
||||||
local forced = forcedFor(m.id)
|
local forced = forcedFor(m.id)
|
||||||
local status, detail =
|
local status, detail
|
||||||
|
if safeMode then
|
||||||
|
status, detail = "safe_mode", "Disabled by safe mode"
|
||||||
|
else
|
||||||
|
status, detail =
|
||||||
statusFor(byId, m.id, enabledSet, enabled, version, forcedFor)
|
statusFor(byId, m.id, enabledSet, enabled, version, forcedFor)
|
||||||
|
end
|
||||||
-- nil, not false, when the panel is showing every game at once
|
-- nil, not false, when the panel is showing every game at once
|
||||||
local here = nil
|
local here = nil
|
||||||
if version then here = ModTargets.runsHere(m, version, nil, forced) end
|
if version then here = ModTargets.runsHere(m, version, nil, forced) end
|
||||||
@@ -332,7 +340,8 @@ function LauncherMods.deriveList(manifests, options, version)
|
|||||||
local answers = {}
|
local answers = {}
|
||||||
for _, game in ipairs(GameVersion.ORDER) do
|
for _, game in ipairs(GameVersion.ORDER) do
|
||||||
local answer = SaveData.modEnabled(options, m.id, game)
|
local answer = SaveData.modEnabled(options, m.id, game)
|
||||||
answers[game] = answer == true or (answer == nil and not m.experimental)
|
answers[game] = not safeMode
|
||||||
|
and (answer == true or (answer == nil and not m.experimental))
|
||||||
end
|
end
|
||||||
return answers
|
return answers
|
||||||
end)(),
|
end)(),
|
||||||
@@ -346,6 +355,7 @@ function LauncherMods.deriveList(manifests, options, version)
|
|||||||
-- panel is showing (src/mods/ModTargets.lua)
|
-- panel is showing (src/mods/ModTargets.lua)
|
||||||
targets = ModTargets.chip(m),
|
targets = ModTargets.chip(m),
|
||||||
targetsHere = here,
|
targetsHere = here,
|
||||||
|
safeMode = safeMode,
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
return out
|
return out
|
||||||
@@ -586,6 +596,7 @@ end
|
|||||||
-- answer. The loader and the in-game manager use the same scope on next boot.
|
-- answer. The loader and the in-game manager use the same scope on next boot.
|
||||||
function LauncherMods.setEnabled(id, enabled, version)
|
function LauncherMods.setEnabled(id, enabled, version)
|
||||||
local options = SaveData.loadOptions()
|
local options = SaveData.loadOptions()
|
||||||
|
if SaveData.isSafeMode(options) then return false end
|
||||||
SaveData.setModEnabled(options, id, enabled, SaveData.modScope(version))
|
SaveData.setModEnabled(options, id, enabled, SaveData.modScope(version))
|
||||||
SaveData.saveOptions(options)
|
SaveData.saveOptions(options)
|
||||||
LauncherMods.syncActiveProfile(options)
|
LauncherMods.syncActiveProfile(options)
|
||||||
@@ -599,6 +610,7 @@ end
|
|||||||
-- and leaves a half-applied state behind if one of them fails.
|
-- and leaves a half-applied state behind if one of them fails.
|
||||||
function LauncherMods.setAllEnabled(ids, enabled, version)
|
function LauncherMods.setAllEnabled(ids, enabled, version)
|
||||||
local options = SaveData.loadOptions()
|
local options = SaveData.loadOptions()
|
||||||
|
if SaveData.isSafeMode(options) then return false end
|
||||||
local scope = SaveData.modScope(version)
|
local scope = SaveData.modScope(version)
|
||||||
for _, id in ipairs(ids or {}) do
|
for _, id in ipairs(ids or {}) do
|
||||||
if scope then
|
if scope then
|
||||||
@@ -1184,6 +1196,7 @@ end
|
|||||||
|
|
||||||
function LauncherMods.applyProfile(profileName, options)
|
function LauncherMods.applyProfile(profileName, options)
|
||||||
options = options or SaveData.loadOptions()
|
options = options or SaveData.loadOptions()
|
||||||
|
if SaveData.isSafeMode(options) then return false end
|
||||||
local profiles = options.modProfiles or {}
|
local profiles = options.modProfiles or {}
|
||||||
local targetProfile
|
local targetProfile
|
||||||
for _, p in ipairs(profiles) do
|
for _, p in ipairs(profiles) do
|
||||||
|
|||||||
@@ -259,6 +259,7 @@ function Loader.new(opts)
|
|||||||
modInput = {}, modEnv = {}, stepsQueues = {},
|
modInput = {}, modEnv = {}, stepsQueues = {},
|
||||||
fs = (opts and opts.fs) or (love and love.filesystem),
|
fs = (opts and opts.fs) or (love and love.filesystem),
|
||||||
dev = dev,
|
dev = dev,
|
||||||
|
safeMode = false,
|
||||||
-- Which generation this boot is (1 or 2). Fixed at construction: the
|
-- Which generation this boot is (1 or 2). Fixed at construction: the
|
||||||
-- active version is set once in main.lua's bootGame before anything
|
-- active version is set once in main.lua's bootGame before anything
|
||||||
-- builds a loader, and a run never changes generation underneath one.
|
-- builds a loader, and a run never changes generation underneath one.
|
||||||
@@ -300,6 +301,8 @@ end
|
|||||||
function Loader:_loadState()
|
function Loader:_loadState()
|
||||||
self.disabled = {}
|
self.disabled = {}
|
||||||
local options = SaveData.loadOptions(self.fs)
|
local options = SaveData.loadOptions(self.fs)
|
||||||
|
self.safeMode = SaveData.isSafeMode(options)
|
||||||
|
Runtime.safeMode = self.safeMode
|
||||||
local scope = self:_enableScope()
|
local scope = self:_enableScope()
|
||||||
local ids = {}
|
local ids = {}
|
||||||
for id in pairs(options.mods or {}) do ids[id] = true end
|
for id in pairs(options.mods or {}) do ids[id] = true end
|
||||||
@@ -415,6 +418,7 @@ function Loader:_writeOptionSchemas()
|
|||||||
end
|
end
|
||||||
|
|
||||||
function Loader:setEnabled(id, enabled)
|
function Loader:setEnabled(id, enabled)
|
||||||
|
if self.safeMode then return false end
|
||||||
if not self.mods[id] then return false end
|
if not self.mods[id] then return false end
|
||||||
self.disabled[id] = not enabled
|
self.disabled[id] = not enabled
|
||||||
self.mods[id].enabled = enabled
|
self.mods[id].enabled = enabled
|
||||||
@@ -427,6 +431,7 @@ end
|
|||||||
-- choice could not be persisted for a game, so the caller does not promise a
|
-- choice could not be persisted for a game, so the caller does not promise a
|
||||||
-- restart will honour it.
|
-- restart will honour it.
|
||||||
function Loader:setGen2Forced(id, forced)
|
function Loader:setGen2Forced(id, forced)
|
||||||
|
if self.safeMode then return false, false end
|
||||||
if not self.mods[id] then return false, false end
|
if not self.mods[id] then return false, false end
|
||||||
self.gen2Forced[id] = forced or nil
|
self.gen2Forced[id] = forced or nil
|
||||||
self:_saveState()
|
self:_saveState()
|
||||||
@@ -1539,6 +1544,9 @@ function Loader:load(data)
|
|||||||
require("src.mods.Builtins").install(self.content, data, self.generation)
|
require("src.mods.Builtins").install(self.content, data, self.generation)
|
||||||
self:_loadState()
|
self:_loadState()
|
||||||
self:_discover()
|
self:_discover()
|
||||||
|
if self.safeMode then
|
||||||
|
for id in pairs(self.mods) do self.disabled[id] = true end
|
||||||
|
end
|
||||||
-- Existing installs stored one shared answer. Once their manifests are
|
-- Existing installs stored one shared answer. Once their manifests are
|
||||||
-- known, split that answer across every game before the next launcher/game
|
-- known, split that answer across every game before the next launcher/game
|
||||||
-- toggle can change one independently. _loadState already used the same
|
-- toggle can change one independently. _loadState already used the same
|
||||||
@@ -1574,7 +1582,7 @@ function Loader:load(data)
|
|||||||
-- the one build where its env var is set.
|
-- the one build where its env var is set.
|
||||||
for id, mod in pairs(self.mods) do
|
for id, mod in pairs(self.mods) do
|
||||||
local envName = mod.manifest.force_enable_env
|
local envName = mod.manifest.force_enable_env
|
||||||
if envName and os.getenv(envName) == "1" then
|
if not self.safeMode and envName and os.getenv(envName) == "1" then
|
||||||
self.disabled[id] = nil
|
self.disabled[id] = nil
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -1740,6 +1748,7 @@ function Loader:status()
|
|||||||
local manifest = {}
|
local manifest = {}
|
||||||
for key, value in pairs(mod.manifest) do manifest[key] = value end
|
for key, value in pairs(mod.manifest) do manifest[key] = value end
|
||||||
manifest.enabled = mod.enabled ~= false
|
manifest.enabled = mod.enabled ~= false
|
||||||
|
manifest.safeMode = self.safeMode == true
|
||||||
manifest.state = mod.state or (manifest.enabled and "loaded" or "disabled")
|
manifest.state = mod.state or (manifest.enabled and "loaded" or "disabled")
|
||||||
manifest.error = mod.failure
|
manifest.error = mod.failure
|
||||||
-- set instead of `error` when the mod was left out for a reason that is
|
-- set instead of `error` when the mod was left out for a reason that is
|
||||||
|
|||||||
@@ -365,9 +365,13 @@ end
|
|||||||
|
|
||||||
function ManagerState:detailRows(m)
|
function ManagerState:detailRows(m)
|
||||||
local rows = {}
|
local rows = {}
|
||||||
|
if Runtime.safeMode then
|
||||||
|
rows[#rows + 1] = { label = "SAFE MODE ACTIVE", inert = true }
|
||||||
|
else
|
||||||
rows[#rows + 1] = { label = m.enabled and "DISABLE" or "ENABLE",
|
rows[#rows + 1] = { label = m.enabled and "DISABLE" or "ENABLE",
|
||||||
action = function() self:beginToggle(m) end }
|
action = function() self:beginToggle(m) end }
|
||||||
if self:schemaFor(m) then
|
end
|
||||||
|
if not Runtime.safeMode and self:schemaFor(m) then
|
||||||
rows[#rows + 1] = { label = Strings("OPTIONS.."),
|
rows[#rows + 1] = { label = Strings("OPTIONS.."),
|
||||||
action = function() self:openOptions(m) end }
|
action = function() self:openOptions(m) end }
|
||||||
end
|
end
|
||||||
@@ -383,7 +387,8 @@ function ManagerState:detailRows(m)
|
|||||||
-- what this mod does.
|
-- what this mod does.
|
||||||
local loader = self.game.mods
|
local loader = self.game.mods
|
||||||
local version, gen = self:targetGame()
|
local version, gen = self:targetGame()
|
||||||
if loader and loader.setGen2Forced and not ModTargets.supports(m, version, gen) then
|
if loader and loader.setGen2Forced and not Runtime.safeMode
|
||||||
|
and not ModTargets.supports(m, version, gen) then
|
||||||
rows[#rows + 1] = {
|
rows[#rows + 1] = {
|
||||||
label = m.gen2Forced and Strings("DON'T TRY HERE") or Strings("TRY HERE ANYWAY"),
|
label = m.gen2Forced and Strings("DON'T TRY HERE") or Strings("TRY HERE ANYWAY"),
|
||||||
action = function() self:toggleGen2Force(m) end }
|
action = function() self:toggleGen2Force(m) end }
|
||||||
@@ -674,6 +679,10 @@ end
|
|||||||
-- ------- the enable/disable flow
|
-- ------- the enable/disable flow
|
||||||
|
|
||||||
function ManagerState:beginToggle(m)
|
function ManagerState:beginToggle(m)
|
||||||
|
if Runtime.safeMode then
|
||||||
|
self:notify("SAFE MODE ACTIVE")
|
||||||
|
return
|
||||||
|
end
|
||||||
if not m then return end
|
if not m then return end
|
||||||
local want = not m.enabled
|
local want = not m.enabled
|
||||||
local loader = self.game.mods
|
local loader = self.game.mods
|
||||||
@@ -711,6 +720,10 @@ end
|
|||||||
-- override is scoped to THIS game, and a boot that cannot name one keeps it in
|
-- override is scoped to THIS game, and a boot that cannot name one keeps it in
|
||||||
-- memory only, which the notice says rather than promising a restart.
|
-- memory only, which the notice says rather than promising a restart.
|
||||||
function ManagerState:toggleGen2Force(m)
|
function ManagerState:toggleGen2Force(m)
|
||||||
|
if Runtime.safeMode then
|
||||||
|
self:notify("SAFE MODE ACTIVE")
|
||||||
|
return
|
||||||
|
end
|
||||||
local loader = self.game.mods
|
local loader = self.game.mods
|
||||||
if not (loader and loader.setGen2Forced) then return end
|
if not (loader and loader.setGen2Forced) then return end
|
||||||
local want = not m.gen2Forced
|
local want = not m.gen2Forced
|
||||||
@@ -743,6 +756,10 @@ function ManagerState:enableScope()
|
|||||||
end
|
end
|
||||||
|
|
||||||
function ManagerState:commitToggle(apply)
|
function ManagerState:commitToggle(apply)
|
||||||
|
if Runtime.safeMode then
|
||||||
|
self:notify("SAFE MODE ACTIVE")
|
||||||
|
return
|
||||||
|
end
|
||||||
local loader = self.game.mods
|
local loader = self.game.mods
|
||||||
local opts = self:optionsTable()
|
local opts = self:optionsTable()
|
||||||
local scope = self:enableScope()
|
local scope = self:enableScope()
|
||||||
@@ -757,6 +774,10 @@ function ManagerState:commitToggle(apply)
|
|||||||
end
|
end
|
||||||
|
|
||||||
function ManagerState:discardChanges()
|
function ManagerState:discardChanges()
|
||||||
|
if Runtime.safeMode then
|
||||||
|
self:notify("SAFE MODE ACTIVE")
|
||||||
|
return
|
||||||
|
end
|
||||||
local loader = self.game.mods
|
local loader = self.game.mods
|
||||||
local opts = self:optionsTable()
|
local opts = self:optionsTable()
|
||||||
local scope = self:enableScope()
|
local scope = self:enableScope()
|
||||||
@@ -802,6 +823,10 @@ function ManagerState:persistOptions()
|
|||||||
end
|
end
|
||||||
|
|
||||||
function ManagerState:applyProfile(p)
|
function ManagerState:applyProfile(p)
|
||||||
|
if Runtime.safeMode then
|
||||||
|
self:notify("SAFE MODE ACTIVE")
|
||||||
|
return
|
||||||
|
end
|
||||||
local mods = self:manifestMap()
|
local mods = self:manifestMap()
|
||||||
local set = self:enabledSet()
|
local set = self:enabledSet()
|
||||||
local combined = {}
|
local combined = {}
|
||||||
@@ -963,6 +988,10 @@ function ManagerState:optionValue(modId, row)
|
|||||||
end
|
end
|
||||||
|
|
||||||
function ManagerState:setOption(modId, key, value)
|
function ManagerState:setOption(modId, key, value)
|
||||||
|
if Runtime.safeMode then
|
||||||
|
self:notify("SAFE MODE ACTIVE")
|
||||||
|
return false
|
||||||
|
end
|
||||||
local save = self.game.save
|
local save = self.game.save
|
||||||
if save and save.options then
|
if save and save.options then
|
||||||
save.options.modOptions = save.options.modOptions or {}
|
save.options.modOptions = save.options.modOptions or {}
|
||||||
@@ -1123,6 +1152,10 @@ function ManagerState:buildOptionRows(m, schema)
|
|||||||
end
|
end
|
||||||
|
|
||||||
function ManagerState:openOptions(m)
|
function ManagerState:openOptions(m)
|
||||||
|
if Runtime.safeMode then
|
||||||
|
self:notify("SAFE MODE ACTIVE")
|
||||||
|
return
|
||||||
|
end
|
||||||
local schema = self:schemaFor(m)
|
local schema = self:schemaFor(m)
|
||||||
if not schema then
|
if not schema then
|
||||||
self:notify("NO OPTIONS")
|
self:notify("NO OPTIONS")
|
||||||
|
|||||||
@@ -33,11 +33,21 @@ Runtime.currentMod = nil
|
|||||||
-- currentMod went back to nil (src/mods/Sandbox.lua)
|
-- currentMod went back to nil (src/mods/Sandbox.lua)
|
||||||
Runtime.modRequire = nil
|
Runtime.modRequire = nil
|
||||||
|
|
||||||
|
Runtime.safeMode = false
|
||||||
|
|
||||||
function Runtime.install(events, hooks, errors)
|
function Runtime.install(events, hooks, errors)
|
||||||
Runtime.events, Runtime.hooks = events, hooks
|
Runtime.events, Runtime.hooks = events, hooks
|
||||||
Runtime.errors = errors
|
Runtime.errors = errors
|
||||||
end
|
end
|
||||||
|
|
||||||
|
function Runtime.reset()
|
||||||
|
Runtime.events = NullEvents
|
||||||
|
Runtime.hooks = NullHooks
|
||||||
|
Runtime.errors = nil
|
||||||
|
Runtime.currentMod = nil
|
||||||
|
Runtime.modRequire = nil
|
||||||
|
end
|
||||||
|
|
||||||
-- attribute a runtime failure to the mod that owns the offending record.
|
-- attribute a runtime failure to the mod that owns the offending record.
|
||||||
-- "base" is the engine's own owner id: a vanilla record that fails is a
|
-- "base" is the engine's own owner id: a vanilla record that fails is a
|
||||||
-- console line, not something the manager can ask the player to disable.
|
-- console line, not something the manager can ask the player to disable.
|
||||||
|
|||||||
@@ -86,6 +86,7 @@ local function drain()
|
|||||||
else
|
else
|
||||||
j.status = msg.ok and "ok" or "error"
|
j.status = msg.ok and "ok" or "error"
|
||||||
j.body, j.err, j.path = msg.body, msg.err, msg.path
|
j.body, j.err, j.path = msg.body, msg.err, msg.path
|
||||||
|
j.code = msg.code
|
||||||
j.progress = msg.ok and 1 or j.progress
|
j.progress = msg.ok and 1 or j.progress
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -143,6 +144,14 @@ function Fetch.post(url, body, opts)
|
|||||||
contentType = opts.contentType, maxSeconds = opts.maxSeconds })
|
contentType = opts.contentType, maxSeconds = opts.maxSeconds })
|
||||||
end
|
end
|
||||||
|
|
||||||
|
function Fetch.request(url, opts)
|
||||||
|
opts = opts or {}
|
||||||
|
return submit({ kind = "request", url = url,
|
||||||
|
method = opts.method, body = opts.body, headers = opts.headers,
|
||||||
|
userAgent = opts.userAgent or "gen1recomp",
|
||||||
|
maxSeconds = opts.maxSeconds })
|
||||||
|
end
|
||||||
|
|
||||||
-- Download a URL to `saveRel`, a path relative to the LOVE save directory.
|
-- Download a URL to `saveRel`, a path relative to the LOVE save directory.
|
||||||
-- Progress is reported as a 0..1 fraction when `size` is known.
|
-- Progress is reported as a 0..1 fraction when `size` is known.
|
||||||
function Fetch.download(url, saveRel, opts)
|
function Fetch.download(url, saveRel, opts)
|
||||||
|
|||||||
@@ -113,6 +113,22 @@ local function doPost(job)
|
|||||||
post({ id = job.id, ok = true, done = true })
|
post({ id = job.id, ok = true, done = true })
|
||||||
end
|
end
|
||||||
|
|
||||||
|
local function doRequest(job)
|
||||||
|
if not HostShell then
|
||||||
|
post({ id = job.id, ok = false, err = "no transport" })
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local body, err, code = HostShell.httpRequest(job.url, {
|
||||||
|
method = job.method, body = job.body, headers = job.headers,
|
||||||
|
userAgent = job.userAgent,
|
||||||
|
maxTime = tonumber(job.maxSeconds) or GET_MAX_SECONDS })
|
||||||
|
if not code then
|
||||||
|
post({ id = job.id, ok = false, err = err or "request failed" })
|
||||||
|
return
|
||||||
|
end
|
||||||
|
post({ id = job.id, ok = true, body = body or "", code = code, done = true })
|
||||||
|
end
|
||||||
|
|
||||||
while true do
|
while true do
|
||||||
local job = cmdCh:demand()
|
local job = cmdCh:demand()
|
||||||
-- The flag is checked before the job's KIND, so a worker woken by a
|
-- The flag is checked before the job's KIND, so a worker woken by a
|
||||||
@@ -131,6 +147,9 @@ while true do
|
|||||||
elseif job.kind == "post" then
|
elseif job.kind == "post" then
|
||||||
local ok, err = pcall(doPost, job)
|
local ok, err = pcall(doPost, job)
|
||||||
if not ok then post({ id = job.id, ok = false, err = tostring(err) }) end
|
if not ok then post({ id = job.id, ok = false, err = tostring(err) }) end
|
||||||
|
elseif job.kind == "request" then
|
||||||
|
local ok, err = pcall(doRequest, job)
|
||||||
|
if not ok then post({ id = job.id, ok = false, err = tostring(err) }) end
|
||||||
elseif job.kind == "download" then
|
elseif job.kind == "download" then
|
||||||
local ok, err = pcall(doDownload, job)
|
local ok, err = pcall(doDownload, job)
|
||||||
if not ok then post({ id = job.id, ok = false, err = tostring(err) }) end
|
if not ok then post({ id = job.id, ok = false, err = tostring(err) }) end
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
local GameViewport = require("src.render.GameViewport")
|
||||||
|
local TouchSkin = require("src.core.TouchSkin")
|
||||||
|
|
||||||
|
local Playfield = {}
|
||||||
|
|
||||||
|
Playfield.WIDTH, Playfield.HEIGHT = 160, 144
|
||||||
|
|
||||||
|
Playfield.entered = false
|
||||||
|
Playfield.box = nil
|
||||||
|
|
||||||
|
local function clampRect(x, y, w, h, sw, sh)
|
||||||
|
if type(w) ~= "number" or type(h) ~= "number" then return nil end
|
||||||
|
if w ~= w or h ~= h then return nil end
|
||||||
|
x = math.floor(tonumber(x) or 0)
|
||||||
|
y = math.floor(tonumber(y) or 0)
|
||||||
|
w, h = math.floor(w), math.floor(h)
|
||||||
|
if x < 0 then w, x = w + x, 0 end
|
||||||
|
if y < 0 then h, y = h + y, 0 end
|
||||||
|
if x + w > sw then w = sw - x end
|
||||||
|
if y + h > sh then h = sh - y end
|
||||||
|
if w < 1 or h < 1 then return nil end
|
||||||
|
return x, y, w, h
|
||||||
|
end
|
||||||
|
|
||||||
|
function Playfield.cutout(sw, sh)
|
||||||
|
if Playfield.entered then return nil end
|
||||||
|
if type(sw) ~= "number" or type(sh) ~= "number" then return nil end
|
||||||
|
if sw < 1 or sh < 1 then return nil end
|
||||||
|
if type(TouchSkin.viewport) ~= "function" then return nil end
|
||||||
|
local ok, x, y, w, h, fill, expand = pcall(TouchSkin.viewport, sw, sh)
|
||||||
|
if not ok then return nil end
|
||||||
|
local cx, cy, cw, ch = clampRect(x, y, w, h, sw, sh)
|
||||||
|
if not cx then return nil end
|
||||||
|
return cx, cy, cw, ch, fill == true, expand == true
|
||||||
|
end
|
||||||
|
|
||||||
|
function Playfield.rect(sw, sh)
|
||||||
|
local x, y, w, h, _, expand = Playfield.cutout(sw, sh)
|
||||||
|
if not x then return 0, 0, sw or 0, sh or 0, false end
|
||||||
|
if expand then return x, y, w, h, true end
|
||||||
|
local s = math.max(1, math.floor(math.min(w / Playfield.WIDTH,
|
||||||
|
h / Playfield.HEIGHT)))
|
||||||
|
local pw = math.min(w, Playfield.WIDTH * s)
|
||||||
|
local ph = math.min(h, Playfield.HEIGHT * s)
|
||||||
|
return x + math.floor((w - pw) / 2), y + math.floor((h - ph) / 2), pw, ph, true
|
||||||
|
end
|
||||||
|
|
||||||
|
function Playfield.enter(x, y, w, h)
|
||||||
|
Playfield.entered = true
|
||||||
|
Playfield.box = { x = x, y = y, w = w, h = h }
|
||||||
|
end
|
||||||
|
|
||||||
|
function Playfield.leave()
|
||||||
|
Playfield.entered = false
|
||||||
|
Playfield.box = nil
|
||||||
|
end
|
||||||
|
|
||||||
|
function Playfield.dimensions()
|
||||||
|
if Playfield.entered and Playfield.box then
|
||||||
|
return Playfield.box.w, Playfield.box.h
|
||||||
|
end
|
||||||
|
return GameViewport.dimensions()
|
||||||
|
end
|
||||||
|
|
||||||
|
function Playfield.push(sw, sh)
|
||||||
|
local x, y, w, h, active = Playfield.rect(sw, sh)
|
||||||
|
local G = love.graphics
|
||||||
|
G.push("all")
|
||||||
|
if active then G.setScissor(x, y, w, h) end
|
||||||
|
G.translate(x, y)
|
||||||
|
Playfield.enter(x, y, w, h)
|
||||||
|
return w, h, x, y, active
|
||||||
|
end
|
||||||
|
|
||||||
|
function Playfield.pop()
|
||||||
|
Playfield.leave()
|
||||||
|
love.graphics.setScissor()
|
||||||
|
love.graphics.pop()
|
||||||
|
end
|
||||||
|
|
||||||
|
return Playfield
|
||||||
@@ -15,7 +15,7 @@ local Runtime = require("src.mods.Runtime")
|
|||||||
local GameViewport = require("src.render.GameViewport")
|
local GameViewport = require("src.render.GameViewport")
|
||||||
-- leaf module (no renderer dependency), so requiring it here cannot cycle
|
-- leaf module (no renderer dependency), so requiring it here cannot cycle
|
||||||
local FaithfulRes = require("src.core.FaithfulRes")
|
local FaithfulRes = require("src.core.FaithfulRes")
|
||||||
local TouchSkin = require("src.core.TouchSkin")
|
local Playfield = require("src.render.Playfield")
|
||||||
|
|
||||||
local Renderer = {}
|
local Renderer = {}
|
||||||
|
|
||||||
@@ -86,12 +86,12 @@ local function displayMetrics()
|
|||||||
if dpiX < 1e-6 then dpiX = 1 end
|
if dpiX < 1e-6 then dpiX = 1 end
|
||||||
if dpiY < 1e-6 then dpiY = 1 end
|
if dpiY < 1e-6 then dpiY = 1 end
|
||||||
local vx, vy = 0, 0
|
local vx, vy = 0, 0
|
||||||
local sx, sy, sw, sh = TouchSkin.viewport(pw, ph)
|
local cut, grow = false, false
|
||||||
if sw and sw >= 1 and sh >= 1 then
|
local sx, sy, sw, sh, _, expand = Playfield.cutout(pw, ph)
|
||||||
vx, vy = math.floor(sx), math.floor(sy)
|
if sx then
|
||||||
pw, ph = math.floor(sw), math.floor(sh)
|
vx, vy, pw, ph, cut, grow = sx, sy, sw, sh, true, expand
|
||||||
end
|
end
|
||||||
return ww, wh, pw, ph, dpiX, dpiY, vx, vy
|
return ww, wh, pw, ph, dpiX, dpiY, vx, vy, cut, grow
|
||||||
end
|
end
|
||||||
|
|
||||||
function Renderer:init()
|
function Renderer:init()
|
||||||
@@ -269,7 +269,7 @@ end
|
|||||||
-- corners; flat mode returns exactly today's size (growth factor is 1 when
|
-- corners; flat mode returns exactly today's size (growth factor is 1 when
|
||||||
-- tilt is inactive).
|
-- tilt is inactive).
|
||||||
function Renderer:worldViewSize()
|
function Renderer:worldViewSize()
|
||||||
local _, _, pw, ph = displayMetrics()
|
local _, _, pw, ph, _, _, _, _, cut, grow = displayMetrics()
|
||||||
-- FAITHFUL RATIO on mobile. The world pass deliberately expands to cover the
|
-- FAITHFUL RATIO on mobile. The world pass deliberately expands to cover the
|
||||||
-- WHOLE display, so letterbox voids become more map instead of black bars.
|
-- WHOLE display, so letterbox voids become more map instead of black bars.
|
||||||
-- That is why the lock appeared to do nothing in the overworld: it shrank
|
-- That is why the lock appeared to do nothing in the overworld: it shrank
|
||||||
@@ -281,13 +281,11 @@ function Renderer:worldViewSize()
|
|||||||
-- this is the same sum with the viewport standing in for the window, so
|
-- this is the same sum with the viewport standing in for the window, so
|
||||||
-- both platforms show the same map area at the same zoom.
|
-- both platforms show the same map area at the same zoom.
|
||||||
local cap = FaithfulRes.scaleCap()
|
local cap = FaithfulRes.scaleCap()
|
||||||
if not cap and TouchSkin.hasViewport() then
|
if not cap and cut and not grow then cap = self:fitScale() end
|
||||||
local page = TouchSkin.page()
|
|
||||||
if not page.viewportExpand then cap = self:fitScale() end
|
|
||||||
end
|
|
||||||
if cap then
|
if cap then
|
||||||
local uiw, uih = self:uiSize()
|
local uiw, uih = self:uiSize()
|
||||||
pw, ph = uiw * cap, uih * cap
|
pw = cut and math.min(pw, uiw * cap) or uiw * cap
|
||||||
|
ph = cut and math.min(ph, uih * cap) or uih * cap
|
||||||
end
|
end
|
||||||
local sp = Zoom.scale(self:fitScale())
|
local sp = Zoom.scale(self:fitScale())
|
||||||
local vw, vh = math.ceil(pw / sp), math.ceil(ph / sp)
|
local vw, vh = math.ceil(pw / sp), math.ceil(ph / sp)
|
||||||
@@ -346,19 +344,20 @@ end
|
|||||||
-- window is the classic wipe unchanged.
|
-- window is the classic wipe unchanged.
|
||||||
--
|
--
|
||||||
-- Sx/Sy are LOVE-unit scales (Sy defaults to Sx on uniform surfaces).
|
-- Sx/Sy are LOVE-unit scales (Sy defaults to Sx on uniform surfaces).
|
||||||
function Renderer:drawBattleWipe(wipe, ww, wh, ox, oy, vpw, vph, Sx, Sy)
|
function Renderer:drawBattleWipe(wipe, ww, wh, ox, oy, vpw, vph, Sx, Sy, wx, wy)
|
||||||
if not wipe or not wipe.prog or wipe.prog <= 0 then return end
|
if not wipe or not wipe.prog or wipe.prog <= 0 then return end
|
||||||
Sy = Sy or Sx
|
Sy = Sy or Sx
|
||||||
|
wx, wy = wx or 0, wy or 0
|
||||||
local TW, TH = 8 * Sx, 8 * Sy
|
local TW, TH = 8 * Sx, 8 * Sy
|
||||||
if TW < 1 then TW = 1 end
|
if TW < 1 then TW = 1 end
|
||||||
if TH < 1 then TH = 1 end
|
if TH < 1 then TH = 1 end
|
||||||
local prog = math.min(1, wipe.prog)
|
local prog = math.min(1, wipe.prog)
|
||||||
|
|
||||||
love.graphics.setColor(0, 0, 0, 1)
|
love.graphics.setColor(0, 0, 0, 1)
|
||||||
love.graphics.setScissor(0, 0, ww, wh)
|
love.graphics.setScissor(wx, wy, ww, wh)
|
||||||
|
|
||||||
if prog >= 1 then
|
if prog >= 1 then
|
||||||
love.graphics.rectangle("fill", 0, 0, ww, wh)
|
love.graphics.rectangle("fill", wx, wy, ww, wh)
|
||||||
love.graphics.setScissor()
|
love.graphics.setScissor()
|
||||||
love.graphics.setColor(1, 1, 1, 1)
|
love.graphics.setColor(1, 1, 1, 1)
|
||||||
return
|
return
|
||||||
@@ -366,10 +365,10 @@ function Renderer:drawBattleWipe(wipe, ww, wh, ox, oy, vpw, vph, Sx, Sy)
|
|||||||
|
|
||||||
-- whole-tile padding out to each window edge, keeping the grid in phase
|
-- whole-tile padding out to each window edge, keeping the grid in phase
|
||||||
-- with the letterbox's tiles
|
-- with the letterbox's tiles
|
||||||
local padL = math.max(0, math.ceil(ox / TW))
|
local padL = math.max(0, math.ceil((ox - wx) / TW))
|
||||||
local padT = math.max(0, math.ceil(oy / TH))
|
local padT = math.max(0, math.ceil((oy - wy) / TH))
|
||||||
local padR = math.max(0, math.ceil((ww - ox - vpw) / TW))
|
local padR = math.max(0, math.ceil((wx + ww - ox - vpw) / TW))
|
||||||
local padB = math.max(0, math.ceil((wh - oy - vph) / TH))
|
local padB = math.max(0, math.ceil((wy + wh - oy - vph) / TH))
|
||||||
local lbCols = math.max(1, math.floor(vpw / TW + 0.5))
|
local lbCols = math.max(1, math.floor(vpw / TW + 0.5))
|
||||||
local lbRows = math.max(1, math.floor(vph / TH + 0.5))
|
local lbRows = math.max(1, math.floor(vph / TH + 0.5))
|
||||||
local cols, rows = padL + lbCols + padR, padT + lbRows + padB
|
local cols, rows = padL + lbCols + padR, padT + lbRows + padB
|
||||||
@@ -396,9 +395,9 @@ function Renderer:drawBattleWipe(wipe, ww, wh, ox, oy, vpw, vph, Sx, Sy)
|
|||||||
for row = 0, rows - 1 do
|
for row = 0, rows - 1 do
|
||||||
local y = y0 + row * TH
|
local y = y0 + row * TH
|
||||||
if row % 2 == 0 then
|
if row % 2 == 0 then
|
||||||
love.graphics.rectangle("fill", 0, y, w, TH)
|
love.graphics.rectangle("fill", wx, y, w, TH)
|
||||||
else
|
else
|
||||||
love.graphics.rectangle("fill", ww - w, y, w, TH)
|
love.graphics.rectangle("fill", wx + ww - w, y, w, TH)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
elseif style == "vstripes" then
|
elseif style == "vstripes" then
|
||||||
@@ -406,21 +405,21 @@ function Renderer:drawBattleWipe(wipe, ww, wh, ox, oy, vpw, vph, Sx, Sy)
|
|||||||
for col = 0, cols - 1 do
|
for col = 0, cols - 1 do
|
||||||
local x = x0 + col * TW
|
local x = x0 + col * TW
|
||||||
if col % 2 == 0 then
|
if col % 2 == 0 then
|
||||||
love.graphics.rectangle("fill", x, 0, TW, h)
|
love.graphics.rectangle("fill", x, wy, TW, h)
|
||||||
else
|
else
|
||||||
love.graphics.rectangle("fill", x, wh - h, TW, h)
|
love.graphics.rectangle("fill", x, wy + wh - h, TW, h)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
elseif style == "shrink" then
|
elseif style == "shrink" then
|
||||||
local h, w = wh / 2 * prog, ww / 2 * prog
|
local h, w = wh / 2 * prog, ww / 2 * prog
|
||||||
love.graphics.rectangle("fill", 0, 0, ww, h)
|
love.graphics.rectangle("fill", wx, wy, ww, h)
|
||||||
love.graphics.rectangle("fill", 0, wh - h, ww, h)
|
love.graphics.rectangle("fill", wx, wy + wh - h, ww, h)
|
||||||
love.graphics.rectangle("fill", 0, 0, w, wh)
|
love.graphics.rectangle("fill", wx, wy, w, wh)
|
||||||
love.graphics.rectangle("fill", ww - w, 0, w, wh)
|
love.graphics.rectangle("fill", wx + ww - w, wy, w, wh)
|
||||||
else -- split: a black cross growing out of the centre in both axes
|
else -- split: a black cross growing out of the centre in both axes
|
||||||
local h, w = wh / 2 * prog, ww / 2 * prog
|
local h, w = wh / 2 * prog, ww / 2 * prog
|
||||||
love.graphics.rectangle("fill", 0, wh / 2 - h, ww, h * 2)
|
love.graphics.rectangle("fill", wx, wy + wh / 2 - h, ww, h * 2)
|
||||||
love.graphics.rectangle("fill", ww / 2 - w, 0, w * 2, wh)
|
love.graphics.rectangle("fill", wx + ww / 2 - w, wy, w * 2, wh)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
love.graphics.setScissor()
|
love.graphics.setScissor()
|
||||||
@@ -542,7 +541,8 @@ end
|
|||||||
-- into (nil = default framebuffer; presentCanvas when CRT is on).
|
-- into (nil = default framebuffer; presentCanvas when CRT is on).
|
||||||
-- Returns true on success; false (no shader/mesh) tells endFrame to fall
|
-- Returns true on success; false (no shader/mesh) tells endFrame to fall
|
||||||
-- back to the flat blit unchanged.
|
-- back to the flat blit unchanged.
|
||||||
function Renderer:drawTiltedWorld(zoneList, sx, sy, wox, woy, target)
|
function Renderer:drawTiltedWorld(zoneList, sx, sy, wox, woy, target,
|
||||||
|
boxX, boxY, boxW, boxH)
|
||||||
local shader = self:tiltShader()
|
local shader = self:tiltShader()
|
||||||
local mesh = self:tiltMesh()
|
local mesh = self:tiltMesh()
|
||||||
if not (shader and mesh) then return false end
|
if not (shader and mesh) then return false end
|
||||||
@@ -593,12 +593,16 @@ function Renderer:drawTiltedWorld(zoneList, sx, sy, wox, woy, target)
|
|||||||
mesh:setTexture(self.tiltCanvas)
|
mesh:setTexture(self.tiltCanvas)
|
||||||
mesh:setVertices(Tilt.meshCorners(wvw, wvh))
|
mesh:setVertices(Tilt.meshCorners(wvw, wvh))
|
||||||
love.graphics.push()
|
love.graphics.push()
|
||||||
|
if boxW and boxH and boxW > 0 and boxH > 0 then
|
||||||
|
love.graphics.setScissor(boxX, boxY, boxW, boxH)
|
||||||
|
end
|
||||||
love.graphics.translate(wox, woy)
|
love.graphics.translate(wox, woy)
|
||||||
love.graphics.scale(sx, sy)
|
love.graphics.scale(sx, sy)
|
||||||
love.graphics.setColor(1, 1, 1, 1)
|
love.graphics.setColor(1, 1, 1, 1)
|
||||||
love.graphics.setShader(shader)
|
love.graphics.setShader(shader)
|
||||||
love.graphics.draw(mesh)
|
love.graphics.draw(mesh)
|
||||||
love.graphics.setShader()
|
love.graphics.setShader()
|
||||||
|
love.graphics.setScissor()
|
||||||
love.graphics.pop()
|
love.graphics.pop()
|
||||||
return true
|
return true
|
||||||
end
|
end
|
||||||
@@ -755,20 +759,23 @@ end
|
|||||||
-- scissored through the shade-remap shader, later zones on top.
|
-- scissored through the shade-remap shader, later zones on top.
|
||||||
-- When GBC FX is active the composite is drawn into presentCanvas and
|
-- When GBC FX is active the composite is drawn into presentCanvas and
|
||||||
-- presented through the GBC FX shader as a final pass.
|
-- presented through the GBC FX shader as a final pass.
|
||||||
function Renderer:endFrame(zones, worldZones)
|
function Renderer:frameRects()
|
||||||
GameViewport.setTarget()
|
local ww, wh, pw, ph, dpiX, dpiY, vx, vy, cut = displayMetrics()
|
||||||
local ww, wh, pw, ph, dpiX, dpiY, vx, vy = displayMetrics()
|
local r = {
|
||||||
local vux, vuy = vx / dpiX, vy / dpiY
|
ww = ww, wh = wh, pw = pw, ph = ph, dpiX = dpiX, dpiY = dpiY,
|
||||||
local vuw, vuh = pw / dpiX, ph / dpiY
|
vx = vx, vy = vy, cut = cut,
|
||||||
|
vux = vx / dpiX, vuy = vy / dpiY, vuw = pw / dpiX, vuh = ph / dpiY,
|
||||||
|
}
|
||||||
-- Sp = integer framebuffer pixels per GB pixel;
|
-- Sp = integer framebuffer pixels per GB pixel;
|
||||||
-- Sx/Sy = LOVE-unit draw scales (may differ when dpiX ≠ dpiY).
|
-- Sx/Sy = LOVE-unit draw scales (may differ when dpiX ≠ dpiY).
|
||||||
local Sp = self:fitScale()
|
local Sp = self:fitScale()
|
||||||
local Sx, Sy = Sp / dpiX, Sp / dpiY
|
r.Sp, r.Sx, r.Sy = Sp, Sp / dpiX, Sp / dpiY
|
||||||
local uiw, uih = self:uiSize()
|
local uiw, uih = self:uiSize()
|
||||||
local vpw, vph = uiw * Sx, uih * Sy
|
r.uiw, r.uih = uiw, uih
|
||||||
|
r.vpw, r.vph = uiw * r.Sx, uih * r.Sy
|
||||||
-- Snap the letterbox origin to a framebuffer pixel, then convert to units.
|
-- Snap the letterbox origin to a framebuffer pixel, then convert to units.
|
||||||
local ox = (vx + math.floor((pw - uiw * Sp) / 2)) / dpiX
|
r.ox = (vx + math.floor((pw - uiw * Sp) / 2)) / dpiX
|
||||||
local oy = (vy + math.floor((ph - uih * Sp) / 2)) / dpiY
|
r.oy = (vy + math.floor((ph - uih * Sp) / 2)) / dpiY
|
||||||
-- The UI has its own scale: it steps down as the survey zoom goes out (see
|
-- The UI has its own scale: it steps down as the survey zoom goes out (see
|
||||||
-- uiScale), so it can be smaller than the world letterbox. Un-zoomed these
|
-- uiScale), so it can be smaller than the world letterbox. Un-zoomed these
|
||||||
-- are identical to Sp/ox/oy and every rect below is what it always was.
|
-- are identical to Sp/ox/oy and every rect below is what it always was.
|
||||||
@@ -782,10 +789,38 @@ function Renderer:endFrame(zones, worldZones)
|
|||||||
if self.uiFill then
|
if self.uiFill then
|
||||||
Up = math.min(ph / uih, pw / uiw)
|
Up = math.min(ph / uih, pw / uiw)
|
||||||
end
|
end
|
||||||
local Ux, Uy = Up / dpiX, Up / dpiY
|
if uiw * Up > pw or uih * Up > ph then
|
||||||
local uvpw, uvph = uiw * Ux, uih * Uy
|
Up = math.min(ph / uih, pw / uiw)
|
||||||
local uox = (vx + math.floor((pw - uiw * Up) / 2)) / dpiX
|
end
|
||||||
local uoy = (vy + math.floor((ph - uih * Up) / 2)) / dpiY
|
r.Up, r.Ux, r.Uy = Up, Up / dpiX, Up / dpiY
|
||||||
|
r.uvpw, r.uvph = uiw * r.Ux, uih * r.Uy
|
||||||
|
r.uox = (vx + math.floor((pw - uiw * Up) / 2)) / dpiX
|
||||||
|
r.uoy = (vy + math.floor((ph - uih * Up) / 2)) / dpiY
|
||||||
|
return r
|
||||||
|
end
|
||||||
|
|
||||||
|
function Renderer.clipToView(r, x, y, w, h)
|
||||||
|
local x2, y2 = math.min(x + w, r.vux + r.vuw), math.min(y + h, r.vuy + r.vuh)
|
||||||
|
x, y = math.max(x, r.vux), math.max(y, r.vuy)
|
||||||
|
return x, y, math.max(0, x2 - x), math.max(0, y2 - y)
|
||||||
|
end
|
||||||
|
|
||||||
|
function Renderer:playfieldRect()
|
||||||
|
local r = self:frameRects()
|
||||||
|
return r.vux, r.vuy, r.vuw, r.vuh, r.cut
|
||||||
|
end
|
||||||
|
|
||||||
|
function Renderer:endFrame(zones, worldZones)
|
||||||
|
GameViewport.setTarget()
|
||||||
|
local R = self:frameRects()
|
||||||
|
local ww, wh, pw, ph = R.ww, R.wh, R.pw, R.ph
|
||||||
|
local dpiX, dpiY, vx, vy, cut = R.dpiX, R.dpiY, R.vx, R.vy, R.cut
|
||||||
|
local vux, vuy, vuw, vuh = R.vux, R.vuy, R.vuw, R.vuh
|
||||||
|
local Sp, Sx, Sy = R.Sp, R.Sx, R.Sy
|
||||||
|
local uiw, uih = R.uiw, R.uih
|
||||||
|
local vpw, vph, ox, oy = R.vpw, R.vph, R.ox, R.oy
|
||||||
|
local Ux, Uy = R.Ux, R.Uy
|
||||||
|
local uvpw, uvph, uox, uoy = R.uvpw, R.uvph, R.uox, R.uoy
|
||||||
local GBCFX = require("src.render.GBCFX")
|
local GBCFX = require("src.render.GBCFX")
|
||||||
-- Forced mono/Classic modes still need a whole-screen zone when a state
|
-- Forced mono/Classic modes still need a whole-screen zone when a state
|
||||||
-- exposes no SGB packets (raw DMG canvas), so sendColors can remap.
|
-- exposes no SGB packets (raw DMG canvas), so sendColors can remap.
|
||||||
@@ -814,6 +849,7 @@ function Renderer:endFrame(zones, worldZones)
|
|||||||
ww = ww, wh = wh, pw = pw, ph = ph, ox = ox, oy = oy,
|
ww = ww, wh = wh, pw = pw, ph = ph, ox = ox, oy = oy,
|
||||||
vpw = vpw, vph = vph, uiw = uiw, uih = uih,
|
vpw = vpw, vph = vph, uiw = uiw, uih = uih,
|
||||||
scale = Sp, Sx = Sx, Sy = Sy, dpiX = dpiX, dpiY = dpiY,
|
scale = Sp, Sx = Sx, Sy = Sy, dpiX = dpiX, dpiY = dpiY,
|
||||||
|
viewX = vux, viewY = vuy, viewWidth = vuw, viewHeight = vuh,
|
||||||
secondScreen = require("src.render.SecondScreen"),
|
secondScreen = require("src.render.SecondScreen"),
|
||||||
}
|
}
|
||||||
if Runtime.call("render.compose", function() return false end, self, ctx) == true then
|
if Runtime.call("render.compose", function() return false end, self, ctx) == true then
|
||||||
@@ -901,23 +937,29 @@ function Renderer:endFrame(zones, worldZones)
|
|||||||
clearR, clearG, clearB = PaletteFX.paperShade(Game and Game.data)
|
clearR, clearG, clearB = PaletteFX.paperShade(Game and Game.data)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
love.graphics.setColor(clearR, clearG, clearB, 1)
|
if cut then
|
||||||
|
love.graphics.setColor(0, 0, 0, 1)
|
||||||
love.graphics.rectangle("fill", 0, 0, ww, wh)
|
love.graphics.rectangle("fill", 0, 0, ww, wh)
|
||||||
|
end
|
||||||
|
love.graphics.setColor(clearR, clearG, clearB, 1)
|
||||||
|
love.graphics.rectangle("fill", vux, vuy, vuw, vuh)
|
||||||
if extendedBlackBand then
|
if extendedBlackBand then
|
||||||
love.graphics.setColor(bandR, bandG, bandB, 1)
|
love.graphics.setColor(bandR, bandG, bandB, 1)
|
||||||
love.graphics.rectangle("fill", uox, 0, uvpw, wh)
|
love.graphics.rectangle("fill", uox, vuy, uvpw, vuh)
|
||||||
end
|
end
|
||||||
love.graphics.setColor(1, 1, 1, 1)
|
love.graphics.setColor(1, 1, 1, 1)
|
||||||
-- render.letterbox: SGB borders / custom void art in the bars around the
|
-- render.letterbox: SGB borders / custom void art in the bars around the
|
||||||
-- 160x144 (or world) blit. Drawn after the clear and before the game
|
-- 160x144 (or world) blit. Drawn after the clear and before the game
|
||||||
-- canvas so the playfield sits on top of the border.
|
-- canvas so the playfield sits on top of the border.
|
||||||
if Runtime.wantsHook("render.letterbox") then
|
if Runtime.wantsHook("render.letterbox") then
|
||||||
|
if cut then love.graphics.setScissor(vux, vuy, vuw, vuh) end
|
||||||
Runtime.call("render.letterbox", function() end, {
|
Runtime.call("render.letterbox", function() end, {
|
||||||
ww = ww, wh = wh, pw = pw, ph = ph,
|
ww = ww, wh = wh, pw = pw, ph = ph,
|
||||||
ox = ox, oy = oy, vpw = vpw, vph = vph,
|
ox = ox, oy = oy, vpw = vpw, vph = vph,
|
||||||
scale = Sp, dpiX = dpiX, dpiY = dpiY,
|
scale = Sp, dpiX = dpiX, dpiY = dpiY,
|
||||||
worldActive = self.worldActive and true or false,
|
worldActive = self.worldActive and true or false,
|
||||||
})
|
})
|
||||||
|
if cut then love.graphics.setScissor() end
|
||||||
end
|
end
|
||||||
|
|
||||||
-- see Renderer:blitCanvas; bound here to the frame's dpi so the composite
|
-- see Renderer:blitCanvas; bound here to the frame's dpi so the composite
|
||||||
@@ -928,6 +970,10 @@ function Renderer:endFrame(zones, worldZones)
|
|||||||
bx, by, boxX, boxY, boxW, boxH, dpiX, dpiY)
|
bx, by, boxX, boxY, boxW, boxH, dpiX, dpiY)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
local function clipToView(x, y, w, h)
|
||||||
|
return Renderer.clipToView(R, x, y, w, h)
|
||||||
|
end
|
||||||
|
|
||||||
if self.worldOverride then
|
if self.worldOverride then
|
||||||
-- A render pipeline already produced the whole world -- terrain,
|
-- A render pipeline already produced the whole world -- terrain,
|
||||||
-- characters and its own FX overlay -- as one window-resolution image,
|
-- characters and its own FX overlay -- as one window-resolution image,
|
||||||
@@ -938,9 +984,9 @@ function Renderer:endFrame(zones, worldZones)
|
|||||||
love.graphics.setScissor(vux, vuy, vuw, vuh)
|
love.graphics.setScissor(vux, vuy, vuw, vuh)
|
||||||
local loveMajor = love.getVersion()
|
local loveMajor = love.getVersion()
|
||||||
if love.system and love.system.getOS and love.system.getOS() == "iOS" and loveMajor >= 12 then
|
if love.system and love.system.getOS and love.system.getOS() == "iOS" and loveMajor >= 12 then
|
||||||
love.graphics.draw(self.worldOverride, 0, wh, 0, 1 / dpiX, -1 / dpiY)
|
love.graphics.draw(self.worldOverride, vux, vuy + vuh, 0, 1 / dpiX, -1 / dpiY)
|
||||||
else
|
else
|
||||||
love.graphics.draw(self.worldOverride, 0, 0, 0, 1 / dpiX, 1 / dpiY)
|
love.graphics.draw(self.worldOverride, vux, vuy, 0, 1 / dpiX, 1 / dpiY)
|
||||||
end
|
end
|
||||||
love.graphics.setScissor()
|
love.graphics.setScissor()
|
||||||
-- the screen-space overlays the flat path draws over its composite
|
-- the screen-space overlays the flat path draws over its composite
|
||||||
@@ -964,7 +1010,8 @@ function Renderer:endFrame(zones, worldZones)
|
|||||||
-- falls through to the flat blit, keeping the flat frame byte-for-byte
|
-- falls through to the flat blit, keeping the flat frame byte-for-byte
|
||||||
-- identical to today.
|
-- identical to today.
|
||||||
local projected =
|
local projected =
|
||||||
Tilt.active() and self:drawTiltedWorld(worldZones or zones, sx, sy, wox, woy, present)
|
Tilt.active() and self:drawTiltedWorld(worldZones or zones, sx, sy, wox, woy,
|
||||||
|
present, vux, vuy, vuw, vuh)
|
||||||
if not projected then
|
if not projected then
|
||||||
if worldZones then
|
if worldZones then
|
||||||
blit(self.worldCanvas, sx, sy, worldZones, sx, sy, wox, woy, vux, vuy, vuw, vuh)
|
blit(self.worldCanvas, sx, sy, worldZones, sx, sy, wox, woy, vux, vuy, vuw, vuh)
|
||||||
@@ -1061,7 +1108,7 @@ function Renderer:endFrame(zones, worldZones)
|
|||||||
and not FaithfulRes.scaleCap() then
|
and not FaithfulRes.scaleCap() then
|
||||||
local ok, Game = pcall(require, "src.core.Game")
|
local ok, Game = pcall(require, "src.core.Game")
|
||||||
love.graphics.setColor(PaletteFX.paperShade(ok and Game and Game.data))
|
love.graphics.setColor(PaletteFX.paperShade(ok and Game and Game.data))
|
||||||
love.graphics.rectangle("fill", uox, 0, uvpw, wh)
|
love.graphics.rectangle("fill", uox, vuy, uvpw, vuh)
|
||||||
love.graphics.setColor(1, 1, 1, 1)
|
love.graphics.setColor(1, 1, 1, 1)
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -1070,9 +1117,9 @@ function Renderer:endFrame(zones, worldZones)
|
|||||||
-- always been.
|
-- always been.
|
||||||
local anchors = self.uiAnchors
|
local anchors = self.uiAnchors
|
||||||
if not anchors or #anchors == 0 then
|
if not anchors or #anchors == 0 then
|
||||||
blit(self.canvas, Ux, Uy, zones, Ux, Uy, uox, uoy, uox, uoy, uvpw, uvph)
|
blit(self.canvas, Ux, Uy, zones, Ux, Uy, uox, uoy, clipToView(uox, uoy, uvpw, uvph))
|
||||||
else
|
else
|
||||||
local rest = { { uox, uoy, uvpw, uvph } }
|
local rest = { { clipToView(uox, uoy, uvpw, uvph) } }
|
||||||
local placed = {}
|
local placed = {}
|
||||||
for _, a in ipairs(anchors) do
|
for _, a in ipairs(anchors) do
|
||||||
local dw, dh = a.w * Ux, a.h * Uy
|
local dw, dh = a.w * Ux, a.h * Uy
|
||||||
@@ -1086,19 +1133,19 @@ function Renderer:endFrame(zones, worldZones)
|
|||||||
local dx, dy
|
local dx, dy
|
||||||
if a.anchor == "bottom" then
|
if a.anchor == "bottom" then
|
||||||
dx = uox + a.x * Ux -- horizontally it stays with the letterbox
|
dx = uox + a.x * Ux -- horizontally it stays with the letterbox
|
||||||
dy = wh - gapB - dh
|
dy = vuy + vuh - gapB - dh
|
||||||
elseif a.anchor == "top" then
|
elseif a.anchor == "top" then
|
||||||
dx = uox + a.x * Ux -- horizontally it stays with the letterbox
|
dx = uox + a.x * Ux -- horizontally it stays with the letterbox
|
||||||
dy = a.y * Uy
|
dy = vuy + a.y * Uy
|
||||||
elseif a.anchor == "topright" then
|
elseif a.anchor == "topright" then
|
||||||
dx = ww - gapR - dw
|
dx = vux + vuw - gapR - dw
|
||||||
dy = a.y * Uy
|
dy = vuy + a.y * Uy
|
||||||
else -- unknown anchor: leave it where it is
|
else -- unknown anchor: leave it where it is
|
||||||
dx, dy = uox + a.x * Ux, uoy + a.y * Uy
|
dx, dy = uox + a.x * Ux, uoy + a.y * Uy
|
||||||
end
|
end
|
||||||
if a.windowClamped then
|
if a.windowClamped then
|
||||||
dx = math.max(0, math.min(math.max(0, ww - dw), dx))
|
dx = math.max(vux, math.min(math.max(vux, vux + vuw - dw), dx))
|
||||||
dy = math.max(0, math.min(math.max(0, wh - dh), dy))
|
dy = math.max(vuy, math.min(math.max(vuy, vuy + vuh - dh), dy))
|
||||||
end
|
end
|
||||||
placed[#placed + 1] = { a = a, dx = dx, dy = dy, dw = dw, dh = dh }
|
placed[#placed + 1] = { a = a, dx = dx, dy = dy, dw = dw, dh = dh }
|
||||||
if a.extract then
|
if a.extract then
|
||||||
@@ -1113,13 +1160,14 @@ function Renderer:endFrame(zones, worldZones)
|
|||||||
-- The zone scissors are computed from the same origin, so an SGB
|
-- The zone scissors are computed from the same origin, so an SGB
|
||||||
-- region travels with the element instead of staying in the letterbox.
|
-- region travels with the element instead of staying in the letterbox.
|
||||||
blit(p.a.canvas or self.canvas, Ux, Uy, zones, Ux, Uy,
|
blit(p.a.canvas or self.canvas, Ux, Uy, zones, Ux, Uy,
|
||||||
p.dx - p.a.x * Ux, p.dy - p.a.y * Uy, p.dx, p.dy, p.dw, p.dh)
|
p.dx - p.a.x * Ux, p.dy - p.a.y * Uy,
|
||||||
|
clipToView(p.dx, p.dy, p.dw, p.dh))
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
local uiRedraws = PaletteFX.uiSpriteRedraws()
|
local uiRedraws = PaletteFX.uiSpriteRedraws()
|
||||||
if uiRedraws[1] then
|
if uiRedraws[1] then
|
||||||
love.graphics.setColor(1, 1, 1, 1)
|
love.graphics.setColor(1, 1, 1, 1)
|
||||||
love.graphics.setScissor(uox, uoy, uvpw, uvph)
|
love.graphics.setScissor(clipToView(uox, uoy, uvpw, uvph))
|
||||||
for _, r in ipairs(uiRedraws) do
|
for _, r in ipairs(uiRedraws) do
|
||||||
if r.quad then
|
if r.quad then
|
||||||
love.graphics.draw(r.image, r.quad, uox + r.x * Ux, uoy + r.y * Uy,
|
love.graphics.draw(r.image, r.quad, uox + r.x * Ux, uoy + r.y * Uy,
|
||||||
@@ -1135,7 +1183,8 @@ function Renderer:endFrame(zones, worldZones)
|
|||||||
-- over the finished composite rather than under the UI blit. On hardware
|
-- over the finished composite rather than under the UI blit. On hardware
|
||||||
-- it is the tilemap being overwritten -- there is nothing it does not cover.
|
-- it is the tilemap being overwritten -- there is nothing it does not cover.
|
||||||
if self.battleWipe then
|
if self.battleWipe then
|
||||||
self:drawBattleWipe(self.battleWipe, ww, wh, ox, oy, vpw, vph, Sx, Sy)
|
self:drawBattleWipe(self.battleWipe, vuw, vuh, ox, oy, vpw, vph, Sx, Sy,
|
||||||
|
vux, vuy)
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Palette-register effects (BattleTransition_FlashScreen's rBGP writes, the
|
-- Palette-register effects (BattleTransition_FlashScreen's rBGP writes, the
|
||||||
@@ -1157,7 +1206,7 @@ function Renderer:endFrame(zones, worldZones)
|
|||||||
if FaithfulRes.scaleCap() then
|
if FaithfulRes.scaleCap() then
|
||||||
love.graphics.rectangle("fill", ox, oy, vpw, vph)
|
love.graphics.rectangle("fill", ox, oy, vpw, vph)
|
||||||
else
|
else
|
||||||
love.graphics.rectangle("fill", 0, 0, ww, wh)
|
love.graphics.rectangle("fill", vux, vuy, vuw, vuh)
|
||||||
end
|
end
|
||||||
love.graphics.setColor(1, 1, 1, 1)
|
love.graphics.setColor(1, 1, 1, 1)
|
||||||
end
|
end
|
||||||
@@ -1181,6 +1230,7 @@ function Renderer:endFrame(zones, worldZones)
|
|||||||
generation = 1,
|
generation = 1,
|
||||||
}) == true
|
}) == true
|
||||||
if not outputHandled then
|
if not outputHandled then
|
||||||
|
if cut then love.graphics.setScissor(vux, vuy, vuw, vuh) end
|
||||||
if GBCFX.active() then
|
if GBCFX.active() then
|
||||||
-- shader grid/shadow math is in framebuffer pixels
|
-- shader grid/shadow math is in framebuffer pixels
|
||||||
GBCFX.present(composed, Sp)
|
GBCFX.present(composed, Sp)
|
||||||
@@ -1190,6 +1240,7 @@ function Renderer:endFrame(zones, worldZones)
|
|||||||
love.graphics.setColor(1, 1, 1, 1)
|
love.graphics.setColor(1, 1, 1, 1)
|
||||||
love.graphics.draw(composed, 0, 0)
|
love.graphics.draw(composed, 0, 0)
|
||||||
end
|
end
|
||||||
|
if cut then love.graphics.setScissor() end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
self.worldActive = false
|
self.worldActive = false
|
||||||
@@ -1202,6 +1253,7 @@ function Renderer:endFrame(zones, worldZones)
|
|||||||
gameWidth = vpw, gameHeight = vph,
|
gameWidth = vpw, gameHeight = vph,
|
||||||
scale = Sp,
|
scale = Sp,
|
||||||
dpiX = dpiX, dpiY = dpiY,
|
dpiX = dpiX, dpiY = dpiY,
|
||||||
|
viewX = vux, viewY = vuy, viewWidth = vuw, viewHeight = vuh,
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,197 @@
|
|||||||
|
local Json = require("src.link.Json")
|
||||||
|
|
||||||
|
local SyncClient = {}
|
||||||
|
SyncClient.__index = SyncClient
|
||||||
|
|
||||||
|
SyncClient.DEFAULT_URL = os.getenv("POKEPORT_SYNC_URL")
|
||||||
|
or "https://sync.147.182.215.255.sslip.io"
|
||||||
|
SyncClient.MAX_BLOB = 2 * 1024 * 1024
|
||||||
|
SyncClient.MAX_RESPONSE = 4 * 1024 * 1024
|
||||||
|
SyncClient.TIMEOUT = 25
|
||||||
|
|
||||||
|
function SyncClient.normalizeCode(code)
|
||||||
|
if type(code) ~= "string" and type(code) ~= "number" then return nil end
|
||||||
|
local digits = tostring(code):gsub("[^%d]", "")
|
||||||
|
if #digits ~= 8 then return nil end
|
||||||
|
return digits
|
||||||
|
end
|
||||||
|
|
||||||
|
function SyncClient.formatCode(code)
|
||||||
|
local digits = SyncClient.normalizeCode(code)
|
||||||
|
if not digits then return nil end
|
||||||
|
return digits:sub(1, 4) .. "-" .. digits:sub(5, 8)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function escape(s)
|
||||||
|
return (tostring(s):gsub("[^%w%-%._~]", function(c)
|
||||||
|
return ("%%%02X"):format(c:byte())
|
||||||
|
end))
|
||||||
|
end
|
||||||
|
|
||||||
|
local function query(params)
|
||||||
|
local names = {}
|
||||||
|
for name in pairs(params or {}) do names[#names + 1] = tostring(name) end
|
||||||
|
table.sort(names)
|
||||||
|
local out = {}
|
||||||
|
for _, name in ipairs(names) do
|
||||||
|
out[#out + 1] = escape(name) .. "=" .. escape(params[name])
|
||||||
|
end
|
||||||
|
if #out == 0 then return "" end
|
||||||
|
return "?" .. table.concat(out, "&")
|
||||||
|
end
|
||||||
|
|
||||||
|
function SyncClient.new(opts)
|
||||||
|
opts = opts or {}
|
||||||
|
local base = opts.baseUrl or SyncClient.DEFAULT_URL
|
||||||
|
base = tostring(base):gsub("/+$", "")
|
||||||
|
local transport = opts.transport
|
||||||
|
if not transport then
|
||||||
|
transport = require("src.sync.SyncTransport").new()
|
||||||
|
end
|
||||||
|
return setmetatable({
|
||||||
|
baseUrl = base,
|
||||||
|
transport = transport,
|
||||||
|
account = opts.account,
|
||||||
|
token = opts.token,
|
||||||
|
}, SyncClient)
|
||||||
|
end
|
||||||
|
|
||||||
|
function SyncClient:setAuth(account, token)
|
||||||
|
self.account = type(account) == "string" and account ~= "" and account or nil
|
||||||
|
self.token = type(token) == "string" and token ~= "" and token or nil
|
||||||
|
end
|
||||||
|
|
||||||
|
function SyncClient:clearAuth()
|
||||||
|
self.account, self.token = nil, nil
|
||||||
|
end
|
||||||
|
|
||||||
|
function SyncClient:isLinked()
|
||||||
|
return self.account ~= nil and self.token ~= nil
|
||||||
|
end
|
||||||
|
|
||||||
|
function SyncClient:send(method, path, body, opts)
|
||||||
|
opts = opts or {}
|
||||||
|
local headers = { ["Accept"] = "application/json" }
|
||||||
|
local payload
|
||||||
|
if body ~= nil then
|
||||||
|
local ok, encoded = pcall(Json.encode, body)
|
||||||
|
if not ok then return nil, "could not encode the request" end
|
||||||
|
payload = encoded
|
||||||
|
headers["Content-Type"] = "application/json"
|
||||||
|
end
|
||||||
|
if not opts.noAuth then
|
||||||
|
if not self:isLinked() then return nil, "this device is not linked" end
|
||||||
|
headers["x-sync-account"] = self.account
|
||||||
|
headers["x-sync-token"] = self.token
|
||||||
|
end
|
||||||
|
local url = self.baseUrl .. path .. query(opts.params)
|
||||||
|
local handle = self.transport:begin({
|
||||||
|
url = url, method = method, body = payload, headers = headers,
|
||||||
|
maxSeconds = opts.maxSeconds or SyncClient.TIMEOUT,
|
||||||
|
})
|
||||||
|
if handle == nil then return nil, "no network transport" end
|
||||||
|
return handle
|
||||||
|
end
|
||||||
|
|
||||||
|
function SyncClient:poll(handle)
|
||||||
|
if handle == nil then return { status = "error", err = "no request" } end
|
||||||
|
local res = self.transport:poll(handle)
|
||||||
|
if res.status == "pending" then return { status = "pending" } end
|
||||||
|
if res.status ~= "ok" then
|
||||||
|
return { status = "error", err = res.err or "sync request failed" }
|
||||||
|
end
|
||||||
|
local raw = res.body or ""
|
||||||
|
local code = tonumber(res.code) or 0
|
||||||
|
if #raw > SyncClient.MAX_RESPONSE then
|
||||||
|
return { status = "error", code = code, err = "the reply was too large" }
|
||||||
|
end
|
||||||
|
local data, decodeErr = Json.decode(raw, SyncClient.MAX_RESPONSE)
|
||||||
|
if type(data) ~= "table" then
|
||||||
|
local why = Json.describeUnexpected(raw) or decodeErr or "unreadable reply"
|
||||||
|
if code >= 400 then
|
||||||
|
return { status = "error", code = code,
|
||||||
|
err = ("the server answered %d"):format(code) }
|
||||||
|
end
|
||||||
|
return { status = "error", code = code, err = why }
|
||||||
|
end
|
||||||
|
if code >= 400 or data.error then
|
||||||
|
local err = data.error
|
||||||
|
if type(err) ~= "string" or err == "" then
|
||||||
|
err = ("the server answered %d"):format(code)
|
||||||
|
end
|
||||||
|
return { status = "error", code = code, data = data, err = err }
|
||||||
|
end
|
||||||
|
return { status = "ok", code = code, data = data }
|
||||||
|
end
|
||||||
|
|
||||||
|
function SyncClient:release(handle)
|
||||||
|
if handle ~= nil then self.transport:release(handle) end
|
||||||
|
end
|
||||||
|
|
||||||
|
function SyncClient:create(deviceLabel)
|
||||||
|
return self:send("POST", "/sync/create",
|
||||||
|
{ device = deviceLabel or "device" }, { noAuth = true })
|
||||||
|
end
|
||||||
|
|
||||||
|
function SyncClient:link(code1, code2, deviceLabel)
|
||||||
|
local a = SyncClient.normalizeCode(code1)
|
||||||
|
local b = SyncClient.normalizeCode(code2)
|
||||||
|
if not a or not b then return nil, "both codes are 8 digits" end
|
||||||
|
return self:send("POST", "/sync/link",
|
||||||
|
{ code1 = a, code2 = b, device = deviceLabel or "device" },
|
||||||
|
{ noAuth = true })
|
||||||
|
end
|
||||||
|
|
||||||
|
function SyncClient:fetchState()
|
||||||
|
return self:send("GET", "/sync/state")
|
||||||
|
end
|
||||||
|
|
||||||
|
function SyncClient:putSave(entry)
|
||||||
|
if type(entry) ~= "table" then return nil, "missing save entry" end
|
||||||
|
if type(entry.blob) ~= "string" or entry.blob == "" then
|
||||||
|
return nil, "missing save data"
|
||||||
|
end
|
||||||
|
if #entry.blob > SyncClient.MAX_BLOB then
|
||||||
|
return nil, "this save is too large to sync"
|
||||||
|
end
|
||||||
|
return self:send("PUT", "/sync/save", {
|
||||||
|
version = entry.version,
|
||||||
|
slot = entry.slot,
|
||||||
|
meta = entry.meta,
|
||||||
|
blob = entry.blob,
|
||||||
|
baseRev = entry.baseRev,
|
||||||
|
force = entry.force and true or nil,
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
|
function SyncClient:getSave(version, id)
|
||||||
|
return self:send("GET", "/sync/save", nil,
|
||||||
|
{ params = { version = version, id = id } })
|
||||||
|
end
|
||||||
|
|
||||||
|
function SyncClient:putMods(manifest)
|
||||||
|
return self:send("PUT", "/sync/mods", { manifest = manifest })
|
||||||
|
end
|
||||||
|
|
||||||
|
function SyncClient:getMods()
|
||||||
|
return self:send("GET", "/sync/mods")
|
||||||
|
end
|
||||||
|
|
||||||
|
function SyncClient:shareMods(manifest)
|
||||||
|
return self:send("POST", "/sync/modshare", { manifest = manifest })
|
||||||
|
end
|
||||||
|
|
||||||
|
function SyncClient:fetchShare(code)
|
||||||
|
local trimmed = tostring(code or ""):gsub("%s", ""):upper()
|
||||||
|
if not trimmed:match("^[A-Z2-9]+$") or #trimmed ~= 6 then
|
||||||
|
return nil, "share codes are 6 characters"
|
||||||
|
end
|
||||||
|
return self:send("GET", "/sync/modshare", nil,
|
||||||
|
{ noAuth = true, params = { code = trimmed } })
|
||||||
|
end
|
||||||
|
|
||||||
|
function SyncClient:unlink(device)
|
||||||
|
return self:send("POST", "/sync/unlink", { device = device })
|
||||||
|
end
|
||||||
|
|
||||||
|
return SyncClient
|
||||||
@@ -0,0 +1,690 @@
|
|||||||
|
local SyncClient = require("src.sync.SyncClient")
|
||||||
|
local SyncState = require("src.sync.SyncState")
|
||||||
|
local SyncMods = require("src.sync.SyncMods")
|
||||||
|
|
||||||
|
local SyncEngine = {}
|
||||||
|
SyncEngine.__index = SyncEngine
|
||||||
|
|
||||||
|
SyncEngine.UPLOAD_DEBOUNCE = 5
|
||||||
|
SyncEngine.AUTO_INTERVAL = 300
|
||||||
|
SyncEngine.MAX_STEPS_PER_UPDATE = 8
|
||||||
|
|
||||||
|
local IDLE_STATUS = "Ready"
|
||||||
|
local UNLINKED_STATUS = "Not set up"
|
||||||
|
|
||||||
|
local function saveApi()
|
||||||
|
return require("src.core.SaveData")
|
||||||
|
end
|
||||||
|
|
||||||
|
local function gameVersions()
|
||||||
|
return require("src.core.GameVersion").ORDER
|
||||||
|
end
|
||||||
|
|
||||||
|
local function slotForPlaythrough(options, version, playthroughId)
|
||||||
|
local byVersion = options.playthroughIds and options.playthroughIds[version]
|
||||||
|
for slotId, id in pairs(byVersion or {}) do
|
||||||
|
if id == playthroughId then return slotId end
|
||||||
|
end
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
function SyncEngine.defaultSaves()
|
||||||
|
return {
|
||||||
|
list = function()
|
||||||
|
local SaveData = saveApi()
|
||||||
|
local options = SaveData.loadOptions()
|
||||||
|
local out = {}
|
||||||
|
for _, version in ipairs(gameVersions()) do
|
||||||
|
for _, slot in ipairs(SaveData.listSlots(version)) do
|
||||||
|
if slot.exists then
|
||||||
|
local source = SaveData.readSlotSource(version, slot.id)
|
||||||
|
local save = source and SaveData.decode(source)
|
||||||
|
if type(save) == "table" then
|
||||||
|
local meta = type(save.meta) == "table" and save.meta or {}
|
||||||
|
local id = meta.playthroughId
|
||||||
|
if type(id) ~= "string" or id == "" then
|
||||||
|
local byVersion = options.playthroughIds
|
||||||
|
and options.playthroughIds[version]
|
||||||
|
id = byVersion and byVersion[slot.id] or nil
|
||||||
|
end
|
||||||
|
if id then
|
||||||
|
local name, summary = SaveData.slotSummary(save)
|
||||||
|
out[#out + 1] = {
|
||||||
|
version = version,
|
||||||
|
slot = slot.id,
|
||||||
|
playthroughId = id,
|
||||||
|
blob = source,
|
||||||
|
meta = {
|
||||||
|
savedAt = tonumber(meta.savedAt),
|
||||||
|
sessionStart = tonumber(meta.sessionStart),
|
||||||
|
playthroughId = id,
|
||||||
|
format = meta.format,
|
||||||
|
engine = meta.engine,
|
||||||
|
playTime = tonumber(save.playTime),
|
||||||
|
summary = {
|
||||||
|
name = name,
|
||||||
|
badges = summary and summary.badges,
|
||||||
|
timeText = summary and summary.timeText,
|
||||||
|
dexCount = summary and summary.dexCount,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return out
|
||||||
|
end,
|
||||||
|
|
||||||
|
write = function(version, playthroughId, blob, mode)
|
||||||
|
local SaveData = saveApi()
|
||||||
|
local save = SaveData.decode(blob)
|
||||||
|
if type(save) ~= "table" then return nil, "the downloaded save is unreadable" end
|
||||||
|
save.version = save.version or version
|
||||||
|
local options = SaveData.loadOptions()
|
||||||
|
local slotId
|
||||||
|
if mode == "new" then
|
||||||
|
save.meta = type(save.meta) == "table" and save.meta or {}
|
||||||
|
save.meta.playthroughId = SaveData.newPlaythroughId()
|
||||||
|
else
|
||||||
|
slotId = slotForPlaythrough(options, version, playthroughId)
|
||||||
|
end
|
||||||
|
if not slotId then
|
||||||
|
slotId = SaveData.createSlot(version)
|
||||||
|
if not slotId then return nil, "could not make a save slot" end
|
||||||
|
end
|
||||||
|
local ok, err = SaveData.writeSlot(version, slotId, save)
|
||||||
|
if not ok then return nil, err or "could not write the save" end
|
||||||
|
options = SaveData.loadOptions()
|
||||||
|
options.playthroughIds = options.playthroughIds or {}
|
||||||
|
options.playthroughIds[version] = options.playthroughIds[version] or {}
|
||||||
|
options.playthroughIds[version][slotId] =
|
||||||
|
save.meta and save.meta.playthroughId or playthroughId
|
||||||
|
SaveData.saveOptions(options)
|
||||||
|
return slotId
|
||||||
|
end,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
function SyncEngine.overlaps(a, b)
|
||||||
|
if type(a) ~= "table" or type(b) ~= "table" then return false end
|
||||||
|
local aStart, aEnd = tonumber(a.sessionStart), tonumber(a.savedAt)
|
||||||
|
local bStart, bEnd = tonumber(b.sessionStart), tonumber(b.savedAt)
|
||||||
|
if not (aStart and aEnd and bStart and bEnd) then return false end
|
||||||
|
return aStart <= bEnd and bStart <= aEnd
|
||||||
|
end
|
||||||
|
|
||||||
|
function SyncEngine.new(opts)
|
||||||
|
opts = opts or {}
|
||||||
|
local eng = setmetatable({}, SyncEngine)
|
||||||
|
eng.fs = opts.fs
|
||||||
|
eng.state = opts.state or SyncState.load(eng.fs)
|
||||||
|
eng.client = opts.client or SyncClient.new({
|
||||||
|
baseUrl = opts.baseUrl, transport = opts.transport })
|
||||||
|
eng.saves = opts.saves or SyncEngine.defaultSaves()
|
||||||
|
eng.modDeps = opts.modDeps
|
||||||
|
eng.now = opts.now or os.time
|
||||||
|
eng.persist = opts.persist ~= false
|
||||||
|
eng.phase = "idle"
|
||||||
|
eng.error = nil
|
||||||
|
eng.conflicts = {}
|
||||||
|
eng.codes = nil
|
||||||
|
eng.modPlan = nil
|
||||||
|
eng.shareCode = nil
|
||||||
|
eng.clock = 0
|
||||||
|
eng.queue = {}
|
||||||
|
eng.pending = nil
|
||||||
|
eng.uploadAt = nil
|
||||||
|
eng.client:setAuth(eng.state.account, eng.state.deviceToken)
|
||||||
|
eng.status = eng:defaultStatus()
|
||||||
|
return eng
|
||||||
|
end
|
||||||
|
|
||||||
|
function SyncEngine.shared(opts)
|
||||||
|
if SyncEngine._shared == nil then
|
||||||
|
local ok, eng = pcall(SyncEngine.new, opts or {})
|
||||||
|
SyncEngine._shared = (ok and type(eng) == "table") and eng or false
|
||||||
|
end
|
||||||
|
return SyncEngine._shared or nil
|
||||||
|
end
|
||||||
|
|
||||||
|
function SyncEngine.forgetShared()
|
||||||
|
SyncEngine._shared = nil
|
||||||
|
end
|
||||||
|
|
||||||
|
function SyncEngine:defaultStatus()
|
||||||
|
if not SyncState.linked(self.state) then return UNLINKED_STATUS end
|
||||||
|
return IDLE_STATUS
|
||||||
|
end
|
||||||
|
|
||||||
|
function SyncEngine:linked()
|
||||||
|
return SyncState.linked(self.state)
|
||||||
|
end
|
||||||
|
|
||||||
|
function SyncEngine:busy()
|
||||||
|
return self.pending ~= nil or #self.queue > 0 or self.modApply ~= nil
|
||||||
|
end
|
||||||
|
|
||||||
|
function SyncEngine:_persist()
|
||||||
|
if not self.persist then return end
|
||||||
|
SyncState.save(self.state, self.fs)
|
||||||
|
end
|
||||||
|
|
||||||
|
function SyncEngine:_fail(message)
|
||||||
|
self.phase = "error"
|
||||||
|
self.error = tostring(message or "sync failed")
|
||||||
|
self.status = "Sync failed: " .. self.error
|
||||||
|
self.queue = {}
|
||||||
|
self.pending = nil
|
||||||
|
end
|
||||||
|
|
||||||
|
function SyncEngine:_finish()
|
||||||
|
if #self.conflicts > 0 then
|
||||||
|
self.phase = "conflict"
|
||||||
|
local overlap = false
|
||||||
|
for _, row in ipairs(self.conflicts) do
|
||||||
|
if row.overlap then overlap = true end
|
||||||
|
end
|
||||||
|
self.status = overlap
|
||||||
|
and "These saves were played at the same time."
|
||||||
|
or "This save also changed on another device."
|
||||||
|
return
|
||||||
|
end
|
||||||
|
self.phase = "idle"
|
||||||
|
self.error = nil
|
||||||
|
self.state.lastSyncAt = self.now()
|
||||||
|
self.status = self:defaultStatus()
|
||||||
|
self:_persist()
|
||||||
|
end
|
||||||
|
|
||||||
|
function SyncEngine:_request(handle, err, onOk, onErr)
|
||||||
|
if not handle then
|
||||||
|
self:_fail(err or "could not start the request")
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
self.pending = { handle = handle, onOk = onOk, onErr = onErr }
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
function SyncEngine:_enqueue(fn)
|
||||||
|
self.queue[#self.queue + 1] = fn
|
||||||
|
end
|
||||||
|
|
||||||
|
function SyncEngine:cancel()
|
||||||
|
if self.pending then self.client:release(self.pending.handle) end
|
||||||
|
self.pending = nil
|
||||||
|
self.queue = {}
|
||||||
|
self.modApply = nil
|
||||||
|
self.uploadAt = nil
|
||||||
|
if self.phase ~= "conflict" then
|
||||||
|
self.phase = "idle"
|
||||||
|
self.status = self:defaultStatus()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
function SyncEngine:noteSaveWritten()
|
||||||
|
if not (self.state.enabled and self:linked()) then return end
|
||||||
|
self.uploadAt = self.clock + SyncEngine.UPLOAD_DEBOUNCE
|
||||||
|
end
|
||||||
|
|
||||||
|
function SyncEngine:update(dt)
|
||||||
|
self.clock = self.clock + (tonumber(dt) or 0)
|
||||||
|
if self.pending then
|
||||||
|
local res = self.client:poll(self.pending.handle)
|
||||||
|
if res.status == "pending" then return end
|
||||||
|
local job = self.pending
|
||||||
|
self.pending = nil
|
||||||
|
self.client:release(job.handle)
|
||||||
|
if res.status == "ok" then
|
||||||
|
local ok, err = pcall(job.onOk, self, res)
|
||||||
|
if not ok then self:_fail(err) end
|
||||||
|
else
|
||||||
|
local handled = false
|
||||||
|
if job.onErr then
|
||||||
|
local ok, result = pcall(job.onErr, self, res)
|
||||||
|
if not ok then self:_fail(result) return end
|
||||||
|
handled = result == true
|
||||||
|
end
|
||||||
|
if not handled then self:_fail(res.err) end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if self.pending then return end
|
||||||
|
if self.modApply then
|
||||||
|
self:_stepModApply()
|
||||||
|
return
|
||||||
|
end
|
||||||
|
if self.uploadAt and self.clock >= self.uploadAt and not self:busy() then
|
||||||
|
self.uploadAt = nil
|
||||||
|
if self.state.enabled and self:linked() then self:syncNow() end
|
||||||
|
end
|
||||||
|
local steps = 0
|
||||||
|
while not self.pending and #self.queue > 0
|
||||||
|
and steps < SyncEngine.MAX_STEPS_PER_UPDATE do
|
||||||
|
steps = steps + 1
|
||||||
|
local task = table.remove(self.queue, 1)
|
||||||
|
local ok, err = pcall(task, self)
|
||||||
|
if not ok then self:_fail(err) return end
|
||||||
|
if not self.pending and #self.queue == 0 and self.phase ~= "error" then
|
||||||
|
self:_finish()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
function SyncEngine:createAccount(label)
|
||||||
|
if self:busy() then return false, "sync is busy" end
|
||||||
|
self.phase = "checking"
|
||||||
|
self.status = "Creating a sync account..."
|
||||||
|
self.error = nil
|
||||||
|
local handle, err = self.client:create(label)
|
||||||
|
return self:_request(handle, err, function(eng, res)
|
||||||
|
local data = res.data or {}
|
||||||
|
if type(data.account) ~= "string" or type(data.deviceToken) ~= "string" then
|
||||||
|
eng:_fail("the server sent an unexpected reply")
|
||||||
|
return
|
||||||
|
end
|
||||||
|
eng.codes = {
|
||||||
|
code1 = SyncClient.formatCode(data.code1) or tostring(data.code1 or ""),
|
||||||
|
code2 = SyncClient.formatCode(data.code2) or tostring(data.code2 or ""),
|
||||||
|
}
|
||||||
|
eng.state.account = data.account
|
||||||
|
eng.state.deviceToken = data.deviceToken
|
||||||
|
eng.state.deviceId = type(data.device) == "string" and data.device or nil
|
||||||
|
eng.state.deviceLabel = label
|
||||||
|
eng.state.enabled = true
|
||||||
|
eng.client:setAuth(data.account, data.deviceToken)
|
||||||
|
eng.phase = "idle"
|
||||||
|
eng.status = "Sync account created"
|
||||||
|
eng:_persist()
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
function SyncEngine:linkDevice(code1, code2, label)
|
||||||
|
if self:busy() then return false, "sync is busy" end
|
||||||
|
local a = SyncClient.normalizeCode(code1)
|
||||||
|
local b = SyncClient.normalizeCode(code2)
|
||||||
|
if not a or not b then
|
||||||
|
self:_fail("both codes are 8 digits")
|
||||||
|
return false, "both codes are 8 digits"
|
||||||
|
end
|
||||||
|
self.phase = "checking"
|
||||||
|
self.status = "Linking this device..."
|
||||||
|
self.error = nil
|
||||||
|
local handle, err = self.client:link(a, b, label)
|
||||||
|
return self:_request(handle, err, function(eng, res)
|
||||||
|
local data = res.data or {}
|
||||||
|
if type(data.account) ~= "string" or type(data.deviceToken) ~= "string" then
|
||||||
|
eng:_fail("the server sent an unexpected reply")
|
||||||
|
return
|
||||||
|
end
|
||||||
|
eng.state.account = data.account
|
||||||
|
eng.state.deviceToken = data.deviceToken
|
||||||
|
eng.state.deviceId = type(data.device) == "string" and data.device or nil
|
||||||
|
eng.state.deviceLabel = label
|
||||||
|
eng.state.enabled = true
|
||||||
|
eng.client:setAuth(data.account, data.deviceToken)
|
||||||
|
eng.status = "This device is linked"
|
||||||
|
eng:_persist()
|
||||||
|
eng:syncNow()
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
function SyncEngine:_forgetLocal()
|
||||||
|
self.state = SyncState.defaults()
|
||||||
|
self.client:clearAuth()
|
||||||
|
self.codes = nil
|
||||||
|
self.conflicts = {}
|
||||||
|
self.devices = nil
|
||||||
|
self.phase = "idle"
|
||||||
|
self.status = UNLINKED_STATUS
|
||||||
|
self:_persist()
|
||||||
|
end
|
||||||
|
|
||||||
|
function SyncEngine:unlink()
|
||||||
|
if not self:linked() then
|
||||||
|
self:_forgetLocal()
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
if self:busy() then return false, "sync is busy" end
|
||||||
|
self.phase = "checking"
|
||||||
|
self.status = "Unlinking this device..."
|
||||||
|
self.error = nil
|
||||||
|
local handle, err = self.client:unlink(self.state.deviceId)
|
||||||
|
return self:_request(handle, err, function(eng)
|
||||||
|
eng:_forgetLocal()
|
||||||
|
end, function(eng, res)
|
||||||
|
if res.code == 401 or res.code == 404 then
|
||||||
|
eng:_forgetLocal()
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
return false
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
function SyncEngine:unlinkDevice(deviceId)
|
||||||
|
if type(deviceId) ~= "string" or deviceId == "" then
|
||||||
|
return false, "no such device"
|
||||||
|
end
|
||||||
|
if not self:linked() then return false, "this device is not linked" end
|
||||||
|
if deviceId == self.state.deviceId then return self:unlink() end
|
||||||
|
if self:busy() then return false, "sync is busy" end
|
||||||
|
self.phase = "checking"
|
||||||
|
self.status = "Unlinking that device..."
|
||||||
|
self.error = nil
|
||||||
|
local handle, err = self.client:unlink(deviceId)
|
||||||
|
return self:_request(handle, err, function(eng)
|
||||||
|
eng.status = "That device was unlinked"
|
||||||
|
eng.phase = "idle"
|
||||||
|
eng:syncNow()
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
function SyncEngine:setEnabled(enabled)
|
||||||
|
self.state.enabled = enabled and true or false
|
||||||
|
self:_persist()
|
||||||
|
return self.state.enabled
|
||||||
|
end
|
||||||
|
|
||||||
|
function SyncEngine:syncNow()
|
||||||
|
if not self:linked() then return false, "this device is not linked" end
|
||||||
|
if self.pending then return false, "sync is busy" end
|
||||||
|
self.queue = {}
|
||||||
|
self.conflicts = {}
|
||||||
|
self.state.pendingConflicts = {}
|
||||||
|
self.phase = "checking"
|
||||||
|
self.status = "Checking for changes..."
|
||||||
|
self.error = nil
|
||||||
|
local handle, err = self.client:fetchState()
|
||||||
|
return self:_request(handle, err, function(eng, res)
|
||||||
|
eng:_planFrom(res.data or {})
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
function SyncEngine:_planFrom(remoteState)
|
||||||
|
self.devices = nil
|
||||||
|
if type(remoteState.devices) == "table" then
|
||||||
|
local list = {}
|
||||||
|
for _, row in ipairs(remoteState.devices) do
|
||||||
|
if type(row) == "table" and type(row.id) == "string" and row.id ~= "" then
|
||||||
|
list[#list + 1] = {
|
||||||
|
id = row.id,
|
||||||
|
label = type(row.label) == "string" and row.label ~= "" and row.label
|
||||||
|
or "device",
|
||||||
|
createdAt = tonumber(row.createdAt),
|
||||||
|
current = row.current == true or row.id == self.state.deviceId,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
self.devices = list
|
||||||
|
end
|
||||||
|
local remote = type(remoteState.saves) == "table" and remoteState.saves or {}
|
||||||
|
local locals = self.saves.list() or {}
|
||||||
|
local seen = {}
|
||||||
|
for _, entry in ipairs(locals) do
|
||||||
|
local key = SyncState.key(entry.version, entry.playthroughId)
|
||||||
|
if key then
|
||||||
|
seen[key] = true
|
||||||
|
local row = remote[key]
|
||||||
|
local knownRev = SyncState.rev(self.state, key)
|
||||||
|
local stamp = SyncState.stamp(self.state, key)
|
||||||
|
local localChanged = stamp == nil
|
||||||
|
or tonumber(entry.meta and entry.meta.savedAt) ~= stamp
|
||||||
|
local remoteRev = row and tonumber(row.rev)
|
||||||
|
local remoteChanged = row ~= nil and remoteRev ~= knownRev
|
||||||
|
if not row then
|
||||||
|
self:_queueUpload(entry, key, false)
|
||||||
|
elseif localChanged and remoteChanged then
|
||||||
|
self:_addConflict(entry, key, row)
|
||||||
|
elseif localChanged then
|
||||||
|
self:_queueUpload(entry, key, false)
|
||||||
|
elseif remoteChanged then
|
||||||
|
self:_queueDownload(key, entry.version, entry.playthroughId, "replace")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
for key, row in pairs(remote) do
|
||||||
|
if not seen[key] then
|
||||||
|
local version, id = SyncState.splitKey(key)
|
||||||
|
if version and id then
|
||||||
|
self:_queueDownload(key, version, id, "replace", tonumber(row.rev))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if #self.queue == 0 then self:_finish() end
|
||||||
|
end
|
||||||
|
|
||||||
|
function SyncEngine:_addConflict(entry, key, row)
|
||||||
|
local remoteMeta = row
|
||||||
|
if type(row.meta) == "table" then
|
||||||
|
remoteMeta = row.meta
|
||||||
|
elseif type(row.remoteMeta) == "table" then
|
||||||
|
remoteMeta = row.remoteMeta
|
||||||
|
end
|
||||||
|
self.conflicts[#self.conflicts + 1] = {
|
||||||
|
key = key,
|
||||||
|
version = entry.version,
|
||||||
|
playthroughId = entry.playthroughId,
|
||||||
|
slot = entry.slot,
|
||||||
|
entry = entry,
|
||||||
|
localMeta = entry.meta,
|
||||||
|
remoteMeta = remoteMeta,
|
||||||
|
remoteRev = tonumber(row.rev),
|
||||||
|
overlap = SyncEngine.overlaps(entry.meta, remoteMeta),
|
||||||
|
}
|
||||||
|
local pending = self.state.pendingConflicts or {}
|
||||||
|
self.state.pendingConflicts = pending
|
||||||
|
for _, row in ipairs(pending) do
|
||||||
|
if row.key == key then return end
|
||||||
|
end
|
||||||
|
pending[#pending + 1] = {
|
||||||
|
key = key,
|
||||||
|
version = entry.version,
|
||||||
|
playthroughId = entry.playthroughId,
|
||||||
|
overlap = SyncEngine.overlaps(entry.meta, remoteMeta),
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
function SyncEngine:_queueUpload(entry, key, force)
|
||||||
|
self:_enqueue(function(eng)
|
||||||
|
eng.phase = "uploading"
|
||||||
|
eng.status = "Uploading saves..."
|
||||||
|
local handle, err = eng.client:putSave({
|
||||||
|
version = entry.version,
|
||||||
|
slot = entry.slot,
|
||||||
|
meta = entry.meta,
|
||||||
|
blob = entry.blob,
|
||||||
|
baseRev = SyncState.rev(eng.state, key),
|
||||||
|
force = force,
|
||||||
|
})
|
||||||
|
eng:_request(handle, err, function(e, res)
|
||||||
|
local data = res.data or {}
|
||||||
|
SyncState.setRev(e.state, key, tonumber(data.rev),
|
||||||
|
entry.meta and entry.meta.savedAt)
|
||||||
|
e:_persist()
|
||||||
|
if not e:busy() then e:_finish() end
|
||||||
|
end, function(e, res)
|
||||||
|
if res.code == 409 then
|
||||||
|
local row = res.data or {}
|
||||||
|
e:_addConflict(entry, key, row)
|
||||||
|
if not e:busy() then e:_finish() end
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
return false
|
||||||
|
end)
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
function SyncEngine:_queueDownload(key, version, playthroughId, mode, knownRev)
|
||||||
|
self:_enqueue(function(eng)
|
||||||
|
eng.phase = "downloading"
|
||||||
|
eng.status = "Downloading saves..."
|
||||||
|
local handle, err = eng.client:getSave(version, playthroughId)
|
||||||
|
eng:_request(handle, err, function(e, res)
|
||||||
|
local data = res.data or {}
|
||||||
|
if type(data.blob) ~= "string" or data.blob == "" then
|
||||||
|
e:_fail("the server sent no save data")
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local slotId, writeErr = e.saves.write(version, playthroughId, data.blob, mode)
|
||||||
|
if not slotId then
|
||||||
|
e:_fail(writeErr or "could not write the downloaded save")
|
||||||
|
return
|
||||||
|
end
|
||||||
|
if mode ~= "new" then
|
||||||
|
local meta = type(data.meta) == "table" and data.meta or {}
|
||||||
|
SyncState.setRev(e.state, key, tonumber(data.rev) or knownRev,
|
||||||
|
tonumber(meta.savedAt))
|
||||||
|
end
|
||||||
|
e:_persist()
|
||||||
|
if not e:busy() then e:_finish() end
|
||||||
|
end)
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
function SyncEngine:resolveConflict(key, choice)
|
||||||
|
local index
|
||||||
|
for i, row in ipairs(self.conflicts) do
|
||||||
|
if row.key == key then index = i break end
|
||||||
|
end
|
||||||
|
if not index then return false, "no such conflict" end
|
||||||
|
local conflict = table.remove(self.conflicts, index)
|
||||||
|
local kept = {}
|
||||||
|
for _, row in ipairs(self.state.pendingConflicts or {}) do
|
||||||
|
if row.key ~= key then kept[#kept + 1] = row end
|
||||||
|
end
|
||||||
|
self.state.pendingConflicts = kept
|
||||||
|
|
||||||
|
if choice == "local" then
|
||||||
|
SyncState.setRev(self.state, key, conflict.remoteRev, nil)
|
||||||
|
self:_queueUpload(conflict.entry, key, true)
|
||||||
|
elseif choice == "remote" then
|
||||||
|
self:_queueDownload(key, conflict.version, conflict.playthroughId,
|
||||||
|
"replace", conflict.remoteRev)
|
||||||
|
elseif choice == "both" then
|
||||||
|
self:_queueDownload(key, conflict.version, conflict.playthroughId,
|
||||||
|
"new", conflict.remoteRev)
|
||||||
|
SyncState.setRev(self.state, key, conflict.remoteRev, nil)
|
||||||
|
self:_queueUpload(conflict.entry, key, true)
|
||||||
|
else
|
||||||
|
return false, "unknown resolution"
|
||||||
|
end
|
||||||
|
self.phase = "uploading"
|
||||||
|
self.status = "Applying your choice..."
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
function SyncEngine:uploadMods()
|
||||||
|
if not self:linked() then return false, "this device is not linked" end
|
||||||
|
if self:busy() then return false, "sync is busy" end
|
||||||
|
local manifest = SyncMods.build(self.modDeps)
|
||||||
|
self.phase = "uploading"
|
||||||
|
self.status = "Uploading the mod list..."
|
||||||
|
local handle, err = self.client:putMods(manifest)
|
||||||
|
return self:_request(handle, err, function(eng)
|
||||||
|
eng.phase = "idle"
|
||||||
|
eng.status = "Mod list synced"
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
function SyncEngine:fetchModPlan()
|
||||||
|
if not self:linked() then return false, "this device is not linked" end
|
||||||
|
if self:busy() then return false, "sync is busy" end
|
||||||
|
self.phase = "downloading"
|
||||||
|
self.status = "Reading the mod list..."
|
||||||
|
local handle, err = self.client:getMods()
|
||||||
|
return self:_request(handle, err, function(eng, res)
|
||||||
|
local data = res.data or {}
|
||||||
|
local manifest = type(data.manifest) == "table" and data.manifest or data
|
||||||
|
eng.modPlan = SyncMods.plan(manifest, eng.modDeps)
|
||||||
|
eng.phase = "idle"
|
||||||
|
eng.status = SyncMods.planEmpty(eng.modPlan)
|
||||||
|
and "Mods already match" or "Mod changes ready to apply"
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
function SyncEngine:shareMods()
|
||||||
|
if not self:linked() then return false, "this device is not linked" end
|
||||||
|
if self:busy() then return false, "sync is busy" end
|
||||||
|
local manifest = SyncMods.build(self.modDeps)
|
||||||
|
self.phase = "uploading"
|
||||||
|
self.status = "Sharing the mod list..."
|
||||||
|
local handle, err = self.client:shareMods(manifest)
|
||||||
|
return self:_request(handle, err, function(eng, res)
|
||||||
|
local data = res.data or {}
|
||||||
|
eng.shareCode = type(data.code) == "string" and data.code or nil
|
||||||
|
eng.phase = "idle"
|
||||||
|
eng.status = eng.shareCode and ("Share code " .. eng.shareCode)
|
||||||
|
or "The server sent no share code"
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
function SyncEngine:fetchShare(code)
|
||||||
|
if self:busy() then return false, "sync is busy" end
|
||||||
|
self.phase = "downloading"
|
||||||
|
self.status = "Fetching that mod list..."
|
||||||
|
local handle, err = self.client:fetchShare(code)
|
||||||
|
return self:_request(handle, err, function(eng, res)
|
||||||
|
local data = res.data or {}
|
||||||
|
local manifest = type(data.manifest) == "table" and data.manifest or data
|
||||||
|
eng.modPlan = SyncMods.plan(manifest, eng.modDeps)
|
||||||
|
eng.phase = "idle"
|
||||||
|
eng.status = SyncMods.planEmpty(eng.modPlan)
|
||||||
|
and "Mods already match" or "Mod changes ready to apply"
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
function SyncEngine:applyModPlan(progress)
|
||||||
|
if not self.modPlan then return false, "no mod plan" end
|
||||||
|
if self.modApply then return false, "the mods are already being applied" end
|
||||||
|
local steps = SyncMods.steps(self.modPlan, self.modDeps)
|
||||||
|
if #steps == 0 then
|
||||||
|
self.modPlan = nil
|
||||||
|
self.status = "Mods already match"
|
||||||
|
if progress then progress(0, 0, nil, true) end
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
self.modApply = { steps = steps, index = 0, failures = {},
|
||||||
|
progress = progress }
|
||||||
|
self.phase = "applying"
|
||||||
|
self.status = ("Applying mods... 0 of %d"):format(#steps)
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
function SyncEngine:applyingMods()
|
||||||
|
return self.modApply ~= nil
|
||||||
|
end
|
||||||
|
|
||||||
|
function SyncEngine:_stepModApply()
|
||||||
|
local job = self.modApply
|
||||||
|
local step = job.steps[job.index + 1]
|
||||||
|
job.index = job.index + 1
|
||||||
|
local ok, res, why = pcall(step.run)
|
||||||
|
if not ok then
|
||||||
|
job.failures[#job.failures + 1] = tostring(res)
|
||||||
|
elseif not res then
|
||||||
|
job.failures[#job.failures + 1] = tostring(why or step.label)
|
||||||
|
end
|
||||||
|
local total = #job.steps
|
||||||
|
local done = job.index >= total
|
||||||
|
if not done then
|
||||||
|
self.status = ("Applying mods... %d of %d"):format(job.index, total)
|
||||||
|
if job.progress then
|
||||||
|
pcall(job.progress, job.index, total, step.label, false)
|
||||||
|
end
|
||||||
|
return
|
||||||
|
end
|
||||||
|
self.modApply = nil
|
||||||
|
self.modPlan = nil
|
||||||
|
self.phase = "idle"
|
||||||
|
if #job.failures > 0 then
|
||||||
|
self.status = "Some mods could not be applied: "
|
||||||
|
.. table.concat(job.failures, "; ")
|
||||||
|
else
|
||||||
|
self.status = "Mods applied"
|
||||||
|
end
|
||||||
|
if job.progress then
|
||||||
|
pcall(job.progress, job.index, total, step.label, true)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
return SyncEngine
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
local SyncMods = {}
|
||||||
|
|
||||||
|
SyncMods.REV = 1
|
||||||
|
|
||||||
|
local function versions()
|
||||||
|
local ok, GameVersion = pcall(require, "src.core.GameVersion")
|
||||||
|
if ok and GameVersion and GameVersion.ORDER then return GameVersion.ORDER end
|
||||||
|
return { "red", "blue", "yellow", "gold" }
|
||||||
|
end
|
||||||
|
|
||||||
|
local function defaultDeps()
|
||||||
|
return {
|
||||||
|
installed = function()
|
||||||
|
return require("src.mods.LauncherMods").list()
|
||||||
|
end,
|
||||||
|
indexes = function()
|
||||||
|
return require("src.mods.ModIndex").sources()
|
||||||
|
end,
|
||||||
|
addIndex = function(url)
|
||||||
|
return require("src.mods.ModIndex").addSource(url)
|
||||||
|
end,
|
||||||
|
findEntry = function(id)
|
||||||
|
local ModIndex = require("src.mods.ModIndex")
|
||||||
|
for _, source in ipairs(ModIndex.sources()) do
|
||||||
|
local cached = ModIndex.readCache(source.feed)
|
||||||
|
for _, entry in ipairs((cached and cached.mods) or {}) do
|
||||||
|
if entry.id == id then return entry end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return nil
|
||||||
|
end,
|
||||||
|
install = function(entry)
|
||||||
|
return require("src.mods.LauncherMods").installFromIndex(entry)
|
||||||
|
end,
|
||||||
|
setEnabled = function(id, enabled, version)
|
||||||
|
return require("src.mods.LauncherMods").setEnabled(id, enabled, version)
|
||||||
|
end,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
local function deps(given)
|
||||||
|
local out = defaultDeps()
|
||||||
|
if type(given) == "table" then
|
||||||
|
for k, v in pairs(given) do out[k] = v end
|
||||||
|
end
|
||||||
|
return out
|
||||||
|
end
|
||||||
|
|
||||||
|
local function sourceOf(row)
|
||||||
|
local github = row.github
|
||||||
|
or (type(row.manifest) == "table" and row.manifest.github)
|
||||||
|
if type(github) == "string" and github ~= "" then
|
||||||
|
return "github:" .. github
|
||||||
|
end
|
||||||
|
return "local"
|
||||||
|
end
|
||||||
|
|
||||||
|
function SyncMods.build(given)
|
||||||
|
local d = deps(given)
|
||||||
|
local manifest = { rev = SyncMods.REV, indexes = {}, mods = {} }
|
||||||
|
for _, row in ipairs(d.indexes() or {}) do
|
||||||
|
local url = row.url or row.feed
|
||||||
|
if type(url) == "string" and url ~= "" then
|
||||||
|
manifest.indexes[#manifest.indexes + 1] = url
|
||||||
|
end
|
||||||
|
end
|
||||||
|
table.sort(manifest.indexes)
|
||||||
|
for _, row in ipairs(d.installed() or {}) do
|
||||||
|
if type(row) == "table" and type(row.id) == "string" then
|
||||||
|
local enabledFor = {}
|
||||||
|
local answers = row.enabledByVersion or {}
|
||||||
|
for _, version in ipairs(versions()) do
|
||||||
|
if answers[version] then enabledFor[#enabledFor + 1] = version end
|
||||||
|
end
|
||||||
|
manifest.mods[#manifest.mods + 1] = {
|
||||||
|
id = row.id,
|
||||||
|
version = row.version,
|
||||||
|
source = sourceOf(row),
|
||||||
|
enabledFor = enabledFor,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
table.sort(manifest.mods, function(a, b) return a.id < b.id end)
|
||||||
|
return manifest
|
||||||
|
end
|
||||||
|
|
||||||
|
function SyncMods.plan(manifest, given)
|
||||||
|
local d = deps(given)
|
||||||
|
local plan = { indexes = {}, toInstall = {}, toEnable = {}, missing = {} }
|
||||||
|
if type(manifest) ~= "table" then return plan end
|
||||||
|
|
||||||
|
local haveIndex = {}
|
||||||
|
for _, row in ipairs(d.indexes() or {}) do
|
||||||
|
if type(row.url) == "string" then haveIndex[row.url] = true end
|
||||||
|
if type(row.feed) == "string" then haveIndex[row.feed] = true end
|
||||||
|
end
|
||||||
|
for _, url in ipairs(manifest.indexes or {}) do
|
||||||
|
if type(url) == "string" and url ~= "" and not haveIndex[url] then
|
||||||
|
plan.indexes[#plan.indexes + 1] = url
|
||||||
|
haveIndex[url] = true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local installed = {}
|
||||||
|
for _, row in ipairs(d.installed() or {}) do
|
||||||
|
if type(row) == "table" and type(row.id) == "string" then
|
||||||
|
installed[row.id] = row
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
for _, mod in ipairs(manifest.mods or {}) do
|
||||||
|
if type(mod) == "table" and type(mod.id) == "string" then
|
||||||
|
local here = installed[mod.id]
|
||||||
|
local available = here ~= nil
|
||||||
|
if not here then
|
||||||
|
local entry = d.findEntry(mod.id)
|
||||||
|
if entry then
|
||||||
|
available = true
|
||||||
|
plan.toInstall[#plan.toInstall + 1] =
|
||||||
|
{ id = mod.id, version = mod.version, entry = entry }
|
||||||
|
else
|
||||||
|
plan.missing[#plan.missing + 1] =
|
||||||
|
{ id = mod.id, version = mod.version, source = mod.source }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if available then
|
||||||
|
local answers = (here and here.enabledByVersion) or {}
|
||||||
|
for _, version in ipairs(mod.enabledFor or {}) do
|
||||||
|
if answers[version] ~= true then
|
||||||
|
plan.toEnable[#plan.toEnable + 1] = { id = mod.id, version = version }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return plan
|
||||||
|
end
|
||||||
|
|
||||||
|
function SyncMods.planEmpty(plan)
|
||||||
|
if type(plan) ~= "table" then return true end
|
||||||
|
return #(plan.indexes or {}) == 0 and #(plan.toInstall or {}) == 0
|
||||||
|
and #(plan.toEnable or {}) == 0
|
||||||
|
end
|
||||||
|
|
||||||
|
function SyncMods.steps(plan, given)
|
||||||
|
local d = deps(given)
|
||||||
|
local out = {}
|
||||||
|
if type(plan) ~= "table" then return out end
|
||||||
|
local broken = {}
|
||||||
|
|
||||||
|
for _, url in ipairs(plan.indexes or {}) do
|
||||||
|
out[#out + 1] = { label = url, run = function()
|
||||||
|
local ok, err = d.addIndex(url)
|
||||||
|
if not ok then return nil, tostring(err or url) end
|
||||||
|
return true
|
||||||
|
end }
|
||||||
|
end
|
||||||
|
for _, mod in ipairs(plan.toInstall or {}) do
|
||||||
|
out[#out + 1] = { label = mod.id, run = function()
|
||||||
|
local ok, err = d.install(mod.entry)
|
||||||
|
if not ok then
|
||||||
|
broken[mod.id] = true
|
||||||
|
return nil, mod.id .. ": " .. tostring(err or "install failed")
|
||||||
|
end
|
||||||
|
return true
|
||||||
|
end }
|
||||||
|
end
|
||||||
|
for _, want in ipairs(plan.toEnable or {}) do
|
||||||
|
out[#out + 1] = { label = want.id, run = function()
|
||||||
|
if broken[want.id] then return true end
|
||||||
|
local ok, err = d.setEnabled(want.id, true, want.version)
|
||||||
|
if ok == false then
|
||||||
|
return nil, want.id .. ": " .. tostring(err or "could not enable")
|
||||||
|
end
|
||||||
|
return true
|
||||||
|
end }
|
||||||
|
end
|
||||||
|
return out
|
||||||
|
end
|
||||||
|
|
||||||
|
function SyncMods.apply(plan, progress, given)
|
||||||
|
if type(plan) ~= "table" then return false, "nothing to apply" end
|
||||||
|
local steps = SyncMods.steps(plan, given)
|
||||||
|
local failures = {}
|
||||||
|
for i, step in ipairs(steps) do
|
||||||
|
local ok, err = step.run()
|
||||||
|
if not ok then failures[#failures + 1] = err end
|
||||||
|
if progress then progress(i, #steps, step.label) end
|
||||||
|
end
|
||||||
|
if #failures > 0 then
|
||||||
|
return false, table.concat(failures, "; ")
|
||||||
|
end
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
return SyncMods
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
local SaveData = require("src.core.SaveData")
|
||||||
|
|
||||||
|
local SyncState = {}
|
||||||
|
|
||||||
|
SyncState.KEY = "saveSync"
|
||||||
|
|
||||||
|
function SyncState.defaults()
|
||||||
|
return {
|
||||||
|
enabled = false,
|
||||||
|
lastSyncAt = 0,
|
||||||
|
revs = {},
|
||||||
|
stamps = {},
|
||||||
|
pendingConflicts = {},
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
local function str(v)
|
||||||
|
if type(v) == "string" and v ~= "" then return v end
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local function num(v)
|
||||||
|
local n = tonumber(v)
|
||||||
|
if type(n) ~= "number" or n ~= n or n == math.huge or n == -math.huge then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
return n
|
||||||
|
end
|
||||||
|
|
||||||
|
function SyncState.sanitize(raw)
|
||||||
|
local out = SyncState.defaults()
|
||||||
|
if type(raw) ~= "table" then return out end
|
||||||
|
out.enabled = raw.enabled == true
|
||||||
|
out.account = str(raw.account)
|
||||||
|
out.deviceToken = str(raw.deviceToken)
|
||||||
|
out.deviceId = str(raw.deviceId)
|
||||||
|
out.deviceLabel = str(raw.deviceLabel)
|
||||||
|
out.lastSyncAt = num(raw.lastSyncAt) or 0
|
||||||
|
if type(raw.revs) == "table" then
|
||||||
|
for key, rev in pairs(raw.revs) do
|
||||||
|
local n = num(rev)
|
||||||
|
if type(key) == "string" and n then out.revs[key] = n end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if type(raw.stamps) == "table" then
|
||||||
|
for key, at in pairs(raw.stamps) do
|
||||||
|
local n = num(at)
|
||||||
|
if type(key) == "string" and n then out.stamps[key] = n end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if type(raw.pendingConflicts) == "table" then
|
||||||
|
for _, row in ipairs(raw.pendingConflicts) do
|
||||||
|
if type(row) == "table" and str(row.key) then
|
||||||
|
out.pendingConflicts[#out.pendingConflicts + 1] = {
|
||||||
|
key = row.key,
|
||||||
|
version = str(row.version),
|
||||||
|
playthroughId = str(row.playthroughId),
|
||||||
|
overlap = row.overlap == true,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return out
|
||||||
|
end
|
||||||
|
|
||||||
|
function SyncState.load(fs)
|
||||||
|
local opts = SaveData.loadOptions(fs)
|
||||||
|
return SyncState.sanitize(opts and opts[SyncState.KEY])
|
||||||
|
end
|
||||||
|
|
||||||
|
function SyncState.save(state, fs)
|
||||||
|
local opts = SaveData.loadOptions(fs)
|
||||||
|
opts[SyncState.KEY] = SyncState.sanitize(state)
|
||||||
|
SaveData.saveOptions(opts, fs)
|
||||||
|
return opts[SyncState.KEY]
|
||||||
|
end
|
||||||
|
|
||||||
|
function SyncState.update(fn, fs)
|
||||||
|
local state = SyncState.load(fs)
|
||||||
|
fn(state)
|
||||||
|
return SyncState.save(state, fs)
|
||||||
|
end
|
||||||
|
|
||||||
|
function SyncState.clear(fs)
|
||||||
|
return SyncState.save(SyncState.defaults(), fs)
|
||||||
|
end
|
||||||
|
|
||||||
|
function SyncState.linked(state)
|
||||||
|
return type(state) == "table" and str(state.account) ~= nil
|
||||||
|
and str(state.deviceToken) ~= nil
|
||||||
|
end
|
||||||
|
|
||||||
|
function SyncState.key(version, playthroughId)
|
||||||
|
if type(version) ~= "string" or version == "" then return nil end
|
||||||
|
if type(playthroughId) ~= "string" or playthroughId == "" then return nil end
|
||||||
|
return version .. "/" .. playthroughId
|
||||||
|
end
|
||||||
|
|
||||||
|
function SyncState.splitKey(key)
|
||||||
|
if type(key) ~= "string" then return nil end
|
||||||
|
local version, id = key:match("^([^/]+)/(.+)$")
|
||||||
|
return version, id
|
||||||
|
end
|
||||||
|
|
||||||
|
function SyncState.rev(state, key)
|
||||||
|
if type(state) ~= "table" or type(state.revs) ~= "table" then return nil end
|
||||||
|
return state.revs[key]
|
||||||
|
end
|
||||||
|
|
||||||
|
function SyncState.stamp(state, key)
|
||||||
|
if type(state) ~= "table" or type(state.stamps) ~= "table" then return nil end
|
||||||
|
return state.stamps[key]
|
||||||
|
end
|
||||||
|
|
||||||
|
function SyncState.setRev(state, key, rev, savedAt)
|
||||||
|
if type(state) ~= "table" or type(key) ~= "string" then return end
|
||||||
|
state.revs = state.revs or {}
|
||||||
|
state.stamps = state.stamps or {}
|
||||||
|
state.revs[key] = num(rev)
|
||||||
|
state.stamps[key] = num(savedAt)
|
||||||
|
end
|
||||||
|
|
||||||
|
function SyncState.forget(state, key)
|
||||||
|
if type(state) ~= "table" or type(key) ~= "string" then return end
|
||||||
|
if type(state.revs) == "table" then state.revs[key] = nil end
|
||||||
|
if type(state.stamps) == "table" then state.stamps[key] = nil end
|
||||||
|
end
|
||||||
|
|
||||||
|
return SyncState
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
local Transport = {}
|
||||||
|
Transport.__index = Transport
|
||||||
|
|
||||||
|
function Transport.new(fetch)
|
||||||
|
return setmetatable({ fetch = fetch or require("src.net.Fetch") }, Transport)
|
||||||
|
end
|
||||||
|
|
||||||
|
function Transport:begin(req)
|
||||||
|
return self.fetch.request(req.url, {
|
||||||
|
method = req.method,
|
||||||
|
body = req.body,
|
||||||
|
headers = req.headers,
|
||||||
|
maxSeconds = req.maxSeconds,
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
|
function Transport:poll(handle)
|
||||||
|
local st = self.fetch.poll(handle)
|
||||||
|
if st.status == "pending" then return { status = "pending" } end
|
||||||
|
if st.status == "cancelled" then
|
||||||
|
return { status = "error", err = "sync request cancelled" }
|
||||||
|
end
|
||||||
|
if st.status ~= "ok" then
|
||||||
|
return { status = "error", err = st.err or "sync request failed" }
|
||||||
|
end
|
||||||
|
return { status = "ok", body = st.body or "", code = tonumber(st.code) }
|
||||||
|
end
|
||||||
|
|
||||||
|
function Transport:release(handle)
|
||||||
|
if handle ~= nil and self.fetch.release then self.fetch.release(handle) end
|
||||||
|
end
|
||||||
|
|
||||||
|
function Transport:cancel(handle)
|
||||||
|
if handle ~= nil and self.fetch.cancel then self.fetch.cancel(handle) end
|
||||||
|
end
|
||||||
|
|
||||||
|
function Transport:available()
|
||||||
|
return self.fetch.available and self.fetch.available() or false
|
||||||
|
end
|
||||||
|
|
||||||
|
return Transport
|
||||||
@@ -180,8 +180,8 @@ local function release(game)
|
|||||||
and mon.ot == game.save.player.name then
|
and mon.ot == game.save.player.name then
|
||||||
require("src.core.Sound").playCry(game.data, mon.species)
|
require("src.core.Sound").playCry(game.data, mon.species)
|
||||||
game.stack:push(TextBox.new(game,
|
game.stack:push(TextBox.new(game,
|
||||||
(t._PikachuUnhappyText or Strings("%s looks\nunhappy about it!", name))
|
((t._PikachuUnhappyText or Strings("%s looks\nunhappy about it!", name))
|
||||||
:gsub("{RAM:wNameBuffer}", name)))
|
:gsub("{RAM:wNameBuffer}", name))))
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
game.stack:push(TextBox.new(game,
|
game.stack:push(TextBox.new(game,
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ local Theme = require("src.ui.Theme")
|
|||||||
local FieldDefaults = require("src.world.FieldDefaults")
|
local FieldDefaults = require("src.world.FieldDefaults")
|
||||||
local Map = require("src.world.Map")
|
local Map = require("src.world.Map")
|
||||||
local Strings = require("src.core.Strings")
|
local Strings = require("src.core.Strings")
|
||||||
|
local Status = require("src.battle.Status")
|
||||||
|
|
||||||
local PartyMenu = {}
|
local PartyMenu = {}
|
||||||
PartyMenu.__index = PartyMenu
|
PartyMenu.__index = PartyMenu
|
||||||
@@ -821,7 +822,7 @@ function PartyMenu:draw()
|
|||||||
if mon.hp <= 0 then
|
if mon.hp <= 0 then
|
||||||
Font.draw(Strings("FNT"), 136, y)
|
Font.draw(Strings("FNT"), 136, y)
|
||||||
elseif mon.status then
|
elseif mon.status then
|
||||||
Font.draw(mon.status, 136, y)
|
Font.draw(Status.hudLabelFor(self.game.data.statuses, mon.status), 136, y)
|
||||||
end
|
end
|
||||||
-- the tile HP bar (DrawHP2 + SetPartyMenuHPBarColor). grayFill:
|
-- the tile HP bar (DrawHP2 + SetPartyMenuHPBarColor). grayFill:
|
||||||
-- tinting the fill AND running it through the row's zone
|
-- tinting the fill AND running it through the row's zone
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ local Font = require("src.render.Font")
|
|||||||
local TypeChart = require("src.battle.TypeChart")
|
local TypeChart = require("src.battle.TypeChart")
|
||||||
local Strings = require("src.core.Strings")
|
local Strings = require("src.core.Strings")
|
||||||
local Stats = require("src.pokemon.Stats")
|
local Stats = require("src.pokemon.Stats")
|
||||||
|
local Status = require("src.battle.Status")
|
||||||
|
|
||||||
local SummaryMenu = {}
|
local SummaryMenu = {}
|
||||||
SummaryMenu.__index = SummaryMenu
|
SummaryMenu.__index = SummaryMenu
|
||||||
@@ -145,7 +146,7 @@ function SummaryMenu:draw()
|
|||||||
HudTiles.drawHPBar(data, 11, 3, mon, 1, barZoned) -- wHPBarType 1
|
HudTiles.drawHPBar(data, 11, 3, mon, 1, barZoned) -- wHPBarType 1
|
||||||
Font.draw(("%3d/%3d"):format(mon.hp, mon.stats.hp), 96, 32)
|
Font.draw(("%3d/%3d"):format(mon.hp, mon.stats.hp), 96, 32)
|
||||||
Font.draw(Strings("STATUS/"), 72, 48)
|
Font.draw(Strings("STATUS/"), 72, 48)
|
||||||
Font.draw(mon.status or "OK", 128, 48)
|
Font.draw(Status.hudLabelFor(data.statuses, mon.status) or "OK", 128, 48)
|
||||||
|
|
||||||
-- stats box (0,8) 10x10: names rows 9/11/13/15, values indented
|
-- stats box (0,8) 10x10: names rows 9/11/13/15, values indented
|
||||||
Font.drawBox(0, 8, 10, 10)
|
Font.drawBox(0, 8, 10, 10)
|
||||||
|
|||||||
@@ -198,6 +198,7 @@ function TitleState.new(game, opts)
|
|||||||
self.game = game
|
self.game = game
|
||||||
self.onNewGame = opts.onNewGame
|
self.onNewGame = opts.onNewGame
|
||||||
self.onContinue = opts.onContinue
|
self.onContinue = opts.onContinue
|
||||||
|
self.onExit = opts.onExit
|
||||||
-- branding comes from field.title with the shipped art as fallback, so
|
-- branding comes from field.title with the shipped art as fallback, so
|
||||||
-- a total conversion rebrands the title without replacing the screen
|
-- a total conversion rebrands the title without replacing the screen
|
||||||
local title = (game.data.field and game.data.field.title) or {}
|
local title = (game.data.field and game.data.field.title) or {}
|
||||||
@@ -514,7 +515,9 @@ function TitleState:openMenu()
|
|||||||
require("src.ui.Screens").push(game, "OptionsMenu")
|
require("src.ui.Screens").push(game, "OptionsMenu")
|
||||||
end })
|
end })
|
||||||
table.insert(items, { label = Strings("EXIT GAME"), onSelect = function()
|
table.insert(items, { label = Strings("EXIT GAME"), onSelect = function()
|
||||||
if love.event and love.event.quit then
|
if self.onExit then
|
||||||
|
self.onExit()
|
||||||
|
elseif love.event and love.event.quit then
|
||||||
love.event.quit()
|
love.event.quit()
|
||||||
end
|
end
|
||||||
end })
|
end })
|
||||||
|
|||||||
@@ -2311,6 +2311,7 @@ function BattleState:openParty(forced)
|
|||||||
-- :2702; engine/pokemon/party_menu.asm:660-679). Only the voluntary list
|
-- :2702; engine/pokemon/party_menu.asm:660-679). Only the voluntary list
|
||||||
-- carries BattleMonMenu; PickPartyMonInBattle has no submenu.
|
-- carries BattleMonMenu; PickPartyMonInBattle has no submenu.
|
||||||
prompt = forced and "which" or "choose",
|
prompt = forced and "which" or "choose",
|
||||||
|
battle = true,
|
||||||
battleSubmenu = not forced,
|
battleSubmenu = not forced,
|
||||||
onCancel = function()
|
onCancel = function()
|
||||||
stack:pop()
|
stack:pop()
|
||||||
@@ -2783,6 +2784,7 @@ function BattleState:openShiftParty()
|
|||||||
self.phase = "submenu"
|
self.phase = "submenu"
|
||||||
Screens.push(self.game, "Gen2PartyMenu", {
|
Screens.push(self.game, "Gen2PartyMenu", {
|
||||||
prompt = "which",
|
prompt = "which",
|
||||||
|
battle = true,
|
||||||
onCancel = function()
|
onCancel = function()
|
||||||
stack:pop()
|
stack:pop()
|
||||||
self.phase = "resolving"
|
self.phase = "resolving"
|
||||||
@@ -3156,6 +3158,7 @@ function BattleState:useOnPartyMon(itemId, action)
|
|||||||
self.phase = "submenu"
|
self.phase = "submenu"
|
||||||
Screens.push(self.game, "Gen2PartyMenu", {
|
Screens.push(self.game, "Gen2PartyMenu", {
|
||||||
prompt = "useItem",
|
prompt = "useItem",
|
||||||
|
battle = true,
|
||||||
party = self.battle.party or (self.save and self.save.party),
|
party = self.battle.party or (self.save and self.save.party),
|
||||||
onCancel = function()
|
onCancel = function()
|
||||||
stack:pop()
|
stack:pop()
|
||||||
|
|||||||
@@ -28,7 +28,7 @@
|
|||||||
-- covered by tests; the state at the bottom is the only part that draws.
|
-- covered by tests; the state at the bottom is the only part that draws.
|
||||||
|
|
||||||
local GbcPalette = require("src.render.GbcPalette")
|
local GbcPalette = require("src.render.GbcPalette")
|
||||||
local GameViewport = require("src.render.GameViewport")
|
local Playfield = require("src.render.Playfield")
|
||||||
local Palettes = require("src.world.gen2.Palettes")
|
local Palettes = require("src.world.gen2.Palettes")
|
||||||
local Runtime = require("src.mods.Runtime")
|
local Runtime = require("src.mods.Runtime")
|
||||||
local SpriteAnims = require("src.ui.gen2.SpriteAnims")
|
local SpriteAnims = require("src.ui.gen2.SpriteAnims")
|
||||||
@@ -510,7 +510,7 @@ function BattleTransition:blackAt(col, row)
|
|||||||
end
|
end
|
||||||
|
|
||||||
function BattleTransition:draw()
|
function BattleTransition:draw()
|
||||||
local w, h = GameViewport.dimensions()
|
local w, h = Playfield.dimensions()
|
||||||
self:drawWidescreen(w, h)
|
self:drawWidescreen(w, h)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -43,16 +43,29 @@ end
|
|||||||
-- pixel rows out of glyphs. This is the same rule src/render/Renderer.lua
|
-- pixel rows out of glyphs. This is the same rule src/render/Renderer.lua
|
||||||
-- fitScale applies to the Gen 1 UI canvas; the surround a widescreen screen
|
-- fitScale applies to the Gen 1 UI canvas; the surround a widescreen screen
|
||||||
-- paints still fills the window, the PANEL is what stays on the grid.
|
-- paints still fills the window, the PANEL is what stays on the grid.
|
||||||
|
local function playfieldRect(winW, winH)
|
||||||
|
local ok, Playfield = pcall(require, "src.render.Playfield")
|
||||||
|
if ok and Playfield.rect then
|
||||||
|
local okv, x, y, w, h = pcall(Playfield.rect, winW, winH)
|
||||||
|
if okv and w and w >= 1 and h and h >= 1 then
|
||||||
|
return x, y, w, h
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return 0, 0, winW or 0, winH or 0
|
||||||
|
end
|
||||||
|
|
||||||
function Chrome.fitScale(winW, winH)
|
function Chrome.fitScale(winW, winH)
|
||||||
return math.max(1, math.floor(math.min((winW or 0) / (Chrome.SCREEN_W * 8),
|
local _, _, w, h = playfieldRect(winW, winH)
|
||||||
(winH or 0) / (Chrome.SCREEN_H * 8))))
|
return math.max(1, math.floor(math.min(w / (Chrome.SCREEN_W * 8),
|
||||||
|
h / (Chrome.SCREEN_H * 8))))
|
||||||
end
|
end
|
||||||
|
|
||||||
-- The centred origin that goes with it, so a caller does not re-derive it.
|
-- The centred origin that goes with it, so a caller does not re-derive it.
|
||||||
function Chrome.fitOrigin(winW, winH, scale)
|
function Chrome.fitOrigin(winW, winH, scale)
|
||||||
scale = scale or Chrome.fitScale(winW, winH)
|
scale = scale or Chrome.fitScale(winW, winH)
|
||||||
return math.floor((winW - Chrome.SCREEN_W * 8 * scale) / 2),
|
local x, y, w, h = playfieldRect(winW, winH)
|
||||||
math.floor((winH - Chrome.SCREEN_H * 8 * scale) / 2)
|
return x + math.floor((w - Chrome.SCREEN_W * 8 * scale) / 2),
|
||||||
|
y + math.floor((h - Chrome.SCREEN_H * 8 * scale) / 2)
|
||||||
end
|
end
|
||||||
|
|
||||||
-- A bordered box, tile coords. Leaves the draw color black for text.
|
-- A bordered box, tile coords. Leaves the draw color black for text.
|
||||||
|
|||||||
@@ -76,6 +76,23 @@ local BATTLE_SUBMENU_LEFT, BATTLE_SUBMENU_TOP = 11, 11
|
|||||||
|
|
||||||
-- HP bar is 6 tiles wide (48px) in the party list.
|
-- HP bar is 6 tiles wide (48px) in the party list.
|
||||||
|
|
||||||
|
local function gridIndex(index, count, direction)
|
||||||
|
if count < 1 then return nil end
|
||||||
|
local row, col = math.floor((index - 1) / 2), (index - 1) % 2
|
||||||
|
if direction == "left" or direction == "right" then
|
||||||
|
local other = row * 2 + (1 - col) + 1
|
||||||
|
return other <= count and other or index
|
||||||
|
end
|
||||||
|
local step = direction == "up" and -1 or direction == "down" and 1
|
||||||
|
if not step then return nil end
|
||||||
|
local rows = math.ceil(count / 2)
|
||||||
|
for offset = 1, rows do
|
||||||
|
local other = ((row + step * offset) % rows) * 2 + col + 1
|
||||||
|
if other <= count then return other end
|
||||||
|
end
|
||||||
|
return index
|
||||||
|
end
|
||||||
|
|
||||||
function PartyMenu:wantsFillScale() return true end
|
function PartyMenu:wantsFillScale() return true end
|
||||||
function PartyMenu:drawsWidescreen() return true end
|
function PartyMenu:drawsWidescreen() return true end
|
||||||
|
|
||||||
@@ -114,6 +131,7 @@ function PartyMenu.new(game, opts)
|
|||||||
self.wantsSubmenu = opts.submenu == true
|
self.wantsSubmenu = opts.submenu == true
|
||||||
-- BattleMenu_PKMN's `callfar BattleMonMenu` (engine/battle/core.asm:4810).
|
-- BattleMenu_PKMN's `callfar BattleMonMenu` (engine/battle/core.asm:4810).
|
||||||
self.wantsBattleSubmenu = opts.battleSubmenu == true
|
self.wantsBattleSubmenu = opts.battleSubmenu == true
|
||||||
|
self.battle = opts.battle == true
|
||||||
self.submenu = nil
|
self.submenu = nil
|
||||||
-- The held slot while SwitchPartyMons' second pick is open; nil otherwise.
|
-- The held slot while SwitchPartyMons' second pick is open; nil otherwise.
|
||||||
self.switchFrom = nil
|
self.switchFrom = nil
|
||||||
@@ -146,6 +164,13 @@ function PartyMenu:isCancel()
|
|||||||
return self.index > #self.party
|
return self.index > #self.party
|
||||||
end
|
end
|
||||||
|
|
||||||
|
function PartyMenu:gridNavigation()
|
||||||
|
if not self.battle
|
||||||
|
or not Runtime.wantsHook("ui.party.grid_navigation") then return false end
|
||||||
|
return Runtime.call("ui.party.grid_navigation", function() return false end,
|
||||||
|
self) == true
|
||||||
|
end
|
||||||
|
|
||||||
-- ------------------------------------------------------------- mon submenu
|
-- ------------------------------------------------------------- mon submenu
|
||||||
|
|
||||||
-- GetMonSubmenuItems, in its own order: every field move the mon knows first,
|
-- GetMonSubmenuItems, in its own order: every field move the mon knows first,
|
||||||
@@ -477,7 +502,18 @@ function PartyMenu:update(_dt)
|
|||||||
return
|
return
|
||||||
end
|
end
|
||||||
local total = self:count()
|
local total = self:count()
|
||||||
if input:wasPressed("up") then
|
local grid
|
||||||
|
if self:gridNavigation() then
|
||||||
|
local direction = input:wasPressed("left") and "left"
|
||||||
|
or input:wasPressed("right") and "right"
|
||||||
|
or input:wasPressed("up") and "up"
|
||||||
|
or input:wasPressed("down") and "down"
|
||||||
|
grid = gridIndex(self.index, #self.party, direction)
|
||||||
|
end
|
||||||
|
if grid then
|
||||||
|
self.index = grid
|
||||||
|
self:storeCursor()
|
||||||
|
elseif input:wasPressed("up") then
|
||||||
self.index = self.index > 1 and self.index - 1 or total
|
self.index = self.index > 1 and self.index - 1 or total
|
||||||
elseif input:wasPressed("down") then
|
elseif input:wasPressed("down") then
|
||||||
self.index = self.index < total and self.index + 1 or 1
|
self.index = self.index < total and self.index + 1 or 1
|
||||||
|
|||||||
@@ -856,8 +856,8 @@ end
|
|||||||
|
|
||||||
-- -------------------------------------------------------------------- pager
|
-- -------------------------------------------------------------------- pager
|
||||||
-- Prev / Next / "1-12 of 151". Drawn even for a single page, so a list is
|
-- Prev / Next / "1-12 of 151". Drawn even for a single page, so a list is
|
||||||
-- never silently truncated. This is the ONLY way the launcher moves through
|
-- never silently truncated. A long list still PAGES rather than scrolling:
|
||||||
-- a long list: no scrollbars, no momentum, bounded row count per frame.
|
-- no momentum, bounded row count per frame.
|
||||||
-- Returns the new page (1-based) and the row height consumed.
|
-- Returns the new page (1-based) and the row height consumed.
|
||||||
local pagerLabels = {}
|
local pagerLabels = {}
|
||||||
|
|
||||||
@@ -930,6 +930,64 @@ function Kit.wheelPage(x, y, w, h, page, total, perPage)
|
|||||||
return math.floor(moved)
|
return math.floor(moved)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
function Kit.scrollExtent(contentH, viewH)
|
||||||
|
return math.max(0, (contentH or 0) - math.max(0, viewH or 0))
|
||||||
|
end
|
||||||
|
|
||||||
|
function Kit.scrollClamp(offset, maxScroll)
|
||||||
|
return math.max(0, math.min(offset or 0, math.max(0, maxScroll or 0)))
|
||||||
|
end
|
||||||
|
|
||||||
|
function Kit.scrollStep(scale)
|
||||||
|
return math.floor(48 * (scale or Kit.scale))
|
||||||
|
end
|
||||||
|
|
||||||
|
function Kit.scrollBarW(scale)
|
||||||
|
return math.max(2, math.floor(4 * (scale or Kit.scale)))
|
||||||
|
end
|
||||||
|
|
||||||
|
function Kit.scrollGutter(scale)
|
||||||
|
return Kit.scrollBarW(scale) + math.max(2, math.floor(4 * (scale or Kit.scale)))
|
||||||
|
end
|
||||||
|
|
||||||
|
function Kit.scrollHandoff(offset, maxScroll, delta)
|
||||||
|
local want = (offset or 0) + (delta or 0)
|
||||||
|
local at = Kit.scrollClamp(want, maxScroll)
|
||||||
|
return at, want - at
|
||||||
|
end
|
||||||
|
|
||||||
|
function Kit.scrollWheel(offset, maxScroll, x, y, w, h, step)
|
||||||
|
local at = Kit.scrollClamp(offset, maxScroll)
|
||||||
|
local wheel = Kit.wheelY or 0
|
||||||
|
if Kit.blockClicks or wheel == 0 or (maxScroll or 0) <= 0 then
|
||||||
|
return at, false
|
||||||
|
end
|
||||||
|
if not Kit.hit(x, y, w, h) then return at, false end
|
||||||
|
local moved = Kit.scrollClamp(at - wheel * (step or Kit.scrollStep()),
|
||||||
|
maxScroll)
|
||||||
|
if moved == at then return at, false end
|
||||||
|
Kit.wheelY = 0
|
||||||
|
return moved, true
|
||||||
|
end
|
||||||
|
|
||||||
|
function Kit.scrollBegin(x, y, w, h, offset, maxScroll)
|
||||||
|
Kit.pushClip(x, y, math.max(0, w or 0), math.max(0, h or 0))
|
||||||
|
return y - Kit.scrollClamp(offset, maxScroll)
|
||||||
|
end
|
||||||
|
|
||||||
|
function Kit.scrollEnd(x, y, w, h, offset, maxScroll)
|
||||||
|
Kit.popClip()
|
||||||
|
if (maxScroll or 0) <= 0 or (h or 0) <= 0 or (w or 0) <= 0 then return end
|
||||||
|
local barW = Kit.scrollBarW()
|
||||||
|
local barX = x + w - barW
|
||||||
|
local at = Kit.scrollClamp(offset, maxScroll)
|
||||||
|
local thumbH = math.max(math.floor(20 * Kit.scale),
|
||||||
|
math.floor(h * (h / (h + maxScroll))))
|
||||||
|
local thumbY = y + (h - thumbH) * (at / maxScroll)
|
||||||
|
Theme.fill(barX, y, barW, h, PAL.bg, 0.35)
|
||||||
|
Theme.fill(barX, thumbY, barW, thumbH, PAL.muted, 0.7)
|
||||||
|
end
|
||||||
|
|
||||||
-- ------------------------------------------------------------------ spinner
|
-- ------------------------------------------------------------------ spinner
|
||||||
-- The one animated element in the UI: a rotating arc of ticks. Drawn as N
|
-- The one animated element in the UI: a rotating arc of ticks. Drawn as N
|
||||||
-- short lines at descending alpha, which needs no shader, no canvas and no
|
-- short lines at descending alpha, which needs no shader, no canvas and no
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ local Collision = require("src.world.Collision")
|
|||||||
local Encounter = require("src.world.Encounter")
|
local Encounter = require("src.world.Encounter")
|
||||||
local FieldDefaults = require("src.world.FieldDefaults")
|
local FieldDefaults = require("src.world.FieldDefaults")
|
||||||
local GameVersion = require("src.core.GameVersion")
|
local GameVersion = require("src.core.GameVersion")
|
||||||
local GameViewport = require("src.render.GameViewport")
|
|
||||||
local Logger = require("src.core.Logger")
|
local Logger = require("src.core.Logger")
|
||||||
local Map = require("src.world.Map")
|
local Map = require("src.world.Map")
|
||||||
local MapLoader = require("src.world.MapLoader")
|
local MapLoader = require("src.world.MapLoader")
|
||||||
@@ -5196,7 +5195,7 @@ function OverworldState:drawWorld()
|
|||||||
-- point projects under the pipeline's own camera. That is the direct
|
-- point projects under the pipeline's own camera. That is the direct
|
||||||
-- analogue of what :billboard does for tilt, and it keeps exactly one
|
-- analogue of what :billboard does for tilt, and it keeps exactly one
|
||||||
-- copy of every effect: the closures above are the ones that run.
|
-- copy of every effect: the closures above are the ones that run.
|
||||||
local pw, ph = GameViewport.dimensions()
|
local _, _, pw, ph = Game.renderer:playfieldRect()
|
||||||
local pscale = Zoom.scale(Game.renderer:fitScale())
|
local pscale = Zoom.scale(Game.renderer:fitScale())
|
||||||
local ctx = {
|
local ctx = {
|
||||||
state = self, cam = cam, vw = vw, vh = vh, bgY = bgY,
|
state = self, cam = cam, vw = vw, vh = vh, bgY = bgY,
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ local Font = require("src.render.Font")
|
|||||||
-- a mod has taken a facade (src/mods/Gen2Compat.lua).
|
-- a mod has taken a facade (src/mods/Gen2Compat.lua).
|
||||||
local Gen1Facade = require("src.mods.Gen2Compat")
|
local Gen1Facade = require("src.mods.Gen2Compat")
|
||||||
local GbcPalette = require("src.render.GbcPalette")
|
local GbcPalette = require("src.render.GbcPalette")
|
||||||
local GameViewport = require("src.render.GameViewport")
|
local Playfield = require("src.render.Playfield")
|
||||||
local Gen2Save = require("src.core.gen2.Save")
|
local Gen2Save = require("src.core.gen2.Save")
|
||||||
local HallOfFame = require("src.core.gen2.HallOfFame")
|
local HallOfFame = require("src.core.gen2.HallOfFame")
|
||||||
local HiddenItems = require("src.world.gen2.HiddenItems")
|
local HiddenItems = require("src.world.gen2.HiddenItems")
|
||||||
@@ -7499,7 +7499,7 @@ function World:interactBody()
|
|||||||
end
|
end
|
||||||
|
|
||||||
function World:fitScale()
|
function World:fitScale()
|
||||||
local w, h = GameViewport.dimensions()
|
local w, h = Playfield.dimensions()
|
||||||
return math.max(1, math.floor(math.min(w / 160, h / 144)))
|
return math.max(1, math.floor(math.min(w / 160, h / 144)))
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -8336,7 +8336,7 @@ function World:rebuildNeighbors()
|
|||||||
self.neighbors = {}
|
self.neighbors = {}
|
||||||
if not self.map then return end
|
if not self.map then return end
|
||||||
local s = self:zoomScale()
|
local s = self:zoomScale()
|
||||||
local ww, wh = GameViewport.dimensions()
|
local ww, wh = Playfield.dimensions()
|
||||||
local vw = math.ceil(ww / s)
|
local vw = math.ceil(ww / s)
|
||||||
local vh = math.ceil(wh / s)
|
local vh = math.ceil(wh / s)
|
||||||
if vw % 2 ~= 0 then vw = vw + 1 end
|
if vw % 2 ~= 0 then vw = vw + 1 end
|
||||||
@@ -9748,7 +9748,7 @@ function World:drawGround(s)
|
|||||||
if canvas then
|
if canvas then
|
||||||
bw, bh = canvas:getDimensions()
|
bw, bh = canvas:getDimensions()
|
||||||
else
|
else
|
||||||
bw, bh = GameViewport.dimensions()
|
bw, bh = Playfield.dimensions()
|
||||||
end
|
end
|
||||||
if BorderFill.fillBlock(self.map.def) == false then
|
if BorderFill.fillBlock(self.map.def) == false then
|
||||||
-- BLACK: World:draw clears to a brown letterbox, so the void itself
|
-- BLACK: World:draw clears to a brown letterbox, so the void itself
|
||||||
@@ -10017,7 +10017,7 @@ end
|
|||||||
|
|
||||||
function World:draw()
|
function World:draw()
|
||||||
local G = love.graphics
|
local G = love.graphics
|
||||||
local w, h = GameViewport.dimensions()
|
local w, h = Playfield.dimensions()
|
||||||
self:refreshColorMode()
|
self:refreshColorMode()
|
||||||
G.clear(0.07, 0.05, 0.02, 1)
|
G.clear(0.07, 0.05, 0.02, 1)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,128 @@
|
|||||||
|
return function(game)
|
||||||
|
local U = dofile("tests/drivers/util.lua")
|
||||||
|
local RomImporter = require("src.import.RomImporter")
|
||||||
|
|
||||||
|
local dir = os.getenv("SHOT_DIR") or "/tmp/syncmodal"
|
||||||
|
os.execute('mkdir -p "' .. dir .. '" 2>/dev/null')
|
||||||
|
love.window.setMode(1024, 768, { resizable = true, highdpi = true })
|
||||||
|
U.wait(2)
|
||||||
|
|
||||||
|
local eng = {
|
||||||
|
phase = "idle", status = "Ready", conflicts = {}, state = { enabled = true },
|
||||||
|
isLinked = false, isBusy = false,
|
||||||
|
linked = function(self) return self.isLinked end,
|
||||||
|
busy = function(self) return self.isBusy end,
|
||||||
|
createAccount = function(self)
|
||||||
|
self.isLinked = true
|
||||||
|
self.codes = { code1 = "1234-5678", code2 = "8765-4321" }
|
||||||
|
self.status = "Sync account created"
|
||||||
|
return true
|
||||||
|
end,
|
||||||
|
linkDevice = function(self) self.isLinked = true return true end,
|
||||||
|
syncNow = function(self) self.status = "Checking for changes..." return true end,
|
||||||
|
unlink = function(self) self.isLinked, self.codes = false, nil return true end,
|
||||||
|
shareMods = function(self) self.shareCode = "K7QW3M" return true end,
|
||||||
|
fetchShare = function(self) return true end,
|
||||||
|
applyModPlan = function(self) self.modPlan = nil return true end,
|
||||||
|
resolveConflict = function(self) self.conflicts = {} self.phase = "idle" return true end,
|
||||||
|
}
|
||||||
|
|
||||||
|
local imp = RomImporter.new(function() end, { launcher = true })
|
||||||
|
imp._sync = eng
|
||||||
|
imp._syncTransportOk = true
|
||||||
|
|
||||||
|
local pending = nil
|
||||||
|
love.draw = function()
|
||||||
|
imp:draw()
|
||||||
|
if pending then
|
||||||
|
local path = pending
|
||||||
|
pending = nil
|
||||||
|
love.graphics.captureScreenshot(function(imagedata)
|
||||||
|
local f = io.open(path, "wb")
|
||||||
|
if f then f:write(imagedata:encode("png"):getString()) f:close() end
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
local function shot(name)
|
||||||
|
pending = dir .. "/" .. name
|
||||||
|
for _ = 1, 90 do
|
||||||
|
if not pending then break end
|
||||||
|
imp:update(1 / 60)
|
||||||
|
coroutine.yield()
|
||||||
|
end
|
||||||
|
U.wait(3)
|
||||||
|
local f = io.open(dir .. "/" .. name, "rb")
|
||||||
|
U.log(f and "shot" or "FAIL shot", name)
|
||||||
|
if f then f:close() end
|
||||||
|
end
|
||||||
|
|
||||||
|
imp:_openSync()
|
||||||
|
U.wait(3)
|
||||||
|
U.log("modal view:", imp._syncModal.view, "linked:", tostring(eng:linked()))
|
||||||
|
shot("sync_new.png")
|
||||||
|
|
||||||
|
imp:_syncView("link")
|
||||||
|
imp:_syncFocusField("code1")
|
||||||
|
imp:textinput("1234-5678")
|
||||||
|
imp:_syncFocusField("code2")
|
||||||
|
imp:textinput("8765ab4321")
|
||||||
|
U.wait(2)
|
||||||
|
U.log("codes typed:", imp._syncModal.code1, imp._syncModal.code2)
|
||||||
|
shot("sync_link.png")
|
||||||
|
|
||||||
|
imp:_syncView("home")
|
||||||
|
imp:_syncCreate()
|
||||||
|
U.wait(2)
|
||||||
|
U.log("codes shown:", eng.codes.code1, eng.codes.code2)
|
||||||
|
shot("sync_codes.png")
|
||||||
|
|
||||||
|
eng.isBusy = true
|
||||||
|
eng.status = "Uploading saves..."
|
||||||
|
U.wait(2)
|
||||||
|
shot("sync_busy.png")
|
||||||
|
eng.isBusy = false
|
||||||
|
eng.status = "Ready"
|
||||||
|
|
||||||
|
imp:_syncView("mods")
|
||||||
|
imp:_syncShareMods()
|
||||||
|
eng.modPlan = {
|
||||||
|
indexes = { "https://example.invalid/index.json" },
|
||||||
|
toInstall = { { id = "jp_green" }, { id = "randomizer" } },
|
||||||
|
toEnable = { { id = "jp_green", version = "red" } },
|
||||||
|
missing = { { id = "gone" } },
|
||||||
|
}
|
||||||
|
U.wait(2)
|
||||||
|
U.log("share code:", tostring(eng.shareCode))
|
||||||
|
shot("sync_mods.png")
|
||||||
|
|
||||||
|
eng.phase = "conflict"
|
||||||
|
eng.status = "These saves were played at the same time."
|
||||||
|
eng.conflicts = { {
|
||||||
|
key = "red/abcd1234", version = "red", overlap = true,
|
||||||
|
localMeta = { savedAt = os.time(), sessionStart = os.time() - 3600,
|
||||||
|
summary = { name = "ASH", badges = 3, timeText = "5:42", dexCount = 40 } },
|
||||||
|
remoteMeta = { savedAt = os.time() - 600, sessionStart = os.time() - 4200,
|
||||||
|
summary = { name = "ASH", badges = 4, timeText = "6:10", dexCount = 44 } },
|
||||||
|
} }
|
||||||
|
U.wait(2)
|
||||||
|
shot("sync_conflict.png")
|
||||||
|
|
||||||
|
love.window.setMode(520, 760, { resizable = true, highdpi = true })
|
||||||
|
U.wait(3)
|
||||||
|
shot("sync_conflict_narrow.png")
|
||||||
|
|
||||||
|
eng.phase = "idle"
|
||||||
|
eng.conflicts = {}
|
||||||
|
imp:_syncView("home")
|
||||||
|
U.wait(2)
|
||||||
|
shot("sync_home_narrow.png")
|
||||||
|
|
||||||
|
imp:_closeSync()
|
||||||
|
U.wait(2)
|
||||||
|
U.log("closed:", tostring(imp._syncModal == nil))
|
||||||
|
shot("sync_closed.png")
|
||||||
|
|
||||||
|
U.log("done")
|
||||||
|
love.event.quit()
|
||||||
|
while true do coroutine.yield() end
|
||||||
|
end
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
return function(game)
|
||||||
|
local U = dofile("tests/drivers/util.lua")
|
||||||
|
local RomImporter = require("src.import.RomImporter")
|
||||||
|
local TouchSkin = require("src.core.TouchSkin")
|
||||||
|
local Fetch = require("src.net.Fetch")
|
||||||
|
|
||||||
|
local dir = os.getenv("SHOT_DIR") or "/tmp/skinurl"
|
||||||
|
os.execute('mkdir -p "' .. dir .. '" 2>/dev/null')
|
||||||
|
love.window.setMode(1024, 768, { resizable = true, highdpi = true })
|
||||||
|
U.wait(2)
|
||||||
|
|
||||||
|
local imp = RomImporter.new(function() end, {
|
||||||
|
launcher = true,
|
||||||
|
onOpenSkinStudio = function() end,
|
||||||
|
})
|
||||||
|
|
||||||
|
local pending = nil
|
||||||
|
love.draw = function()
|
||||||
|
imp:draw()
|
||||||
|
if pending then
|
||||||
|
local path = pending
|
||||||
|
pending = nil
|
||||||
|
love.graphics.captureScreenshot(function(imagedata)
|
||||||
|
local f = io.open(path, "wb")
|
||||||
|
if f then f:write(imagedata:encode("png"):getString()) f:close() end
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
local function shot(name)
|
||||||
|
pending = dir .. "/" .. name
|
||||||
|
for _ = 1, 90 do
|
||||||
|
if not pending then break end
|
||||||
|
imp:update(1 / 60)
|
||||||
|
coroutine.yield()
|
||||||
|
end
|
||||||
|
U.wait(3)
|
||||||
|
local f = io.open(dir .. "/" .. name, "rb")
|
||||||
|
U.log(f and "shot" or "FAIL shot", name)
|
||||||
|
if f then f:close() end
|
||||||
|
end
|
||||||
|
|
||||||
|
imp:_switchTab("skins")
|
||||||
|
U.wait(3)
|
||||||
|
|
||||||
|
U.log("name from url:", RomImporter.skinUrlName(
|
||||||
|
"https://example.com/pads/Neon.deltaskin"))
|
||||||
|
U.log("name from cfg:", RomImporter.skinUrlName(
|
||||||
|
"https://example.com/overlay.cfg"))
|
||||||
|
|
||||||
|
imp.skinUrl = "https://example.com/pads/neon.deltaskin"
|
||||||
|
imp._skinUrlFocus = true
|
||||||
|
U.wait(2)
|
||||||
|
shot("skins_url_typed.png")
|
||||||
|
|
||||||
|
local realDownload, realPoll, realRelease =
|
||||||
|
Fetch.download, Fetch.poll, Fetch.release
|
||||||
|
local state = { status = "pending", progress = 0.4 }
|
||||||
|
Fetch.download = function(url, dest)
|
||||||
|
U.log("download:", url, "->", dest)
|
||||||
|
return 1
|
||||||
|
end
|
||||||
|
Fetch.poll = function() return state end
|
||||||
|
Fetch.release = function() end
|
||||||
|
|
||||||
|
imp._skinUrlFocus = false
|
||||||
|
imp:_addSkinFromUrl()
|
||||||
|
U.log("in flight:", tostring(imp._skinFetch ~= nil))
|
||||||
|
U.wait(2)
|
||||||
|
shot("skins_url_downloading.png")
|
||||||
|
|
||||||
|
state = { status = "error", err = "could not resolve host" }
|
||||||
|
imp:_pumpSkinFetch()
|
||||||
|
U.log("failure notice:", imp._skinNotice.text)
|
||||||
|
U.wait(2)
|
||||||
|
shot("skins_url_failed.png")
|
||||||
|
|
||||||
|
Fetch.download, Fetch.poll, Fetch.release = realDownload, realPoll, realRelease
|
||||||
|
|
||||||
|
local staged = TouchSkin.export(
|
||||||
|
assert(TouchSkin.load("assets/skins/gb_anim", "gb_anim")),
|
||||||
|
"skins/url_probe.zip")
|
||||||
|
local raw = love.filesystem.read("skins/url_probe.zip")
|
||||||
|
love.filesystem.remove("skins/url_probe.zip")
|
||||||
|
U.log("staged:", tostring(staged), "bytes:", raw and #raw or 0)
|
||||||
|
imp:_installSkinData("downloaded_pad.zip", raw)
|
||||||
|
U.log("install notice:", imp._skinNotice.text)
|
||||||
|
U.wait(2)
|
||||||
|
shot("skins_url_installed.png")
|
||||||
|
|
||||||
|
local skins = imp:_ensureSkins(true)
|
||||||
|
for _, e in ipairs(skins) do
|
||||||
|
U.log((" %s format=%s buttons=%d"):format(e.id, tostring(e.format),
|
||||||
|
e.controls))
|
||||||
|
end
|
||||||
|
|
||||||
|
imp._skinActions = { id = skins[1] and skins[1].id }
|
||||||
|
U.wait(2)
|
||||||
|
shot("skins_actions_sheet.png")
|
||||||
|
|
||||||
|
for _, kind in ipairs({ "native", "retroarch", "delta" }) do
|
||||||
|
local path = imp:_exportSkin(skins[1].id, kind)
|
||||||
|
U.log("export " .. kind .. ":", tostring(path))
|
||||||
|
end
|
||||||
|
imp._skinActions = nil
|
||||||
|
U.wait(2)
|
||||||
|
shot("skins_exported.png")
|
||||||
|
|
||||||
|
love.window.setMode(520, 820, { resizable = true, highdpi = true })
|
||||||
|
U.wait(3)
|
||||||
|
shot("skins_url_narrow.png")
|
||||||
|
|
||||||
|
U.log("done")
|
||||||
|
love.event.quit()
|
||||||
|
while true do coroutine.yield() end
|
||||||
|
end
|
||||||
@@ -0,0 +1,227 @@
|
|||||||
|
return function(game)
|
||||||
|
local U = dofile("tests/drivers/util.lua")
|
||||||
|
local TouchControls = require("src.core.TouchControls")
|
||||||
|
local TouchSkin = require("src.core.TouchSkin")
|
||||||
|
local Playfield = require("src.render.Playfield")
|
||||||
|
|
||||||
|
local dir = os.getenv("SHOT_DIR") or "/tmp/skin-cutout"
|
||||||
|
local gen2 = game.overworld == nil
|
||||||
|
local failures, checks = 0, 0
|
||||||
|
|
||||||
|
local SKIN = [[
|
||||||
|
return {
|
||||||
|
name = "containment_probe",
|
||||||
|
pages = {
|
||||||
|
{
|
||||||
|
name = "probe",
|
||||||
|
fullScreen = true,
|
||||||
|
viewport = { x = 0.25, y = 0.1, w = 0.5, h = 0.6 },
|
||||||
|
controls = { { bind = "nul", x = 0.5, y = 0.92, w = 0.04, h = 0.04 } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
]]
|
||||||
|
love.filesystem.createDirectory("skins/containment_probe")
|
||||||
|
assert(love.filesystem.write("skins/containment_probe/skin.lua", SKIN))
|
||||||
|
|
||||||
|
love.window.setMode(1280, 720, { resizable = true, highdpi = true })
|
||||||
|
love.graphics.setBackgroundColor(0, 0, 0, 1)
|
||||||
|
U.wait(2)
|
||||||
|
|
||||||
|
local options = gen2 and game.options or game.save.options
|
||||||
|
options.touchControls = { enabled = true, skin = "containment_probe" }
|
||||||
|
options.tilt = 0
|
||||||
|
options.zoom = 0
|
||||||
|
options.pipelines = {}
|
||||||
|
options.videoMode = "windowed"
|
||||||
|
options.faithfulRes = 0
|
||||||
|
game:applyOptions()
|
||||||
|
love.window.setMode(1280, 720, { resizable = true, highdpi = true })
|
||||||
|
U.wait(4)
|
||||||
|
|
||||||
|
U.log("gen:", gen2 and 2 or 1, "skin:", tostring(TouchControls.skinId),
|
||||||
|
"err:", tostring(TouchControls.skinError))
|
||||||
|
U.log("drawable:", TouchSkin.drawable(), "hasViewport:", TouchSkin.hasViewport())
|
||||||
|
|
||||||
|
local function cutoutPx()
|
||||||
|
local pw, ph = love.graphics.getPixelDimensions()
|
||||||
|
local x, y, w, h = Playfield.cutout(pw, ph)
|
||||||
|
return x, y, w, h, pw, ph
|
||||||
|
end
|
||||||
|
|
||||||
|
local cx, cy, cw, ch, pw, ph = cutoutPx()
|
||||||
|
if not cx then
|
||||||
|
U.log("FAIL no cutout is active; nothing to prove")
|
||||||
|
love.event.quit()
|
||||||
|
while true do coroutine.yield() end
|
||||||
|
end
|
||||||
|
U.log(("cutout px: %d,%d %dx%d in %dx%d"):format(cx, cy, cw, ch, pw, ph))
|
||||||
|
|
||||||
|
local INSET = 4
|
||||||
|
local function scan(label, data)
|
||||||
|
local w, h = data:getWidth(), data:getHeight()
|
||||||
|
local sx, sy = w / pw, h / ph
|
||||||
|
local x1, y1 = math.floor(cx * sx) - INSET, math.floor(cy * sy) - INSET
|
||||||
|
local x2 = math.ceil((cx + cw) * sx) + INSET
|
||||||
|
local y2 = math.ceil((cy + ch) * sy) + INSET
|
||||||
|
local bad, firstX, firstY, worst = 0, nil, nil, 0
|
||||||
|
local inked = 0
|
||||||
|
local step = math.max(2, math.floor(math.min(w, h) / 360))
|
||||||
|
for y = 0, h - 1, step do
|
||||||
|
for x = 0, w - 1, step do
|
||||||
|
local r, g, b = data:getPixel(x, y)
|
||||||
|
local lit = math.max(r, g, b)
|
||||||
|
local outside = x < x1 or x >= x2 or y < y1 or y >= y2
|
||||||
|
if outside then
|
||||||
|
if lit > 0.02 then
|
||||||
|
bad = bad + 1
|
||||||
|
if not firstX then firstX, firstY = x, y end
|
||||||
|
if lit > worst then worst = lit end
|
||||||
|
end
|
||||||
|
elseif lit > 0.02 then
|
||||||
|
inked = inked + 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
checks = checks + 1
|
||||||
|
if bad > 0 then
|
||||||
|
failures = failures + 1
|
||||||
|
U.log(("FAIL %s: %d lit samples outside the cutout (first %d,%d, max %.2f)")
|
||||||
|
:format(label, bad, firstX, firstY, worst))
|
||||||
|
elseif inked == 0 then
|
||||||
|
failures = failures + 1
|
||||||
|
U.log("FAIL " .. label .. ": nothing drew inside the cutout either")
|
||||||
|
else
|
||||||
|
U.log(("ok %s: contained (%d lit samples inside)"):format(label, inked))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local pending = nil
|
||||||
|
local function probe(label)
|
||||||
|
U.wait(2)
|
||||||
|
pending = label
|
||||||
|
love.graphics.captureScreenshot(function(data)
|
||||||
|
scan(pending, data)
|
||||||
|
pending = nil
|
||||||
|
end)
|
||||||
|
for _ = 1, 180 do
|
||||||
|
if not pending then break end
|
||||||
|
coroutine.yield()
|
||||||
|
end
|
||||||
|
if pending then
|
||||||
|
failures = failures + 1
|
||||||
|
U.log("FAIL " .. tostring(pending) .. ": screenshot never arrived")
|
||||||
|
pending = nil
|
||||||
|
end
|
||||||
|
if os.getenv("SHOT_PNG") == "1" then
|
||||||
|
U.shot(game, ("%s/%s.png"):format(dir, label:gsub("[^%w]+", "_")))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
if gen2 then
|
||||||
|
for i = 1, 2 do
|
||||||
|
probe("gold_boot_" .. i)
|
||||||
|
U.wait(60)
|
||||||
|
end
|
||||||
|
for _ = 1, 240 do
|
||||||
|
if game.world and game.world.map then break end
|
||||||
|
game.input.pressQueue[#game.input.pressQueue + 1] = "start"
|
||||||
|
U.wait(4)
|
||||||
|
end
|
||||||
|
if game.world and game.world.map then
|
||||||
|
probe("gold_overworld")
|
||||||
|
local Zoom = require("src.render.Zoom")
|
||||||
|
Zoom.allowSurvey = true
|
||||||
|
for _, off in ipairs({ -2, -1, 1, 2 }) do
|
||||||
|
Zoom.offset = off
|
||||||
|
probe("gold_zoom_" .. (off < 0 and "out" or "in") .. math.abs(off))
|
||||||
|
end
|
||||||
|
Zoom.offset = 0
|
||||||
|
local function tap(button, frames)
|
||||||
|
game.input.pressQueue[#game.input.pressQueue + 1] = button
|
||||||
|
game.input.state[button] = true
|
||||||
|
U.wait(2)
|
||||||
|
game.input.state[button] = false
|
||||||
|
U.wait(frames or 12)
|
||||||
|
end
|
||||||
|
tap("start", 24)
|
||||||
|
probe("gold_start_menu")
|
||||||
|
tap("b", 12)
|
||||||
|
probe("gold_after_menu")
|
||||||
|
else
|
||||||
|
U.log("FAIL gold world never booted")
|
||||||
|
failures = failures + 1
|
||||||
|
end
|
||||||
|
else
|
||||||
|
local Pokemon = require("src.pokemon.Pokemon")
|
||||||
|
game.save.party = { Pokemon.new(game.data, "CHARIZARD", 50) }
|
||||||
|
game.save.player.name = "bryan"
|
||||||
|
U.teleport(game, "PALLET_TOWN", 10, 8, "down")
|
||||||
|
U.wait(12)
|
||||||
|
probe("red_overworld")
|
||||||
|
|
||||||
|
local Zoom = require("src.render.Zoom")
|
||||||
|
local Renderer = require("src.render.Renderer")
|
||||||
|
Zoom.allowSurvey = true
|
||||||
|
local lo, hi = Zoom.offsetRange(Renderer:fitScale())
|
||||||
|
for off = lo, hi do
|
||||||
|
Zoom.offset = off
|
||||||
|
probe("red_zoom_" .. Zoom.offsetLabel(off))
|
||||||
|
end
|
||||||
|
Zoom.offset = 0
|
||||||
|
|
||||||
|
U.tap(game, "start")
|
||||||
|
U.wait(20)
|
||||||
|
probe("red_start_menu")
|
||||||
|
|
||||||
|
local function stress(label, mutate)
|
||||||
|
local state = game.stack:top()
|
||||||
|
local original = state.draw
|
||||||
|
state.draw = function(...)
|
||||||
|
original(...)
|
||||||
|
mutate()
|
||||||
|
end
|
||||||
|
probe(label)
|
||||||
|
state.draw = original
|
||||||
|
end
|
||||||
|
stress("red_screen_veil", function()
|
||||||
|
Renderer.screenVeil = { 1, 0.85 }
|
||||||
|
end)
|
||||||
|
stress("red_battle_wipe", function()
|
||||||
|
Renderer.battleWipe = { style = "spiralin", prog = 0.45 }
|
||||||
|
end)
|
||||||
|
stress("red_letterbox_paper", function()
|
||||||
|
Renderer.extendedWorldBand = true
|
||||||
|
end)
|
||||||
|
stress("red_ui_anchor", function()
|
||||||
|
Renderer.uiCentered = false
|
||||||
|
Renderer:setUIAnchor(0, 96, 160, 48, "bottom")
|
||||||
|
end)
|
||||||
|
U.tap(game, "b")
|
||||||
|
U.wait(10)
|
||||||
|
|
||||||
|
game.save.options.uiLayout = "dynamic"
|
||||||
|
game:applyOptions()
|
||||||
|
U.tap(game, "start")
|
||||||
|
U.wait(20)
|
||||||
|
probe("red_dynamic_start_menu")
|
||||||
|
U.tap(game, "b")
|
||||||
|
U.wait(10)
|
||||||
|
game.save.options.uiLayout = "centered"
|
||||||
|
game:applyOptions()
|
||||||
|
|
||||||
|
local Tilt = require("src.render.Tilt")
|
||||||
|
game.save.options.tilt = 1
|
||||||
|
Tilt.applyOptions(game.save.options)
|
||||||
|
U.wait(30)
|
||||||
|
probe("red_tilt")
|
||||||
|
game.save.options.tilt = 0
|
||||||
|
Tilt.applyOptions(game.save.options)
|
||||||
|
U.wait(20)
|
||||||
|
end
|
||||||
|
|
||||||
|
U.log(("done: %d/%d frames contained, %d failures")
|
||||||
|
:format(checks - failures, checks, failures))
|
||||||
|
love.event.quit()
|
||||||
|
while true do coroutine.yield() end
|
||||||
|
end
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
-- Test returning to launcher from Gen 1 and Gen 2 on Android without closing the process
|
||||||
|
-- luajit tests/engine/android_exit_to_launcher_test.lua
|
||||||
|
|
||||||
|
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||||
|
|
||||||
|
local T = require("tests.harness")
|
||||||
|
local check, eq = T.check, T.eq
|
||||||
|
love = love or require("tests.love_stub")
|
||||||
|
|
||||||
|
local TitleState = require("src.ui.TitleState")
|
||||||
|
local Gen2MainMenu = require("src.ui.gen2.MainMenu")
|
||||||
|
local Runtime = require("src.mods.Runtime")
|
||||||
|
|
||||||
|
-- 1. Test Gen 1 TitleState onExit callback support
|
||||||
|
do
|
||||||
|
local exitCalled = false
|
||||||
|
local dummyGame = {
|
||||||
|
data = { field = {} },
|
||||||
|
stack = {
|
||||||
|
states = {},
|
||||||
|
push = function(self, state) table.insert(self.states, state) end,
|
||||||
|
top = function(self) return self.states[#self.states] end,
|
||||||
|
pop = function(self) return table.remove(self.states) end,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
local state = TitleState.new(dummyGame, {
|
||||||
|
onExit = function()
|
||||||
|
exitCalled = true
|
||||||
|
end,
|
||||||
|
})
|
||||||
|
state:openMenu()
|
||||||
|
local menu = dummyGame.stack:top()
|
||||||
|
check(menu ~= nil, "TitleState:openMenu opens a menu")
|
||||||
|
local exitItem = nil
|
||||||
|
for _, item in ipairs(menu.items or {}) do
|
||||||
|
if tostring(item.label):find("EXIT", 1, true) then
|
||||||
|
exitItem = item
|
||||||
|
break
|
||||||
|
end
|
||||||
|
end
|
||||||
|
check(exitItem ~= nil, "Gen 1 TitleState menu contains an EXIT GAME item")
|
||||||
|
if exitItem and exitItem.onSelect then
|
||||||
|
exitItem.onSelect()
|
||||||
|
end
|
||||||
|
check(exitCalled, "Selecting EXIT GAME in Gen 1 TitleState invokes onExit callback")
|
||||||
|
end
|
||||||
|
|
||||||
|
-- 2. Test Gen 2 MainMenu onExit callback support
|
||||||
|
do
|
||||||
|
local exitCalled = false
|
||||||
|
local dummyGame2 = {
|
||||||
|
data = {},
|
||||||
|
stack = {
|
||||||
|
states = {},
|
||||||
|
push = function(self, state) table.insert(self.states, state) end,
|
||||||
|
top = function(self) return self.states[#self.states] end,
|
||||||
|
pop = function(self) return table.remove(self.states) end,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
local menu = Gen2MainMenu.new(dummyGame2, {
|
||||||
|
hasSave = false,
|
||||||
|
onExit = function()
|
||||||
|
exitCalled = true
|
||||||
|
end,
|
||||||
|
})
|
||||||
|
menu:choose("exit")
|
||||||
|
check(exitCalled, "Selecting EXIT GAME in Gen 2 MainMenu invokes onExit callback")
|
||||||
|
end
|
||||||
|
|
||||||
|
-- 3. Test Runtime.reset restores NullEvents and NullHooks
|
||||||
|
do
|
||||||
|
Runtime.install({ emit = function() end }, { call = function() end }, { "err" })
|
||||||
|
check(Runtime.errors ~= nil, "Runtime has errors list after install")
|
||||||
|
Runtime.reset()
|
||||||
|
check(Runtime.errors == nil, "Runtime.reset clears errors")
|
||||||
|
check(Runtime.currentMod == nil, "Runtime.reset clears currentMod")
|
||||||
|
check(Runtime.modRequire == nil, "Runtime.reset clears modRequire")
|
||||||
|
end
|
||||||
|
|
||||||
|
T.finish("android_exit_to_launcher_test")
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
-- tests/engine/android_shortcuts_payload_test.lua
|
||||||
|
-- Tests Android dynamic shortcuts synchronization, launch options intent resolution,
|
||||||
|
-- and love.handlers.intent_game in-process game hot-swapping.
|
||||||
|
-- luajit tests/engine/android_shortcuts_payload_test.lua
|
||||||
|
|
||||||
|
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||||
|
|
||||||
|
local T = require("tests.harness")
|
||||||
|
local check, eq = T.check, T.eq
|
||||||
|
love = love or require("tests.love_stub")
|
||||||
|
|
||||||
|
require("main")
|
||||||
|
|
||||||
|
-- 1. Verify LaunchOptions handles getLaunchGame on Android
|
||||||
|
local LaunchOptions = require("src.core.LaunchOptions")
|
||||||
|
|
||||||
|
local savedOS = love.system and love.system.getOS
|
||||||
|
local savedGetLaunchGame = love.system and love.system.getLaunchGame
|
||||||
|
|
||||||
|
love.system = love.system or {}
|
||||||
|
love.system.getOS = function() return "Android" end
|
||||||
|
love.system.getLaunchGame = function() return "gold" end
|
||||||
|
|
||||||
|
local game, slot = LaunchOptions.resolve({})
|
||||||
|
check(game == "gold", "LaunchOptions resolves intent game from love.system.getLaunchGame on Android")
|
||||||
|
|
||||||
|
local gameCli, slotCli = LaunchOptions.resolve({ "--game=red" })
|
||||||
|
check(gameCli == "red", "CLI --game flag overrides intent game")
|
||||||
|
|
||||||
|
-- 2. Verify RomImporter.syncAndroidShortcuts ranking and 4-item cap
|
||||||
|
local RomImporter = require("src.import.RomImporter")
|
||||||
|
|
||||||
|
local originalIsReady = RomImporter.isReady
|
||||||
|
local capturedShortcuts = nil
|
||||||
|
|
||||||
|
love.system.updateShortcuts = function(versions)
|
||||||
|
capturedShortcuts = versions
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Mock isReady
|
||||||
|
RomImporter.isReady = function(v)
|
||||||
|
return v == "red" or v == "gold" or v == "blue" or v == "yellow"
|
||||||
|
end
|
||||||
|
|
||||||
|
local ok = RomImporter.syncAndroidShortcuts("gold")
|
||||||
|
check(ok == true, "syncAndroidShortcuts returns true on Android")
|
||||||
|
check(#capturedShortcuts == 4, "syncAndroidShortcuts caps at 4 items")
|
||||||
|
check(capturedShortcuts[1] == "gold", "activeVersion 'gold' is placed first")
|
||||||
|
|
||||||
|
-- Test with subset of ready games (e.g. only Red and Gold)
|
||||||
|
RomImporter.isReady = function(v)
|
||||||
|
return v == "red" or v == "gold"
|
||||||
|
end
|
||||||
|
|
||||||
|
capturedShortcuts = nil
|
||||||
|
RomImporter.syncAndroidShortcuts("red")
|
||||||
|
check(#capturedShortcuts == 2, "syncAndroidShortcuts only includes ready ROMs")
|
||||||
|
check(capturedShortcuts[1] == "red" and capturedShortcuts[2] == "gold", "ready ROMs correctly passed")
|
||||||
|
|
||||||
|
-- Test on non-Android platform (safe no-op)
|
||||||
|
love.system.getOS = function() return "Linux" end
|
||||||
|
capturedShortcuts = nil
|
||||||
|
local nonAndroidOk = RomImporter.syncAndroidShortcuts("red")
|
||||||
|
check(nonAndroidOk == false, "syncAndroidShortcuts safely no-ops on non-Android")
|
||||||
|
check(capturedShortcuts == nil, "no shortcuts updated on non-Android")
|
||||||
|
|
||||||
|
-- 3. Verify love.handlers.intent_game definition
|
||||||
|
check(type(love.handlers.intent_game) == "function", "main.lua defines love.handlers.intent_game")
|
||||||
|
|
||||||
|
-- Restore
|
||||||
|
RomImporter.isReady = originalIsReady
|
||||||
|
if savedOS then
|
||||||
|
love.system.getOS = savedOS
|
||||||
|
else
|
||||||
|
love.system.getOS = nil
|
||||||
|
end
|
||||||
|
love.system.getLaunchGame = savedGetLaunchGame
|
||||||
|
love.system.updateShortcuts = nil
|
||||||
|
|
||||||
|
print("8/8 checks passed (android_shortcuts_payload_test)")
|
||||||
@@ -397,7 +397,8 @@ local GEN2_HOOKS = {
|
|||||||
"world.tod", "map.palette", "fieldmove.eligibility",
|
"world.tod", "map.palette", "fieldmove.eligibility",
|
||||||
-- menus and the battle intro
|
-- menus and the battle intro
|
||||||
"ui.start_menu.items", "ui.title_menu.items", "ui.options.rows",
|
"ui.start_menu.items", "ui.title_menu.items", "ui.options.rows",
|
||||||
"ui.party.submenu", "ui.naming.grid", "ui.pc.items", "ui.list_menu",
|
"ui.party.submenu", "ui.party.grid_navigation", "ui.naming.grid",
|
||||||
|
"ui.pc.items", "ui.list_menu",
|
||||||
"transition.style",
|
"transition.style",
|
||||||
-- battle
|
-- battle
|
||||||
"battle.damage", "battle.crit", "battle.accuracy", "battle.turn_order",
|
"battle.damage", "battle.crit", "battle.accuracy", "battle.turn_order",
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||||
|
|
||||||
|
local T = require("tests.harness")
|
||||||
|
local check, eq = T.check, T.eq
|
||||||
|
love = love or require("tests.love_stub")
|
||||||
|
|
||||||
|
local SaveData = require("src.core.SaveData")
|
||||||
|
local Save = require("src.core.gen2.Save")
|
||||||
|
|
||||||
|
local function memfs()
|
||||||
|
local files = {}
|
||||||
|
return {
|
||||||
|
files = files,
|
||||||
|
write = function(path, content) files[path] = content return true end,
|
||||||
|
read = function(path) return files[path] end,
|
||||||
|
remove = function(path) files[path] = nil return true end,
|
||||||
|
getInfo = function(path)
|
||||||
|
if files[path] ~= nil then return { type = "file" } end
|
||||||
|
return nil
|
||||||
|
end,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
local fs = memfs()
|
||||||
|
SaveData.saveOptions(SaveData.defaultOptions(), fs)
|
||||||
|
local opts = Save.loadOptions(fs)
|
||||||
|
opts.modOptions = { nuzlocke = { dupes = true } }
|
||||||
|
opts.modProfiles = { { name = "casual", enabled = {} } }
|
||||||
|
opts.activeProfile = "casual"
|
||||||
|
opts.mods = { nuzlocke = true }
|
||||||
|
opts.modsByVersion = { gold = { hardmode = true } }
|
||||||
|
opts.textSpeed = "SLOW"
|
||||||
|
check(Save.saveOptions(opts, fs), "gold options write lands")
|
||||||
|
|
||||||
|
local file = SaveData.loadOptions(fs)
|
||||||
|
eq(file.modOptions and file.modOptions.nuzlocke and file.modOptions.nuzlocke.dupes,
|
||||||
|
true, "modOptions lands flat where gen1 and the launcher read it")
|
||||||
|
eq(file.activeProfile, "casual", "activeProfile lands flat")
|
||||||
|
eq(file.modProfiles and file.modProfiles[1] and file.modProfiles[1].name,
|
||||||
|
"casual", "modProfiles lands flat")
|
||||||
|
eq(file.mods and file.mods.nuzlocke, true, "enable flags land flat")
|
||||||
|
eq(file.modsByVersion and file.modsByVersion.gold
|
||||||
|
and file.modsByVersion.gold.hardmode, true, "per-version flags land flat")
|
||||||
|
eq(file[Save.OPTIONS_KEY].modOptions, nil, "gold block no longer traps modOptions")
|
||||||
|
eq(file[Save.OPTIONS_KEY].activeProfile, nil,
|
||||||
|
"gold block no longer traps activeProfile")
|
||||||
|
eq(file[Save.OPTIONS_KEY].textSpeed, "SLOW", "gold-only keys stay in the gold block")
|
||||||
|
|
||||||
|
local back = Save.loadOptions(fs)
|
||||||
|
eq(back.modOptions.nuzlocke.dupes, true, "flat modOptions round-trips into gold's table")
|
||||||
|
eq(back.activeProfile, "casual", "flat activeProfile round-trips")
|
||||||
|
|
||||||
|
local fs2 = memfs()
|
||||||
|
fs2.files["options.lua"] = [[return { gold = { textSpeed = "FAST",
|
||||||
|
modOptions = { nuzlocke = { dupes = true } }, activeProfile = "old" } }]]
|
||||||
|
local legacy = Save.loadOptions(fs2)
|
||||||
|
eq(legacy.modOptions and legacy.modOptions.nuzlocke
|
||||||
|
and legacy.modOptions.nuzlocke.dupes, true,
|
||||||
|
"modOptions trapped in the gold block migrates out")
|
||||||
|
eq(legacy.activeProfile, "old", "trapped activeProfile migrates")
|
||||||
|
eq(legacy.textSpeed, "FAST", "gold-only keys still merge")
|
||||||
|
check(Save.saveOptions(legacy, fs2), "migrated write lands")
|
||||||
|
local migrated = SaveData.loadOptions(fs2)
|
||||||
|
eq(migrated.modOptions and migrated.modOptions.nuzlocke.dupes, true,
|
||||||
|
"migration lands the trapped store flat")
|
||||||
|
eq(migrated[Save.OPTIONS_KEY].modOptions, nil, "migration empties the trap")
|
||||||
|
|
||||||
|
local fs3 = memfs()
|
||||||
|
fs3.files["options.lua"] = [[return { modOptions = { nuzlocke = { dupes = false } },
|
||||||
|
gold = { modOptions = { nuzlocke = { dupes = true } } } }]]
|
||||||
|
local both = Save.loadOptions(fs3)
|
||||||
|
eq(both.modOptions.nuzlocke.dupes, false, "flat modOptions wins over a trapped copy")
|
||||||
|
|
||||||
|
local Game2 = require("src.core.Game2")
|
||||||
|
check(type(Game2.writeOptions) == "function", "Game2 exposes writeOptions")
|
||||||
|
eq(Game2.writeOptions, Game2.persistOptions, "writeOptions is the persist path")
|
||||||
|
|
||||||
|
local ManagerState = require("src.mods.ManagerState")
|
||||||
|
local wrote = false
|
||||||
|
ManagerState.persistOptions({ game = { writeOptions = function() wrote = true end } })
|
||||||
|
check(wrote, "ManagerState:persistOptions writes through game.writeOptions")
|
||||||
|
|
||||||
|
T.finish("gen2_mod_options_persist")
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||||
|
|
||||||
|
local T = require("tests.harness")
|
||||||
|
local check, eq = T.check, T.eq
|
||||||
|
love = love or require("tests.love_stub")
|
||||||
|
|
||||||
|
local SaveData = require("src.core.SaveData")
|
||||||
|
local Save = require("src.core.gen2.Save")
|
||||||
|
|
||||||
|
local function memfs()
|
||||||
|
local files = {}
|
||||||
|
return {
|
||||||
|
files = files,
|
||||||
|
write = function(path, content) files[path] = content return true end,
|
||||||
|
read = function(path) return files[path] end,
|
||||||
|
remove = function(path) files[path] = nil return true end,
|
||||||
|
getInfo = function(path)
|
||||||
|
if files[path] ~= nil then return { type = "file" } end
|
||||||
|
return nil
|
||||||
|
end,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
local fs = memfs()
|
||||||
|
local seed = SaveData.defaultOptions()
|
||||||
|
seed.touchControls = { enabled = true, skin = "gb_anim" }
|
||||||
|
seed.haptics = "off"
|
||||||
|
seed[Save.OPTIONS_KEY] = { textSpeed = "FAST", touchControls = { enabled = false } }
|
||||||
|
check(SaveData.saveOptions(seed, fs) ~= nil, "seed write lands")
|
||||||
|
|
||||||
|
local opts = Save.loadOptions(fs)
|
||||||
|
eq(opts.touchControls and opts.touchControls.skin, "gb_anim",
|
||||||
|
"gold sees the skin the launcher picked")
|
||||||
|
eq(opts.touchControls.enabled, true, "top-level touchControls wins over the gold block")
|
||||||
|
eq(opts.haptics, "off", "top-level haptics wins over the gold default")
|
||||||
|
eq(opts.textSpeed, "FAST", "gold-block keys still merge")
|
||||||
|
|
||||||
|
local fs2 = memfs()
|
||||||
|
fs2.files["options.lua"] =
|
||||||
|
"return { gold = { touchControls = { enabled = false } } }"
|
||||||
|
local opts2 = Save.loadOptions(fs2)
|
||||||
|
eq(opts2.touchControls and opts2.touchControls.enabled, true,
|
||||||
|
"shared touchControls (default-folded) wins over a stale gold-block copy")
|
||||||
|
|
||||||
|
local fs3 = memfs()
|
||||||
|
SaveData.saveOptions(SaveData.defaultOptions(), fs3)
|
||||||
|
local gopts = Save.loadOptions(fs3)
|
||||||
|
gopts.touchControls = { enabled = true, skin = "tv_crt" }
|
||||||
|
gopts.haptics = "strong"
|
||||||
|
gopts.textSpeed = "SLOW"
|
||||||
|
check(Save.saveOptions(gopts, fs3), "gold options write lands")
|
||||||
|
|
||||||
|
local file = SaveData.loadOptions(fs3)
|
||||||
|
eq(file.touchControls and file.touchControls.skin, "tv_crt",
|
||||||
|
"gold's touch pick lands on the shared top-level key")
|
||||||
|
eq(file.haptics, "strong", "gold's haptics lands on the shared top-level key")
|
||||||
|
eq(file[Save.OPTIONS_KEY].touchControls, nil, "gold block no longer shadows touchControls")
|
||||||
|
eq(file[Save.OPTIONS_KEY].haptics, nil, "gold block no longer shadows haptics")
|
||||||
|
eq(file[Save.OPTIONS_KEY].textSpeed, "SLOW", "gold-only keys stay in the gold block")
|
||||||
|
|
||||||
|
local g1 = Save.loadOptions(fs3)
|
||||||
|
eq(g1.touchControls.skin, "tv_crt", "hoisted value round-trips back into gold")
|
||||||
|
|
||||||
|
local TouchSkin = require("src.core.TouchSkin")
|
||||||
|
local Chrome = require("src.ui.gen2.Chrome")
|
||||||
|
|
||||||
|
local savedViewport = TouchSkin.viewport
|
||||||
|
TouchSkin.viewport = function() return nil end
|
||||||
|
eq(Chrome.fitScale(640, 576), 4, "no cutout: integer fit against the window")
|
||||||
|
local ox, oy = Chrome.fitOrigin(640, 576)
|
||||||
|
eq(ox, 0, "no cutout: centred x")
|
||||||
|
eq(oy, 0, "no cutout: centred y")
|
||||||
|
|
||||||
|
TouchSkin.viewport = function(w, h) return w * 0.25, h * 0.125, w * 0.5, h * 0.5 end
|
||||||
|
eq(Chrome.fitScale(640, 576), 2, "cutout: integer fit against the cutout rect")
|
||||||
|
local cx, cy = Chrome.fitOrigin(640, 576)
|
||||||
|
eq(cx, 160 + (320 - 320) / 2, "cutout: origin starts at the cutout")
|
||||||
|
eq(cy, 72 + math.floor((288 - 288) / 2), "cutout: origin starts at the cutout y")
|
||||||
|
|
||||||
|
TouchSkin.viewport = function() error("boom") end
|
||||||
|
eq(Chrome.fitScale(640, 576), 4, "a throwing viewport degrades to the window fit")
|
||||||
|
|
||||||
|
TouchSkin.viewport = savedViewport
|
||||||
|
|
||||||
|
T.finish("gen2_touch_skin_options")
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||||
|
|
||||||
|
local T = require("tests.harness")
|
||||||
|
local check, eq = T.check, T.eq
|
||||||
|
love = love or require("tests.love_stub")
|
||||||
|
|
||||||
|
local HostShell = require("src.core.HostShell")
|
||||||
|
|
||||||
|
local TOKEN = "0123456789abcdef0123456789abcdef"
|
||||||
|
local BODY = '{"blob":"return {}"}'
|
||||||
|
|
||||||
|
HostShell.haveCurl = function() return false end
|
||||||
|
love.system.getOS = function() return "Android" end
|
||||||
|
|
||||||
|
local calls = {}
|
||||||
|
local reply = "STATUS 200\n" .. '{"ok":true}'
|
||||||
|
|
||||||
|
love.system.httpRequest = function(url, method, headers, body, userAgent)
|
||||||
|
calls[#calls + 1] = { url = url, method = method, headers = headers,
|
||||||
|
body = body, userAgent = userAgent }
|
||||||
|
if type(reply) == "function" then return reply() end
|
||||||
|
return reply
|
||||||
|
end
|
||||||
|
|
||||||
|
check(HostShell.canHttpRequest(),
|
||||||
|
"the bridge counts as a request transport where curl does not exist")
|
||||||
|
|
||||||
|
local body, err, code = HostShell.httpRequest("https://sync.example/sync/save", {
|
||||||
|
method = "PUT",
|
||||||
|
body = BODY,
|
||||||
|
headers = {
|
||||||
|
["x-sync-account"] = "aa11bb22cc33dd44",
|
||||||
|
["x-sync-token"] = TOKEN,
|
||||||
|
["Content-Type"] = "application/json",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
eq(code, 200, "a bridge request completes: " .. tostring(err))
|
||||||
|
eq(body, '{"ok":true}', "and the body arrives with the status line stripped")
|
||||||
|
eq(err, nil, "with no error alongside it")
|
||||||
|
|
||||||
|
eq(#calls, 1, "the bridge is called once")
|
||||||
|
local sent = calls[1]
|
||||||
|
eq(sent.url, "https://sync.example/sync/save", "the url goes through untouched")
|
||||||
|
eq(sent.method, "PUT", "and so does the method curl would have taken with -X")
|
||||||
|
eq(sent.body, BODY, "the save blob rides the body argument, not the url")
|
||||||
|
eq(sent.userAgent, "gen1recomp", "with the default user agent")
|
||||||
|
|
||||||
|
local seen = {}
|
||||||
|
for i = 1, #sent.headers, 2 do seen[sent.headers[i]] = sent.headers[i + 1] end
|
||||||
|
eq(seen["x-sync-token"], TOKEN, "auth headers arrive as flat name, value pairs")
|
||||||
|
eq(seen["x-sync-account"], "aa11bb22cc33dd44", "for the account id too")
|
||||||
|
eq(seen["Content-Type"], "application/json", "and for the content type")
|
||||||
|
eq(seen["User-Agent"], nil,
|
||||||
|
"the user agent stays its own argument rather than a duplicate header")
|
||||||
|
|
||||||
|
calls = {}
|
||||||
|
reply = "STATUS 409\n" .. '{"error":"the save moved on"}'
|
||||||
|
body, err, code = HostShell.httpRequest("https://sync.example/sync/save", {
|
||||||
|
method = "PUT", body = BODY, headers = { ["Accept"] = "application/json" },
|
||||||
|
})
|
||||||
|
eq(code, 409, "a conflict comes back as a status, not as a transport failure")
|
||||||
|
eq(body, '{"error":"the save moved on"}',
|
||||||
|
"and its body survives, which is the whole point of the request arm")
|
||||||
|
eq(err, nil, "a 4xx is the caller's to interpret")
|
||||||
|
|
||||||
|
calls = {}
|
||||||
|
reply = "ERROR the reply was too large\n"
|
||||||
|
body, err, code = HostShell.httpRequest("https://sync.example/sync/state", {
|
||||||
|
method = "GET",
|
||||||
|
})
|
||||||
|
eq(code, nil, "an ERROR envelope has no status")
|
||||||
|
eq(body, nil, "and no body")
|
||||||
|
check(err and err:find("the reply was too large", 1, true) ~= nil,
|
||||||
|
"the bridge's own complaint reaches the caller: " .. tostring(err))
|
||||||
|
check(err and err:find("https://sync.example/sync/state", 1, true) ~= nil,
|
||||||
|
"named with the url that failed")
|
||||||
|
|
||||||
|
calls = {}
|
||||||
|
reply = "STATUS 200\n" .. '{"ok":true}'
|
||||||
|
body, err, code = HostShell.httpRequest("https://sync.example/sync/save", {
|
||||||
|
method = "PUT", body = BODY,
|
||||||
|
headers = { ["x-sync-token"] = TOKEN .. "\r\nx-sync-account: stolen" },
|
||||||
|
})
|
||||||
|
eq(code, nil, "a header value carrying CRLF is refused")
|
||||||
|
eq(err, "bad request header", "with the same complaint the curl branch gives")
|
||||||
|
eq(#calls, 0, "and the bridge is never reached")
|
||||||
|
|
||||||
|
calls = {}
|
||||||
|
body, err, code = HostShell.httpRequest("https://sync.example/sync/save", {
|
||||||
|
method = "PATCH", body = BODY,
|
||||||
|
})
|
||||||
|
eq(code, nil, "a method the bridge cannot express is refused")
|
||||||
|
check(err and err:find("PATCH", 1, true) ~= nil,
|
||||||
|
"naming the method: " .. tostring(err))
|
||||||
|
eq(#calls, 0, "without calling the bridge")
|
||||||
|
|
||||||
|
calls = {}
|
||||||
|
reply = function() return nil end
|
||||||
|
body, err, code = HostShell.httpRequest("https://sync.example/sync/save", {
|
||||||
|
method = "PUT", body = BODY,
|
||||||
|
})
|
||||||
|
eq(code, nil, "an old app under a newer engine returns nothing")
|
||||||
|
check(err and err:find("update the app", 1, true) ~= nil,
|
||||||
|
"and degrades to an update notice rather than a crash: " .. tostring(err))
|
||||||
|
|
||||||
|
love.system.httpRequest = nil
|
||||||
|
love.system.httpDownload = function() return false end
|
||||||
|
check(not HostShell.canHttpRequest(),
|
||||||
|
"a build with only the download bridge cannot make signed requests")
|
||||||
|
body, err, code = HostShell.httpRequest("https://sync.example/sync/save", {
|
||||||
|
method = "PUT", body = BODY,
|
||||||
|
})
|
||||||
|
eq(code, nil, "so the request does not go out")
|
||||||
|
check(err and err:find("update the app", 1, true) ~= nil,
|
||||||
|
"and says what to do about it: " .. tostring(err))
|
||||||
|
|
||||||
|
love.system.httpDownload = nil
|
||||||
|
body, err, code = HostShell.httpRequest("https://sync.example/sync/save", {
|
||||||
|
method = "PUT", body = BODY,
|
||||||
|
})
|
||||||
|
eq(err, "no request transport on this platform",
|
||||||
|
"a platform with no bridge at all keeps its old answer")
|
||||||
|
|
||||||
|
T.finish("host shell bridge request")
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||||
|
|
||||||
|
local T = require("tests.harness")
|
||||||
|
local check, eq = T.check, T.eq
|
||||||
|
love = love or require("tests.love_stub")
|
||||||
|
|
||||||
|
local HostShell = require("src.core.HostShell")
|
||||||
|
|
||||||
|
local MARK = "\n__gen1recomp_http__"
|
||||||
|
local SAVE_DIR = "/tmp/pokeport-stub-save"
|
||||||
|
local TOKEN = "0123456789abcdef0123456789abcdef"
|
||||||
|
local BODY = '{"blob":"return {}"}'
|
||||||
|
|
||||||
|
local realOpen = io.open
|
||||||
|
local realPopen = io.popen
|
||||||
|
local realRemove = os.remove
|
||||||
|
local realHaveCurl = HostShell.haveCurl
|
||||||
|
|
||||||
|
local files, removed = {}, {}
|
||||||
|
local popenCommand
|
||||||
|
|
||||||
|
HostShell.haveCurl = function() return true end
|
||||||
|
os.remove = function(path)
|
||||||
|
removed[path] = true
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
io.open = function(path, mode)
|
||||||
|
local entry = { path = path, mode = mode, text = "" }
|
||||||
|
files[#files + 1] = entry
|
||||||
|
return {
|
||||||
|
write = function(_, value)
|
||||||
|
entry.text = entry.text .. value
|
||||||
|
return true
|
||||||
|
end,
|
||||||
|
close = function() return true end,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
io.popen = function(command)
|
||||||
|
popenCommand = command
|
||||||
|
return {
|
||||||
|
read = function() return '{"ok":true}' .. MARK .. "200" end,
|
||||||
|
close = function() return true end,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
local body, err, code = HostShell.httpRequest("https://sync.example/sync/save", {
|
||||||
|
method = "PUT",
|
||||||
|
body = BODY,
|
||||||
|
headers = {
|
||||||
|
["x-sync-account"] = "aa11bb22cc33dd44",
|
||||||
|
["x-sync-token"] = TOKEN,
|
||||||
|
["Content-Type"] = "application/json",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
io.open = realOpen
|
||||||
|
io.popen = realPopen
|
||||||
|
os.remove = realRemove
|
||||||
|
HostShell.haveCurl = realHaveCurl
|
||||||
|
|
||||||
|
eq(code, 200, "the request completes: " .. tostring(err))
|
||||||
|
eq(body, '{"ok":true}', "and the response body comes back without the marker")
|
||||||
|
|
||||||
|
check(popenCommand:find(TOKEN, 1, true) == nil,
|
||||||
|
"the device token never reaches the command line")
|
||||||
|
check(popenCommand:find("aa11bb22cc33dd44", 1, true) == nil,
|
||||||
|
"and neither does the account id")
|
||||||
|
check(popenCommand:find(BODY, 1, true) == nil,
|
||||||
|
"the save blob stays out of the command line too")
|
||||||
|
|
||||||
|
local headerFile, bodyFile
|
||||||
|
for _, entry in ipairs(files) do
|
||||||
|
if entry.text:find("x-sync-token", 1, true) then headerFile = entry end
|
||||||
|
if entry.text == BODY then bodyFile = entry end
|
||||||
|
end
|
||||||
|
check(headerFile ~= nil, "the headers are staged in a file")
|
||||||
|
check(bodyFile ~= nil, "and so is the body")
|
||||||
|
eq(headerFile.mode, "wb", "the header file is written as bytes")
|
||||||
|
check(headerFile.text:find("x%-sync%-token: " .. TOKEN) ~= nil,
|
||||||
|
"with one header per line for curl to read")
|
||||||
|
check(headerFile.text:find("User%-Agent: ") ~= nil,
|
||||||
|
"including the user agent curl would otherwise take on argv")
|
||||||
|
check(popenCommand:find("-H '@" .. headerFile.path .. "'", 1, true) ~= nil,
|
||||||
|
"and curl is pointed at that file")
|
||||||
|
|
||||||
|
check(headerFile.path:find(SAVE_DIR, 1, true) == 1,
|
||||||
|
"staging happens in the user-private save directory, not shared /tmp")
|
||||||
|
check(bodyFile.path:find(SAVE_DIR, 1, true) == 1,
|
||||||
|
"for the body as well")
|
||||||
|
check(headerFile.path ~= bodyFile.path,
|
||||||
|
"two concurrent requests cannot collide on one name")
|
||||||
|
eq(removed[headerFile.path], true, "the staged headers are deleted afterwards")
|
||||||
|
eq(removed[bodyFile.path], true, "and so is the staged body")
|
||||||
|
|
||||||
|
T.finish("host shell request headers")
|
||||||
@@ -0,0 +1,324 @@
|
|||||||
|
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||||
|
|
||||||
|
local T = require("tests.harness")
|
||||||
|
local check, eq = T.check, T.eq
|
||||||
|
love = love or require("tests.love_stub")
|
||||||
|
|
||||||
|
love.graphics.setLineJoin = love.graphics.setLineJoin or function() end
|
||||||
|
love.graphics.newShader = love.graphics.newShader or function() return {} end
|
||||||
|
|
||||||
|
local Kit = require("src.ui.kit.Kit")
|
||||||
|
local RomImporter = require("src.import.RomImporter")
|
||||||
|
local LauncherView = require("src.import.LauncherView")
|
||||||
|
|
||||||
|
local function window(w, h)
|
||||||
|
love.graphics.getDimensions = function() return w, h end
|
||||||
|
love.graphics.getPixelDimensions = function() return w, h end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function pointer(x, y)
|
||||||
|
love.mouse.getPosition = function() return x, y end
|
||||||
|
end
|
||||||
|
|
||||||
|
eq(Kit.scrollExtent(800, 500), 300, "the extent is exactly the overflow")
|
||||||
|
eq(Kit.scrollExtent(400, 500), 0, "content that fits has no extent")
|
||||||
|
eq(Kit.scrollExtent(400, -50), 400, "a negative viewport is no room, not more")
|
||||||
|
eq(Kit.scrollExtent(nil, nil), 0, "an unmeasured region has no extent")
|
||||||
|
|
||||||
|
eq(Kit.scrollClamp(-10, 300), 0, "an offset above the top clamps to it")
|
||||||
|
eq(Kit.scrollClamp(5000, 300), 300, "an offset past the end clamps to it")
|
||||||
|
eq(Kit.scrollClamp(120, 0), 0, "a region with no travel sits at the top")
|
||||||
|
|
||||||
|
local at, left = Kit.scrollHandoff(0, 300, 120)
|
||||||
|
eq(at, 120, "a move inside the extent is taken in full")
|
||||||
|
eq(left, 0, "and hands nothing on")
|
||||||
|
at, left = Kit.scrollHandoff(250, 300, 120)
|
||||||
|
eq(at, 300, "a move past the end stops at the end")
|
||||||
|
eq(left, 70, "and hands the remainder to whatever is behind it")
|
||||||
|
at, left = Kit.scrollHandoff(0, 300, -80)
|
||||||
|
eq(at, 0, "a move above the top stops at the top")
|
||||||
|
eq(left, -80, "and hands that remainder on with its sign")
|
||||||
|
|
||||||
|
local function wheelCase(offset, maxScroll, wheel, mx, my)
|
||||||
|
Kit.blockClicks = false
|
||||||
|
Kit.mouseX, Kit.mouseY = mx, my
|
||||||
|
Kit.wheelY = wheel
|
||||||
|
Kit._clipRect = nil
|
||||||
|
local moved, took = Kit.scrollWheel(offset, maxScroll, 0, 0, 100, 100, 50)
|
||||||
|
return moved, took, Kit.wheelY
|
||||||
|
end
|
||||||
|
|
||||||
|
local moved, took, leftWheel = wheelCase(0, 300, -1, 50, 50)
|
||||||
|
eq(moved, 50, "a notch over the region moves it by one step")
|
||||||
|
eq(took, true, "and reports the region took it")
|
||||||
|
eq(leftWheel, 0, "so nothing reaches the surface behind it")
|
||||||
|
|
||||||
|
moved, took, leftWheel = wheelCase(300, 300, -1, 50, 50)
|
||||||
|
eq(moved, 300, "a region already at its bottom does not move")
|
||||||
|
eq(took, false, "and does not claim the notch")
|
||||||
|
eq(leftWheel, -1, "which is what lets the page scroll take over")
|
||||||
|
|
||||||
|
moved, took, leftWheel = wheelCase(0, 300, 1, 50, 50)
|
||||||
|
eq(moved, 0, "a region at the top ignores an upward notch")
|
||||||
|
eq(leftWheel, 1, "and passes it on")
|
||||||
|
|
||||||
|
moved, took, leftWheel = wheelCase(0, 300, -1, 400, 400)
|
||||||
|
eq(took, false, "a notch outside the region is not the region's")
|
||||||
|
eq(leftWheel, -1, "and stays queued")
|
||||||
|
|
||||||
|
Kit.blockClicks = true
|
||||||
|
Kit.mouseX, Kit.mouseY, Kit.wheelY = 50, 50, -1
|
||||||
|
moved, took = Kit.scrollWheel(0, 300, 0, 0, 100, 100, 50)
|
||||||
|
eq(took, false, "a shielded frame (modal up) leaves the region alone")
|
||||||
|
eq(Kit.wheelY, -1, "so the modal's own scroller still sees the notch")
|
||||||
|
Kit.blockClicks = false
|
||||||
|
|
||||||
|
local function skinLauncher(count)
|
||||||
|
local imp = RomImporter.new(function() end, { launcher = true })
|
||||||
|
imp.tab = "skins"
|
||||||
|
local skins = {}
|
||||||
|
for i = 1, count do
|
||||||
|
skins[i] = { id = "skin" .. i, source = "user", controls = 8, pages = 1 }
|
||||||
|
end
|
||||||
|
imp._skins = skins
|
||||||
|
imp._ensureSkins = function() return skins end
|
||||||
|
return imp
|
||||||
|
end
|
||||||
|
|
||||||
|
window(360, 780)
|
||||||
|
local imp = skinLauncher(12)
|
||||||
|
LauncherView.draw(imp)
|
||||||
|
LauncherView.draw(imp)
|
||||||
|
local rect = imp._tabRegionRect
|
||||||
|
check(rect ~= nil, "the panel publishes the rect its region occupies")
|
||||||
|
check((imp._tabScrollMax.skins or 0) > 0,
|
||||||
|
"a panel with more rows than its viewport scrolls")
|
||||||
|
eq(imp._tabScroll.skins, 0, "a freshly drawn panel sits at the top")
|
||||||
|
check(imp._tabContentH.skins > rect.h,
|
||||||
|
"the region's content is taller than the viewport it is clipped to")
|
||||||
|
|
||||||
|
pointer(rect.x + 10, rect.y + 10)
|
||||||
|
imp._wheelY = -1
|
||||||
|
LauncherView.draw(imp)
|
||||||
|
local step = math.floor(48 * Kit.scale)
|
||||||
|
eq(imp._tabScroll.skins, step, "one notch scrolls the panel by one step")
|
||||||
|
eq(imp._pageScroll, 0,
|
||||||
|
"and the page under it does not move while the panel still can")
|
||||||
|
|
||||||
|
for _ = 1, 30 do
|
||||||
|
imp._wheelY = -1
|
||||||
|
LauncherView.draw(imp)
|
||||||
|
end
|
||||||
|
eq(imp._tabScroll.skins, imp._tabScrollMax.skins,
|
||||||
|
"held down, the panel reaches its own bottom")
|
||||||
|
eq(imp._pageScroll, imp._pageScrollMax,
|
||||||
|
"and only then does the leftover scroll the page")
|
||||||
|
|
||||||
|
for _ = 1, 40 do
|
||||||
|
imp._wheelY = 1
|
||||||
|
LauncherView.draw(imp)
|
||||||
|
end
|
||||||
|
eq(imp._tabScroll.skins, 0, "scrolling back up returns the panel to the top")
|
||||||
|
eq(imp._pageScroll, 0, "and the page with it")
|
||||||
|
|
||||||
|
imp._wheelY = -1
|
||||||
|
LauncherView.draw(imp)
|
||||||
|
local parked = imp._tabScroll.skins
|
||||||
|
check(parked > 0, "the skins panel is parked mid-scroll")
|
||||||
|
imp:_switchTab("red")
|
||||||
|
LauncherView.draw(imp)
|
||||||
|
eq(imp._tabScroll.red or 0, 0, "the game tab has its own offset")
|
||||||
|
eq(imp._tabScroll.skins, parked, "and the skins offset survives the switch")
|
||||||
|
imp:_switchTab("skins")
|
||||||
|
LauncherView.draw(imp)
|
||||||
|
eq(imp._tabScroll.skins, parked, "coming back lands where the player left")
|
||||||
|
|
||||||
|
imp._skins = {}
|
||||||
|
imp._ensureSkins = function() return {} end
|
||||||
|
LauncherView.draw(imp)
|
||||||
|
LauncherView.draw(imp)
|
||||||
|
eq(imp._tabScrollMax.skins, 0, "a panel that now fits has no travel")
|
||||||
|
eq(imp._tabScroll.skins, 0, "and its offset comes back with it")
|
||||||
|
|
||||||
|
local mods = {}
|
||||||
|
for i = 1, 60 do
|
||||||
|
mods[#mods + 1] = {
|
||||||
|
id = "mod" .. i, name = "Mod " .. i, version = "1.0.0",
|
||||||
|
status = "ok", badge = "gameplay", description = "a mod",
|
||||||
|
enabledByVersion = { red = true },
|
||||||
|
}
|
||||||
|
end
|
||||||
|
window(360, 780)
|
||||||
|
local modImp = RomImporter.new(function() end, { launcher = true })
|
||||||
|
modImp.tab = "mods"
|
||||||
|
modImp.mods = mods
|
||||||
|
modImp._ensureMods = function() return mods end
|
||||||
|
LauncherView.draw(modImp)
|
||||||
|
LauncherView.draw(modImp)
|
||||||
|
check((modImp._modScrollMax or 0) > 0,
|
||||||
|
"60 mods overflow the list viewport inside the panel")
|
||||||
|
local list = modImp._modListRect
|
||||||
|
check(list.x + list.w
|
||||||
|
<= modImp._tabRegionRect.x + modImp._tabRegionRect.w - Kit.scrollBarW(),
|
||||||
|
"the rows stop short of the region's scrollbar gutter")
|
||||||
|
pointer(list.x + 10, list.y + 10)
|
||||||
|
modImp._wheelY = -1
|
||||||
|
LauncherView.draw(modImp)
|
||||||
|
check((modImp.modScroll or 0) > 0, "a notch over the mod list scrolls the list")
|
||||||
|
eq(modImp._tabScroll.mods or 0, 0, "not the panel region around it")
|
||||||
|
eq(modImp._pageScroll, 0, "and not the page behind that")
|
||||||
|
|
||||||
|
modImp._modActions = "mod1"
|
||||||
|
local shielded = modImp.modScroll
|
||||||
|
local shieldedPage = modImp._pageScroll
|
||||||
|
pointer(list.x + 10, list.y + 10)
|
||||||
|
modImp._wheelY = -1
|
||||||
|
LauncherView.draw(modImp)
|
||||||
|
eq(modImp.modScroll, shielded, "a shielded mod list ignores the notch")
|
||||||
|
eq(modImp._tabScroll.mods or 0, 0, "and so does the region under the scrim")
|
||||||
|
eq(modImp._pageScroll, shieldedPage, "and the page behind that")
|
||||||
|
modImp._modActions = nil
|
||||||
|
modImp._wheelY = 0
|
||||||
|
LauncherView.draw(modImp)
|
||||||
|
|
||||||
|
window(360, 780)
|
||||||
|
local gameImp = RomImporter.new(function() end, { launcher = true })
|
||||||
|
gameImp.tab = "red"
|
||||||
|
gameImp.ready = { red = true }
|
||||||
|
gameImp.slots = { red = {} }
|
||||||
|
for i = 1, 8 do
|
||||||
|
gameImp.slots.red[i] = { id = "slot" .. i, name = "Slot " .. i }
|
||||||
|
end
|
||||||
|
gameImp._ensureSlots = function() end
|
||||||
|
LauncherView.draw(gameImp)
|
||||||
|
LauncherView.draw(gameImp)
|
||||||
|
check((gameImp._tabScrollMax.red or 0) > 0,
|
||||||
|
"a game tab whose cart and slots outgrow the viewport scrolls too")
|
||||||
|
check(gameImp._tabContentH.red > gameImp._tabRegionRect.h,
|
||||||
|
"because it reports its NATURAL height, not the height it was given")
|
||||||
|
pointer(gameImp._tabRegionRect.x + 10, gameImp._tabRegionRect.y + 10)
|
||||||
|
gameImp._wheelY = -1
|
||||||
|
LauncherView.draw(gameImp)
|
||||||
|
check((gameImp._tabScroll.red or 0) > 0, "and a notch over it moves it")
|
||||||
|
|
||||||
|
window(360, 780)
|
||||||
|
local touchImp = skinLauncher(12)
|
||||||
|
LauncherView.draw(touchImp)
|
||||||
|
LauncherView.draw(touchImp)
|
||||||
|
local treg = touchImp._tabRegionRect
|
||||||
|
local tmax = touchImp._tabScrollMax.skins
|
||||||
|
check(tmax > 0, "the touched panel has travel")
|
||||||
|
LauncherView.touchpressed(touchImp, 1, treg.x + 20, treg.y + 40)
|
||||||
|
LauncherView.touchmoved(touchImp, 1, treg.x + 20, treg.y + 40 - 200)
|
||||||
|
eq(touchImp._tabScroll.skins, math.min(200, tmax),
|
||||||
|
"dragging up scrolls the panel by the finger's travel")
|
||||||
|
eq(touchImp._pageScroll, 0, "while the panel still has travel, the page waits")
|
||||||
|
LauncherView.touchmoved(touchImp, 1, treg.x + 20, treg.y + 40 - 200 - tmax * 2)
|
||||||
|
eq(touchImp._tabScroll.skins, tmax, "a longer drag reaches the panel's bottom")
|
||||||
|
check((touchImp._pageScroll or 0) > 0, "and spills into the page from there")
|
||||||
|
LauncherView.touchreleased(touchImp, 1, treg.x + 20, treg.y - 400)
|
||||||
|
|
||||||
|
window(360, 780)
|
||||||
|
local dragMods = RomImporter.new(function() end, { launcher = true })
|
||||||
|
dragMods.tab = "mods"
|
||||||
|
dragMods.mods = mods
|
||||||
|
dragMods._ensureMods = function() return mods end
|
||||||
|
LauncherView.draw(dragMods)
|
||||||
|
LauncherView.draw(dragMods)
|
||||||
|
local dlist = dragMods._modListRect
|
||||||
|
local dListMax = dragMods._modScrollMax
|
||||||
|
local dRegionMax = dragMods._tabScrollMax.mods
|
||||||
|
check(dListMax > 0 and dRegionMax > 0,
|
||||||
|
"the mods tab has both an inner list and a region to scroll")
|
||||||
|
LauncherView.touchpressed(dragMods, 7, dlist.x + 20, dlist.y + 30)
|
||||||
|
LauncherView.touchmoved(dragMods, 7, dlist.x + 20, dlist.y + 30 - 60)
|
||||||
|
eq(dragMods.modScroll, math.min(60, dListMax),
|
||||||
|
"the first pixels of the drag move the list")
|
||||||
|
eq(dragMods._tabScroll.mods or 0, 0, "and nothing else")
|
||||||
|
LauncherView.touchmoved(dragMods, 7, dlist.x + 20,
|
||||||
|
dlist.y + 30 - 60 - dListMax - dRegionMax * 2)
|
||||||
|
eq(dragMods.modScroll, dListMax, "carrying on saturates the list")
|
||||||
|
eq(dragMods._tabScroll.mods, dRegionMax,
|
||||||
|
"then the same gesture walks the region to its bottom")
|
||||||
|
check((dragMods._pageScroll or 0) > 0, "and only then reaches the page")
|
||||||
|
LauncherView.touchreleased(dragMods, 7, dlist.x + 20, dlist.y - 900)
|
||||||
|
|
||||||
|
dragMods._skins = { { id = "s1", source = "user", controls = 8, pages = 1 } }
|
||||||
|
for i = 2, 12 do
|
||||||
|
dragMods._skins[i] = { id = "s" .. i, source = "user", controls = 8, pages = 1 }
|
||||||
|
end
|
||||||
|
dragMods._ensureSkins = function() return dragMods._skins end
|
||||||
|
dragMods.modScroll = 0
|
||||||
|
local heldModScroll = dragMods.modScroll
|
||||||
|
local overList = dlist.y + 30
|
||||||
|
dragMods:_switchTab("skins")
|
||||||
|
LauncherView.draw(dragMods)
|
||||||
|
LauncherView.draw(dragMods)
|
||||||
|
local sreg = dragMods._tabRegionRect
|
||||||
|
check((dragMods._tabScrollMax.skins or 0) > 0, "the skins tab has travel")
|
||||||
|
LauncherView.touchpressed(dragMods, 9, sreg.x + 20, overList)
|
||||||
|
LauncherView.touchmoved(dragMods, 9, sreg.x + 20, overList - 200)
|
||||||
|
check((dragMods._tabScroll.skins or 0) > 0,
|
||||||
|
"a drag on the skins tab scrolls the skins tab")
|
||||||
|
eq(dragMods.modScroll, heldModScroll,
|
||||||
|
"and leaves the mod list where the player parked it")
|
||||||
|
LauncherView.touchreleased(dragMods, 9, sreg.x + 20, sreg.y - 400)
|
||||||
|
|
||||||
|
love.graphics.polygon = love.graphics.polygon or function() end
|
||||||
|
window(360, 780)
|
||||||
|
local padImp = skinLauncher(12)
|
||||||
|
LauncherView.draw(padImp)
|
||||||
|
LauncherView.draw(padImp)
|
||||||
|
local preg = padImp._tabRegionRect
|
||||||
|
padImp._padCursorActive = true
|
||||||
|
padImp._padCursor = { x = preg.x + 10, y = preg.y + preg.h - 4 }
|
||||||
|
LauncherView.wheelmoved(padImp, 0, -1)
|
||||||
|
LauncherView.draw(padImp)
|
||||||
|
check((padImp._tabScroll.skins or 0) > 0,
|
||||||
|
"the pad's synthesized wheel scrolls the region its cursor sits in")
|
||||||
|
eq(padImp._pageScroll, 0, "and not the page behind it")
|
||||||
|
|
||||||
|
window(1280, 720)
|
||||||
|
local edgeImp = skinLauncher(40)
|
||||||
|
LauncherView.draw(edgeImp)
|
||||||
|
LauncherView.draw(edgeImp)
|
||||||
|
local ereg = edgeImp._tabRegionRect
|
||||||
|
check((edgeImp._tabScrollMax.skins or 0) > 0, "the wide window still overflows")
|
||||||
|
eq(edgeImp._pageScrollMax, 0, "with no page scroll left to catch the notch")
|
||||||
|
check(ereg.y + ereg.h < 720, "and a region that ends above the safe area")
|
||||||
|
edgeImp._padCursorActive = true
|
||||||
|
edgeImp._padCursor = { x = ereg.x + 20, y = 719 }
|
||||||
|
edgeImp._padAxis = { lefty = 1 }
|
||||||
|
edgeImp._padDir = {}
|
||||||
|
pointer(ereg.x + 20, 719)
|
||||||
|
edgeImp:_updatePadCursor(0.5)
|
||||||
|
check((edgeImp._wheelY or 0) < 0, "the edge push synthesizes a notch")
|
||||||
|
LauncherView.draw(edgeImp)
|
||||||
|
check((edgeImp._tabScroll.skins or 0) > 0,
|
||||||
|
"which reaches the tab region even though the cursor is below it")
|
||||||
|
|
||||||
|
local function read(path)
|
||||||
|
local f = assert(io.open(path, "r"))
|
||||||
|
local src = f:read("*a")
|
||||||
|
f:close()
|
||||||
|
return src
|
||||||
|
end
|
||||||
|
|
||||||
|
local view = read("src/import/LauncherView.lua")
|
||||||
|
check(view:find("Kit.scrollBegin(", 1, true) ~= nil,
|
||||||
|
"the panel dispatch opens a scroll region")
|
||||||
|
check(view:find("Kit.scrollEnd(", 1, true) ~= nil, "and closes it")
|
||||||
|
check(view:find("modListWantsWheel", 1, true) ~= nil,
|
||||||
|
"the nested mod list is asked before the region takes a notch")
|
||||||
|
check(view:find("start.region", 1, true) ~= nil,
|
||||||
|
"a touch drag that began in the region scrolls the region")
|
||||||
|
check(view:find("Kit.scrollGutter(", 1, true) ~= nil,
|
||||||
|
"the panels lay out inside a gutter, so the thumb covers no control")
|
||||||
|
check(view:find("Kit.scrollHandoff(tabScrollAt(imp)", 1, true) ~= nil,
|
||||||
|
"and hands its leftover to the page, like the wheel does")
|
||||||
|
|
||||||
|
local kit = read("src/ui/kit/Kit.lua")
|
||||||
|
check(kit:find("function Kit.scrollWheel", 1, true) ~= nil,
|
||||||
|
"the kit owns the wheel rule, so no panel hand-rolls a fifth copy")
|
||||||
|
|
||||||
|
T.finish("launcher scroll regions")
|
||||||
@@ -0,0 +1,268 @@
|
|||||||
|
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||||
|
|
||||||
|
local T = require("tests.harness")
|
||||||
|
local check, eq = T.check, T.eq
|
||||||
|
love = love or require("tests.love_stub")
|
||||||
|
|
||||||
|
love.graphics.setLineJoin = love.graphics.setLineJoin or function() end
|
||||||
|
love.graphics.newShader = love.graphics.newShader or function() return {} end
|
||||||
|
|
||||||
|
local RomImporter = require("src.import.RomImporter")
|
||||||
|
local LauncherView = require("src.import.LauncherView")
|
||||||
|
local TouchSkin = require("src.core.TouchSkin")
|
||||||
|
|
||||||
|
local function read(path)
|
||||||
|
local f = assert(io.open(path, "r"))
|
||||||
|
local src = f:read("*a")
|
||||||
|
f:close()
|
||||||
|
return src
|
||||||
|
end
|
||||||
|
|
||||||
|
local function window(w, h)
|
||||||
|
love.graphics.getDimensions = function() return w, h end
|
||||||
|
love.graphics.getPixelDimensions = function() return w, h end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function launcher()
|
||||||
|
return RomImporter.new(function() end, { launcher = true })
|
||||||
|
end
|
||||||
|
|
||||||
|
eq(RomImporter.skinUrlName("https://example.com/pads/gbc.zip"), "gbc.zip",
|
||||||
|
"a direct .zip keeps its name")
|
||||||
|
eq(RomImporter.skinUrlName("https://example.com/pads/Neon.deltaskin"),
|
||||||
|
"Neon.deltaskin", "and so does a .deltaskin")
|
||||||
|
eq(RomImporter.skinUrlName("https://example.com/overlay.cfg"), "overlay.cfg",
|
||||||
|
"a bare RetroArch cfg is kept as a cfg")
|
||||||
|
eq(RomImporter.skinUrlName("https://example.com/pads/gbc.zip?raw=1"), "gbc.zip",
|
||||||
|
"a query string is not part of the name")
|
||||||
|
eq(RomImporter.skinUrlName("https://example.com/pads/gbc.zip#frag"), "gbc.zip",
|
||||||
|
"nor is a fragment")
|
||||||
|
eq(RomImporter.skinUrlName("https://example.com/download"), "download.zip",
|
||||||
|
"an extension-less link is treated as an archive")
|
||||||
|
eq(RomImporter.skinUrlName("https://example.com/a b/../pad.tar"), "pad.zip",
|
||||||
|
"an unknown extension is replaced, and the name is sanitized")
|
||||||
|
check(RomImporter.skinUrlName("https://example.com/"):match("^[%w%._%-]+$"),
|
||||||
|
"the download name can never escape the skins folder")
|
||||||
|
|
||||||
|
local name, payload = RomImporter.wrapSkinPayload("overlay.cfg",
|
||||||
|
"overlays = 1\noverlay0_descs = 0\n")
|
||||||
|
eq(name, "overlay.zip", "a downloaded .cfg is wrapped into an archive")
|
||||||
|
eq(payload:sub(1, 2), "PK", "which is a real zip")
|
||||||
|
check(payload:find("overlays = 1", 1, true) ~= nil,
|
||||||
|
"carrying the cfg text inside it")
|
||||||
|
check(payload:find("overlay.cfg", 1, true) ~= nil,
|
||||||
|
"under the name RetroArch parsing expects")
|
||||||
|
|
||||||
|
local zipName, zipData = RomImporter.wrapSkinPayload("pad.zip", "PK\3\4stuff")
|
||||||
|
eq(zipName, "pad.zip", "a zip is passed through untouched")
|
||||||
|
eq(zipData, "PK\3\4stuff", "bytes and all")
|
||||||
|
eq(select(1, RomImporter.wrapSkinPayload("pad.deltaskin", "PK\3\4x")),
|
||||||
|
"pad.deltaskin", "and so is a .deltaskin")
|
||||||
|
local pkName, pkData = RomImporter.wrapSkinPayload("overlay.cfg", "PK\3\4real")
|
||||||
|
eq(pkName, "overlay.zip", "a .cfg link that serves zip bytes is renamed, not refused")
|
||||||
|
eq(pkData, "PK\3\4real", "and its bytes are left alone")
|
||||||
|
|
||||||
|
local imp = launcher()
|
||||||
|
check(not imp:_addSkinFromUrl(""), "an empty link is refused")
|
||||||
|
check(imp._skinNotice and not imp._skinNotice.ok, "with a visible error")
|
||||||
|
eq(imp._skinFetch, nil, "and no download is started")
|
||||||
|
check(not imp:_addSkinFromUrl("file:///etc/passwd"),
|
||||||
|
"a non-http link is refused")
|
||||||
|
eq(imp._skinFetch, nil, "and still starts nothing")
|
||||||
|
check(not imp:_addSkinFromUrl("skins/local.zip"),
|
||||||
|
"a bare path is not a link either")
|
||||||
|
|
||||||
|
local Fetch = require("src.net.Fetch")
|
||||||
|
local realDownload, realPoll, realRelease = Fetch.download, Fetch.poll,
|
||||||
|
Fetch.release
|
||||||
|
local asked
|
||||||
|
Fetch.download = function(url, dest) asked = { url = url, dest = dest } return 7 end
|
||||||
|
Fetch.poll = function() return { status = "pending", progress = 0.5 } end
|
||||||
|
Fetch.release = function() end
|
||||||
|
|
||||||
|
imp = launcher()
|
||||||
|
imp.skinUrl = "https://example.com/pads/neon.deltaskin"
|
||||||
|
check(imp:_addSkinFromUrl(), "a good link starts a download")
|
||||||
|
check(imp._skinFetch ~= nil, "and parks the job on the importer")
|
||||||
|
eq(asked.url, "https://example.com/pads/neon.deltaskin", "the url is fetched")
|
||||||
|
check(asked.dest:find("neon.deltaskin", 1, true) ~= nil,
|
||||||
|
"into a file named after the link")
|
||||||
|
check(asked.dest:find("%.%.") == nil, "with no traversal in the path")
|
||||||
|
check(not imp:_addSkinFromUrl("https://example.com/other.zip"),
|
||||||
|
"a second add while one is in flight is ignored")
|
||||||
|
|
||||||
|
imp:_pumpSkinFetch()
|
||||||
|
check(imp._skinFetch ~= nil, "a pending download stays in flight")
|
||||||
|
|
||||||
|
local installed
|
||||||
|
imp._installSkinData = function(_, n, d) installed = { name = n, data = d } return "neon" end
|
||||||
|
Fetch.poll = function()
|
||||||
|
return { status = "ok", path = "skins/_download/neon.deltaskin" }
|
||||||
|
end
|
||||||
|
love.filesystem.write("skins/_download/neon.deltaskin", "PK\3\4payload")
|
||||||
|
imp:_pumpSkinFetch()
|
||||||
|
eq(imp._skinFetch, nil, "a finished download is released")
|
||||||
|
check(installed ~= nil, "and its bytes go to the installer")
|
||||||
|
eq(installed.name, "neon.deltaskin", "under the downloaded name")
|
||||||
|
eq(love.filesystem.read("skins/_download/neon.deltaskin"), nil,
|
||||||
|
"the temporary download is cleaned up")
|
||||||
|
eq(imp.skinUrl, "", "and the field is cleared for the next one")
|
||||||
|
|
||||||
|
imp = launcher()
|
||||||
|
imp._installSkinData = function() return nil end
|
||||||
|
Fetch.download = function() return 8 end
|
||||||
|
Fetch.poll = function() return { status = "error", err = "404" } end
|
||||||
|
imp:_addSkinFromUrl("https://example.com/missing.zip")
|
||||||
|
imp:_pumpSkinFetch()
|
||||||
|
eq(imp._skinFetch, nil, "a failed download is released too")
|
||||||
|
check(imp._skinNotice and not imp._skinNotice.ok, "and reported")
|
||||||
|
check(tostring(imp._skinNotice.text):find("404", 1, true) ~= nil,
|
||||||
|
"with the reason attached")
|
||||||
|
|
||||||
|
Fetch.download, Fetch.poll, Fetch.release = realDownload, realPoll, realRelease
|
||||||
|
|
||||||
|
imp = launcher()
|
||||||
|
eq(imp:_installSkinData("pad.zip", ""), nil, "an empty payload is refused")
|
||||||
|
check(imp._skinNotice and not imp._skinNotice.ok, "and says so")
|
||||||
|
eq(imp:_installSkinData("notes.txt", "hello"), nil, "a non-archive is refused")
|
||||||
|
|
||||||
|
love.filesystem.write("skins/warny.zip/overlay.cfg", [[
|
||||||
|
overlays = 1
|
||||||
|
overlay0_name = "warny"
|
||||||
|
overlay0_normalized = true
|
||||||
|
overlay0_descs = 2
|
||||||
|
overlay0_desc0 = "a,0.5,0.5,rect,0.05,0.05"
|
||||||
|
]])
|
||||||
|
imp = launcher()
|
||||||
|
eq(imp:_installSkinData("warny.zip", "PK\3\4stub"), "warny", "a skin installs")
|
||||||
|
check(imp._skinNotice.ok, "with an ok notice")
|
||||||
|
check(tostring(imp._skinNotice.text):find("missing desc", 1, true) ~= nil,
|
||||||
|
"that repeats what the importer had to complain about")
|
||||||
|
|
||||||
|
love.filesystem.write("skins/vecty.deltaskin/info.json", [[
|
||||||
|
{ "name": "Vecty", "gameTypeIdentifier": "com.rileytestut.delta.game.gbc",
|
||||||
|
"representations": { "iphone": { "standard": { "portrait": {
|
||||||
|
"assets": { "resizable": "iphone_portrait.pdf" },
|
||||||
|
"mappingSize": {"width":320,"height":480},
|
||||||
|
"items": [ { "inputs": ["a"], "frame": {"x":0,"y":0,"width":32,"height":32} } ]
|
||||||
|
} } } } }
|
||||||
|
]])
|
||||||
|
imp = launcher()
|
||||||
|
eq(imp:_installSkinData("vecty.deltaskin", "PK\3\4stub"), nil,
|
||||||
|
"a PDF-only Delta skin does not install silently")
|
||||||
|
check(imp._skinNotice and not imp._skinNotice.ok, "the tab reports the refusal")
|
||||||
|
check(tostring(imp._skinNotice.text):find("PDF artwork", 1, true) ~= nil,
|
||||||
|
"and says why, instead of listing a skin with no buttons")
|
||||||
|
|
||||||
|
local function dropped(fileName)
|
||||||
|
return { getFilename = function() return fileName end,
|
||||||
|
open = function() return false end }
|
||||||
|
end
|
||||||
|
|
||||||
|
local routed
|
||||||
|
local function routeDrop(tab, fileName)
|
||||||
|
routed = nil
|
||||||
|
local drop = launcher()
|
||||||
|
drop.tab = tab
|
||||||
|
drop._installSkinZip = function() routed = "skin" end
|
||||||
|
drop._installMod = function() routed = "mod" end
|
||||||
|
drop.startData = function() routed = "rom" end
|
||||||
|
drop:filedropped(dropped(fileName))
|
||||||
|
return routed
|
||||||
|
end
|
||||||
|
|
||||||
|
eq(routeDrop("mods", "pad.deltaskin"), "skin",
|
||||||
|
"a dropped .deltaskin installs as a skin from any tab")
|
||||||
|
eq(routeDrop("skins", "pad.deltaskin"), "skin", "and from the skins tab")
|
||||||
|
eq(routeDrop("skins", "Neon.DeltaSkin"), "skin", "whatever its case")
|
||||||
|
eq(routeDrop("skins", "pad.zip"), "skin", "a zip on the skins tab is still a skin")
|
||||||
|
eq(routeDrop("mods", "pad.zip"), "mod", "and a mod anywhere else")
|
||||||
|
|
||||||
|
check(TouchSkin.saveTo(TouchSkin.newSkin("uxskin"), "uxskin") ~= nil,
|
||||||
|
"a skin to list")
|
||||||
|
imp = launcher()
|
||||||
|
local entries = imp:_ensureSkins(true)
|
||||||
|
check(#entries > 0, "the installed skins are listed")
|
||||||
|
local byId = {}
|
||||||
|
for _, entry in ipairs(entries) do
|
||||||
|
byId[entry.id] = entry
|
||||||
|
check(type(entry.format) == "string",
|
||||||
|
entry.id .. " reports the format it was parsed from")
|
||||||
|
end
|
||||||
|
eq(byId.uxskin and byId.uxskin.format, "native",
|
||||||
|
"a skin.lua skin is badged as the native format")
|
||||||
|
|
||||||
|
imp = launcher()
|
||||||
|
eq(imp:_exportSkin("no-such-skin", "native"), nil, "exporting a ghost fails")
|
||||||
|
check(imp._skinNotice and not imp._skinNotice.ok, "with an error notice")
|
||||||
|
|
||||||
|
local first = entries[1]
|
||||||
|
imp = launcher()
|
||||||
|
local path = imp:_exportSkin(first.id, "delta")
|
||||||
|
check(path ~= nil and path:match("%.deltaskin$") ~= nil,
|
||||||
|
"a bundled skin exports as a .deltaskin")
|
||||||
|
check(imp._skinNotice.ok, "and the tab reports where it landed")
|
||||||
|
check(tostring(imp._skinNotice.text):find(path, 1, true) ~= nil,
|
||||||
|
"naming the path, which is the whole mobile story")
|
||||||
|
check(imp._skinExport ~= nil and imp._skinExport.path == path,
|
||||||
|
"the export is remembered so Show file can reveal it")
|
||||||
|
path = imp:_exportSkin(first.id, "retroarch")
|
||||||
|
check(path ~= nil and path:match("%.zip$") ~= nil,
|
||||||
|
"and as a RetroArch .zip")
|
||||||
|
path = imp:_exportSkin(first.id, "native")
|
||||||
|
check(path ~= nil and path:match("%.zip$") ~= nil, "and as a gen1recomp .zip")
|
||||||
|
|
||||||
|
window(420, 900)
|
||||||
|
imp = launcher()
|
||||||
|
imp.tab = "skins"
|
||||||
|
LauncherView.draw(imp)
|
||||||
|
LauncherView.draw(imp)
|
||||||
|
check(true, "the skins tab draws with the URL row")
|
||||||
|
imp._skinActions = { id = entries[1].id }
|
||||||
|
LauncherView.draw(imp)
|
||||||
|
check(imp._skinActions ~= nil, "the actions sheet stays up while it draws")
|
||||||
|
imp._skinFetch = { name = "neon.zip" }
|
||||||
|
LauncherView.draw(imp)
|
||||||
|
imp._skinFetch = nil
|
||||||
|
|
||||||
|
window(320, 640)
|
||||||
|
LauncherView.draw(imp)
|
||||||
|
LauncherView.draw(imp)
|
||||||
|
check(true, "and on a phone-width window, where Paste gives up its room")
|
||||||
|
|
||||||
|
local view = read("src/import/LauncherView.lua")
|
||||||
|
local rom = read("src/import/RomImporter.lua")
|
||||||
|
|
||||||
|
check(view:find('"skins-url"', 1, true) ~= nil,
|
||||||
|
"the skins tab carries an add-by-URL field")
|
||||||
|
check(view:find('"skins-url-add"', 1, true) ~= nil, "with a button to submit it")
|
||||||
|
check(view:find('"skins-url-paste"', 1, true) ~= nil,
|
||||||
|
"and a paste button, because a phone cannot type a URL")
|
||||||
|
check(view:find("_addSkinFromUrl", 1, true) ~= nil,
|
||||||
|
"which reaches the importer's downloader")
|
||||||
|
check(view:find("Loader.inline", 1, true) ~= nil,
|
||||||
|
"and the row shows progress while it runs")
|
||||||
|
check(view:find("SKIN_FORMAT_LABEL", 1, true) ~= nil,
|
||||||
|
"rows carry a format badge")
|
||||||
|
check(view:find("buildSkinActionsModal", 1, true) ~= nil,
|
||||||
|
"the gear opens an actions sheet")
|
||||||
|
check(view:find("_exportSkin", 1, true) ~= nil, "which can export the skin")
|
||||||
|
check(view:find("skinact-exp-delta", 1, true) ~= nil,
|
||||||
|
"including as a Delta skin")
|
||||||
|
local modals = view:match("local function modalUp%(imp%)(.-)\nend")
|
||||||
|
check(modals and modals:find("_skinActions", 1, true) ~= nil,
|
||||||
|
"the sheet raises the modal shield like every other popup")
|
||||||
|
check(view:find("imp.onOpenSkinStudio(imp.modScope or \"red\", id)", 1, true)
|
||||||
|
~= nil, "and still hands the studio a real game version")
|
||||||
|
|
||||||
|
check(rom:find("deltaskin", 1, true) ~= nil,
|
||||||
|
"the desktop file picker offers .deltaskin")
|
||||||
|
check(rom:find("_pumpSkinFetch", 1, true) ~= nil,
|
||||||
|
"the skin download is pumped from update()")
|
||||||
|
local update = rom:match("function RomImporter:update%(dt%)(.-)\nend\n")
|
||||||
|
check(update and update:find("_pumpSkinFetch", 1, true) ~= nil,
|
||||||
|
"from inside update itself, not just declared")
|
||||||
|
check(TouchSkin.ARCHIVE_EXTS.deltaskin == true,
|
||||||
|
"and the installer accepts the extension")
|
||||||
|
|
||||||
|
T.finish("launcher_skins_ux")
|
||||||
@@ -0,0 +1,307 @@
|
|||||||
|
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||||
|
|
||||||
|
local T = require("tests.harness")
|
||||||
|
local check, eq = T.check, T.eq
|
||||||
|
love = love or require("tests.love_stub")
|
||||||
|
|
||||||
|
love.graphics.setLineJoin = love.graphics.setLineJoin or function() end
|
||||||
|
love.graphics.polygon = love.graphics.polygon or function() end
|
||||||
|
love.graphics.newShader = love.graphics.newShader or function() return {} end
|
||||||
|
|
||||||
|
local Kit = require("src.ui.kit.Kit")
|
||||||
|
local RomImporter = require("src.import.RomImporter")
|
||||||
|
local LauncherView = require("src.import.LauncherView")
|
||||||
|
|
||||||
|
local function read(path)
|
||||||
|
local f = assert(io.open(path, "r"))
|
||||||
|
local src = f:read("*a")
|
||||||
|
f:close()
|
||||||
|
return src
|
||||||
|
end
|
||||||
|
|
||||||
|
eq(RomImporter.syncDigits("1234-5678"), "12345678",
|
||||||
|
"the dash people read the code with is not part of it")
|
||||||
|
eq(RomImporter.syncDigits(" 12 34 "), "1234", "spaces are dropped")
|
||||||
|
eq(RomImporter.syncDigits("abc9"), "9", "letters cannot enter a digit code")
|
||||||
|
eq(RomImporter.syncDigits("123456789012"), "12345678",
|
||||||
|
"a code is eight digits and no more")
|
||||||
|
eq(RomImporter.syncDigits(nil), "", "an empty field stays empty")
|
||||||
|
|
||||||
|
eq(RomImporter.syncShareCode("abc234"), "ABC234", "share codes are upper case")
|
||||||
|
eq(RomImporter.syncShareCode("A1B0C-D"), "ABCD",
|
||||||
|
"1 and 0 are not in the share alphabet")
|
||||||
|
eq(RomImporter.syncShareCode("ABCDEFGH"), "ABCDEF",
|
||||||
|
"a share code is six characters")
|
||||||
|
|
||||||
|
local function fakeEngine(over)
|
||||||
|
local eng = {
|
||||||
|
phase = "idle", status = "Ready", conflicts = {}, state = { enabled = true },
|
||||||
|
calls = {},
|
||||||
|
isLinked = false,
|
||||||
|
linked = function(self) return self.isLinked end,
|
||||||
|
busy = function(self) return self.isBusy == true end,
|
||||||
|
createAccount = function(self, label)
|
||||||
|
self.calls[#self.calls + 1] = { "create", label }
|
||||||
|
self.isLinked = true
|
||||||
|
self.codes = { code1 = "1234-5678", code2 = "8765-4321" }
|
||||||
|
return true
|
||||||
|
end,
|
||||||
|
linkDevice = function(self, a, b, label)
|
||||||
|
self.calls[#self.calls + 1] = { "link", a, b, label }
|
||||||
|
if #tostring(a) ~= 8 or #tostring(b) ~= 8 then return false end
|
||||||
|
self.isLinked = true
|
||||||
|
return true
|
||||||
|
end,
|
||||||
|
syncNow = function(self)
|
||||||
|
self.calls[#self.calls + 1] = { "syncNow" }
|
||||||
|
return true
|
||||||
|
end,
|
||||||
|
unlink = function(self)
|
||||||
|
self.calls[#self.calls + 1] = { "unlink" }
|
||||||
|
self.isLinked, self.codes = false, nil
|
||||||
|
return true
|
||||||
|
end,
|
||||||
|
shareMods = function(self)
|
||||||
|
self.calls[#self.calls + 1] = { "shareMods" }
|
||||||
|
self.shareCode = "K7QW3M"
|
||||||
|
return true
|
||||||
|
end,
|
||||||
|
fetchShare = function(self, code)
|
||||||
|
self.calls[#self.calls + 1] = { "fetchShare", code }
|
||||||
|
return true
|
||||||
|
end,
|
||||||
|
applyModPlan = function(self, progress)
|
||||||
|
self.calls[#self.calls + 1] = { "applyModPlan" }
|
||||||
|
if progress then progress(1, 2, "a") progress(2, 2, "b") end
|
||||||
|
return true
|
||||||
|
end,
|
||||||
|
resolveConflict = function(self, key, choice)
|
||||||
|
self.calls[#self.calls + 1] = { "resolve", key, choice }
|
||||||
|
return true
|
||||||
|
end,
|
||||||
|
}
|
||||||
|
for k, v in pairs(over or {}) do eng[k] = v end
|
||||||
|
return eng
|
||||||
|
end
|
||||||
|
|
||||||
|
local function launcher(eng)
|
||||||
|
local imp = RomImporter.new(function() end, { launcher = true })
|
||||||
|
imp._sync = eng
|
||||||
|
imp._syncTransportOk = true
|
||||||
|
return imp
|
||||||
|
end
|
||||||
|
|
||||||
|
local eng = fakeEngine()
|
||||||
|
local imp = launcher(eng)
|
||||||
|
|
||||||
|
eq(imp._syncModal, nil, "the modal is closed until the header button opens it")
|
||||||
|
imp:_openSync()
|
||||||
|
check(imp._syncModal ~= nil, "the header button opens the modal")
|
||||||
|
eq(imp._syncModal.view, "home", "and lands on the home view")
|
||||||
|
eq(imp._syncFocus, nil, "with no field taking the keyboard")
|
||||||
|
|
||||||
|
imp:_syncView("link")
|
||||||
|
eq(imp._syncModal.view, "link", "Link this device swaps the view")
|
||||||
|
imp:_syncFocusField("code1")
|
||||||
|
eq(imp._syncFocus, "code1", "tapping a field focuses it")
|
||||||
|
imp:textinput("12ab34")
|
||||||
|
eq(imp._syncModal.code1, "1234", "typed letters never reach a code field")
|
||||||
|
imp:textinput("5678")
|
||||||
|
eq(imp._syncModal.code1, "12345678", "the field fills to eight digits")
|
||||||
|
imp:textinput("9")
|
||||||
|
eq(imp._syncModal.code1, "12345678", "and refuses a ninth")
|
||||||
|
imp:keypressed("backspace")
|
||||||
|
eq(imp._syncModal.code1, "1234567", "backspace drops one digit")
|
||||||
|
imp:textinput("8")
|
||||||
|
|
||||||
|
imp:_syncFocusField("code2")
|
||||||
|
eq(imp._syncFocus, "code2", "focus moves to the second code")
|
||||||
|
eq(imp._syncModal.code1, "12345678", "without disturbing the first")
|
||||||
|
imp:_syncFocusField("code2")
|
||||||
|
eq(imp._syncFocus, nil, "tapping the focused field again releases it")
|
||||||
|
imp:_syncFocusField("code2")
|
||||||
|
imp:textinput("87654321")
|
||||||
|
|
||||||
|
imp:_syncLink()
|
||||||
|
eq(eng.calls[#eng.calls][1], "link", "Link sends both codes to the engine")
|
||||||
|
eq(eng.calls[#eng.calls][2], "12345678", "the first code as typed")
|
||||||
|
eq(eng.calls[#eng.calls][3], "87654321", "and the second")
|
||||||
|
eq(imp._syncModal.view, "home", "a linked device comes back to the home view")
|
||||||
|
eq(imp._syncModal.code1, "", "and the codes are not left lying in the field")
|
||||||
|
eq(imp._syncModal.code2, "", "either of them")
|
||||||
|
eq(imp._syncFocus, nil, "with the keyboard released")
|
||||||
|
|
||||||
|
local short = launcher(fakeEngine())
|
||||||
|
short:_openSync()
|
||||||
|
short:_syncView("link")
|
||||||
|
short._syncModal.code1, short._syncModal.code2 = "1234", "87654321"
|
||||||
|
eq(short:_syncLink(), false, "a short code does not link")
|
||||||
|
eq(short._syncModal.code1, "1234",
|
||||||
|
"and what was typed stays put to be corrected")
|
||||||
|
|
||||||
|
imp:_syncFocusField("code1")
|
||||||
|
imp:keypressed("escape")
|
||||||
|
eq(imp._syncFocus, nil, "escape out of a field releases the keyboard")
|
||||||
|
check(imp._syncModal ~= nil, "and leaves the modal up")
|
||||||
|
imp:keypressed("escape")
|
||||||
|
eq(imp._syncModal, nil, "escape closes the modal")
|
||||||
|
|
||||||
|
imp:_openSync()
|
||||||
|
imp:_syncView("mods")
|
||||||
|
imp:_syncShareMods()
|
||||||
|
eq(eng.shareCode, "K7QW3M", "Share mod list asks the engine for a code")
|
||||||
|
imp:_syncFocusField("share")
|
||||||
|
imp:textinput("k7qw3m")
|
||||||
|
eq(imp._syncModal.share, "K7QW3M", "a typed share code is normalized")
|
||||||
|
imp:_syncGetShare()
|
||||||
|
eq(eng.calls[#eng.calls][2], "K7QW3M", "and handed to the engine as typed")
|
||||||
|
imp._syncModal.progress = nil
|
||||||
|
imp:_syncApplyMods()
|
||||||
|
eq(imp._syncModal.progress, nil,
|
||||||
|
"the progress line is cleared once the apply returns")
|
||||||
|
|
||||||
|
imp:_syncResolve("red/abc", "both")
|
||||||
|
eq(eng.calls[#eng.calls][1], "resolve", "the conflict buttons call the engine")
|
||||||
|
eq(eng.calls[#eng.calls][3], "both", "with the choice the player pressed")
|
||||||
|
|
||||||
|
imp:_syncUnlink()
|
||||||
|
eq(eng.isLinked, false, "Unlink drops the device")
|
||||||
|
eq(imp._syncModal.view, "home", "and the modal returns to the home view")
|
||||||
|
|
||||||
|
local bare = RomImporter.new(function() end, { launcher = true })
|
||||||
|
bare._sync = false
|
||||||
|
bare._syncTransportOk = true
|
||||||
|
bare:_openSync()
|
||||||
|
check(bare._syncModal ~= nil, "the modal opens without an engine")
|
||||||
|
bare:_closeSync()
|
||||||
|
eq(bare._syncModal, nil, "and closes again")
|
||||||
|
|
||||||
|
local function controls(imp2)
|
||||||
|
love.graphics.getDimensions = function() return 900, 780 end
|
||||||
|
love.graphics.getPixelDimensions = love.graphics.getDimensions
|
||||||
|
Kit.audit = {}
|
||||||
|
local ok, err = pcall(LauncherView.draw, imp2)
|
||||||
|
local labels = {}
|
||||||
|
for _, r in ipairs(Kit.audit or {}) do
|
||||||
|
if r.class == "control" then labels[r.label] = true end
|
||||||
|
end
|
||||||
|
Kit.audit = nil
|
||||||
|
check(ok, "the sync modal draws: " .. tostring(err))
|
||||||
|
return labels
|
||||||
|
end
|
||||||
|
|
||||||
|
local rEng = fakeEngine()
|
||||||
|
local rImp = launcher(rEng)
|
||||||
|
rImp:_openSync()
|
||||||
|
local labels = controls(rImp)
|
||||||
|
check(labels["Create sync account"], "an unlinked device is offered an account")
|
||||||
|
check(labels["Link this device"], "and the link road")
|
||||||
|
|
||||||
|
rEng:createAccount("mac")
|
||||||
|
labels = controls(rImp)
|
||||||
|
check(labels["Sync now"], "a linked device can sync on demand")
|
||||||
|
check(labels["Unlink this device"], "and unlink")
|
||||||
|
check(labels["Share or get a mod list"], "and reach the mod list road")
|
||||||
|
|
||||||
|
rImp:_syncView("link")
|
||||||
|
labels = controls(rImp)
|
||||||
|
check(labels["Back"], "the link view can back out")
|
||||||
|
|
||||||
|
rImp:_syncView("mods")
|
||||||
|
rEng.shareCode = "K7QW3M"
|
||||||
|
rEng.modPlan = { indexes = { "https://x" }, toInstall = { { id = "a" } },
|
||||||
|
toEnable = {}, missing = {} }
|
||||||
|
labels = controls(rImp)
|
||||||
|
check(labels["Share mod list"], "the mod view shares a list")
|
||||||
|
check(labels["Get mod list"], "and fetches one")
|
||||||
|
check(labels["Apply these mods"], "a fetched plan can be applied")
|
||||||
|
|
||||||
|
rImp:_syncView("home")
|
||||||
|
rEng.devices = {
|
||||||
|
{ id = "0a1b2c3d", label = "OS X", current = true },
|
||||||
|
{ id = "99998888", label = "Android" },
|
||||||
|
}
|
||||||
|
labels = controls(rImp)
|
||||||
|
check(labels["Unlink Android"], "the other linked devices can be revoked here")
|
||||||
|
check(labels["OS X \194\183 this device"],
|
||||||
|
"and this one is named rather than offered twice")
|
||||||
|
|
||||||
|
local devRows = LauncherView.syncDeviceRows(rEng)
|
||||||
|
eq(#devRows, 2, "the modal reads the device list off the engine")
|
||||||
|
eq(devRows[1].current, true, "knowing which one is this device")
|
||||||
|
eq(#LauncherView.syncDeviceRows({}), 0,
|
||||||
|
"an engine that has not synced yet lists nothing")
|
||||||
|
|
||||||
|
local offline = launcher(fakeEngine())
|
||||||
|
offline._syncTransportOk = false
|
||||||
|
offline:_openSync()
|
||||||
|
labels = controls(offline)
|
||||||
|
check(not labels["Create sync account"],
|
||||||
|
"a device with no way to send signed requests is not offered an account")
|
||||||
|
check(labels["Close"], "it just explains itself and closes")
|
||||||
|
|
||||||
|
rEng.devices = nil
|
||||||
|
rEng.phase = "conflict"
|
||||||
|
rEng.conflicts = { {
|
||||||
|
key = "red/abc", version = "red", overlap = true,
|
||||||
|
localMeta = { savedAt = 1700000000, sessionStart = 1699999000,
|
||||||
|
summary = { name = "ASH", badges = 3, timeText = "5:42", dexCount = 40 } },
|
||||||
|
remoteMeta = { savedAt = 1700000500, sessionStart = 1699999500,
|
||||||
|
summary = { name = "ASH", badges = 4, timeText = "6:10", dexCount = 44 } },
|
||||||
|
} }
|
||||||
|
labels = controls(rImp)
|
||||||
|
check(labels["Keep this device"], "a conflict offers this device")
|
||||||
|
check(labels["Keep the other device"], "the other device")
|
||||||
|
check(labels["Keep both"], "and keeping both")
|
||||||
|
check(not labels["Sync now"],
|
||||||
|
"a conflict takes over the modal until it is answered")
|
||||||
|
|
||||||
|
local side = LauncherView.syncSideText({ savedAt = 1700000000,
|
||||||
|
summary = { name = "ASH", badges = 3, timeText = "5:42", dexCount = 40 } })
|
||||||
|
check(side:find("ASH", 1, true) ~= nil, "a side summary names the trainer")
|
||||||
|
check(side:find("3 badges", 1, true) ~= nil, "counts badges")
|
||||||
|
check(side:find("5:42", 1, true) ~= nil, "and shows play time")
|
||||||
|
eq(LauncherView.syncSideText(nil), "no details",
|
||||||
|
"a side with no metadata says so rather than drawing blank")
|
||||||
|
|
||||||
|
local quiet = launcher(fakeEngine())
|
||||||
|
quiet:_pumpSync(0.016)
|
||||||
|
eq(quiet._syncModal, nil, "a quiet auto-sync never interrupts the launcher")
|
||||||
|
|
||||||
|
local raised = launcher(fakeEngine({ phase = "conflict",
|
||||||
|
conflicts = { { key = "red/abc", version = "red" } } }))
|
||||||
|
raised:_pumpSync(0.016)
|
||||||
|
check(raised._syncModal ~= nil,
|
||||||
|
"a conflict found by the boot sync opens the prompt on its own")
|
||||||
|
raised:_closeSync()
|
||||||
|
raised:_pumpSync(0.016)
|
||||||
|
eq(raised._syncModal, nil,
|
||||||
|
"and a prompt the player dismissed does not reopen every frame")
|
||||||
|
|
||||||
|
local view = read("src/import/LauncherView.lua")
|
||||||
|
local impSrc = read("src/import/RomImporter.lua")
|
||||||
|
|
||||||
|
check(view:find('"tab-sync"', 1, true) ~= nil,
|
||||||
|
"the header tab row carries a Save Sync button")
|
||||||
|
local header = view:match("local HEADER_TABS = %{(.-)%}\n")
|
||||||
|
check(header and header:find('id = "skins"', 1, true) ~= nil,
|
||||||
|
"and it sits beside the skins tab")
|
||||||
|
check(view:find('"BETA"', 1, true) ~= nil,
|
||||||
|
"the button and the modal are labelled BETA")
|
||||||
|
check(view:find("buildSyncModal", 1, true) ~= nil,
|
||||||
|
"the sync UI is a modal, so it works from any tab")
|
||||||
|
local modals = view:match("local function modalUp%(imp%)(.-)\nend")
|
||||||
|
check(modals and modals:find("_syncModal", 1, true) ~= nil,
|
||||||
|
"the modal raises the click shield like every other one")
|
||||||
|
check(view:find("if imp._syncModal then buildSyncModal", 1, true) ~= nil,
|
||||||
|
"and buildModals routes it")
|
||||||
|
|
||||||
|
check(impSrc:find("_pumpSync(dt)", 1, true) ~= nil,
|
||||||
|
"the launcher pumps the sync engine every frame")
|
||||||
|
local pump = impSrc:match("function RomImporter:_pumpSync%(dt%)(.-)\nend\n")
|
||||||
|
check(pump and pump:find("self.launcher", 1, true) ~= nil,
|
||||||
|
"only the interactive launcher boots an engine of its own")
|
||||||
|
check(impSrc:find("_syncTypeInto", 1, true) ~= nil,
|
||||||
|
"text input is routed through the code filter")
|
||||||
|
|
||||||
|
T.finish("launcher_sync_modal")
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
-- BoxMenu's Yellow-only "Pikachu looks unhappy" release path
|
||||||
|
-- (release()'s isYellow()/species=="PIKACHU"/otId/ot branch) pushes its
|
||||||
|
-- TextBox with `TextBox.new(game, (...):gsub(...))` -- the gsub call is
|
||||||
|
-- the last argument, unparenthesized, so Lua expands its second return
|
||||||
|
-- value (the substitution count) into TextBox.new's third parameter,
|
||||||
|
-- onDone. TextBox.lua later calls onDone() unconditionally once the box
|
||||||
|
-- is dismissed, and a number is not callable: every release of your own
|
||||||
|
-- caught Pikachu in Yellow crashed, regardless of its nickname (unlike
|
||||||
|
-- the separate %-escape gsub bug, this needs no special save content --
|
||||||
|
-- ordinary play reaches it every time). ROM-free: registers a fake
|
||||||
|
-- Data.pokemon.PIKACHU cloned from the fixture species so the species ==
|
||||||
|
-- "PIKACHU" check can be exercised without a real ROM import.
|
||||||
|
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||||
|
|
||||||
|
local T = require("tests.modkit")
|
||||||
|
local Data = T.fixtures.fresh()
|
||||||
|
local ids = T.fixtures.ids
|
||||||
|
require("src.render.Font").load(Data)
|
||||||
|
|
||||||
|
-- clone a real fixture species under the literal id release() checks for
|
||||||
|
Data.pokemon.PIKACHU = Data.pokemon[ids.species[1]]
|
||||||
|
|
||||||
|
local Pokemon = require("src.pokemon.Pokemon")
|
||||||
|
local Boxes = require("src.pokemon.Boxes")
|
||||||
|
local TextBox = require("src.render.TextBox")
|
||||||
|
local BoxMenu = require("src.ui.BoxMenu")
|
||||||
|
local ListMenu = require("src.ui.ListMenu")
|
||||||
|
local ChoiceBox = require("src.ui.ChoiceBox")
|
||||||
|
local SaveData = require("src.core.SaveData")
|
||||||
|
local GameVersion = require("src.core.GameVersion")
|
||||||
|
local Sound = require("src.core.Sound")
|
||||||
|
|
||||||
|
local realCry, realPlay = Sound.playCry, Sound.play
|
||||||
|
Sound.playCry = function() end
|
||||||
|
Sound.play = function() end
|
||||||
|
|
||||||
|
local stack = { states = {} }
|
||||||
|
function stack:push(s) self.states[#self.states + 1] = s end
|
||||||
|
function stack:pop()
|
||||||
|
local t = self.states[#self.states]
|
||||||
|
self.states[#self.states] = nil
|
||||||
|
return t
|
||||||
|
end
|
||||||
|
function stack:top() return self.states[#self.states] end
|
||||||
|
function stack:update(dt)
|
||||||
|
local t = self:top()
|
||||||
|
if t and t.update then t:update(dt) end
|
||||||
|
end
|
||||||
|
|
||||||
|
local pressed = {}
|
||||||
|
local function press(btn)
|
||||||
|
pressed = { [btn] = true }
|
||||||
|
stack:update(1 / 60)
|
||||||
|
pressed = {}
|
||||||
|
end
|
||||||
|
|
||||||
|
local function topMt() return getmetatable(stack:top()) end
|
||||||
|
local function mash(btn, cond, n)
|
||||||
|
for _ = 1, (n or 400) do
|
||||||
|
if cond() then return true end
|
||||||
|
press(btn)
|
||||||
|
end
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
GameVersion.set("yellow")
|
||||||
|
|
||||||
|
local save = SaveData.newGame()
|
||||||
|
local game = {
|
||||||
|
data = Data,
|
||||||
|
save = save,
|
||||||
|
stack = stack,
|
||||||
|
input = {
|
||||||
|
wasPressed = function(_, key) return pressed[key] or false end,
|
||||||
|
isDown = function() return false end,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
game.save.options = game.save.options or {}
|
||||||
|
game.save.options.textSpeed = 1
|
||||||
|
|
||||||
|
local box = Boxes.active(save)
|
||||||
|
local mon = Pokemon.new(Data, "PIKACHU", 5)
|
||||||
|
mon.otId = save.player.id
|
||||||
|
mon.ot = save.player.name
|
||||||
|
box[1] = mon
|
||||||
|
|
||||||
|
stack:push(BoxMenu.new(game))
|
||||||
|
press("down"); press("down"); press("a") -- open RELEASE list
|
||||||
|
T.check(topMt() == ListMenu, "RELEASE opens the box list")
|
||||||
|
|
||||||
|
-- release() calls Sound.playCry (stubbed) then pushes the "unhappy"
|
||||||
|
-- TextBox before any confirmation prompt -- pre-fix this line itself
|
||||||
|
-- raises "attempt to call field 'onDone' (a number value)" the moment
|
||||||
|
-- TextBox.new stores the leaked count and something dismisses the box.
|
||||||
|
local ok, err = pcall(function()
|
||||||
|
press("a") -- choose the Pikachu; release() runs synchronously here
|
||||||
|
T.check(topMt() == TextBox, "the unhappy-Pikachu TextBox opens directly, no confirm prompt")
|
||||||
|
-- dismiss it: this is what calls onDone, which is where the pre-fix
|
||||||
|
-- leaked count used to crash
|
||||||
|
mash("a", function() return topMt() ~= TextBox end)
|
||||||
|
end)
|
||||||
|
T.check(ok, "releasing your own caught Pikachu in Yellow does not crash: " .. tostring(err))
|
||||||
|
|
||||||
|
GameVersion.set("red")
|
||||||
|
Sound.playCry, Sound.play = realCry, realPlay
|
||||||
|
T.finish("pikachu_unhappy_release_crash")
|
||||||
@@ -26,5 +26,7 @@ check(helperStart ~= nil, "requiredFilesFor helper exists")
|
|||||||
local helper = src:sub(helperStart, start)
|
local helper = src:sub(helperStart, start)
|
||||||
check(helper:find("VERSION_REQUIRED_FILES_OVERRIDE", 1, true) ~= nil,
|
check(helper:find("VERSION_REQUIRED_FILES_OVERRIDE", 1, true) ~= nil,
|
||||||
"requiredFilesFor consults VERSION_REQUIRED_FILES_OVERRIDE")
|
"requiredFilesFor consults VERSION_REQUIRED_FILES_OVERRIDE")
|
||||||
|
check(src:find('"assets/generated/battle/hud/balls.png"', 1, true) ~= nil,
|
||||||
|
"Gold caches require the trainer HUD ball sheet")
|
||||||
|
|
||||||
T.finish()
|
T.finish()
|
||||||
|
|||||||
@@ -0,0 +1,137 @@
|
|||||||
|
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||||
|
|
||||||
|
local S = require("tests.harness").suite("safe mode and issue report")
|
||||||
|
local check = S.check
|
||||||
|
|
||||||
|
local SaveData = require("src.core.SaveData")
|
||||||
|
local LauncherMods = require("src.mods.LauncherMods")
|
||||||
|
local IssueReport = require("src.core.IssueReport")
|
||||||
|
local Version = require("src.core.Version")
|
||||||
|
|
||||||
|
local options = SaveData.defaultOptions()
|
||||||
|
check(not SaveData.isSafeMode(options), "safe mode defaults off")
|
||||||
|
SaveData.setSafeMode(options, true)
|
||||||
|
check(SaveData.isSafeMode(options), "safe mode can be enabled")
|
||||||
|
|
||||||
|
local manifests = {
|
||||||
|
{ id = "alpha", name = "Alpha", version = "1.0.0", experimental = false,
|
||||||
|
raw = {}, dependencySpecs = {}, conflictSpecs = {} },
|
||||||
|
{ id = "beta", name = "Beta", version = "1.0.0", experimental = false,
|
||||||
|
raw = {}, dependencySpecs = {}, conflictSpecs = {} },
|
||||||
|
}
|
||||||
|
options.mods.alpha = false
|
||||||
|
options.mods.beta = true
|
||||||
|
local rows = LauncherMods.deriveList(manifests, options, "red")
|
||||||
|
check(#rows == 2, "safe mode keeps installed mods visible")
|
||||||
|
check(not rows[1].enabled and not rows[2].enabled,
|
||||||
|
"safe mode disables every launcher mod row")
|
||||||
|
check(rows[1].status == "safe_mode" and rows[2].status == "safe_mode",
|
||||||
|
"safe mode explains every disabled launcher row")
|
||||||
|
|
||||||
|
SaveData.setSafeMode(options, false)
|
||||||
|
rows = LauncherMods.deriveList(manifests, options, "red")
|
||||||
|
local byId = {}
|
||||||
|
for _, row in ipairs(rows) do byId[row.id] = row end
|
||||||
|
check(not byId.alpha.enabled and byId.beta.enabled,
|
||||||
|
"turning safe mode off restores saved mod choices")
|
||||||
|
|
||||||
|
local previousLove = _G.love
|
||||||
|
local openedURL
|
||||||
|
_G.love = {
|
||||||
|
getVersion = function() return 12, 0, 0, "Mysterious Mysteries" end,
|
||||||
|
system = {
|
||||||
|
getOS = function() return "iOS" end,
|
||||||
|
getModel = function() return "iPad Test" end,
|
||||||
|
openURL = function(url) openedURL = url end,
|
||||||
|
},
|
||||||
|
graphics = {
|
||||||
|
getRendererInfo = function()
|
||||||
|
return "Metal", "3.0", "Apple", "Simulator GPU"
|
||||||
|
end,
|
||||||
|
getDimensions = function() return 1024, 768 end,
|
||||||
|
getPixelDimensions = function() return 2048, 1536 end,
|
||||||
|
},
|
||||||
|
window = {
|
||||||
|
getMode = function() return 1024, 768, { fullscreen = false } end,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
local url, fields, info = IssueReport.build({
|
||||||
|
safeMode = true,
|
||||||
|
lastVersion = "gold",
|
||||||
|
}, {
|
||||||
|
version = "gold",
|
||||||
|
mods = { { id = "alpha", name = "Alpha", enabled = true } },
|
||||||
|
})
|
||||||
|
check(url:find("template=bug_report.yml", 1, true) ~= nil,
|
||||||
|
"report URL selects the bug form")
|
||||||
|
check(url:find("title=bug%3A%20replace%20this%20with%20a%20meaningful%20title", 1, true) ~= nil,
|
||||||
|
"report URL uses the requested bug title")
|
||||||
|
check(info.os == "iOS",
|
||||||
|
"report metadata maps the platform")
|
||||||
|
check(fields.mods_which == "",
|
||||||
|
"safe mode leaves the optional mod list blank")
|
||||||
|
check(not url:find("game=", 1, true)
|
||||||
|
and not url:find("os=", 1, true)
|
||||||
|
and not url:find("mods_enabled=", 1, true),
|
||||||
|
"report URL omits unsupported dropdown and checkbox prefills")
|
||||||
|
check(fields.summary == "" and fields.location == "" and fields.screenshot == ""
|
||||||
|
and fields.steps == "" and fields.expected == "",
|
||||||
|
"report leaves user-entered fields blank")
|
||||||
|
check(info.metadata:find("Device: iPad Test", 1, true) ~= nil
|
||||||
|
and info.metadata:find("LÖVE: 12.0.0", 1, true) ~= nil
|
||||||
|
and info.metadata:find("Safe mode: on", 1, true) ~= nil,
|
||||||
|
"report metadata includes device and app details")
|
||||||
|
check(not info.metadata:find("unknown", 1, true),
|
||||||
|
"report metadata omits unknown values")
|
||||||
|
check(not info.metadata:find("Game id", 1, true)
|
||||||
|
and not info.metadata:find("Game:", 1, true)
|
||||||
|
and not info.metadata:find("Mods:", 1, true)
|
||||||
|
and not info.metadata:find("Processors", 1, true)
|
||||||
|
and not info.metadata:find("Power", 1, true),
|
||||||
|
"report metadata omits redundant system fields")
|
||||||
|
|
||||||
|
local previousEngine = Version.engine
|
||||||
|
Version.engine = "0.1.50"
|
||||||
|
local _, versionFields, versionInfo = IssueReport.build({}, { mods = {} })
|
||||||
|
check(versionFields.version == "0.1.50"
|
||||||
|
and versionInfo.metadata:find("App: gen1recomp v0.1.50", 1, true) ~= nil,
|
||||||
|
"report uses the stamped app version")
|
||||||
|
Version.engine = previousEngine
|
||||||
|
local _, _, developmentInfo = IssueReport.build({}, { mods = {} })
|
||||||
|
check(not developmentInfo.metadata:find("0.0.0-dev", 1, true),
|
||||||
|
"report omits an unstamped development version")
|
||||||
|
|
||||||
|
local previousOS = love.system.getOS
|
||||||
|
local previousModel = love.system.getModel
|
||||||
|
local previousIO = _G.io
|
||||||
|
love.system.getOS = function() return "OS X" end
|
||||||
|
love.system.getModel = nil
|
||||||
|
_G.io = {
|
||||||
|
popen = function()
|
||||||
|
return {
|
||||||
|
read = function() return "MacBookPro18,3" end,
|
||||||
|
close = function() end,
|
||||||
|
}
|
||||||
|
end,
|
||||||
|
}
|
||||||
|
local desktopInfo = IssueReport.metadata({}, { mods = {} })
|
||||||
|
check(desktopInfo.device == "MacBookPro18,3",
|
||||||
|
"report finds desktop device model when LOVE has no model")
|
||||||
|
love.system.getOS = function() return "UWP" end
|
||||||
|
local xboxInfo = IssueReport.metadata({}, { mods = {} })
|
||||||
|
check(xboxInfo.os == "Xbox", "report maps the Xbox runtime platform")
|
||||||
|
love.system.getOS = previousOS
|
||||||
|
love.system.getModel = previousModel
|
||||||
|
_G.io = previousIO
|
||||||
|
|
||||||
|
local opened = IssueReport.open({ safeMode = false }, {
|
||||||
|
version = "red",
|
||||||
|
mods = {},
|
||||||
|
})
|
||||||
|
check(opened and openedURL and openedURL:find("title=bug%3A%20replace%20this%20with%20a%20meaningful%20title", 1, true) ~= nil,
|
||||||
|
"report action opens the generated URL")
|
||||||
|
|
||||||
|
_G.love = previousLove
|
||||||
|
|
||||||
|
S.finish()
|
||||||
@@ -0,0 +1,626 @@
|
|||||||
|
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 TouchSkin = require("src.core.TouchSkin")
|
||||||
|
local DeltaSkin = require("src.core.DeltaSkin")
|
||||||
|
local Json = require("src.link.Json")
|
||||||
|
|
||||||
|
local function near(got, want, msg)
|
||||||
|
return check(type(got) == "number" and math.abs(got - want) < 1e-6,
|
||||||
|
("%s (got %s, want %s)"):format(msg, tostring(got), tostring(want)))
|
||||||
|
end
|
||||||
|
|
||||||
|
local function hasWarning(skin, fragment)
|
||||||
|
for _, w in ipairs(skin.warnings or {}) do
|
||||||
|
if tostring(w):find(fragment, 1, true) then return true end
|
||||||
|
end
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
local function unzip(bytes)
|
||||||
|
local out, i = {}, 1
|
||||||
|
while bytes:sub(i, i + 3) == "PK\3\4" do
|
||||||
|
local function u16(off)
|
||||||
|
local a, b = bytes:byte(i + off, i + off + 1)
|
||||||
|
return a + b * 256
|
||||||
|
end
|
||||||
|
local function u32(off)
|
||||||
|
local a, b, c, d = bytes:byte(i + off, i + off + 3)
|
||||||
|
return a + b * 256 + c * 65536 + d * 16777216
|
||||||
|
end
|
||||||
|
local size, nameLen, extraLen = u32(18), u16(26), u16(28)
|
||||||
|
local name = bytes:sub(i + 30, i + 29 + nameLen)
|
||||||
|
local start = i + 30 + nameLen + extraLen
|
||||||
|
out[name] = bytes:sub(start, start + size - 1)
|
||||||
|
out[#out + 1] = name
|
||||||
|
i = start + size
|
||||||
|
end
|
||||||
|
return out
|
||||||
|
end
|
||||||
|
|
||||||
|
local function readBytes(path)
|
||||||
|
local f = assert(io.open(path, "rb"))
|
||||||
|
local data = f:read("*a")
|
||||||
|
f:close()
|
||||||
|
return data
|
||||||
|
end
|
||||||
|
|
||||||
|
local GAMEBOY_CFG = [[
|
||||||
|
overlays = 4
|
||||||
|
|
||||||
|
overlay0_name = "landscape"
|
||||||
|
overlay0_full_screen = true
|
||||||
|
overlay0_normalized = true
|
||||||
|
overlay0_range_mod = 1.5
|
||||||
|
overlay0_alpha_mod = 2.0
|
||||||
|
overlay0_aspect_ratio = 2.22222222222222
|
||||||
|
overlay0_descs = 13
|
||||||
|
overlay0_desc0 = "nul,0.0985,0.6825,rect,0.0525,0.0875"
|
||||||
|
overlay0_desc0_overlay = img/dpad.png
|
||||||
|
overlay0_desc1 = "up,0.0985,0.5950,rect,0.0175,0.0292"
|
||||||
|
overlay0_desc2 = "down,0.0985,0.7700,rect,0.0175,0.0292"
|
||||||
|
overlay0_desc3 = "left,0.0460,0.6825,rect,0.0175,0.0292"
|
||||||
|
overlay0_desc4 = "right,0.1510,0.6825,rect,0.0175,0.0292"
|
||||||
|
overlay0_desc5 = "left|up,0.0460,0.5950,rect,0.0175,0.0292"
|
||||||
|
overlay0_desc6 = "right|up,0.1510,0.5950,rect,0.0175,0.0292"
|
||||||
|
overlay0_desc7 = "left|down,0.0460,0.7700,rect,0.0175,0.0292"
|
||||||
|
overlay0_desc8 = "right|down,0.1510,0.7700,rect,0.0175,0.0292"
|
||||||
|
overlay0_desc9 = "a,0.8975,0.6300,radial,0.0525,0.0875"
|
||||||
|
overlay0_desc9_overlay = img/a.png
|
||||||
|
overlay0_desc10 = "b,0.8100,0.7350,radial,0.0525,0.0875"
|
||||||
|
overlay0_desc10_overlay = img/b.png
|
||||||
|
overlay0_desc11 = "start,0.5500,0.9000,rect,0.0500,0.0400"
|
||||||
|
overlay0_desc12 = "select,0.4500,0.9000,rect,0.0500,0.0400"
|
||||||
|
|
||||||
|
overlay1_name = "portrait"
|
||||||
|
overlay1_full_screen = true
|
||||||
|
overlay1_normalized = true
|
||||||
|
overlay1_aspect_ratio = 0.45
|
||||||
|
overlay1_descs = 2
|
||||||
|
overlay1_desc0 = "a,0.8975,0.6300,radial,0.0875,0.0525"
|
||||||
|
overlay1_desc1 = "b,0.8100,0.7350,radial,0.0875,0.0525"
|
||||||
|
|
||||||
|
overlay2_name = "menu"
|
||||||
|
overlay2_full_screen = true
|
||||||
|
overlay2_normalized = true
|
||||||
|
overlay2_descs = 1
|
||||||
|
overlay2_desc0 = "menu_toggle,0.5,0.5,rect,0.1,0.1"
|
||||||
|
|
||||||
|
overlay3_name = "hide"
|
||||||
|
overlay3_full_screen = true
|
||||||
|
overlay3_normalized = true
|
||||||
|
overlay3_descs = 1
|
||||||
|
overlay3_desc0 = "overlay_next,0.95,0.05,radial,0.04,0.04"
|
||||||
|
overlay3_desc0_next_target = "landscape"
|
||||||
|
]]
|
||||||
|
|
||||||
|
local gameboy = assert(TouchSkin.parse(GAMEBOY_CFG))
|
||||||
|
eq(#gameboy.pages, 4, "the canonical gameboy overlay has four pages")
|
||||||
|
eq(gameboy.pages[1].orient, "landscape", "page 1 auto-rotates landscape")
|
||||||
|
eq(gameboy.pages[2].orient, "portrait", "page 2 auto-rotates portrait")
|
||||||
|
check(TouchSkin.hasOrientPair(gameboy), "so it is an auto-rotate overlay")
|
||||||
|
eq(gameboy.pages[3].orient, nil, "the menu page is not part of the pair")
|
||||||
|
eq(#gameboy.pages[1].controls, 13, "every landscape desc parsed")
|
||||||
|
check(gameboy.pages[1].controls[1].decorative, "the d-pad art desc binds nothing")
|
||||||
|
eq(gameboy.pages[1].controls[1].imagePath, "img/dpad.png", "and carries the art")
|
||||||
|
eq(gameboy.pages[1].imagePath, nil, "the overlay ships no page background")
|
||||||
|
eq(gameboy.pages[4].controls[1].nextTarget, "landscape", "hide jumps back by name")
|
||||||
|
local named = {}
|
||||||
|
for _, ctl in ipairs(gameboy.pages[1].controls) do
|
||||||
|
for _, btn in ipairs(ctl.buttons) do named[btn] = true end
|
||||||
|
end
|
||||||
|
for _, btn in ipairs({ "a", "b", "start", "select", "up", "down", "left", "right" }) do
|
||||||
|
check(named[btn], "landscape binds GB " .. btn)
|
||||||
|
end
|
||||||
|
eq(#gameboy.warnings, 0, "a well-formed overlay warns about nothing")
|
||||||
|
|
||||||
|
local SPACED_CFG = [[
|
||||||
|
overlays = 1
|
||||||
|
overlay0_name = "spaced"
|
||||||
|
overlay0_normalized = true
|
||||||
|
overlay0_descs = 2
|
||||||
|
overlay0_desc0 = "a 0.5 0.5 rect 0.05 0.05"
|
||||||
|
overlay0_desc0_saturate_pct = 0.6
|
||||||
|
overlay0_desc0_exclusive = true
|
||||||
|
overlay0_desc0_movable = true
|
||||||
|
overlay0_desc1 = b,0.25,0.5,radial,0.05,0.05
|
||||||
|
]]
|
||||||
|
local spaced = assert(TouchSkin.parse(SPACED_CFG))
|
||||||
|
near(spaced.pages[1].controls[1].saturatePct, 0.6, "_saturate_pct is parsed")
|
||||||
|
check(spaced.pages[1].controls[1].exclusive, "_exclusive is parsed")
|
||||||
|
check(spaced.pages[1].controls[1].movable, "_movable is parsed on a plain desc")
|
||||||
|
eq(spaced.pages[1].controls[2].exclusive, nil, "and is not inherited")
|
||||||
|
eq(#spaced.pages[1].controls, 2, "a space-separated desc still parses")
|
||||||
|
eq(spaced.pages[1].controls[1].buttons[1], "a", "space-separated bind")
|
||||||
|
near(spaced.pages[1].controls[1].x, 0.5, "space-separated position")
|
||||||
|
eq(spaced.pages[1].controls[2].buttons[1], "b", "an unquoted desc parses too")
|
||||||
|
eq(spaced.pages[1].controls[2].shape, "radial", "and keeps its hitbox shape")
|
||||||
|
|
||||||
|
local SHORT_CFG = [[
|
||||||
|
overlays = 1
|
||||||
|
overlay0_name = "short"
|
||||||
|
overlay0_normalized = true
|
||||||
|
overlay0_descs = 2
|
||||||
|
overlay0_desc0 = "a,0.5,0.5,rect,0.05,0.05"
|
||||||
|
]]
|
||||||
|
local short = assert(TouchSkin.parse(SHORT_CFG))
|
||||||
|
eq(#short.pages[1].controls, 1, "a missing desc is skipped, not faked")
|
||||||
|
check(hasWarning(short, "missing desc 1"), "and the importer says so")
|
||||||
|
|
||||||
|
eq(select(1, TouchSkin.parse("overlay0_descs = 1\n")), nil,
|
||||||
|
"a cfg without the overlays key is refused")
|
||||||
|
|
||||||
|
local AREA_CFG = [[
|
||||||
|
overlays = 1
|
||||||
|
overlay0_name = "portrait"
|
||||||
|
overlay0_full_screen = true
|
||||||
|
overlay0_normalized = true
|
||||||
|
overlay0_descs = 3
|
||||||
|
overlay0_desc0 = "dpad_area,0.2,0.7,rect,0.15,0.1"
|
||||||
|
overlay0_desc0_overlay = img/dpad.png
|
||||||
|
overlay0_desc0_reach_x = 1.5
|
||||||
|
overlay0_desc0_movable = true
|
||||||
|
overlay0_desc1 = "abxy_area,0.8,0.7,radial,0.12,0.08"
|
||||||
|
overlay0_desc1_up = "start"
|
||||||
|
overlay0_desc2 = "analog_left,0.2,0.3,radial,0.1,0.1"
|
||||||
|
overlay0_desc2_saturate_pct = 0.6
|
||||||
|
overlay0_desc2_exclusive = true
|
||||||
|
]]
|
||||||
|
local area = assert(TouchSkin.parse(AREA_CFG))
|
||||||
|
local ap = area.pages[1]
|
||||||
|
near(ap.aspect, 0.5625, "a portrait-named overlay defaults to 9:16")
|
||||||
|
check(not ap.aspectFromCfg, "and that default is not a cfg aspect lock")
|
||||||
|
eq(#ap.controls, 1 + 8 + 8 + 8, "each area desc expands into eight hitboxes")
|
||||||
|
|
||||||
|
local art = ap.controls[1]
|
||||||
|
check(art.decorative, "the dpad_area art is carried by a decoration")
|
||||||
|
eq(art.imagePath, "img/dpad.png", "with the desc's own overlay image")
|
||||||
|
near(art.rangeX, 0.15, "sized like the area it replaces")
|
||||||
|
|
||||||
|
local sectorE = ap.controls[2]
|
||||||
|
eq(sectorE.spec, "right", "the first sector is the one pointing right")
|
||||||
|
near(sectorE.x, 0.2, "every sector sits on the area centre")
|
||||||
|
near(sectorE.y, 0.7, "on both axes")
|
||||||
|
near(sectorE.rangeX, 0.15, "and covers the whole area, not a ninth of it")
|
||||||
|
near(sectorE.reachLeft, 1.5, "the desc reach_x rides onto the sectors as it is")
|
||||||
|
near(sectorE.reachRight, 1.5, "on both sides")
|
||||||
|
eq(sectorE.sector, 1, "the sector index is kept for the hit test")
|
||||||
|
eq(ap.controls[3].spec, "right|down", "the next sector is the lower-right corner")
|
||||||
|
eq(ap.controls[4].spec, "down", "then straight down, y growing downwards")
|
||||||
|
eq(ap.controls[8].spec, "up", "and straight up seven sectors along")
|
||||||
|
check(ap.controls[1].movable, "_movable is parsed")
|
||||||
|
|
||||||
|
local abxy = ap.controls[10]
|
||||||
|
eq(abxy.spec, "a", "abxy right is RetroPad a, which is GB A")
|
||||||
|
eq(abxy.buttons[1], "a", "and reaches that GB button")
|
||||||
|
eq(ap.controls[16].spec, "start", "abxy_area honours an _up override")
|
||||||
|
eq(ap.controls[15].spec, "y|start", "the up-left sector combines both sides")
|
||||||
|
check(ap.controls[15].exclusive == nil, "and inherits nothing the desc did not set")
|
||||||
|
check(ap.controls[14].decorative, "RetroPad Y has no GB button, so that sector is inert")
|
||||||
|
eq(abxy.shape, "radial", "a radial area keeps its ellipse")
|
||||||
|
|
||||||
|
eq(ap.controls[18].spec, "right", "analog_left degrades to a directional pad")
|
||||||
|
check(ap.controls[18].exclusive, "_exclusive rides onto the expanded sectors")
|
||||||
|
eq(ap.controls[23].spec, "left|up", "with all eight sectors")
|
||||||
|
near(ap.controls[18].rangeX, 0.1, "analog sectors share the whole stick area")
|
||||||
|
|
||||||
|
local sq = { x = 0.5, y = 0.5, rangeX = 0.25, rangeY = 0.25, shape = "rect",
|
||||||
|
rangeMod = 1, alphaMod = 1,
|
||||||
|
reachUp = 1, reachDown = 1, reachLeft = 1, reachRight = 1 }
|
||||||
|
local sectors = TouchSkin.expandSectors(sq, TouchSkin.AREA_DEFAULTS.dpad_area)
|
||||||
|
eq(#sectors, 8, "a dpad area expands into eight sector hitboxes")
|
||||||
|
local page = { rect = { x = 0, y = 0, w = 1, h = 1 }, fullScreen = true,
|
||||||
|
aspect = 1, controls = sectors }
|
||||||
|
local function hitSpecs(px, py)
|
||||||
|
local out = {}
|
||||||
|
for _, ctl in ipairs(sectors) do
|
||||||
|
if TouchSkin.hits(page, ctl, 100, 100, px, py, 0, 0) then out[#out + 1] = ctl.spec end
|
||||||
|
end
|
||||||
|
return table.concat(out, "+")
|
||||||
|
end
|
||||||
|
eq(hitSpecs(50, 50), "right", "the exact centre still fires a direction: no dead zone")
|
||||||
|
eq(hitSpecs(60, 50), "right", "a touch to the right of centre is right")
|
||||||
|
eq(hitSpecs(50, 60), "down", "a touch below centre is down, y growing downwards")
|
||||||
|
eq(hitSpecs(50, 40), "up", "a touch above centre is up")
|
||||||
|
eq(hitSpecs(40, 40), "left|up", "a diagonal touch fires both directions")
|
||||||
|
eq(hitSpecs(58, 52), "right", "17 degrees off the axis is still a pure direction")
|
||||||
|
eq(hitSpecs(55, 53), "right|down", "and 31 degrees is the diagonal, not a grid corner")
|
||||||
|
eq(hitSpecs(50, 80), "", "outside the area nothing fires")
|
||||||
|
|
||||||
|
local PIXEL_NO_IMAGE = [[
|
||||||
|
overlays = 1
|
||||||
|
overlay0_name = "pixels"
|
||||||
|
overlay0_descs = 1
|
||||||
|
overlay0_desc0 = "a,120,80,rect,20,10"
|
||||||
|
]]
|
||||||
|
local noImage = assert(TouchSkin.parse(PIXEL_NO_IMAGE))
|
||||||
|
check(hasWarning(noImage, "no base image"),
|
||||||
|
"pixel coords without a base image are called out")
|
||||||
|
check(noImage.pages[1].pixelCoords == false,
|
||||||
|
"and read as normalized rather than dividing by nothing")
|
||||||
|
|
||||||
|
love.filesystem.write("skins/px/overlay.cfg", [[
|
||||||
|
overlays = 1
|
||||||
|
overlay0_name = "px"
|
||||||
|
overlay0_overlay = img/base.png
|
||||||
|
overlay0_full_screen = true
|
||||||
|
overlay0_descs = 2
|
||||||
|
overlay0_desc0 = "a,4,4,rect,2,1"
|
||||||
|
overlay0_desc1 = "b,6,2,rect,1,1"
|
||||||
|
overlay0_desc1_normalized = true
|
||||||
|
]])
|
||||||
|
local px = assert(TouchSkin.load("skins/px", "px"))
|
||||||
|
local pxPage = px.pages[1]
|
||||||
|
check(pxPage.image ~= nil, "the base overlay image loads")
|
||||||
|
local iw, ih = pxPage.image:getDimensions()
|
||||||
|
near(pxPage.controls[1].x, 4 / iw, "pixel x is divided by the base image width")
|
||||||
|
near(pxPage.controls[1].y, 4 / ih, "pixel y is divided by the base image height")
|
||||||
|
near(pxPage.controls[1].rangeX, 2 / iw, "and so are the half extents")
|
||||||
|
near(pxPage.controls[2].x, 6, "a per-desc normalized flag opts that desc out")
|
||||||
|
check(pxPage.pixelCoords == false, "the page is normalized once converted")
|
||||||
|
|
||||||
|
love.filesystem.write("skins/pxbad/overlay.cfg", [[
|
||||||
|
overlays = 1
|
||||||
|
overlay0_name = "pxbad"
|
||||||
|
overlay0_overlay = img/broken.png
|
||||||
|
overlay0_descs = 1
|
||||||
|
overlay0_desc0 = "a,4,4,rect,2,1"
|
||||||
|
]])
|
||||||
|
local savedNewImage = love.graphics.newImage
|
||||||
|
love.graphics.newImage = function() error("unreadable image") end
|
||||||
|
local badPx, badPxErr = TouchSkin.load("skins/pxbad", "pxbad")
|
||||||
|
love.graphics.newImage = savedNewImage
|
||||||
|
eq(badPx, nil, "a skin whose pixel coordinates have no base image fails to load")
|
||||||
|
check(tostring(badPxErr):find("img/broken.png", 1, true) ~= nil,
|
||||||
|
"and the error names the image it could not read")
|
||||||
|
|
||||||
|
local DELTA_JSON = [[
|
||||||
|
{
|
||||||
|
"name": "Test GBC",
|
||||||
|
"identifier": "com.example.gbc.test",
|
||||||
|
"gameTypeIdentifier": "com.rileytestut.delta.game.gbc",
|
||||||
|
"debug": false,
|
||||||
|
"representations": {
|
||||||
|
"iphone": {
|
||||||
|
"edgeToEdge": {
|
||||||
|
"portrait": {
|
||||||
|
"assets": { "small": "p_small.png", "medium": "p_medium.png",
|
||||||
|
"large": "p_large.png" },
|
||||||
|
"items": [
|
||||||
|
{ "inputs": ["a"], "frame": {"x":240,"y":320,"width":64,"height":64},
|
||||||
|
"mask": "circle" },
|
||||||
|
{ "inputs": ["b"], "frame": {"x":160,"y":360,"width":64,"height":64},
|
||||||
|
"extendedEdges": {"right":16} },
|
||||||
|
{ "inputs": {"up":"up","down":"down","left":"left","right":"right"},
|
||||||
|
"frame": {"x":16,"y":320,"width":96,"height":96} },
|
||||||
|
{ "inputs": ["start","select"],
|
||||||
|
"frame": {"x":128,"y":448,"width":64,"height":32} },
|
||||||
|
{ "inputs": ["menu"], "frame": {"x":0,"y":0,"width":32,"height":32} },
|
||||||
|
{ "inputs": ["quickSave"],
|
||||||
|
"frame": {"x":288,"y":0,"width":32,"height":32} }
|
||||||
|
],
|
||||||
|
"mappingSize": {"width":320,"height":480},
|
||||||
|
"extendedEdges": {"top":8,"bottom":8,"left":8,"right":8},
|
||||||
|
"translucent": false,
|
||||||
|
"screens": [{ "inputFrame": {"x":0,"y":0,"width":160,"height":144},
|
||||||
|
"outputFrame": {"x":0,"y":32,"width":320,"height":288} }]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"standard": {
|
||||||
|
"portrait": { "items": [], "mappingSize": {"width":320,"height":480} }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]]
|
||||||
|
|
||||||
|
local delta = assert(TouchSkin ~= nil and DeltaSkin.parse(DELTA_JSON))
|
||||||
|
eq(delta.format, "delta", "a .deltaskin parses into the native model")
|
||||||
|
eq(delta.name, "Test GBC", "info.json name")
|
||||||
|
eq(delta.system, "gbc", "gbc covers both GB and GBC")
|
||||||
|
eq(#delta.pages, 1, "only the orientations present become pages")
|
||||||
|
local dp = delta.pages[1]
|
||||||
|
eq(dp.name, "portrait", "the page is named for its orientation")
|
||||||
|
eq(dp.orient, "portrait", "and locked to it")
|
||||||
|
eq(dp.imagePath, "p_large.png", "the PNG ladder picks the largest for a phone")
|
||||||
|
check(dp.fullScreen, "Delta stretches its skin over the whole surface")
|
||||||
|
check(not dp.aspectFromCfg, "so nothing letterboxes it")
|
||||||
|
near(dp.aspect, 320 / 480, "the page aspect is the mappingSize aspect")
|
||||||
|
eq(#dp.controls, 13, "edgeToEdge wins over standard, so all six items parsed")
|
||||||
|
|
||||||
|
local dA = dp.controls[1]
|
||||||
|
eq(dA.buttons[1], "a", "an inputs array binds its button")
|
||||||
|
eq(dA.shape, "radial", 'mask "circle" becomes a radial hitbox')
|
||||||
|
near(dA.x, 0.85, "frame top-left plus half width is the native centre")
|
||||||
|
near(dA.y, 352 / 480, "and the same for y")
|
||||||
|
near(dA.rangeX, 0.1, "frame width halves into the native half extent")
|
||||||
|
near(dA.reachLeft, 1.25, "orientation extendedEdges become reach")
|
||||||
|
|
||||||
|
local dB = dp.controls[2]
|
||||||
|
near(dB.reachRight, 1.5, "a per-item extendedEdges key overrides that side")
|
||||||
|
near(dB.reachLeft, 1.25, "and leaves the others inherited")
|
||||||
|
|
||||||
|
eq(dp.controls[3].spec, "left|up", "a dpad input object expands to a 3x3 grid")
|
||||||
|
near(dp.controls[3].x, 0.1, "dpad top-left cell x")
|
||||||
|
near(dp.controls[3].rangeX, 0.05, "dpad cells are a third of the frame")
|
||||||
|
near(dp.controls[3].reachLeft, 1.5, "with the extended edge re-scaled onto them")
|
||||||
|
eq(dp.controls[4].spec, "up", "dpad top-centre cell")
|
||||||
|
eq(dp.controls[10].spec, "right|down", "dpad bottom-right cell")
|
||||||
|
|
||||||
|
eq(dp.controls[11].spec, "start|select", "a multi-input item fires both")
|
||||||
|
eq(dp.controls[12].hotkeys[1], "menu", "the Delta menu button becomes a hotkey")
|
||||||
|
check(dp.controls[13].decorative,
|
||||||
|
"quickSave has no engine hotkey, so it is inert rather than a game button")
|
||||||
|
|
||||||
|
check(dp.viewport ~= nil, "screens[] places the emulator picture")
|
||||||
|
near(dp.viewport.y, 32 / 480, "outputFrame y normalizes by mappingSize")
|
||||||
|
near(dp.viewport.h, 288 / 480, "outputFrame height normalizes by mappingSize")
|
||||||
|
|
||||||
|
local bx, by, bw, bh = TouchSkin.pageBox(dp, 1000, 500)
|
||||||
|
eq(bx, 0, "delta page box x") eq(by, 0, "delta page box y")
|
||||||
|
eq(bw, 1000, "delta page box fills the width")
|
||||||
|
eq(bh, 500, "delta page box fills the height")
|
||||||
|
|
||||||
|
local LEGACY_SCREEN = [[
|
||||||
|
{ "gameTypeIdentifier": "public.aoshuang.game.gbc",
|
||||||
|
"representations": { "iphone": { "standard": { "landscape": {
|
||||||
|
"mappingSize": {"width":640,"height":320},
|
||||||
|
"gameScreenFrame": {"x":160,"y":0,"width":320,"height":288},
|
||||||
|
"translucent": true,
|
||||||
|
"items": [ { "inputs": {"up":"analogStickUp","down":"analogStickDown",
|
||||||
|
"left":"analogStickLeft","right":"analogStickRight"},
|
||||||
|
"frame": {"x":0,"y":0,"width":120,"height":120} } ] } } } } }
|
||||||
|
]]
|
||||||
|
local legacy = assert(DeltaSkin.parse(LEGACY_SCREEN))
|
||||||
|
eq(legacy.system, "gbc", "the Manic public.aoshuang prefix is accepted")
|
||||||
|
eq(#legacy.pages, 1, "landscape only")
|
||||||
|
eq(legacy.pages[1].orient, "landscape", "orientation key drives the lock")
|
||||||
|
near(legacy.pages[1].viewport.x, 0.25, "gameScreenFrame is the legacy screen rect")
|
||||||
|
near(legacy.pages[1].alphaMod, 0.7, "translucent dims the controls")
|
||||||
|
eq(#legacy.pages[1].controls, 8, "a thumbstick degrades to a directional pad")
|
||||||
|
eq(legacy.pages[1].controls[1].spec, "left|up", "with the analog names mapped")
|
||||||
|
|
||||||
|
local snes = assert(DeltaSkin.parse([[
|
||||||
|
{ "gameTypeIdentifier": "com.rileytestut.delta.game.snes",
|
||||||
|
"representations": { "iphone": { "standard": { "portrait": {
|
||||||
|
"mappingSize": {"width":320,"height":480}, "items": [] } } } } }
|
||||||
|
]]))
|
||||||
|
check(hasWarning(snes, "not Game Boy"), "a non Game Boy skin warns")
|
||||||
|
eq(#snes.pages, 1, "but still imports")
|
||||||
|
|
||||||
|
eq(select(1, DeltaSkin.parse([[
|
||||||
|
{ "name": "old", "gameTypeIdentifier": "com.rileytestut.GBA4iOS.gba",
|
||||||
|
"representations": { "iphone": { "portrait": { "assets": {} } } } }
|
||||||
|
]])), nil, "a GBA4iOS skin is refused")
|
||||||
|
local _, gbaErr = DeltaSkin.parse([[
|
||||||
|
{ "gameTypeIdentifier": "com.rileytestut.GBA4iOS.gbc", "representations": {} }
|
||||||
|
]])
|
||||||
|
check(tostring(gbaErr):find("GBA4iOS", 1, true) ~= nil,
|
||||||
|
"and the message names the old format")
|
||||||
|
|
||||||
|
local _, noTypeErr = DeltaSkin.parse('{ "representations": {} }')
|
||||||
|
check(tostring(noTypeErr):find("gameTypeIdentifier", 1, true) ~= nil,
|
||||||
|
"info.json without a gameTypeIdentifier is refused by name")
|
||||||
|
eq(select(1, DeltaSkin.parse("not json at all")), nil, "garbage is refused")
|
||||||
|
eq(select(1, DeltaSkin.parse([[
|
||||||
|
{ "gameTypeIdentifier": "com.rileytestut.delta.game.gbc", "representations": {} }
|
||||||
|
]])), nil, "an empty representations tree is refused")
|
||||||
|
|
||||||
|
local PDF_JSON = [[
|
||||||
|
{ "name": "Vector", "gameTypeIdentifier": "com.rileytestut.delta.game.gbc",
|
||||||
|
"representations": { "iphone": { "standard": { "portrait": {
|
||||||
|
"assets": { "resizable": "iphone_portrait.pdf" },
|
||||||
|
"mappingSize": {"width":320,"height":480},
|
||||||
|
"items": [ { "inputs": ["a"], "frame": {"x":0,"y":0,"width":32,"height":32} } ]
|
||||||
|
} } } } }
|
||||||
|
]]
|
||||||
|
local pdf = assert(DeltaSkin.parse(PDF_JSON))
|
||||||
|
eq(pdf.pages[1].imagePath, nil, "a PDF asset is not pretended to be art")
|
||||||
|
local convert = DeltaSkin.needsConversion(pdf)
|
||||||
|
check(convert ~= nil, "PDF-only skins report that they need conversion")
|
||||||
|
if convert then
|
||||||
|
check(convert.pdfOnly, "the report is flagged pdfOnly")
|
||||||
|
eq(convert.files[1], "iphone_portrait.pdf", "and names the file to convert")
|
||||||
|
end
|
||||||
|
eq(DeltaSkin.needsConversion(delta), nil, "a PNG skin needs no conversion")
|
||||||
|
|
||||||
|
local mixed = assert(DeltaSkin.parse([[
|
||||||
|
{ "gameTypeIdentifier": "com.rileytestut.delta.game.gb",
|
||||||
|
"representations": { "iphone": { "standard": { "portrait": {
|
||||||
|
"assets": { "resizable": "art.pdf", "medium": "art.png" },
|
||||||
|
"mappingSize": {"width":320,"height":480}, "items": [] } } } } }
|
||||||
|
]]))
|
||||||
|
eq(mixed.pages[1].imagePath, "art.png", "a raster asset beats the PDF")
|
||||||
|
eq(DeltaSkin.needsConversion(mixed), nil, "so no conversion is needed")
|
||||||
|
|
||||||
|
eq(DeltaSkin.pickAsset({ small = "s.png" }, { targetWidth = 1080 }, {}), "s.png",
|
||||||
|
"the ladder falls back to the largest shipped asset")
|
||||||
|
eq(DeltaSkin.pickAsset({ small = "s.png", medium = "m.png", large = "l.png" },
|
||||||
|
{ targetWidth = 640 }, {}), "s.png",
|
||||||
|
"a small target takes the small asset")
|
||||||
|
eq(DeltaSkin.pickAsset({ normal = "n.png" }, { targetWidth = 640 }, {}), "n.png",
|
||||||
|
'the Manic "normal" alias is accepted')
|
||||||
|
|
||||||
|
love.filesystem.write("skins/wrapped.deltaskin/MySkin/info.json", [[
|
||||||
|
{ "name": "Wrapped", "gameTypeIdentifier": "com.rileytestut.delta.game.gbc",
|
||||||
|
"representations": { "iphone": { "standard": { "portrait": {
|
||||||
|
"assets": { "large": "Portrait.PNG" },
|
||||||
|
"mappingSize": {"width":320,"height":480},
|
||||||
|
"items": [ { "inputs": ["a"], "frame": {"x":0,"y":0,"width":64,"height":64} } ]
|
||||||
|
} } } } }
|
||||||
|
]])
|
||||||
|
love.filesystem.write("skins/wrapped.deltaskin/MySkin/portrait.png", "\137PNG\r\n\26\n")
|
||||||
|
|
||||||
|
local wrappedId, wrappedErr = TouchSkin.installArchive("wrapped.deltaskin", "PK\3\4stub")
|
||||||
|
eq(wrappedId, "wrapped", "a .deltaskin installs under its bare name: " .. tostring(wrappedErr))
|
||||||
|
local wrapped = assert(TouchSkin.load("skins/_mounted/wrapped", "wrapped"))
|
||||||
|
eq(wrapped.format, "delta", "the mounted archive is recognised as a Delta skin")
|
||||||
|
eq(wrapped.name, "Wrapped", "and its name comes from info.json")
|
||||||
|
eq(wrapped.pages[1].imagePath, "MySkin/portrait.png",
|
||||||
|
"the wrapping folder is prefixed onto assets and the real file name wins")
|
||||||
|
eq(#wrapped.pages[1].controls, 1, "the wrapped items parsed")
|
||||||
|
|
||||||
|
love.filesystem.write("skins/vector.deltaskin/info.json", PDF_JSON)
|
||||||
|
local vectorId, vectorErr = TouchSkin.installArchive("vector.deltaskin", "PK\3\4stub")
|
||||||
|
eq(vectorId, nil, "a PDF-only skin is refused instead of installing invisible")
|
||||||
|
check(tostring(vectorErr):find("PDF artwork", 1, true) ~= nil,
|
||||||
|
"with the message that asks for a PNG version")
|
||||||
|
eq(love.filesystem.read("skins/vector.deltaskin"), nil,
|
||||||
|
"and the refused archive is not left behind")
|
||||||
|
|
||||||
|
eq(select(1, TouchSkin.installArchive("skin.gbcskin", "PK\3\4stub")), nil,
|
||||||
|
"a GBA4iOS .gbcskin is refused at the door")
|
||||||
|
local _, legacyErr = TouchSkin.installArchive("skin.gbaskin", "PK\3\4stub")
|
||||||
|
check(tostring(legacyErr):find("GBA4iOS", 1, true) ~= nil,
|
||||||
|
"with a message that names the format")
|
||||||
|
eq(select(1, TouchSkin.installArchive("skin.rar", "PK\3\4stub")), nil,
|
||||||
|
"an unknown archive extension is refused")
|
||||||
|
eq(TouchSkin.archiveId("pad.deltaskin"), "pad", "archiveId strips .deltaskin")
|
||||||
|
eq(TouchSkin.archiveId("pad.zip"), "pad", "archiveId strips .zip")
|
||||||
|
eq(TouchSkin.archiveId("pad"), nil, "a bare name is not an archive")
|
||||||
|
|
||||||
|
local AUTHORED = [[
|
||||||
|
return { name = "Authored", pages = {
|
||||||
|
{ name = "portrait", orient = "portrait", fullScreen = true,
|
||||||
|
viewport = { x = 0, y = 0, w = 1, h = 0.5 },
|
||||||
|
controls = {
|
||||||
|
{ bind = "a", x = 0.8, y = 0.75, w = 0.2, h = 0.1, shape = "radial" },
|
||||||
|
{ bind = "b", x = 0.6, y = 0.8, w = 0.2, h = 0.1, shape = "radial",
|
||||||
|
reachRight = 1.5 },
|
||||||
|
{ bind = "start", x = 0.5, y = 0.95, w = 0.1, h = 0.04 },
|
||||||
|
{ bind = "menu_toggle", x = 0.05, y = 0.05, w = 0.08, h = 0.04 },
|
||||||
|
{ bind = "nul", x = 0.2, y = 0.7, w = 0.3, h = 0.2, image = "img/dpad.png" },
|
||||||
|
} },
|
||||||
|
{ name = "landscape", orient = "landscape", fullScreen = true,
|
||||||
|
controls = {
|
||||||
|
{ bind = "a", x = 0.9, y = 0.8, w = 0.1, h = 0.15, shape = "radial" },
|
||||||
|
} },
|
||||||
|
} }
|
||||||
|
]]
|
||||||
|
local authored = assert(TouchSkin.parseNative(AUTHORED))
|
||||||
|
authored.id = "authored"
|
||||||
|
authored.root = "skins/authored"
|
||||||
|
|
||||||
|
local cfgText = TouchSkin.toRetroArchConfig(authored)
|
||||||
|
check(cfgText:find("overlays = 2", 1, true) ~= nil, "the cfg declares its overlays")
|
||||||
|
local reparsed = assert(TouchSkin.parse(cfgText))
|
||||||
|
eq(#reparsed.pages, 2, "the generated cfg round-trips both pages")
|
||||||
|
eq(reparsed.pages[1].name, "portrait", "and their names")
|
||||||
|
eq(#reparsed.pages[1].controls, 5, "and every desc")
|
||||||
|
eq(reparsed.pages[1].controls[1].spec, "a", "binds survive the round trip")
|
||||||
|
near(reparsed.pages[1].controls[1].x, 0.8, "centres survive the round trip")
|
||||||
|
near(reparsed.pages[1].controls[1].rangeX, 0.1, "half extents survive")
|
||||||
|
eq(reparsed.pages[1].controls[1].shape, "radial", "hitbox shape survives")
|
||||||
|
near(reparsed.pages[1].controls[2].reachRight, 1.5, "per-side reach survives")
|
||||||
|
eq(reparsed.pages[1].controls[4].hotkeys[1], "menu", "hotkeys survive")
|
||||||
|
check(reparsed.pages[1].controls[5].decorative, "decoration stays decoration")
|
||||||
|
eq(reparsed.pages[1].controls[5].imagePath, "img/dpad.png", "and keeps its art")
|
||||||
|
near(reparsed.pages[1].viewport.h, 0.5, "the screen cutout survives")
|
||||||
|
eq(reparsed.pages[1].orient, "portrait", "the orientation lock survives by name")
|
||||||
|
|
||||||
|
local KEY_SKIN = [[
|
||||||
|
return { name = "Keys", pages = {
|
||||||
|
{ name = "portrait", fullScreen = true, controls = {
|
||||||
|
{ bind = "key:escape", x = 0.5, y = 0.5, w = 0.1, h = 0.1 },
|
||||||
|
} },
|
||||||
|
} }
|
||||||
|
]]
|
||||||
|
local keySkin = assert(TouchSkin.parseNative(KEY_SKIN))
|
||||||
|
local keyCfg = TouchSkin.toRetroArchConfig(keySkin)
|
||||||
|
check(keyCfg:find("retrok_escape", 1, true) ~= nil,
|
||||||
|
"a key bind exports in the grammar RetroArch understands")
|
||||||
|
check(keyCfg:find("key:escape", 1, true) == nil, "and not in the native spelling")
|
||||||
|
eq(assert(TouchSkin.parse(keyCfg)).pages[1].controls[1].keys[1], "escape",
|
||||||
|
"which this importer still reads back as the same key")
|
||||||
|
|
||||||
|
local areaCfg = TouchSkin.toRetroArchConfig({ pages = area.pages })
|
||||||
|
local areaBack = assert(TouchSkin.parse(areaCfg))
|
||||||
|
eq(#areaBack.pages[1].controls, #ap.controls,
|
||||||
|
"an area desc exports as one desc, not eight overlapping ones")
|
||||||
|
check(areaCfg:find("dpad_area", 1, true) ~= nil, "the area kind is written back")
|
||||||
|
check(areaCfg:find('_up = "start"', 1, true) ~= nil, "with its output override")
|
||||||
|
eq(areaBack.pages[1].controls[16].spec, "start", "which survives the round trip")
|
||||||
|
|
||||||
|
local areaInfo = assert(DeltaSkin.build({ id = "area", pages = area.pages }))
|
||||||
|
local areaRep = areaInfo.representations.iphone.edgeToEdge.portrait
|
||||||
|
local dpadItem
|
||||||
|
for _, item in ipairs(areaRep.items) do
|
||||||
|
if not dpadItem and type(item.inputs) == "table" and item.inputs.up then
|
||||||
|
dpadItem = item
|
||||||
|
end
|
||||||
|
end
|
||||||
|
check(dpadItem ~= nil, "the same area exports to Delta as one d-pad item")
|
||||||
|
eq(dpadItem.inputs.left, "left", "carrying each direction")
|
||||||
|
eq(#areaRep.items, 3, "one per area desc, not eight stacked on one another")
|
||||||
|
|
||||||
|
local raPath = os.tmpname() .. "-ra.zip"
|
||||||
|
local raWritten, raMissing = TouchSkin.exportRetroArch(authored, raPath)
|
||||||
|
eq(raWritten, raPath, "exportRetroArch writes where it was told")
|
||||||
|
eq(raMissing[1], "img/dpad.png", "and reports art it could not find")
|
||||||
|
local raZip = unzip(readBytes(raPath))
|
||||||
|
eq(raZip[1], "overlay.cfg", "the RetroArch zip leads with overlay.cfg")
|
||||||
|
check(raZip["overlay.cfg"] ~= nil, "and the entry has bytes")
|
||||||
|
check(TouchSkin.parse(raZip["overlay.cfg"]) ~= nil, "which RetroArch grammar accepts")
|
||||||
|
os.remove(raPath)
|
||||||
|
|
||||||
|
local dsPath = os.tmpname() .. ".deltaskin"
|
||||||
|
local dsWritten, _, dsWarnings = TouchSkin.exportDelta(authored, { path = dsPath })
|
||||||
|
eq(dsWritten, dsPath, "exportDelta writes where it was told")
|
||||||
|
check(#dsWarnings > 0, "and warns that per-button art has nowhere to go")
|
||||||
|
local dsZip = unzip(readBytes(dsPath))
|
||||||
|
eq(dsZip[1], "info.json", "the .deltaskin leads with info.json")
|
||||||
|
local info = assert(Json.decode(dsZip["info.json"]))
|
||||||
|
eq(info.gameTypeIdentifier, "com.rileytestut.delta.game.gbc",
|
||||||
|
"the export claims the GBC game type")
|
||||||
|
eq(info.name, "Authored", "and carries the skin name")
|
||||||
|
check(info.identifier:find("authored", 1, true) ~= nil, "identifier names the skin")
|
||||||
|
local rep = info.representations.iphone.edgeToEdge.portrait
|
||||||
|
check(rep ~= nil, "an iPhone edgeToEdge portrait representation is emitted")
|
||||||
|
eq(info.representations.iphone.standard.portrait.mappingSize.width, 1080,
|
||||||
|
"standard portrait maps 1080 wide")
|
||||||
|
eq(rep.mappingSize.height, 1920, "portrait maps 1920 tall")
|
||||||
|
eq(#rep.items, 4, "only bound controls become Delta items")
|
||||||
|
eq(rep.items[1].inputs[1], "a", "the first item is A")
|
||||||
|
eq(rep.items[1].mask, "circle", "a radial hitbox exports as a circle mask")
|
||||||
|
eq(rep.items[1].frame.x, 756, "frame x is top-left, not centre")
|
||||||
|
eq(rep.items[1].frame.width, 216, "frame width is the full extent")
|
||||||
|
eq(rep.items[2].extendedEdges.right, 54, "reach exports as extendedEdges")
|
||||||
|
eq(rep.items[4].inputs[1], "menu", "the menu hotkey exports as a Delta host input")
|
||||||
|
eq(rep.screens[1].inputFrame.width, 160, "the screen crop is a full GB frame")
|
||||||
|
eq(rep.screens[1].outputFrame.height, 960, "and the output frame follows the viewport")
|
||||||
|
eq(info.representations.iphone.edgeToEdge.landscape.mappingSize.width, 1920,
|
||||||
|
"the landscape page maps 1920 wide")
|
||||||
|
|
||||||
|
local back = assert(DeltaSkin.parse(dsZip["info.json"]))
|
||||||
|
eq(#back.pages, 2, "the exported skin re-imports both orientations")
|
||||||
|
local bp = back.pages[1]
|
||||||
|
eq(#bp.controls, 4, "with every bound control")
|
||||||
|
near(bp.controls[1].x, 0.8, "and the same centres it started with")
|
||||||
|
near(bp.controls[1].rangeX, 0.1, "and the same half extents")
|
||||||
|
eq(bp.controls[1].shape, "radial", "and the same hitbox shape")
|
||||||
|
near(bp.controls[2].reachRight, 1.5, "and the same reach")
|
||||||
|
near(bp.viewport.h, 0.5, "and the same screen cutout")
|
||||||
|
os.remove(dsPath)
|
||||||
|
|
||||||
|
love.filesystem.write("skins/collide/overlay.cfg", [[
|
||||||
|
overlays = 1
|
||||||
|
overlay0_name = "collide"
|
||||||
|
overlay0_descs = 1
|
||||||
|
overlay0_desc0 = "a,0.5,0.5,rect,0.05,0.05"
|
||||||
|
]])
|
||||||
|
local collide = assert(TouchSkin.load("skins/collide", "collide"))
|
||||||
|
local defaultDelta = assert(TouchSkin.exportDelta(collide))
|
||||||
|
eq(defaultDelta, "skins/_export/collide.deltaskin",
|
||||||
|
"a default export lands outside the folder the skin list scans")
|
||||||
|
local listedRoot, listedExport
|
||||||
|
for _, entry in ipairs(TouchSkin.list()) do
|
||||||
|
if entry.id == "collide" then listedRoot = entry.root end
|
||||||
|
if entry.id == "_export" then listedExport = true end
|
||||||
|
end
|
||||||
|
eq(listedRoot, "skins/collide", "so the export cannot shadow the skin it came from")
|
||||||
|
check(not listedExport, "and the export folder is not a skin of its own")
|
||||||
|
|
||||||
|
T.finish("skin_format_import")
|
||||||
@@ -0,0 +1,339 @@
|
|||||||
|
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 TouchSkin = require("src.core.TouchSkin")
|
||||||
|
local Studio = require("src.ui.SkinStudio")
|
||||||
|
|
||||||
|
local function near(a, b, tol, msg)
|
||||||
|
check(math.abs(a - b) <= (tol or 1e-6), msg .. " (got " .. tostring(a) ..
|
||||||
|
", want " .. tostring(b) .. ")")
|
||||||
|
end
|
||||||
|
|
||||||
|
local function session()
|
||||||
|
Studio.skin = TouchSkin.newSkin("t")
|
||||||
|
Studio.skinIdField = "t"
|
||||||
|
Studio.pageIndex = 1
|
||||||
|
Studio.selected = nil
|
||||||
|
Studio.canvasIndex = 1
|
||||||
|
Studio.aspectLock = true
|
||||||
|
Studio.drag = nil
|
||||||
|
Studio.dirty = false
|
||||||
|
Studio.images = {}
|
||||||
|
Studio.thumbs = {}
|
||||||
|
Studio.available = {}
|
||||||
|
Studio.availableMeta = {}
|
||||||
|
Studio.undoStack, Studio.redoStack = {}, {}
|
||||||
|
Studio.undoTag, Studio.undoAt = nil, nil
|
||||||
|
Studio.modal, Studio.confirm = nil, nil
|
||||||
|
Studio.status, Studio.statusErr = nil, false
|
||||||
|
Studio.imageTarget = "idle"
|
||||||
|
return Studio.skin
|
||||||
|
end
|
||||||
|
|
||||||
|
session()
|
||||||
|
check(not Studio.canUndo(), "a fresh session has nothing to undo")
|
||||||
|
Studio.addControl()
|
||||||
|
eq(#Studio.page().controls, 1, "a control was added")
|
||||||
|
check(Studio.canUndo(), "adding a control is undoable")
|
||||||
|
Studio.undo()
|
||||||
|
eq(#Studio.page().controls, 0, "undo takes the control back off")
|
||||||
|
check(Studio.canRedo(), "and offers a redo")
|
||||||
|
Studio.redo()
|
||||||
|
eq(#Studio.page().controls, 1, "redo puts it back")
|
||||||
|
check(not Studio.canRedo(), "the redo stack is spent")
|
||||||
|
|
||||||
|
Studio.addControl()
|
||||||
|
check(not Studio.canRedo(), "a fresh edit clears the redo stack")
|
||||||
|
|
||||||
|
session()
|
||||||
|
Studio.addControl()
|
||||||
|
local before = Studio.page().controls[1]
|
||||||
|
Studio.pushUndo()
|
||||||
|
before.x = 0.9
|
||||||
|
Studio.undo()
|
||||||
|
check(Studio.page().controls[1] ~= before,
|
||||||
|
"undo restores a copy, not the edited table")
|
||||||
|
near(Studio.page().controls[1].x, 0.5, 1e-6, "with the pre-edit position")
|
||||||
|
|
||||||
|
session()
|
||||||
|
for _ = 1, Studio.UNDO_CAP + 10 do Studio.pushUndo() end
|
||||||
|
eq(#Studio.undoStack, Studio.UNDO_CAP, "the undo stack is capped")
|
||||||
|
|
||||||
|
session()
|
||||||
|
check(not Studio.undo(), "undo on an empty stack reports nothing to do")
|
||||||
|
check(not Studio.redo(), "and so does redo")
|
||||||
|
|
||||||
|
local realIsDown = love.keyboard and love.keyboard.isDown
|
||||||
|
love.keyboard = love.keyboard or {}
|
||||||
|
local held = {}
|
||||||
|
love.keyboard.isDown = function(...)
|
||||||
|
for _, k in ipairs({ ... }) do if held[k] then return true end end
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
session()
|
||||||
|
Studio.addControl()
|
||||||
|
Studio.addControl()
|
||||||
|
held.lctrl = true
|
||||||
|
Studio.keypressed("z")
|
||||||
|
eq(#Studio.page().controls, 1, "ctrl+Z undoes one step")
|
||||||
|
Studio.keypressed("z")
|
||||||
|
eq(#Studio.page().controls, 0, "and again")
|
||||||
|
held.lshift = true
|
||||||
|
Studio.keypressed("z")
|
||||||
|
eq(#Studio.page().controls, 1, "ctrl+shift+Z redoes instead of undoing further")
|
||||||
|
held.lshift = nil
|
||||||
|
Studio.keypressed("y")
|
||||||
|
eq(#Studio.page().controls, 2, "and ctrl+Y redoes as well")
|
||||||
|
held.lctrl = nil
|
||||||
|
if realIsDown then love.keyboard.isDown = realIsDown end
|
||||||
|
|
||||||
|
session()
|
||||||
|
local ran = 0
|
||||||
|
check(Studio.guard("lose it?", function() ran = ran + 1 end),
|
||||||
|
"a clean skin runs the action straight away")
|
||||||
|
eq(ran, 1, "and does not prompt")
|
||||||
|
check(Studio.confirm == nil, "no prompt is left up")
|
||||||
|
|
||||||
|
Studio.dirty = true
|
||||||
|
check(not Studio.guard("lose it?", function() ran = ran + 1 end),
|
||||||
|
"a dirty skin defers the action")
|
||||||
|
eq(ran, 1, "the action has not run yet")
|
||||||
|
check(Studio.confirm ~= nil, "and a prompt is up")
|
||||||
|
Studio.confirmNo()
|
||||||
|
eq(ran, 1, "cancelling drops the action")
|
||||||
|
check(Studio.confirm == nil, "and closes the prompt")
|
||||||
|
|
||||||
|
Studio.guard("lose it?", function() ran = ran + 1 end)
|
||||||
|
Studio.confirmYes()
|
||||||
|
eq(ran, 2, "confirming runs it")
|
||||||
|
check(Studio.confirm == nil, "and closes the prompt")
|
||||||
|
|
||||||
|
session()
|
||||||
|
Studio.dirty = true
|
||||||
|
Studio.openLoadPicker()
|
||||||
|
check(Studio.confirm ~= nil, "Load prompts over unsaved work")
|
||||||
|
check(Studio.modal == nil, "and does not open the picker yet")
|
||||||
|
Studio.confirmYes()
|
||||||
|
check(Studio.modal ~= nil and Studio.modal.kind == "open",
|
||||||
|
"confirming opens the picker")
|
||||||
|
Studio.closeModal()
|
||||||
|
|
||||||
|
eq(Studio.toggleBindPart("nul", "left"), "left",
|
||||||
|
"a bind starts from decoration")
|
||||||
|
eq(Studio.toggleBindPart("left", "up"), "left|up",
|
||||||
|
"directions combine in the canonical order")
|
||||||
|
eq(Studio.toggleBindPart("up", "left"), "left|up",
|
||||||
|
"and the order does not depend on which was added first")
|
||||||
|
eq(Studio.toggleBindPart("left|up", "up"), "left",
|
||||||
|
"toggling a part off removes it")
|
||||||
|
eq(Studio.toggleBindPart("left", "left"), "nul",
|
||||||
|
"removing the last part leaves decoration")
|
||||||
|
eq(Studio.toggleBindPart("a", "b"), "a|b", "buttons combine as well")
|
||||||
|
check(Studio.hasBindPart("left|down", "down"), "hasBindPart finds a part")
|
||||||
|
check(not Studio.hasBindPart("left|down", "up"), "and misses one that is absent")
|
||||||
|
|
||||||
|
session()
|
||||||
|
check(not Studio.openBindPicker(), "the bind picker needs a selected control")
|
||||||
|
check(Studio.statusErr, "and says so as an error")
|
||||||
|
Studio.addControl()
|
||||||
|
check(Studio.openBindPicker(), "with a control selected it opens")
|
||||||
|
eq(Studio.modal.kind, "bind", "as the bind modal")
|
||||||
|
Studio.setBindSpec("start")
|
||||||
|
eq(Studio.selectedControl().spec, "start", "picking a bind writes the spec")
|
||||||
|
eq(Studio.selectedControl().buttons[1], "start", "and reparses it")
|
||||||
|
Studio.undo()
|
||||||
|
eq(Studio.selectedControl().spec, "a", "the bind change is undoable")
|
||||||
|
Studio.closeModal()
|
||||||
|
|
||||||
|
Studio.toggleSelectedBindPart("b")
|
||||||
|
eq(Studio.selectedControl().spec, "a|b", "the combine chips build a pipe bind")
|
||||||
|
eq(#Studio.selectedControl().buttons, 2, "which fires both buttons")
|
||||||
|
|
||||||
|
local specs = {}
|
||||||
|
for _, group in ipairs(Studio.BIND_GROUPS) do
|
||||||
|
check(#group.specs > 0, group.title .. " lists at least one bind")
|
||||||
|
for _, spec in ipairs(group.specs) do specs[spec] = true end
|
||||||
|
end
|
||||||
|
check(specs["a"] and specs["start"], "the GB buttons are reachable")
|
||||||
|
check(specs["overlay_previous"], "overlay_previous is reachable at last")
|
||||||
|
check(specs["pause_toggle"] and specs["exit_emulator"],
|
||||||
|
"so are the hotkeys the old cycle could not reach")
|
||||||
|
check(specs["key:escape"], "and a keyboard bind can be picked")
|
||||||
|
check(specs["nul"], "decoration is still an option")
|
||||||
|
|
||||||
|
session()
|
||||||
|
Studio.addControl()
|
||||||
|
Studio.addControl()
|
||||||
|
Studio.selected = 1
|
||||||
|
local first = Studio.page().controls[1]
|
||||||
|
check(Studio.moveControlOrder(1), "bring forward moves the control up")
|
||||||
|
eq(Studio.selected, 2, "and follows it with the selection")
|
||||||
|
check(Studio.page().controls[2] == first, "the control really moved")
|
||||||
|
check(not Studio.moveControlOrder(1), "the front control cannot go further")
|
||||||
|
check(Studio.moveControlOrder(-1), "send back moves it down again")
|
||||||
|
eq(Studio.selected, 1, "selection follows back")
|
||||||
|
check(not Studio.moveControlOrder(-1), "and the back control stays put")
|
||||||
|
|
||||||
|
session()
|
||||||
|
Studio.addControl()
|
||||||
|
local ctl = Studio.selectedControl()
|
||||||
|
local canvas = Studio.canvas()
|
||||||
|
local startX, startY = ctl.x, ctl.y
|
||||||
|
Studio.nudge(1, 0)
|
||||||
|
near(ctl.x, startX + 1 / canvas.w, 1e-9, "an arrow moves one canvas pixel")
|
||||||
|
Studio.nudge(0, 1, true)
|
||||||
|
near(ctl.y, startY + 10 / canvas.h, 1e-9, "shift moves ten")
|
||||||
|
Studio.undo()
|
||||||
|
near(Studio.selectedControl().x, startX, 1e-9, "nudging is undoable")
|
||||||
|
Studio.selected = nil
|
||||||
|
check(not Studio.nudge(1, 0), "nothing selected, nothing nudged")
|
||||||
|
|
||||||
|
check(Studio.NUDGES.up[2] == -1 and Studio.NUDGES.down[2] == 1,
|
||||||
|
"up is negative y on the canvas")
|
||||||
|
check(Studio.NUDGES.left[1] == -1 and Studio.NUDGES.right[1] == 1,
|
||||||
|
"and left is negative x")
|
||||||
|
|
||||||
|
local off, line = Studio.snapOffset({ 100, 150, 200 }, { 152, 400 }, 4)
|
||||||
|
near(off, 2, 1e-9, "an edge within tolerance snaps to the guide")
|
||||||
|
near(line, 152, 1e-9, "and reports the line it snapped to")
|
||||||
|
off, line = Studio.snapOffset({ 100 }, { 400 }, 4)
|
||||||
|
eq(off, 0, "a line out of range does not move anything")
|
||||||
|
eq(line, nil, "and reports no guide")
|
||||||
|
off = Studio.snapOffset({ 100, 200 }, { 203, 101 }, 4)
|
||||||
|
near(off, 1, 1e-9, "the nearest candidate wins")
|
||||||
|
|
||||||
|
session()
|
||||||
|
Studio.addControl()
|
||||||
|
local r = { x = 0, y = 0, w = 1000, h = 1000 }
|
||||||
|
local xs, ys = Studio.snapLines(Studio.page(), r, nil)
|
||||||
|
check(#xs >= 6 and #ys >= 6,
|
||||||
|
"snap lines cover the page box and every other control")
|
||||||
|
local skipped = select(1, Studio.snapLines(Studio.page(), r, 1))
|
||||||
|
eq(#skipped, 3, "the dragged control is not a guide for itself")
|
||||||
|
|
||||||
|
session()
|
||||||
|
Studio.addControl()
|
||||||
|
Studio.page().controls[1].x = 0.25
|
||||||
|
Studio.addControl()
|
||||||
|
Studio.selected = 2
|
||||||
|
local moving = Studio.selectedControl()
|
||||||
|
moving.x = 0.6
|
||||||
|
local target = Studio.page().controls[1]
|
||||||
|
local bx, by, bw, bh = 0, 0, 0, 0
|
||||||
|
local cx, cy, hw, hh = TouchSkin.controlGeometry(Studio.page(), moving,
|
||||||
|
r.w, r.h, r.x, r.y)
|
||||||
|
bx, by, bw, bh = cx - hw, cy - hh, hw * 2, hh * 2
|
||||||
|
local tcx = select(1, TouchSkin.controlGeometry(Studio.page(), target,
|
||||||
|
r.w, r.h, r.x, r.y))
|
||||||
|
Studio.drag = { kind = "control-move", mx = 0, my = 0,
|
||||||
|
bx = bx, by = by, bw = bw, bh = bh }
|
||||||
|
Studio.updateDrag((tcx - cx) + 3, 0, r)
|
||||||
|
local cx2 = select(1, TouchSkin.controlGeometry(Studio.page(), moving,
|
||||||
|
r.w, r.h, r.x, r.y))
|
||||||
|
near(cx2, tcx, 1e-6, "a near miss snaps onto the other control's centre")
|
||||||
|
check(Studio.guides ~= nil and Studio.guides.x ~= nil,
|
||||||
|
"and a guide line is recorded for the canvas to draw")
|
||||||
|
Studio.drag = nil
|
||||||
|
|
||||||
|
session()
|
||||||
|
Studio.addPage()
|
||||||
|
Studio.addPage()
|
||||||
|
eq(#Studio.skin.pages, 3, "three pages")
|
||||||
|
check(Studio.setPage(1), "setPage jumps to a page by index")
|
||||||
|
eq(Studio.pageIndex, 1, "and lands there")
|
||||||
|
check(not Studio.setPage(9), "an index past the end is refused")
|
||||||
|
Studio.nextPage()
|
||||||
|
eq(Studio.pageIndex, 2, "next page still cycles")
|
||||||
|
local name, detail = Studio.pageLabel(2)
|
||||||
|
eq(name, "page2", "the page list shows the page name")
|
||||||
|
check(detail:find("controls", 1, true) ~= nil, "and what is on it")
|
||||||
|
|
||||||
|
check(Studio.renamePage("landscape"), "a page can be renamed")
|
||||||
|
eq(Studio.page().name, "landscape", "and keeps the new name")
|
||||||
|
check(not Studio.renamePage(" "), "an empty name is refused")
|
||||||
|
Studio.undo()
|
||||||
|
eq(Studio.page().name, "page2", "renaming is undoable")
|
||||||
|
|
||||||
|
Studio.pageIndex = 2
|
||||||
|
check(Studio.deletePage(2), "a page can be deleted")
|
||||||
|
eq(#Studio.skin.pages, 2, "and the skin loses it")
|
||||||
|
Studio.deletePage(1)
|
||||||
|
check(not Studio.deletePage(1), "the last page cannot be deleted")
|
||||||
|
check(Studio.statusErr, "and the studio says why")
|
||||||
|
|
||||||
|
session()
|
||||||
|
check(not Studio.openImagePicker("idle"), "art needs a selected control")
|
||||||
|
Studio.addControl()
|
||||||
|
check(Studio.openImagePicker("idle"), "with one selected the grid opens")
|
||||||
|
eq(Studio.modal.kind, "image", "as the image modal")
|
||||||
|
eq(Studio.imageTarget, "idle", "aimed at the idle art")
|
||||||
|
check(Studio.openImagePicker("bezel"), "the bezel needs no selection")
|
||||||
|
eq(Studio.currentImagePath(), nil, "a new page has no bezel yet")
|
||||||
|
Studio.imageTarget = "idle"
|
||||||
|
Studio.selectedControl().imagePath = "img/a.png"
|
||||||
|
eq(Studio.currentImagePath(), "img/a.png", "the picker marks the current art")
|
||||||
|
Studio.chooseImage(nil)
|
||||||
|
eq(Studio.selectedControl().imagePath, nil, "picking (none) clears the art")
|
||||||
|
eq(Studio.modal, nil, "and closes the picker")
|
||||||
|
Studio.undo()
|
||||||
|
eq(Studio.selectedControl().imagePath, "img/a.png", "clearing art is undoable")
|
||||||
|
|
||||||
|
session()
|
||||||
|
Studio.setStatus("boom", true)
|
||||||
|
check(Studio.statusErr, "an error status is flagged")
|
||||||
|
Studio.addControl()
|
||||||
|
eq(Studio.status, "boom", "a later edit does not wipe the error off the footer")
|
||||||
|
Studio.setStatus("fine")
|
||||||
|
Studio.addControl()
|
||||||
|
eq(Studio.status, nil, "an ordinary status still clears on the next edit")
|
||||||
|
Studio.setStatus("boom", true)
|
||||||
|
Studio.statusAt = -1000
|
||||||
|
Studio.expireStatus()
|
||||||
|
eq(Studio.status, nil, "and an error clears itself after a few seconds")
|
||||||
|
|
||||||
|
local ids = {}
|
||||||
|
for _, spec in ipairs(Studio.EXPORTS) do ids[spec.id] = spec.label end
|
||||||
|
check(ids.native and ids.retroarch and ids.delta,
|
||||||
|
"the export menu offers all three formats")
|
||||||
|
|
||||||
|
session()
|
||||||
|
Studio.skinIdField = "uxtest"
|
||||||
|
local nativePath = Studio.exportAs("native")
|
||||||
|
check(nativePath ~= nil and nativePath:match("%.zip$") ~= nil,
|
||||||
|
"the native export writes a .zip")
|
||||||
|
local raPath = Studio.exportAs("retroarch")
|
||||||
|
check(raPath ~= nil and raPath:match("%.zip$") ~= nil,
|
||||||
|
"the RetroArch export writes a .zip")
|
||||||
|
local deltaPath = Studio.exportAs("delta")
|
||||||
|
check(deltaPath ~= nil and deltaPath:match("%.deltaskin$") ~= nil,
|
||||||
|
"the Delta export writes a .deltaskin")
|
||||||
|
check(love.filesystem.read(deltaPath) ~= nil, "and the archive is on disk")
|
||||||
|
eq(Studio.lastExport, deltaPath, "the last export is remembered for Show file")
|
||||||
|
|
||||||
|
eq(Studio.skinFormat({ format = "retroarch" }), "RetroArch",
|
||||||
|
"a format badge reads in words")
|
||||||
|
eq(Studio.skinFormat({ format = "delta" }), "Delta", "Delta included")
|
||||||
|
|
||||||
|
love.graphics.getDimensions = love.graphics.getDimensions
|
||||||
|
or function() return 1280, 720 end
|
||||||
|
session()
|
||||||
|
Studio.addControl()
|
||||||
|
for _, kind in ipairs({ "bind", "image", "open", "page", "export" }) do
|
||||||
|
Studio.openModal(kind)
|
||||||
|
check(pcall(Studio.draw), "the studio draws with the " .. kind .. " modal up")
|
||||||
|
end
|
||||||
|
Studio.closeModal()
|
||||||
|
Studio.ask("sure?", function() end)
|
||||||
|
check(pcall(Studio.draw), "and with the confirm prompt up")
|
||||||
|
Studio.confirmNo()
|
||||||
|
|
||||||
|
Studio.openModal("bind")
|
||||||
|
Studio.lastCanvas = { x = 0, y = 0, w = 100, h = 100 }
|
||||||
|
Studio.mousepressed(50, 50, 1)
|
||||||
|
eq(Studio.drag, nil, "a click under an open modal does not grab a control")
|
||||||
|
Studio.closeModal()
|
||||||
|
|
||||||
|
T.finish("skin_studio_ux")
|
||||||