diff --git a/README.md b/README.md index ce58b197..0962cbd1 100644 --- a/README.md +++ b/README.md @@ -111,8 +111,9 @@ SHA-1 before creating any game data. FireRed, LeafGreen, Ruby, Sapphire, and Eme **Platform notes:** [Linux](docs/platforms/linux.md), [iOS](docs/platforms/ios.md), [Xbox Dev Mode](docs/platforms/xbox.md), -[handhelds](docs/platforms/handhelds.md), and -[Nintendo Switch](docs/platforms/switch.md) each have their own install steps. +[handhelds](docs/platforms/handhelds.md), +[Nintendo Switch](docs/platforms/switch.md), and +[PlayStation 4](docs/platforms/ps4.md) each have their own install steps. **Windows Defender:** it sometimes flags the Windows build with a generic detection such as `Trojan:Win32/Wacatac!ml` (#621). This is a known false diff --git a/conf.lua b/conf.lua index 74c9f021..50b63e30 100644 --- a/conf.lua +++ b/conf.lua @@ -81,7 +81,17 @@ function love.conf(t) local osName = love._os local mobile = osName == "Android" or osName == "iOS" local nx = osName == "NX" - if nx then + local ps4 = osName == "PS4" + if ps4 then + -- PS4 (love-ps4): scan-out is fixed at 1920x1080 and the EGL surface allows + -- a single live instance -- recreating it leaves video dead without an + -- error -- so the window is requested once and never changed. + t.window.width = 1920 + t.window.height = 1080 + t.window.fullscreen = false + t.window.resizable = false + t.window.highdpi = false + elseif nx then -- Switch (love-nx): hint handheld 720p. SDL auto-switches portable↔dock -- (720p↔1080p) only when the window is resizable and not exclusive -- fullscreen; NxDisplay.sync also applies the size on boot and dock change. diff --git a/docs/README.md b/docs/README.md index b8a49b2d..860d2083 100644 --- a/docs/README.md +++ b/docs/README.md @@ -20,6 +20,7 @@ editor, and developer setup live on the - [Xbox Dev Mode](platforms/xbox.md) - [Handhelds](platforms/handhelds.md) - [Nintendo Switch](platforms/switch.md) +- [PlayStation 4](platforms/ps4.md) ## Modding diff --git a/docs/platforms/ps4.md b/docs/platforms/ps4.md new file mode 100644 index 00000000..ffa02144 --- /dev/null +++ b/docs/platforms/ps4.md @@ -0,0 +1,11 @@ +# PlayStation 4 + +Homebrew (GoldHEN) build: `gen1recomp-*-ps4.pkg`, the game fused with +[LÖVE for PS4](https://github.com/tomasmorello/love-ps4) (LÖVE 11.5), pinned +like love-nx is for the Switch. PS4 port by +[Tomas Morello](https://github.com/tomasmorello). Tested on hardware with +Red, Blue and Yellow; Gen 2 and Gen 3 are not tested yet. + +- Players: [ps4-install.md](../ps4-install.md). Install the `.pkg` from + USB, copy your own legal ROM over FTP into the inbox, Scan again. +- Builders: `scripts/build_ps4.sh --fetch --fused` (see the script header). diff --git a/docs/ps4-install.md b/docs/ps4-install.md new file mode 100644 index 00000000..b36335f9 --- /dev/null +++ b/docs/ps4-install.md @@ -0,0 +1,73 @@ +# Install Gen1Recomp on PlayStation 4 + +Releases that include PS4 support ship `gen1recomp-*-ps4.pkg`: the game and its +runtime in one installable package. Install it, then import your own legal +`.gb` / `.gbc` ROM. + +> You need a PS4 that runs homebrew (GoldHEN). This project does not help you +> set that up. + +PS4 port by [Tomas Morello](https://github.com/tomasmorello), on +[LÖVE for PS4](https://github.com/tomasmorello/love-ps4) (LÖVE 11.5). + +**Tested on real hardware:** Pokemon Red, Blue and Yellow (ROM import, play, +save and load, mods, returning to the launcher). **Not tested yet:** Gold, +Silver, Crystal, FireRed and LeafGreen. They may work; reports are welcome. + +## 1. Install the package + +1. Open [Releases](https://github.com/bryanthaboi/gen1recomp/releases) and + download `gen1recomp-*-ps4.pkg` (optionally check it against its `.sha256`). +2. Copy it to a USB drive formatted **exFAT** and plug it into the console. +3. **Settings → Debug Settings → Game → Package Installer**, pick the file. +4. Launch **Gen1Recomp** from the home screen. + +Updating: install the newer `.pkg` the same way; it installs over the old one. +Saves, imported ROMs and mods live in `/data/love/pokemon-love2d/`, outside the +package. **Do not delete that folder** when updating. + +## 2. Import your ROM + +The PS4 has no file picker, so the launcher reads an inbox folder, like the +Switch build: + +1. Enable GoldHEN's FTP server (GoldHEN menu), port **2121**. +2. With any FTP client, copy your `.gb` / `.gbc` into + `/data/love/pokemon-love2d/imports/`. +3. In the launcher, press **Scan again**. + +The other inboxes work the same way (copy over FTP, then **Scan again** in +that screen), all under `/data/love/pokemon-love2d/`: + +| What | Folder | +|---|---| +| Mods (`.zip`) | `imports/mods/` | +| Saves (`.sav`) | `imports/saves//`, e.g. `imports/saves/red/` | +| Custom Carts (`.g1rcart`) | `imports/carts/` | + +## 3. Controls + +The launcher uses **D-pad focus navigation**: the highlighted option is the +selected one; Cross confirms, Circle goes back. **Triangle** switches to the +virtual cursor and back. In game, the pad maps like on every other platform. + +To quit, use the **PS button** and close the application, like any PS4 app. + +⚠ **START + SHARE** belongs to GoldHEN (it opens its menu), so the "hold +START + SELECT" force-quit shortcut does not reach the game on PS4. + +## 4. Already have LÖVE for PS4? + +`scripts/build_ps4.sh --loose` produces a plain `game.love`: copy it to +`/data/love/game.love` and start **LÖVE for PS4** instead of installing the +standalone package. + +## Building + +```bash +scripts/build_ps4.sh --fetch --fused --version X.Y.Z +``` + +Needs the OpenOrbis PS4 Toolchain v0.5.4 packaging tools (`OO_PS4_TOOLCHAIN`) +and bash 4+. The runtime is downloaded from a pinned LÖVE for PS4 release and +verified against `scripts/ps4/love-ps4-runtime.sha256`. diff --git a/scripts/build_ps4.sh b/scripts/build_ps4.sh new file mode 100755 index 00000000..fdb32db1 --- /dev/null +++ b/scripts/build_ps4.sh @@ -0,0 +1,143 @@ +#!/usr/bin/env bash +# PlayStation 4 packaging entry point (homebrew, GoldHEN). +# +# Usage: +# scripts/build_ps4.sh --fetch +# scripts/build_ps4.sh --loose +# scripts/build_ps4.sh --fused [--version X.Y.Z] +# scripts/build_ps4.sh --fetch --fused [--version X.Y.Z] +# +# Modes: +# --fetch Download the pinned LÖVE for PS4 runtime zip into +# .bazinga/love-ps4// and verify SHA-256 against +# scripts/ps4/love-ps4-runtime.sha256. +# +# --loose Pack game.love into dist/ps4/loose/ for players who already have +# the generic "LÖVE for PS4" package installed: they copy it to +# /data/love/game.love over FTP. Does not need the runtime. +# +# --fused Build gen1recomp--ps4.pkg: runtime + game.love in one +# installable package (title id GENR00001; its own tile and saves). +# Requires the pin (run --fetch or combine) and the OpenOrbis +# packaging tools: OO_PS4_TOOLCHAIN pointing at an unpacked +# OpenOrbis PS4 Toolchain v0.5.4 (bin/linux or bin/macos with +# PkgTool.Core and create-gp4), plus bash 4+. +# +# The runtime is LÖVE 11.5 for PS4 by Tomas Morello +# (https://github.com/tomasmorello/love-ps4), pinned the way love-nx is pinned +# for the Switch. +# +# Non-goals: installing on a console, FTP uploads, ROM handling. +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +WORK="$ROOT/.bazinga/work" +DIST="$ROOT/dist/ps4" +MANIFEST="$ROOT/scripts/ps4/love-ps4-runtime.sha256" +TITLE_ID="${GEN1_PS4_TITLE_ID:-GENR00001}" +FETCH=0; LOOSE=0; FUSED=0 +VERSION="$(git -C "$ROOT" rev-parse --short HEAD 2>/dev/null || echo dev)" + +say() { printf '\033[1;32m==>\033[0m %s\n' "$*" >&2; } +fail() { printf '\033[1;31merror:\033[0m %s\n' "$*" >&2; exit 1; } + +while [ $# -gt 0 ]; do + case "$1" in + --fetch) FETCH=1; shift ;; + --loose) LOOSE=1; shift ;; + --fused) FUSED=1; shift ;; + --version) VERSION="$2"; shift 2 ;; + -h|--help) sed -n '2,29p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;; + *) fail "unknown argument: $1" ;; + esac +done +[ $((FETCH + LOOSE + FUSED)) -gt 0 ] || fail "specify --fetch, --loose and/or --fused (see --help)" +[ $((LOOSE + FUSED)) -le 1 ] || fail "--loose and --fused cannot be combined" + +manifest_field() { # manifest_field + local v + v="$(awk -v k="$1" '$1 == k { print $2; exit }' "$MANIFEST")" + [ -n "$v" ] || fail "$MANIFEST has no '$1' entry" + printf '%s' "$v" +} +TAG="$(manifest_field tag)" +ZIP_NAME="love-ps4-${TAG#v}-runtime.zip" +RUNTIME_DIR="$ROOT/.bazinga/love-ps4/$TAG" +ZIP="$RUNTIME_DIR/$ZIP_NAME" +BASE_URL="${GEN1_LOVE_PS4_BASE_URL:-https://github.com/tomasmorello/love-ps4/releases/download/$TAG}" + +sha256_of() { + if command -v sha256sum >/dev/null 2>&1; then sha256sum "$1" | awk '{print $1}' + else shasum -a 256 "$1" | awk '{print $1}'; fi +} + +fetch_runtime() { + local want have + want="$(manifest_field "$ZIP_NAME")" + mkdir -p "$RUNTIME_DIR" + if [ -f "$ZIP" ] && [ "$(sha256_of "$ZIP")" = "$want" ]; then + say "runtime $TAG already fetched" + else + say "downloading $BASE_URL/$ZIP_NAME" + curl -fL --retry 3 --retry-delay 1 -o "$ZIP.part" "$BASE_URL/$ZIP_NAME" \ + || { rm -f "$ZIP.part"; fail "download failed: $BASE_URL/$ZIP_NAME"; } + have="$(sha256_of "$ZIP.part")" + [ "$have" = "$want" ] || { rm -f "$ZIP.part"; fail "SHA-256 mismatch for $ZIP_NAME: got $have, pinned $want"; } + mv "$ZIP.part" "$ZIP" + fi + rm -rf "$RUNTIME_DIR/unpacked" + mkdir -p "$RUNTIME_DIR/unpacked" + (cd "$RUNTIME_DIR/unpacked" && unzip -q "$ZIP") +} + +pack_game_love() { + mkdir -p "$WORK" + local love_out="$WORK/game.love" + # pack_love.sh only accepts X.Y.Z; dev builds (a commit hash) stay unstamped. + if printf '%s' "$VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$'; then + "$ROOT/scripts/pack_love.sh" --output "$love_out" --listing "$WORK/love-listing.txt" \ + --version "$VERSION" >/dev/null + else + "$ROOT/scripts/pack_love.sh" --output "$love_out" --listing "$WORK/love-listing.txt" >/dev/null + fi + printf '%s' "$love_out" +} + +# X.Y.Z -> PS4 APP_VER "NN.NN" as (10*X+Y).Z: 0.3.1 -> 03.01, 1.0.0 -> 10.00. +# Monotonic while Y < 10 and Z < 100, so a newer release installs over an older one. +app_ver() { + if printf '%s' "$1" | grep -Eq '^[0-9]+\.[0-9]\.[0-9]{1,2}$'; then + local x y z + IFS=. read -r x y z < copy to /data/love/game.love on the console" +fi + +if [ "$FUSED" -eq 1 ]; then + [ -f "$ZIP" ] || fail "runtime not fetched: run with --fetch" + [ -d "$RUNTIME_DIR/unpacked" ] || fetch_runtime + [ -n "${OO_PS4_TOOLCHAIN:-}" ] || fail "OO_PS4_TOOLCHAIN is not set (OpenOrbis PS4 Toolchain v0.5.4)" + fuse="$(ls "$RUNTIME_DIR"/unpacked/*/fuse-pkg.sh)" + love="$(pack_game_love)" + icon="$ROOT/scripts/ps4/icon0.png" + [ -f "$icon" ] || icon="" + mkdir -p "$DIST" + "$fuse" --love "$love" --title-id "$TITLE_ID" --title "Gen1Recomp" \ + --version "$(app_ver "$VERSION")" --content-label GEN1RECOMP --out-dir "$WORK/ps4-pkg" \ + ${icon:+--icon "$icon"} + cp "$WORK"/ps4-pkg/IV0000-"$TITLE_ID"_00-*.pkg "$DIST/gen1recomp-$VERSION-ps4.pkg" + (cd "$DIST" && sha256_of "gen1recomp-$VERSION-ps4.pkg" > "gen1recomp-$VERSION-ps4.pkg.sha256") + say "dist/ps4/gen1recomp-$VERSION-ps4.pkg" +fi diff --git a/scripts/ps4/icon0.png b/scripts/ps4/icon0.png new file mode 100644 index 00000000..7bca1258 Binary files /dev/null and b/scripts/ps4/icon0.png differ diff --git a/scripts/ps4/love-ps4-runtime.sha256 b/scripts/ps4/love-ps4-runtime.sha256 new file mode 100644 index 00000000..cdbf207b --- /dev/null +++ b/scripts/ps4/love-ps4-runtime.sha256 @@ -0,0 +1,8 @@ +# LÖVE for PS4 — pinned runtime (Tomas Morello) +# https://github.com/tomasmorello/love-ps4/releases +# +# scripts/build_ps4.sh --fetch downloads from the release and refuses +# it unless its SHA-256 matches. Bump both lines together. + +tag v0.9.3 +love-ps4-0.9.3-runtime.zip 8cc66a447738a6772fc79775d7306791fb63542582e420cf08db8cb0420eded7 diff --git a/src/core/FaithfulRes.lua b/src/core/FaithfulRes.lua index 843ebfa5..f9f546ae 100644 --- a/src/core/FaithfulRes.lua +++ b/src/core/FaithfulRes.lua @@ -117,6 +117,7 @@ function FaithfulRes.fixedDisplay() if not love or not love.system or not love.system.getOS then return false end local osName = love.system.getOS() return osName == "Android" or osName == "iOS" or osName == "NX" + or osName == "PS4" end FaithfulRes.isMobile = FaithfulRes.fixedDisplay diff --git a/src/core/Performance.lua b/src/core/Performance.lua index 4d61b65f..b9be0c27 100644 --- a/src/core/Performance.lua +++ b/src/core/Performance.lua @@ -111,6 +111,12 @@ function Performance.detect() if isArm and os == "Linux" then return "low" end + -- PS4: 1.6 GHz Jaguar cores. cpuArch() cannot tell, and detect() fell + -- through to "high", the most demanding tier. balanced drops the 3D tilt, + -- the heaviest extra. + if os == "PS4" then + return "balanced" + end -- Phones and tablets: balanced drops the 3D tilt, the heaviest extra. if os == "Android" or os == "iOS" then return "balanced" diff --git a/src/core/Platform.lua b/src/core/Platform.lua index 2d1adc4a..87adb951 100644 --- a/src/core/Platform.lua +++ b/src/core/Platform.lua @@ -9,6 +9,7 @@ local function compute() or "Unknown" local nx = osName == "NX" local uwp = osName == "UWP" + local ps4 = osName == "PS4" local mobile = osName == "Android" or osName == "iOS" local nativePicker = love and love.system and type(love.system.pickFile) == "function" @@ -18,14 +19,15 @@ local function compute() os = osName, nx = nx, uwp = uwp, + ps4 = ps4, mobile = mobile, - console = nx or uwp, + console = nx or uwp or ps4, hasNativePicker = nativePicker, canSpawnProcess = osName == "OS X" or osName == "Windows" or osName == "Linux", - romImportMode = nx and "save-directory" + romImportMode = (nx or ps4) and "save-directory" or (nativePicker and "native-picker") or "desktop", - networkValidated = not nx and not uwp, + networkValidated = not nx and not uwp and not ps4, -- networkValidated is the self-updater's gate and stays a per-platform -- policy call: a console package cannot replace itself on disk, so that -- answer never depends on whether a transport exists. Fetching a mod @@ -36,7 +38,16 @@ local function compute() -- (#597). The UWP LOVE backend does not export that bridge yet, so this -- still resolves false on Xbox and the launcher still says so, but the -- day the backend grows one, nothing here or in RomImporter has to change. - canFetchRemote = (not nx and not uwp) or nativeHttp, + canFetchRemote = (not nx and not uwp and not ps4) or nativeHttp, + -- How a gamepad drives the launcher when it is the only input: the Switch + -- moves a virtual cursor, Xbox and PS4 move a focus ring (the highlighted + -- control is the selected one; Y/Triangle still toggles the cursor). nil + -- on desktop and handhelds, where the cursor stays latent until used. + padNavigation = (nx and "pointer") or ((uwp or ps4) and "focus") or nil, + -- The system owns leaving the app (iOS Home, PS button): no Quit button. + systemQuit = osName == "iOS" or ps4, + -- How files reach a save-directory inbox, for the hints under its path. + inboxTransfer = (nx and "mtp") or (ps4 and "ftp") or nil, } end @@ -53,10 +64,26 @@ function Platform.isUWP() return Platform.detect().uwp end +function Platform.isPS4() + return Platform.detect().ps4 +end + function Platform.romImportMode() return Platform.detect().romImportMode end +function Platform.padNavigation() + return Platform.detect().padNavigation +end + +function Platform.systemQuit() + return Platform.detect().systemQuit and true or false +end + +function Platform.inboxTransfer() + return Platform.detect().inboxTransfer +end + function Platform.canSpawnProcess() return Platform.detect().canSpawnProcess end diff --git a/src/core/VideoMode.lua b/src/core/VideoMode.lua index c4956b24..dc178169 100644 --- a/src/core/VideoMode.lua +++ b/src/core/VideoMode.lua @@ -23,6 +23,7 @@ function VideoMode.fixedDisplay() if not love or not love.system or not love.system.getOS then return false end local osName = love.system.getOS() return osName == "Android" or osName == "iOS" or osName == "NX" + or osName == "PS4" end VideoMode.isMobile = VideoMode.fixedDisplay diff --git a/src/import/LauncherView.lua b/src/import/LauncherView.lua index 22dd73ad..97be87a4 100644 --- a/src/import/LauncherView.lua +++ b/src/import/LauncherView.lua @@ -43,6 +43,20 @@ local WebClip = require("src.core.WebClip") local PAL = Theme.PAL local LauncherView = {} +-- Platform capabilities as the view needs them. Fields on LauncherView, not +-- locals: this chunk sits at Lua's 200-local limit. They read plain importer +-- fields, not methods, so hand-built importers in tests keep working. +-- Save-directory inbox import (Switch, PS4); same rule as +-- RomImporter:_inboxImport(). +function LauncherView.inboxImport(imp) + return (imp.inboxImport or imp.isNX) and true or false +end + +-- iOS and PS4 leave the app through the system (Home, PS button). +function LauncherView.hasQuitButton(imp) + return not imp.ios and not require("src.core.Platform").systemQuit() +end + local COMMUNITY_URL = "https://bois.icu" -- One dedup window covers a touch release plus the mouse click SDL @@ -1721,9 +1735,9 @@ local function buildHeader(imp, m) -- under the gear and the quit X -- "the settings is covering the logo". -- Reserving the space on both sides costs a little width and cannot -- overlap at any window size. - -- iOS has no quit button (the OS owns app exit), so the cluster is the + -- No quit button on iOS/PS4 (the OS owns app exit), so the cluster is the -- gear alone and the wordmark gets that width back - local clusterN = imp.ios and 2 or 3 + local clusterN = LauncherView.hasQuitButton(imp) and 3 or 2 local clusterW = clusterN * gear + (clusterN - 1) * math.floor(6 * m.s) + m.pad local mobile = not imp.isNX or not m.twoCol local boxX = mobile and (m.x + m.pad) or (m.x + clusterW) @@ -1768,7 +1782,7 @@ local function buildHeader(imp, m) -- inboard of it -- but the two are REGISTERED gear first, because the first -- focusable of the first frame adopts the keyboard ring and that must not be -- the button that exits the app. - local quitX = not imp.ios and rx - gear or nil + local quitX = LauncherView.hasQuitButton(imp) and rx - gear or nil if quitX then rx = quitX - math.floor(6 * m.s) end -- Settings gear. It now also owns the CONTROL settings (touch overlay @@ -1906,14 +1920,18 @@ end -- enabled whether that button may be pressed -- progress 0-1 while an import for THIS version is running local function romModel(imp, version, info, ready, locked) - local importLabel = imp.isNX and Strings("Scan again") or Strings("Import ROM") + local importLabel = LauncherView.inboxImport(imp) and Strings("Scan again") + or Strings("Import ROM") if locked then return { state = Strings("Not supported yet"), detail = Strings("Support for this game is on the way."), label = Strings("Import unavailable"), enabled = false } end local ext = GameVersion.generation(version) == 3 and ".gba" or ".gb/.gbc" - local dropHint = imp.isNX and Strings("Copy the %s via MTP into imports/.", ext) + local viaFtp = require("src.core.Platform").inboxTransfer() == "ftp" + local dropHint = LauncherView.inboxImport(imp) and (viaFtp + and Strings("Copy the %s via FTP into imports/.", ext) + or Strings("Copy the %s via MTP into imports/.", ext)) or (imp.baseRomDiscovery and Strings("Or copy the %s into baseroms/.", ext) or (imp.android and Strings("Copy the %s via USB.", ext) or Strings("Or drop the %s file here.", ext))) @@ -2071,7 +2089,7 @@ local function buildSlotCard(imp, x, y, w, availH, m, version, ready) for _, entry in ipairs(slots) do if entry.id == active then slot = entry break end end local pad, gap = math.floor(14 * m.s), math.floor(8 * m.s) local iw, bh = w - 2 * pad, m.btnH - local importLabel = imp.isNX and Strings("Scan again") or Strings("Import") + local importLabel = LauncherView.inboxImport(imp) and Strings("Scan again") or Strings("Import") local browseLabel = Strings("Other saves (%d)", #slots) local iconExtra = math.floor(bh * 0.42) + math.floor(7 * Kit.scale) local importW = chipWidth(importLabel, m) + iconExtra @@ -3804,12 +3822,18 @@ local function buildFooter(imp, m, y) Theme.fill(m.x, y, m.w, 1, PAL.line, Theme.A.hairline) local cy = y + math.floor(8 * m.s) -- The BCG mark is dark ink; invert it for the black field. - imp.invertShader = imp.invertShader or love.graphics.newShader([[ - vec4 effect(vec4 color, Image tex, vec2 tc, vec2 sc) { - vec4 p = Texel(tex, tc); - return vec4((vec3(1.0) - p.rgb) * color.rgb, p.a * color.a); - } - ]]) + -- pcall: this was the only unguarded newShader on the Gen 1 path, and a + -- driver rejecting this trivial fragment shader took down the whole boot + -- instead of just losing the inverted footer mark. + if imp.invertShader == nil then + local ok, sh = pcall(love.graphics.newShader, [[ + vec4 effect(vec4 color, Image tex, vec2 tc, vec2 sc) { + vec4 p = Texel(tex, tc); + return vec4((vec3(1.0) - p.rgb) * color.rgb, p.a * color.a); + } + ]]) + imp.invertShader = ok and sh or false + end local bw, bh = imp.bcg:getDimensions() local scale = math.min((130 * m.s) / bw, (22 * m.s) / bh) local dw, dh = bw * scale, bh * scale @@ -5625,7 +5649,7 @@ local function buildGameManageModal(imp, m) and love.filesystem.getSaveDirectory() or nil -- The folder link is desktop-only: Android and NX have no browsable path to -- open, and both already print their own transfer hint on the slot card. - local canOpenFolder = saveDir and not imp.android and not imp.isNX + local canOpenFolder = saveDir and not imp.android and not LauncherView.inboxImport(imp) local canWebClip = ready and webClipAvailable(imp) local webClipKey = tostring(version) .. ":" .. tostring(cartId or "") local webClipNotice = imp._webClipNotice diff --git a/src/import/RomImporter.lua b/src/import/RomImporter.lua index 5abf36a4..e2f7aa3f 100644 --- a/src/import/RomImporter.lua +++ b/src/import/RomImporter.lua @@ -613,7 +613,9 @@ function RomImporter:_setNxInboxNotice(version) self.notice = { version = version, status = Strings("Copy your .gb/.gbc/.gba into:"), - detail = Strings("%s/imports/\nDBI MTP → 1: SD Card/%simports/", saveDir, rel), + detail = Platform.inboxTransfer() == "ftp" + and Strings("%s/imports/\nover FTP (GoldHEN, port 2121)", saveDir) + or Strings("%s/imports/\nDBI MTP → 1: SD Card/%simports/", saveDir, rel), } end @@ -621,7 +623,9 @@ function RomImporter:_setNxCartsInboxNotice() local saveDir = love.filesystem.getSaveDirectory() local rel = RomImporter.mtpHintPath(saveDir) if rel ~= "" and rel:sub(-1) ~= "/" then rel = rel .. "/" end - self._cartNotice = Strings( + self._cartNotice = Platform.inboxTransfer() == "ftp" + and Strings("Copy your .g1rcart into:\n%s/imports/carts/\nover FTP (GoldHEN, port 2121)", saveDir) + or Strings( "Copy your .g1rcart into:\n%s/imports/carts/\nDBI MTP → 1: SD Card/%simports/carts/", saveDir, rel) end @@ -632,7 +636,9 @@ function RomImporter:_setNxModsInboxNotice() if rel ~= "" and rel:sub(-1) ~= "/" then rel = rel .. "/" end self.modNotice = { ok = true, - text = Strings("Copy your .zip into:\n%s/imports/mods/\nDBI MTP → 1: SD Card/%simports/mods/", + text = Platform.inboxTransfer() == "ftp" + and Strings("Copy your .zip into:\n%s/imports/mods/\nover FTP (GoldHEN, port 2121)", saveDir) + or Strings("Copy your .zip into:\n%s/imports/mods/\nDBI MTP → 1: SD Card/%simports/mods/", saveDir, rel), } end @@ -654,7 +660,10 @@ function RomImporter:_setNxSavesInboxNotice(version) self.saveNotice[version] = { ok = true, persistent = true, - text = Strings("Copy your %s .sav into:\n%s/%s/\nDBI MTP → 1: SD Card/%s%s/", + text = Platform.inboxTransfer() == "ftp" + and Strings("Copy your %s .sav into:\n%s/%s/\nover FTP (GoldHEN, port 2121)", + game, saveDir, inbox) + or Strings("Copy your %s .sav into:\n%s/%s/\nDBI MTP → 1: SD Card/%s%s/", game, saveDir, inbox, rel, inbox), } end @@ -1041,7 +1050,7 @@ function RomImporter:rescanAction(version) end function RomImporter:_romAction(version) - if self.isNX then + if self:_inboxImport() then if self.ready[version] then self:reimport(version) else self:rescanAction(version) end elseif self.ready[version] then self:reimport(version) @@ -1567,6 +1576,8 @@ function RomImporter.new(onComplete, opts) onEditTouchControls = opts.onEditTouchControls, onOpenSkinStudio = opts.onOpenSkinStudio, isNX = isNX, + -- Save-directory inbox import (Switch, PS4): see _inboxImport(). + inboxImport = romImportMode == "save-directory", romImportMode = romImportMode, mobileFileBridge = mobileFileBridge, android = android, @@ -1740,7 +1751,7 @@ function RomImporter.new(onComplete, opts) -- is up, so a rejected pick can outlive the focus handler (#442). consumePickedRomError(self) end - elseif self.isNX and self.launcher then + elseif self:_inboxImport() and self.launcher then self:ensureImportsDir() self:_setNxInboxNotice() end @@ -2292,7 +2303,7 @@ function RomImporter:_completeImport(version, prefix, displayName) RomImporter.syncAndroidShortcuts(version) -- NX launcher stays put: keep the imports/ cleanup hint instead of -- 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:_inboxImport() and type(displayName) == "string" then self.detail = Strings("%s imported. You may delete the copy from " .. "imports/ when finished.", displayName) else @@ -2721,7 +2732,7 @@ function RomImporter:chooseBoxImport() local isHandheld = os.getenv("HANDHELD") == "1" or os.getenv("PORTMASTER") == "1" or os.getenv("POKEPORT_HANDHELD") == "1" or os.getenv("TRIMUI") == "1" or os.getenv("MUOS") == "1" or os.getenv("KNULLI") == "1" - if self.isNX or self.ios or isHandheld then + if self:_inboxImport() or self.ios or isHandheld then require("src.ui.kit.Kit").FileBrowser.open({ title = "Select Pokémon Box save (.gci / .sav)", mode = "box", onSelect = function(path) self:_importBoxFile(path) end }) return @@ -2793,7 +2804,7 @@ function RomImporter:chooseMod() if self:_importerPickPending() then return end if self.workState == "working" then return end if self._hostPick or self._modInboxInstall then return end - if self.isNX then + if self:_inboxImport() then self:ensureModsInboxDir() self:rescanModsAction() return @@ -3188,7 +3199,7 @@ function RomImporter:chooseRequiredImport(modId, importId) local spec = requiredSpec(manifest, importId) if not spec then return end - if self.isNX then + if self:_inboxImport() then local inbox = "imports/baseroms" local fs = pfs() fs.createDirectory(inbox) @@ -3215,7 +3226,9 @@ function RomImporter:chooseRequiredImport(modId, importId) end end requiredImportNotice(self, modId, importId, lastError - or "No matching file in imports/baseroms/. Copy it there over MTP, then try again.") + or (Platform.inboxTransfer() == "ftp" + and "No matching file in imports/baseroms/. Copy it there over FTP, then try again." + or "No matching file in imports/baseroms/. Copy it there over MTP, then try again.")) self.modNotice = nil return end @@ -3330,7 +3343,7 @@ function RomImporter:chooseSaveImport(version) if self:_importerPickPending() then return end if self.workState == "working" then return end version = self:_resolveSaveVersion(version) - if self.isNX then + if self:_inboxImport() then self:ensureSavesInboxDir(version) self:rescanSavesAction(version) return @@ -3426,7 +3439,7 @@ function RomImporter:exportSave(version, format, scope, slotId) self.saveNotice[noticeScope] = { ok = false, text = tostring(res) } return end - if self.isNX then + if self:_inboxImport() then local saveDir = love.filesystem.getSaveDirectory() local rel = RomImporter.mtpHintPath(saveDir) if rel ~= "" and rel:sub(-1) ~= "/" then rel = rel .. "/" end @@ -3434,7 +3447,9 @@ function RomImporter:exportSave(version, format, scope, slotId) self.saveNotice[noticeScope] = { ok = true, persistent = true, - text = Strings("Exported to %s\nDBI MTP → 1: SD Card/%s%s/", res, rel, outDir), + text = Platform.inboxTransfer() == "ftp" + and Strings("Exported to %s\nget it over FTP (GoldHEN, port 2121)", res) + or Strings("Exported to %s\nDBI MTP → 1: SD Card/%s%s/", res, rel, outDir), } return end @@ -3508,7 +3523,7 @@ function RomImporter:choose(version) if self:_importerPickPending() then return end if self.workState == "working" then return end self.chooseVersion = version or "red" - if self.isNX then + if self:_inboxImport() then -- Same path as the Scan again button: rescan imports/ (or show MTP hint). self:rescanAction(self.chooseVersion) return @@ -3987,7 +4002,15 @@ local PAD_SPEED = 560 -- px/s at full stick deflection local PAD_DPAD_SPEED = 420 function RomImporter:_consolePointerHost() - return (self.isNX or Platform.isUWP()) and true or false + return (self.isNX or Platform.padNavigation() ~= nil) and true or false +end + +-- True when ROMs, mods and saves arrive through the save-directory inbox +-- (imports/) instead of a file picker: "Scan again", transfer hints. Asked +-- here rather than through isNX, which means the Switch itself. isNX still +-- counts so hand-built importers (tests) that only set it keep the NX flow. +function RomImporter:_inboxImport() + return (self.inboxImport or self.isNX) and true or false end function RomImporter:_activatePadCursor() @@ -4201,7 +4224,7 @@ function RomImporter:_updatePadCursor(dt) end if not self._padCursorActive and not self.isNX - and (Platform.isUWP() or self._padNavChosen) then + and (Platform.padNavigation() == "focus" or self._padNavChosen) then self:_navigateWithStick(dt, okKit and Kit or nil) local scrollY = self._padAxis.righty or 0 if math.abs(scrollY) > PAD_DEAD and self._flex then @@ -4436,6 +4459,9 @@ function RomImporter:gamepadpressed(_, button) elseif self._profileRenamePrompt then self._profileRenamePrompt = nil; self:_disarmTextInput(); return elseif self._profileSavePrompt then self._profileSavePrompt = nil; self:_disarmTextInput(); return elseif self._settingsText then self._settingsText = nil; self:_disarmTextInput(); return + -- B leaves Settings the way Escape does (keypressed): a pad-only + -- console had no way out but the pointer. + elseif self._settings then self:_closeSettings(); return end elseif button == "dpup" or action == "dpup" then if okKit then Kit.navigate("up") end @@ -4469,6 +4495,9 @@ function RomImporter:gamepadpressed(_, button) elseif self._profileRenamePrompt then self._profileRenamePrompt = nil; self:_disarmTextInput(); return elseif self._profileSavePrompt then self._profileSavePrompt = nil; self:_disarmTextInput(); return elseif self._settingsText then self._settingsText = nil; self:_disarmTextInput(); return + -- B leaves Settings the way Escape does (keypressed): a pad-only + -- console had no way out but the pointer. + elseif self._settings then self:_closeSettings(); return end elseif button == "dpup" or button == "dpdown" or button == "dpleft" or button == "dpright" then @@ -5530,17 +5559,19 @@ function RomImporter:_syncResolve(key, choice) end function RomImporter:_skinsImportButtonLabel() - if self.isNX then return Strings("Scan again") end + if self:_inboxImport() then return Strings("Scan again") end return Strings("Import skin .zip") end function RomImporter:chooseSkin() if self:_importerPickPending() then return end if self.workState == "working" then return end - if self.isNX then + if self:_inboxImport() then local found = #self:_ensureSkins(true) self._skinNotice = { ok = true, text = Strings( - "%d skins found. Copy a skin .zip into %s/ over MTP, then scan again.", + Platform.inboxTransfer() == "ftp" + and "%d skins found. Copy a skin .zip into %s/ over FTP, then scan again." + or "%d skins found. Copy a skin .zip into %s/ over MTP, then scan again.", found, require("src.core.TouchSkin").USER_ROOT) } return end @@ -5832,12 +5863,12 @@ function RomImporter:rescanCartsAction(version) end function RomImporter:_cartImportButtonLabel() - if self.isNX then return Strings("Scan again") end + if self:_inboxImport() then return Strings("Scan again") end return Strings("Import .g1rcart") end -- "Import .g1rcart" on the Custom Carts modal. Same platform split as ROM, --- mod, and save import: NX rescans imports/carts/, Android and iOS stage +-- mod, and save import: NX and PS4 rescan imports/carts/, Android and iOS stage -- picked_cart.g1rcart through love.system.pickFile("cart"), UWP returns the -- pick from getPickedFile, handhelds use the in-launcher browser, and desktop -- opens the OS dialog (async on Windows so the window keeps drawing). @@ -5845,7 +5876,7 @@ function RomImporter:importCartFile(version) if self:_importerPickPending() then return end if self.workState == "working" then return false end if self._hostPick then return false end - if self.isNX then + if self:_inboxImport() then self:ensureCartsInboxDir() return self:rescanCartsAction(version) end @@ -6718,11 +6749,12 @@ function RomImporter:exportCart(id) local base = fs.getSaveDirectory and fs.getSaveDirectory() or "" if base ~= "" then abs = base .. "/" .. rel end end - if self.isNX then + if self:_inboxImport() then local hint = RomImporter.mtpHintPath(love.filesystem.getSaveDirectory()) if hint ~= "" and hint:sub(-1) ~= "/" then hint = hint .. "/" end self._cartNotice = - Strings("Exported to %s\nDBI MTP → 1: SD Card/%sexports/carts/", abs, hint) + Platform.inboxTransfer() == "ftp" and Strings("Exported to %s\nget it over FTP (GoldHEN, port 2121)", abs) + or Strings("Exported to %s\nDBI MTP → 1: SD Card/%sexports/carts/", abs, hint) return end if self.android then @@ -8474,15 +8506,18 @@ end -- NX / desktop / Android labels and inbox hints for the FlexLove view. function RomImporter:_modsImportButtonLabel() - if self.isNX then return Strings("Scan again") end + if self:_inboxImport() then return Strings("Scan again") end return Strings("Import mod .zip") end function RomImporter:_modsDefaultHint() - if self.isNX then + if self:_inboxImport() then local saveDir = love.filesystem.getSaveDirectory() local rel = RomImporter.mtpHintPath(saveDir) if rel ~= "" and rel:sub(-1) ~= "/" then rel = rel .. "/" end + if Platform.inboxTransfer() == "ftp" then + return Strings("Copy a .zip via FTP (GoldHEN, port 2121) into %s/imports/mods/", saveDir) + end return Strings("Copy a .zip via MTP into %s/imports/mods/\n" .. "DBI MTP → 1: SD Card/%simports/mods/", saveDir, rel) end @@ -8491,13 +8526,17 @@ function RomImporter:_modsDefaultHint() end function RomImporter:_savesDefaultHint(version) - if self.isNX then + if self:_inboxImport() then version = self:_resolveSaveVersion(version) local inbox = savesInboxDir(version) local saveDir = love.filesystem.getSaveDirectory() local rel = RomImporter.mtpHintPath(saveDir) if rel ~= "" and rel:sub(-1) ~= "/" then rel = rel .. "/" end local game = GameVersion.info(version).displayName + if Platform.inboxTransfer() == "ftp" then + return Strings("Copy a %s .sav via FTP (GoldHEN, port 2121) into %s/%s/", + game, saveDir, inbox) + end return Strings("Copy a %s .sav via MTP into %s/%s/\n" .. "DBI MTP → 1: SD Card/%s%s/", game, saveDir, inbox, rel, inbox) end @@ -8508,7 +8547,7 @@ function RomImporter:_savesDefaultHint(version) end function RomImporter:_modsEmptyHint() - if self.isNX then + if self:_inboxImport() then return Strings("No mods installed - copy a .zip into imports/mods/ " .. "and tap Scan again.") end diff --git a/src/ui/kit/Kit.lua b/src/ui/kit/Kit.lua index 07a9b3ad..a4bd532f 100644 --- a/src/ui/kit/Kit.lua +++ b/src/ui/kit/Kit.lua @@ -353,8 +353,12 @@ local function getNavLayer(slot) local id = tostring(slot.id or "") local y = slot.y or 0 - -- Layer 1: Top Bar (Settings / Gear, Close / Quit) - if id == "gear" or id == "settings" or id == "close" or id == "quit" or (y < 45 * Kit.scale and not id:match("^tab%-")) then + -- Layer 1: Top Bar (Settings / Gear, Close / Quit, Save Sync). tab-sync + -- rides the gear's cluster; by its tab- prefix alone it fell into layer 2, + -- so Right from it found nothing and Up jumped sideways to the gear. + if id == "gear" or id == "settings" or id == "close" or id == "quit" + or id == "tab-sync" + or (y < 45 * Kit.scale and not id:match("^tab%-")) then return 1 end @@ -520,6 +524,31 @@ function Kit._resolveNav() end return elseif dir == "up" or dir == "down" then + -- Within the layer first: the nearest control above/below in the same + -- layer wins, so Down from the cart reaches the Scan / Import button under + -- it instead of jumping over it to the footer. The horizontal gap weighs + -- double so a control straight below beats one off to the side. + do + local best, bestScore + for i = 1, n do + local c = Kit._nav[i] + if c.id ~= cur.id and getNavLayer(c) == curLayer then + local dy = (c.y + c.h / 2) - cy + local forward = dir == "down" and dy or -dy + if forward > 1 then + local gap = math.max(0, c.x - (cur.x + cur.w), cur.x - (c.x + c.w)) + local score = forward + gap * 2 + if not bestScore or score < bestScore then + best, bestScore = c, score + end + end + end + end + if best then + Kit.focusId = best.id + return + end + end -- VERTICAL LAYER NAVIGATION (Up/Down steps between layers: 1 <-> 2 <-> 3 <-> 4) local targetLayer = dir == "up" and (curLayer - 1) or (curLayer + 1) targetLayer = math.max(1, math.min(4, targetLayer)) diff --git a/tests/engine/cart_import_picker.lua b/tests/engine/cart_import_picker.lua index aea4f721..f57f3732 100644 --- a/tests/engine/cart_import_picker.lua +++ b/tests/engine/cart_import_picker.lua @@ -146,6 +146,22 @@ eq(nx:importCartFile("red"), false, "an empty NX inbox does not invent a cart") check(nx._cartNotice:find("imports/carts/", 1, true) ~= nil, "and the notice names the MTP folder") +-- PS4 shares the NX inbox (no file picker), with FTP in the hints. +love.system.getOS = function() return "PS4" end +Platform._resetForTests() +local ps4 = RomImporter.new(function() end, { launcher = true }) +ps4._installCartFile = nx._installCartFile +eq(ps4:_cartImportButtonLabel(), "Scan again", "PS4 cart button rescans the inbox") +love.filesystem.write("imports/carts/inbox.g1rcart", "cart-bytes") +eq(ps4:importCartFile("red"), true, "PS4 Import scans imports/carts/") +eq(ps4.installed, "imports/carts/inbox.g1rcart", "and installs the inbox file") +love.filesystem.remove("imports/carts/inbox.g1rcart") +ps4.installed = nil +eq(ps4:importCartFile("red"), false, "an empty PS4 inbox does not invent a cart") +check(ps4._cartNotice:find("over FTP", 1, true) ~= nil + and ps4._cartNotice:find("DBI MTP", 1, true) == nil, + "and the notice says FTP, not the Switch's MTP") + love.system.getOS = saved.getOS love.system.pickFile = saved.pickFile love.system.pickFileKinds = saved.pickFileKinds diff --git a/tests/engine/launcher_pad_nav_layers_test.lua b/tests/engine/launcher_pad_nav_layers_test.lua new file mode 100644 index 00000000..c726c597 --- /dev/null +++ b/tests/engine/launcher_pad_nav_layers_test.lua @@ -0,0 +1,49 @@ +-- Focus-ring navigation between and within layers (found driving the +-- launcher with a pad only, on PS4; the same ring runs on Xbox and on +-- desktop arrow keys). +-- Self-contained: luajit tests/engine/launcher_pad_nav_layers_test.lua + +package.path = "./?.lua;./?/init.lua;" .. package.path +if not _G.love then _G.love = require("tests.love_stub") end + +local T = require("tests.harness") +local eq = T.eq + +local Kit = require("src.ui.kit.Kit") + +-- One frame of focusables: the header cluster, a cart with its action +-- button straight below, and a footer card. +local function frame() + Kit.beginFrame(0, 0, false, 0) + Kit.focusable("tab-sync", 520, 10, 30, 30) + Kit.focusable("gear", 560, 10, 30, 30) + Kit.focusable("tab-red", 20, 60, 40, 24) + Kit.focusable("play-red", 20, 120, 120, 150) + Kit.focusable("rom-red", 20, 290, 120, 30) + Kit.focusable("footer-promo", 20, 400, 200, 40) + Kit.endFrame() +end + +local function step(from, dir) + Kit.scale = 1 + Kit.blockClicks = false + Kit.focusId = from + frame() + Kit.focusId = from + Kit.navigate(dir) + frame() + return Kit.focusId +end + +-- Save Sync sits in the top bar beside the gear, not in the tab row. +eq(step("tab-sync", "right"), "gear", "Right from Save Sync reaches the gear") +eq(step("gear", "left"), "tab-sync", "Left from the gear reaches Save Sync") + +-- Down inside the content layer before leaving it. +eq(step("play-red", "down"), "rom-red", + "Down from the cart reaches the button under it, not the footer") +eq(step("rom-red", "up"), "play-red", "Up from that button returns to the cart") +eq(step("rom-red", "down"), "footer-promo", + "with nothing further down in the layer, Down still reaches the footer") + +T.finish() diff --git a/tests/engine/platform_ps4_test.lua b/tests/engine/platform_ps4_test.lua new file mode 100644 index 00000000..0c8dd67f --- /dev/null +++ b/tests/engine/platform_ps4_test.lua @@ -0,0 +1,67 @@ +-- PS4 capability detection, and the launcher capabilities it shares with or +-- keeps apart from NX / Xbox / iOS / desktop. +-- Self-contained: luajit tests/engine/platform_ps4_test.lua + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local eq = T.eq + +local savedLove = _G.love + +local function withOS(osName, pickFile, fn) + _G.love = { + system = { + getOS = function() return osName end, + pickFile = pickFile, + }, + } + package.loaded["src.core.Platform"] = nil + local Platform = require("src.core.Platform") + Platform._resetForTests() + local ok, err = pcall(fn, Platform) + _G.love = savedLove + package.loaded["src.core.Platform"] = nil + if not ok then error(err) end +end + +-- PS4: the Switch's inbox import, Xbox's focus navigation, iOS's system quit. +withOS("PS4", nil, function(Platform) + local caps = Platform.detect() + eq(caps.ps4, true, "PS4 flag") + eq(caps.nx, false, "PS4 is not NX") + eq(caps.console, true, "PS4 console") + eq(caps.romImportMode, "save-directory", "PS4 imports through the inbox") + eq(caps.canSpawnProcess, false, "PS4 cannot spawn processes") + eq(caps.networkValidated, false, "PS4 has no self-updater") + eq(Platform.isPS4(), true, "isPS4 convenience") + eq(Platform.isNX(), false, "isNX false on PS4") + eq(Platform.padNavigation(), "focus", "PS4 pad drives the focus ring") + eq(Platform.systemQuit(), true, "PS4: the PS button owns quitting") + eq(Platform.inboxTransfer(), "ftp", "PS4 inbox hints say FTP") +end) + +withOS("NX", nil, function(Platform) + eq(Platform.padNavigation(), "pointer", "NX pad drives the virtual cursor") + eq(Platform.systemQuit(), false, "NX keeps the Quit button") + eq(Platform.inboxTransfer(), "mtp", "NX inbox hints say MTP") +end) + +withOS("UWP", function() end, function(Platform) + eq(Platform.padNavigation(), "focus", "Xbox pad drives the focus ring") + eq(Platform.systemQuit(), false, "Xbox keeps the Quit button") + eq(Platform.inboxTransfer(), nil, "Xbox has no inbox") +end) + +withOS("iOS", function() end, function(Platform) + eq(Platform.systemQuit(), true, "iOS: Home owns quitting") + eq(Platform.padNavigation(), nil, "iOS pad cursor stays latent") +end) + +withOS("OS X", nil, function(Platform) + eq(Platform.padNavigation(), nil, "desktop pad cursor stays latent") + eq(Platform.systemQuit(), false, "desktop keeps the Quit button") + eq(Platform.inboxTransfer(), nil, "desktop has no inbox") +end) + +T.finish()