Compare commits

..

18 Commits

Author SHA1 Message Date
bryanthaboi e24f812475 Merge pull request #1553 from bryanthaboi/dev
fix stuff baby
2026-08-19 06:10:44 -04:00
bryanthaboi fddf619ed2 Merge pull request #1527 from thibautbus/fix/status-abbreviation-translation
Translate the status abbreviations shown outside battle
2026-08-19 06:01:14 -04:00
bryanthaboi bf83509ef2 Merge pull request #1542 from AverageConsumer/codex/gen2-ball-cache-invalidation
fix(gen2): refresh caches missing trainer HUD balls
2026-08-19 06:00:48 -04:00
bryanthaboi 6c05b854c4 Merge pull request #1543 from AverageConsumer/codex/gen2-party-grid-navigation
fix(gen2): honor battle party grid navigation
2026-08-19 06:00:21 -04:00
bryanthaboi 2baafab027 Merge pull request #1544 from castdrian/safe-mode-report-issue
feat(launcher): add safe mode and issue reporting
2026-08-19 06:00:00 -04:00
bryanthaboi 813f9d959b Merge pull request #1546 from 1Jamie/feat/android-exit-game-to-launcher
feat(android): add adaptive icons, dynamic shortcuts, in-process hot-swap, and exit-to-launcher
2026-08-19 05:59:34 -04:00
bryanthaboi fba87f028c Merge pull request #1552 from thibautbus/fix/pikachu-unhappy-gsub-crash
Fix a crash releasing your own caught Pikachu in Yellow
2026-08-19 05:59:06 -04:00
bryanthaboi cb4647daf0 Merge branch 'dev' of https://github.com/bryanthaboi/gen1recomp into dev 2026-08-19 05:57:46 -04:00
bryanthaboi 93374fbbbb skin studio updates, save sync CLOSES #1533 2026-08-19 05:57:44 -04:00
thibautbus abe176b26c Fix a crash releasing your own caught Pikachu in Yellow
BoxMenu.lua's release() pushes both its "Once released...OK?" prompt
and its Yellow-only "Pikachu looks unhappy" message through
TextBox.new(game, (t._X or Strings(...)):gsub(...)) -- gsub returns
two values (the text and a substitution count), and since the gsub
call is the last argument in the TextBox.new(...) call with nothing
after it, Lua expands both into the call: the count lands in
TextBox.new's third parameter, onDone. TextBox.lua later calls
onDone() once the box is dismissed; a number is not callable, so
every release of your own caught Pikachu in Yellow crashed --
regardless of its nickname (unlike the separate %-escape gsub bug,
this one needs no special save content, ordinary play reaches it
every time).

Fixed by wrapping the gsub call in an extra pair of parens, which
truncates it to its first return value only -- the same fix already
applied to the neighboring _OnceReleasedText/_MonWasReleasedText
lines on the (separate, unmerged) fix/route-more-messages-through-romtext
branch, where this exact bug shape was first noticed while adding a
third callsite with the same pattern.

tests/engine/pikachu_unhappy_release_crash.lua: registers a fake
Data.pokemon.PIKACHU cloned from the fixture species (ROM-free) so
the species == "PIKACHU" check can be exercised, drives the real
interactive release flow in Yellow on a mon owned by the player, and
confirms the crash. Verified failing pre-fix (exact same
"attempt to call field 'onDone' (a number value)" error) and passing
post-fix.
2026-08-19 11:34:20 +02:00
thibautbus 085180992d Cover the status abbreviation translation fix with a targeted test
Neither tests/parity_status_true_color.lua (SGB recolor rectangle) nor
tests/parity_party_icon_mirror.lua (icon mirroring) check the drawn
status text, so this fix had no coverage. Drive SummaryMenu:draw() and
PartyMenu:draw() with a mod-patched statuses registry and check the
patched label reaches Font.draw instead of the raw status id, plus a
vanilla case confirming the no-mod fallback is unchanged.

Also cover the hudLabel-shadowing bug directly through the real
Registry:patch (not a hand-built table): a label-only patch, the exact
shape a translation mod would send, must reach Status.hudLabelFor for
all five vanilla ids. Confirmed both regressions: reverting
src/ui/*.lua and src/battle/*.lua to dev's pre-fix content fails 2 of
the draw-site checks; reverting only the vanilla hudLabel removal in
Status.lua fails the 3 checks whose French label differs from English
(FRZ/BRN/SLP).
2026-08-19 08:08:20 +02:00
thibautbus 9984958193 Translate the status abbreviations shown outside battle
src/ui/SummaryMenu.lua:148 and src/ui/PartyMenu.lua:824 drew mon.status
(PSN/PAR/BRN/FRZ/SLP) as a bare literal, bypassing translation. Unlike
plain text, a mod translates status labels through the statuses content
registry (mod.content.statuses:patch(id, { label = value }), the same
registry src/battle/BattleState.lua:statusLabel already reads in battle.
Route both screens through the same lookup, extracted as
Status.hudLabelFor(statuses, id) and shared with BattleState:statusLabel
so the hudLabel-or-label fallback rule lives in one place, with the raw
status id kept as the fallback when no record overrides it.

Found along the way: Status.RECORDS' five vanilla entries duplicated
hudLabel = label ("FRZ", hudLabel = "FRZ", ...) for no functional
reason. Since hudLabelFor reads hudLabel before label, and
Registry:patch only overrides fields a mod actually passes, a
translation mod's label-only patch (the natural shape for a status
catalog carrying one string per id, with no separate hudLabel data to
patch) was silently shadowed by the untouched vanilla hudLabel -- the
translation was stored but never displayed, in or out of battle. This
affected BattleState:statusLabel too, before this change and
independently of it. Dropped the redundant hudLabel field from all
five vanilla records: it's declared optional in the schema, and
nothing in this codebase ever gives it a value different from label --
setting it here only recreated the shadowing trap for no observed
benefit. Left a comment above Status.RECORDS warning against
re-adding it.
2026-08-19 08:08:20 +02:00
1jamie 5871469002 fix(tests): avoid false positive Game: pattern match in skin_studio test 2026-08-18 21:16:32 -05:00
1jamie 302b2c9591 feat(android): add adaptive icons, dynamic shortcuts, in-process hot-swap, and exit-to-launcher 2026-08-18 20:37:25 -05:00
Adrian Castro 67a170fd6e feat(launcher): add safe mode and issue reporting 2026-08-19 00:44:30 +02:00
AverageConsumer 66079686fc fix(gen2): honor battle party grid navigation 2026-08-19 00:35:39 +02:00
AverageConsumer cc5ff987ac fix(gen2): refresh caches missing trainer HUD balls 2026-08-18 23:52:25 +02:00
github-actions f8ba51636b chore(ios): update app-repo.json [skip ci] 2026-08-18 17:18:36 -04:00
110 changed files with 11485 additions and 335 deletions
+3 -2
View File
@@ -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.
-1
View File
@@ -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
+96 -18
View File
@@ -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.
+103 -15
View File
@@ -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" >
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

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 &lt;code&gt;" or
* "ERROR &lt;text&gt;", 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 /
+7
View File
@@ -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",
+118
View File
@@ -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:)
+80 -2
View File
@@ -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():
+1 -5
View File
@@ -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
+20 -5
View File
@@ -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
+523
View File
@@ -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
+37 -2
View File
@@ -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
+24 -9
View File
@@ -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
+181
View File
@@ -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
+219
View File
@@ -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
+13
View File
@@ -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")
+39 -2
View File
@@ -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,
} }
+6 -4
View File
@@ -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
+443 -46
View File
@@ -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
+29 -2
View File
@@ -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
+45 -1
View File
@@ -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,
File diff suppressed because it is too large Load Diff
+501 -15
View File
@@ -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
+20 -7
View File
@@ -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.
local decided = SaveData.modEnabled(options, m.id, SaveData.modScope(version)) if not safeMode then
if decided == nil then decided = not m.experimental end local decided = SaveData.modEnabled(options, m.id, SaveData.modScope(version))
if decided then enabledSet[m.id] = true end if decided == nil then decided = not m.experimental 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
statusFor(byId, m.id, enabledSet, enabled, version, forcedFor) if safeMode then
status, detail = "safe_mode", "Disabled by safe mode"
else
status, detail =
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
+10 -1
View File
@@ -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
+37 -4
View File
@@ -365,9 +365,13 @@ end
function ManagerState:detailRows(m) function ManagerState:detailRows(m)
local rows = {} local rows = {}
rows[#rows + 1] = { label = m.enabled and "DISABLE" or "ENABLE", if Runtime.safeMode then
action = function() self:beginToggle(m) end } rows[#rows + 1] = { label = "SAFE MODE ACTIVE", inert = true }
if self:schemaFor(m) then else
rows[#rows + 1] = { label = m.enabled and "DISABLE" or "ENABLE",
action = function() self:beginToggle(m) end }
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")
+10
View File
@@ -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.
+9
View File
@@ -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)
+19
View File
@@ -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
+81
View File
@@ -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
+113 -61
View File
@@ -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
if cut then
love.graphics.setColor(0, 0, 0, 1)
love.graphics.rectangle("fill", 0, 0, ww, wh)
end
love.graphics.setColor(clearR, clearG, clearB, 1) love.graphics.setColor(clearR, clearG, clearB, 1)
love.graphics.rectangle("fill", 0, 0, ww, wh) 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
+197
View File
@@ -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
+690
View File
@@ -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
+196
View File
@@ -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
+129
View File
@@ -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
+41
View File
@@ -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
+2 -2
View File
@@ -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,
+2 -1
View File
@@ -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
+1032 -57
View File
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -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)
+4 -1
View File
@@ -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 })
+3
View File
@@ -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()
+2 -2
View File
@@ -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
+17 -4
View File
@@ -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.
+37 -1
View File
@@ -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
+60 -2
View File
@@ -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
+1 -2
View File
@@ -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,
+5 -5
View File
@@ -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)
+128
View File
@@ -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
+115
View File
@@ -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
+227
View File
@@ -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)")
+2 -1
View File
@@ -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",
+83
View File
@@ -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")
+85
View File
@@ -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")
+125
View File
@@ -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")
+324
View File
@@ -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")
+268
View File
@@ -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")
+307
View File
@@ -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()
+137
View File
@@ -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()
+626
View File
@@ -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")
+339
View File
@@ -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")

Some files were not shown because too many files have changed in this diff Show More