Merge pull request #2504 from tomasmorello/ps4-port

This commit is contained in:
bryanthaboi
2026-10-07 15:43:01 -04:00
18 changed files with 557 additions and 51 deletions
+3 -2
View File
@@ -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
+11 -1
View File
@@ -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.
+1
View File
@@ -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
+11
View File
@@ -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).
+73
View File
@@ -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/<game>/`, 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`.
+143
View File
@@ -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/<tag>/ 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-<ver>-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 <key>
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 <<EOF
$1
EOF
printf '%02d.%02d' "$((10 * x + y))" "$z"
else
printf '00.00' # dev builds: always older than any release
fi
}
[ "$FETCH" -eq 1 ] && fetch_runtime
if [ "$LOOSE" -eq 1 ]; then
mkdir -p "$DIST/loose"
cp "$(pack_game_love)" "$DIST/loose/game.love"
say "dist/ps4/loose/game.love -> 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
Binary file not shown.

After

Width:  |  Height:  |  Size: 276 KiB

+8
View File
@@ -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 <zip> from the release <tag> and refuses
# it unless its SHA-256 matches. Bump both lines together.
tag v0.9.3
love-ps4-0.9.3-runtime.zip 8cc66a447738a6772fc79775d7306791fb63542582e420cf08db8cb0420eded7
+1
View File
@@ -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
+6
View File
@@ -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"
+31 -4
View File
@@ -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
+1
View File
@@ -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
+37 -13
View File
@@ -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
+68 -29
View File
@@ -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
+31 -2
View File
@@ -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))
+16
View File
@@ -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
@@ -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()
+67
View File
@@ -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()