Compare commits

...
Author SHA1 Message Date
The412Banner 55f34ee8d6 shadPS4 CI: save the build caches even when a run fails or is cancelled
Split actions/cache into restore and save steps and run the saves with
if: always(), so a cancelled or failed build still keeps what it compiled
(a run cancelled 23 objects short of done had thrown its cache away).
2026-09-26 18:23:11 -04:00
The412Banner 24b4abdfd4 shadPS4 build: give only shadPS4 the ccache launcher
FEXCore's CMake already wraps its compiles in ccache when it is installed,
so the global CMAKE_<LANG>_COMPILER_LAUNCHER doubled it into
"ccache sloppiness=time_macros ccache clang" and every compile failed.
Pass the launcher to shadPS4's configure alone (patches/0005).
2026-09-26 17:35:45 -04:00
The412Banner fcbbe87dde shadPS4: tolerate guard holes in FEX mapping checks; cache CI builds
Need for Speed Hot Pursuit Remastered stopped when a game thread's
destructors ran: the FEX engine's pre-flight check read /proc/self/maps
and required every page of the thread's writable range to be host-
writable, but the game keeps a PROT_NONE guard hole inside it
("host_permissions=---p", "pthread thread dtors failed at stage 1: 13").
Skip such holes; read-only or W+X pages still fail the check.

The build now compiles through ccache and keeps apt's downloads, both in
the workspace and carried between runs with actions/cache, so a rebuild
with the same pin recompiles only what a patch touched.
2026-09-26 17:30:30 -04:00
The412Banner d0c6998b00 shadPS4: relocate fixed mappings beyond a 39-bit address space
Need for Speed Hot Pursuit Remastered (and EA's engine generally) maps
'EAOSGlobal' fixed at 0xfbffffc000, the top of the PS4 user area. On a
39-bit Android kernel the ARM64 core's user area ends at 0x3fffffffff, so
MapMemory asserted ("Attempted to access invalid address"). Place such a
mapping at the top of the range the device has instead and log the move;
the game receives the new address through out_addr. Experimental: it only
helps if the game uses the returned address rather than a hard-coded one.
2026-09-26 16:41:04 -04:00
The412Banner 8ebe501f34 shadPS4: build SDL's Wayland video driver
The ARM64 core's build script turns SDL's Wayland driver off, so on the
desktop shadPS4 could only reach labwc through Xwayland ("wayland not
available"). Patch it back on (libdecor off, loaded at run time like X11),
install the arm64 Wayland/EGL/xkbcommon headers and the host
wayland-scanner, and check both drivers made it into SDL's config.

AppRun picks the driver: X11 inside gamescope (GAMESCOPE_WAYLAND_DISPLAY),
Wayland when a compositor is there (the desktop), and leaves an explicit
SDL_VIDEODRIVER alone, so game sessions keep today's path.
2026-09-26 15:03:42 -04:00
The412Banner 699ead23f2 Keep libfakeinput's C++ runtime private
libfakeinput.so links libstdc++ and libgcc statically and is preloaded into
every session process, so it exported the whole C++ runtime (~7000 symbols,
__gxx_personality_v0 and __cxa_* among them). Programs then unwound through
its copy: shadPS4 aborted in pthread_exit the first time a game thread ended
(forced unwind -> foreign personality -> terminate). Link with
--exclude-libs,ALL so only the input hooks are exported, fail the build if
the runtime leaks out again, and bump the preload cache so it is rebuilt.
2026-09-26 13:13:39 -04:00
The412Banner d64859fb72 Progress log: PS4 via shadPS4 (parked) 2026-09-26 08:03:37 -04:00
The412Banner 9daf41e18f shadPS4 build: pkg-config for the arm64 target so SDL3 has PulseAudio 2026-09-26 06:16:23 -04:00
The412Banner a9157db693 Front end: emulators described by the package catalog get their own tile
A catalog row with a "frontend" block (system, folders, file types or
a game-folder file, launch arguments, icon URL) is listed like the
built-in emulators, so a new emulator needs only its catalog row. The
last catalog read is kept for offline use. shadPS4 moves to this: its
tile, game scan (eboot.bin folders, param.sfo title, icon0 art) and
arguments now come from its catalog row.
2026-09-26 06:16:23 -04:00
The412Banner b07a2d2842 Package catalog: per-device override file; shadPS4 opens with ROMs/ps4
Download/droiddeck-catalog-url points one device at another catalog,
to install a package from a catalog branch before it is live. The
shadPS4 AppImage adds the ROMs folder's ps4 folder to its game folders
the first time it is opened from the menu.
2026-09-26 06:16:23 -04:00
The412Banner be7a50af46 Front end: shadPS4 for PS4 games
Lists dumped PS4 games (a folder with eboot.bin, title from
sce_sys/param.sfo, art from sce_sys/icon0.png; update and patch folders
left out) under ROMs/ps4 and boots them full screen with -g. Installs
from the package catalog as the shadps4 AppImage.
2026-09-26 06:16:23 -04:00
The412Banner 9a1aee75d5 shadPS4: build the ARM64 PS4 emulator as an AppImage
Cross-builds the ARM64 shadPS4 core (FEXCore guest CPU) in Debian
trixie with a patch so it runs stand-alone in the runtime: SDL
controllers and SDL audio when there is no Android control socket.
2026-09-26 06:16:23 -04:00
16 changed files with 601 additions and 31 deletions

No files matched your search

+72
View File
@@ -0,0 +1,72 @@
name: Build shadPS4 (ARM64)
# The PS4 emulator package: the ARM64 shadPS4 core (FEXCore runs the PS4's x86-64 code) from
# tools/shadps4/source.env, with tools/shadps4/patches so it runs stand-alone in the runtime (SDL
# controllers, PulseAudio), packed as shadps4.AppImage. Published as a release asset the hosted
# package catalog points at; the apk does not carry it.
on:
push:
paths:
- 'tools/shadps4/**'
- '.github/workflows/build-shadps4.yml'
workflow_dispatch:
inputs:
tag:
description: 'Release tag to publish as (e.g. shadps4-be6bc2e-p1); empty = artifact only'
required: false
default: ''
permissions:
contents: write
jobs:
build:
runs-on: ubuntu-24.04
timeout-minutes: 150
steps:
- uses: actions/checkout@v4
# Compiler and package caches carried between runs; a run with the same pin and patches
# recompiles only what changed.
- uses: actions/cache/restore@v4
with:
path: .ccache
key: shadps4-ccache-${{ hashFiles('tools/shadps4/source.env') }}-${{ github.run_id }}
restore-keys: |
shadps4-ccache-${{ hashFiles('tools/shadps4/source.env') }}-
shadps4-ccache-
- uses: actions/cache/restore@v4
with:
path: .apt-cache
key: shadps4-apt-${{ hashFiles('tools/shadps4/build-in-debian.sh') }}
restore-keys: shadps4-apt-
- name: Build in Debian trixie
run: |
set -euo pipefail
df -h /
docker run --rm -v "$PWD:/work" -w /work debian:trixie bash tools/shadps4/build-in-debian.sh
sudo chown -R "$(id -u):$(id -g)" .
# Saved even when the build fails or is cancelled: whatever was compiled is kept.
- uses: actions/cache/save@v4
if: always()
with:
path: .ccache
key: shadps4-ccache-${{ hashFiles('tools/shadps4/source.env') }}-${{ github.run_id }}
- uses: actions/cache/save@v4
if: always()
with:
path: .apt-cache
key: shadps4-apt-${{ hashFiles('tools/shadps4/build-in-debian.sh') }}
- uses: actions/upload-artifact@v4
with:
name: shadps4-appimage
path: |
shadps4.AppImage
shadps4.AppImage.sha256
- name: Publish
if: github.event_name == 'workflow_dispatch' && inputs.tag != ''
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
. tools/shadps4/source.env
gh release create "${{ inputs.tag }}" --title "${{ inputs.tag }}" --notes "shadPS4 ARM64 (FEXCore guest CPU) from ${SHADPS4_REPO} @ ${SHADPS4_REV} with tools/shadps4/patches; installed by the app to /opt/appimages/shadps4.AppImage. sha256 $(cut -d' ' -f1 shadps4.AppImage.sha256)" shadps4.AppImage shadps4.AppImage.sha256 || gh release upload "${{ inputs.tag }}" shadps4.AppImage shadps4.AppImage.sha256 --clobber
+8 -2
View File
@@ -26,7 +26,7 @@ concurrency:
cancel-in-progress: true
env:
PRELOAD_CACHE_VERSION: v1
PRELOAD_CACHE_VERSION: v2
jobs:
build:
@@ -160,7 +160,7 @@ jobs:
D=app/src/main/assets/linuxfs
mkdir -p "$D"
aarch64-linux-gnu-g++ -shared -fPIC -O2 -Wall -Wno-attributes -Wno-nonnull-compare \
-pthread -std=c++17 -static-libstdc++ -static-libgcc \
-pthread -std=c++17 -static-libstdc++ -static-libgcc -Wl,--exclude-libs,ALL \
-o "$D/libfakeinput.so" app/src/main/cpp/fakeinput_steam.cpp -ldl
aarch64-linux-gnu-strip --strip-unneeded "$D/libfakeinput.so"
aarch64-linux-gnu-gcc -shared -fPIC -O2 -Wall -pthread \
@@ -270,6 +270,12 @@ jobs:
for sym in open openat ioctl read close poll ppoll select stat fstat access scandir; do
grep -qx "$sym" <<<"$FAKE" || { echo "::error::libfakeinput.so does not export $sym"; exit 1; }
done
# The static C++ runtime stays private: every session process preloads this library,
# and an exported personality routine takes over other programs' unwinding (shadPS4
# aborted in pthread_exit).
if grep -qxE '__gxx_personality_v0|__cxa_throw|_Unwind_Resume' <<<"$FAKE"; then
echo "::error::libfakeinput.so exports the C++ runtime"; exit 1
fi
SESSION=$(syms "$D/libblsession.so")
for sym in socket bind getsockname setsockopt statfs statvfs; do
grep -qx "$sym" <<<"$SESSION" || { echo "::error::libblsession.so does not export $sym"; exit 1; }
@@ -576,11 +576,13 @@ class MainActivity : ComponentActivity() {
if (catalogLoading || catalog != null) return
catalogLoading = true
Thread({
val fetched = DesktopCatalog.fetch()
val fetched = DesktopCatalog.fetch(this)
ui.post {
catalog = fetched
catalogLoading = false
refreshPackages()
// An emulator the catalog describes gets its tile from it (Library).
if (fetched?.any { it.frontend != null } == true) refresh()
}
}, "catalog-desktop").start()
}
@@ -1228,16 +1230,18 @@ class MainActivity : ComponentActivity() {
else -> "Steam"
} else null
// The libraries, off the main thread: manifests and a folder scan.
val known = catalog
Thread({
val entries = known ?: DesktopCatalog.cached(this)
val games = if (ready) Library.steamGames(this) + com.droiddeck.launcher.frontend.AddedGames.scan(this).map { g ->
com.droiddeck.launcher.frontend.AddedGameArt.resolve(this, g).let { art -> Library.SteamGame(g.appId.toInt(), g.name, art.portrait ?: art.header, "added", g.gameId) }
} else emptyList()
val emus = Library.emulators(this) { id -> DesktopCatalog.installed(this, id) != null }
val emus = Library.emulators(this, entries) { id -> DesktopCatalog.installed(this, id) != null }
ui.post { steamGames = games; emulatorList = emus }
// Box art for the games that have none, fetched after the list is up; the list is
// rebuilt once if any was found.
if (!OfflineMode.enabled(this) && CoverArt.fetchMissing(this, emus.flatMap { it.games })) {
val refreshed = Library.emulators(this) { id -> DesktopCatalog.installed(this, id) != null }
val refreshed = Library.emulators(this, entries) { id -> DesktopCatalog.installed(this, id) != null }
ui.post { emulatorList = refreshed }
}
}, "library").start()
@@ -166,7 +166,7 @@ object DeviceReport {
h("Device switch files in Download")
for (name in listOf("droiddeck-env", "droiddeck-tu-debug", "droiddeck-driver",
"droiddeck-osc", "droiddeck-no-pad", "droiddeck-pad-log",
"droiddeck-no-hud", "droiddeck-wlr-renderer")) {
"droiddeck-no-hud", "droiddeck-wlr-renderer", "droiddeck-catalog-url")) {
val f = File(Environment.getExternalStorageDirectory(), "Download/$name")
if (f.isFile) k(name, FileUtils.readString(f)?.trim()?.replace('\n', ' ')?.ifEmpty { "(present, empty)" } ?: "(present)")
}
@@ -1,7 +1,11 @@
package com.droiddeck.launcher.frontend
import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import com.droiddeck.launcher.R
import com.droiddeck.launcher.core.Downloader
import com.droiddeck.launcher.runtime.DesktopCatalog
import com.droiddeck.launcher.runtime.LinuxRuntime
import com.droiddeck.launcher.session.GameStorage
import com.droiddeck.launcher.session.SessionPrefs
@@ -16,7 +20,8 @@ object Library {
/** [gameId] is what steam://rungameid/ takes: the appid for a Steam title, the shortcut id for an added game. */
class SteamGame(val appId: Int, val name: String, val art: File?, val library: String, val gameId: Long = appId.toLong())
class Rom(val name: String, val hostPath: File, val guestPath: String, val emulatorId: String, val art: File? = null)
class Emulator(val id: String, val name: String, val system: String, val program: String, val installed: Boolean, val games: List<Rom>) {
/** [icon]: a catalog emulator's own icon (its row's iconUrl); the built-in ones are resources. */
class Emulator(val id: String, val name: String, val system: String, val program: String, val installed: Boolean, val games: List<Rom>, val icon: Bitmap? = null) {
/** The emulator's own icon, bundled (the runtime keeps them as theme SVGs the app cannot draw). */
val iconRes: Int get() = when (id) {
"rpcs3" -> R.drawable.emu_rpcs3; "armsx2" -> R.drawable.emu_pcsx2; "dolphin" -> R.drawable.emu_dolphin
@@ -94,8 +99,19 @@ object Library {
.firstOrNull { it.isFile && it.length() > 0L }
}
/** [atPanel]: frames cheap enough to draw at the panel's own size (see [drawsAtPanel]). */
private class Spec(val id: String, val name: String, val system: String, val program: String, val folders: List<String>, val exts: Set<String>, val atPanel: Boolean = false)
/**
* [atPanel]: frames cheap enough to draw at the panel's own size (see [drawsAtPanel]).
* [gameFile]: a game is a folder holding this file (a PS4 dump's eboot.bin), not an image
* file; the file is what boots, [titleSfo] names it and [artPath] is its art, both relative to
* the folder, and a folder ending in one of [skipSuffixes] (an update beside the game) is not
* a game. [args]: the command line, {game} standing for the game. The last four and
* [iconUrl] come from a catalog row (see [fromCatalog]).
*/
private class Spec(
val id: String, val name: String, val system: String, val program: String, val folders: List<String>, val exts: Set<String>,
val atPanel: Boolean = false, val gameFile: String? = null, val skipSuffixes: List<String> = emptyList(),
val titleSfo: String? = null, val artPath: String? = null, val args: List<String>? = null, val iconUrl: String? = null,
)
private val specs = listOf(
Spec("rpcs3", "RPCS3", "PS3", "/opt/appimages/rpcs3.AppImage", listOf("ps3"), setOf("iso")),
// PS2: ARMSX2, the PCSX2 fork with ARM64 recompilers. Upstream PCSX2 interprets the PS2's
@@ -116,15 +132,58 @@ object Library {
private fun displayTitle(name: String): String =
name.replace(Regex("\\s*[(\\[][^)\\]]*[)\\]]"), "").trim().ifEmpty { name }
/** Every system folder name the specs claim, so a loose-file scan of the root skips them. */
private val systemFolders: Set<String> by lazy { specs.flatMap { it.folders }.toSet() }
/** The emulators the catalog describes that the app has no tile of its own for (the last read). */
@Volatile private var catalogSpecs: List<Spec> = emptyList()
private fun allSpecs() = specs + catalogSpecs
/**
* A catalog row's "frontend" block as a tile: {"system": "PS4", "folders": ["ps4"],
* "exts": [...] or "gameFile": "eboot.bin", "skipSuffixes": [...], "titleSfo": ..., "art": ...,
* "args": ["-g", "{game}"], "iconUrl": ..., "program": ..., "atPanel": false}. The program
* defaults to where the catalog puts an appimage.
*/
private fun fromCatalog(entries: List<DesktopCatalog.Entry>): List<Spec> {
val builtIn = specs.map { it.id }.toSet()
fun strings(a: org.json.JSONArray?) = a?.let { arr -> (0 until arr.length()).map { arr.optString(it) }.filter { it.isNotEmpty() } }
return entries.mapNotNull { e ->
val f = e.frontend ?: return@mapNotNull null
if (e.id in builtIn) return@mapNotNull null
runCatching {
Spec(
e.id, e.name, f.optString("system", e.name),
f.optString("program", "").ifEmpty { "/opt/appimages/${e.id}.AppImage" },
strings(f.optJSONArray("folders")).orEmpty().map { it.lowercase() },
strings(f.optJSONArray("exts")).orEmpty().map { it.lowercase() }.toSet(),
atPanel = f.optBoolean("atPanel", false),
gameFile = f.optString("gameFile", "").ifEmpty { null },
skipSuffixes = strings(f.optJSONArray("skipSuffixes")).orEmpty().map { it.uppercase() },
titleSfo = f.optString("titleSfo", "").ifEmpty { null },
artPath = f.optString("art", "").ifEmpty { null },
args = strings(f.optJSONArray("args")),
iconUrl = f.optString("iconUrl", "").takeIf { it.startsWith("https://") },
)
}.getOrNull()
}
}
/** A catalog emulator's icon, kept in files/emu-icons once fetched. */
private fun catalogIcon(context: Context, spec: Spec): Bitmap? {
val url = spec.iconUrl ?: return null
val file = File(context.filesDir, "emu-icons/${spec.id}.png")
if (!file.isFile || file.length() == 0L) {
file.parentFile?.mkdirs()
val part = File(file.path + ".part")
if (!Downloader.downloadFile(url, part, false, null) || !part.renameTo(file)) { part.delete(); return null }
}
return runCatching { BitmapFactory.decodeFile(file.path) }.getOrNull()
}
private val installedIds = mapOf(
"rpcs3" to "rpcs3", "armsx2" to "armsx2", "dolphin" to "dolphin", "duckstation" to "duckstation",
"melonds" to "melonds", "cemu" to "cemu", "ppsspp" to "emulators", "retroarch" to "emulators",
"melonds" to "melonds", "cemu" to "cemu", "shadps4" to "shadps4", "ppsspp" to "emulators", "retroarch" to "emulators",
)
/** The emulator's name for a program path from the rail ("ARMSX2"), or null. */
fun nameForProgram(program: String?): String? = specs.firstOrNull { it.program == program }?.name
fun nameForProgram(program: String?): String? = allSpecs().firstOrNull { it.program == program }?.name
/**
* Whether a program from the rail runs at the panel's own resolution rather than the session's
@@ -132,17 +191,24 @@ object Library {
* nothing, and 720p scaled up to the panel blurs the sharp pixels its screen layout is set
* up for (bannerlator-pad-defaults).
*/
fun drawsAtPanel(program: String?): Boolean = specs.any { it.program == program && it.atPanel }
fun drawsAtPanel(program: String?): Boolean = allSpecs().any { it.program == program && it.atPanel }
/** Desktop catalog package that supplies this emulator. */
fun packageId(emulatorId: String): String? = installedIds[emulatorId]
fun packageId(emulatorId: String): String? = installedIds[emulatorId] ?: catalogSpecs.firstOrNull { it.id == emulatorId }?.id
/** Every emulator the app knows, installed or not, with the games its system folder holds. */
fun emulators(context: Context, installedPackage: (String) -> Boolean): List<Emulator> {
/**
* Every emulator the app knows, installed or not, with the games its system folder holds:
* its own, then those [catalog] describes.
*/
fun emulators(context: Context, catalog: List<DesktopCatalog.Entry>?, installedPackage: (String) -> Boolean): List<Emulator> {
val romsRoot = SessionPrefs.romsDir(context).takeIf { it.isNotEmpty() }?.let(::File)?.takeIf { it.isDirectory }
return specs.map { spec ->
if (catalog != null) catalogSpecs = fromCatalog(catalog)
val all = allSpecs()
// Every system folder name the specs claim, so a loose-file scan of the root skips them.
val systemFolders = all.flatMap { it.folders }.toSet()
return all.map { spec ->
val games = ArrayList<Rom>()
if (romsRoot != null && spec.exts.isNotEmpty()) {
if (romsRoot != null && (spec.exts.isNotEmpty() || spec.gameFile != null)) {
// The system's folder(s), matched without regard to case, and up to three folders
// inside them - a dump usually comes as a folder named for the game with the image
// inside it, and people sort those into folders of their own (ps2/games/<game>/).
@@ -164,6 +230,20 @@ object Library {
art = File(dir, "PS3_GAME/ICON0.PNG").takeIf { it.isFile }))
continue
}
// A game that is a folder (a PS4 dump: eboot.bin, sce_sys/param.sfo), booted by
// the file in it.
if (spec.gameFile != null) {
val boot = File(dir, spec.gameFile)
val upper = dir.name.uppercase()
if (boot.isFile && spec.skipSuffixes.none { upper.endsWith(it) }) {
val sfo = spec.titleSfo?.let { File(dir, it) }?.takeIf { it.isFile }
val title = sfo?.let { readSfo(it)["TITLE"]?.trim()?.takeIf { t -> t.isNotEmpty() } }
val rel = boot.relativeTo(romsRoot).path
games.add(Rom(title ?: displayTitle(dir.name), dir, "/root/ROMs/$rel", spec.id,
art = spec.artPath?.let { File(dir, it) }?.takeIf { it.isFile }))
}
continue
}
val files = dir.listFiles()?.sortedBy { it.name.lowercase() }.orEmpty()
// A disc sheet (.cue, .gdi) names its track files and a playlist (.m3u) its
// discs: the sheet is the game, what it names is part of it. A .bin beside a
@@ -182,7 +262,8 @@ object Library {
if (spec.id == "rpcs3") games.addAll(rpcs3Installed(context))
// A cover found for the game before (CoverArt) where it has no art of its own.
val withArt = games.map { g -> if (g.art != null) g else CoverArt.cached(context, g)?.let { Rom(g.name, g.hostPath, g.guestPath, g.emulatorId, it) } ?: g }
Emulator(spec.id, spec.name, spec.system, spec.program, installedPackage(installedIds.getValue(spec.id)), withArt)
Emulator(spec.id, spec.name, spec.system, spec.program, installedPackage(installedIds[spec.id] ?: spec.id), withArt,
catalogIcon(context, spec))
}
}
@@ -225,7 +306,7 @@ object Library {
} ?: emptyList()
}
/** The string fields of a PARAM.SFO (the PSP/PS3 metadata file): a small binary table. */
/** The string fields of a PARAM.SFO (the PSP/PS3/PS4 metadata file): a small binary table. */
private fun readSfo(file: File): Map<String, String> {
val out = HashMap<String, String>()
try {
@@ -257,7 +338,8 @@ object Library {
}
/** How the emulator is told which game to boot, on its command line. */
fun launchArgs(emulatorId: String, guestPath: String): List<String> = when (emulatorId) {
fun launchArgs(emulatorId: String, guestPath: String): List<String> =
catalogSpecs.firstOrNull { it.id == emulatorId }?.args?.map { it.replace("{game}", guestPath) } ?: when (emulatorId) {
"rpcs3" -> listOf("--no-gui", guestPath)
// Straight into the game in its controller-driven full-screen UI (first-time setup there
// too), and gone when the game is quit from its pause menu (guide button): -batch.
@@ -24,10 +24,37 @@ object DesktopCatalog {
val url: String, val sha256: String, val size: Long, val notes: String,
/** For an appimage: the icon name and menu category of its .desktop entry. */
val icon: String, val category: String,
/**
* How the front end shows an emulator the app has no tile of its own for (Library):
* system, folders, exts or gameFile, args with {game}, iconUrl. A new emulator needs only
* its catalog row.
*/
val frontend: JSONObject? = null,
)
fun fetch(): List<Entry>? {
val body = Downloader.downloadString(CATALOG_URL) ?: return null
private fun cacheFile(context: Context) = File(context.filesDir, "desktop-catalog.json")
/** The catalog as last read, for the front end while offline or before the fetch is back. */
fun cached(context: Context): List<Entry>? = FileUtils.readString(cacheFile(context))?.let(::parse)
/**
* The catalog to read: Download/droiddeck-catalog-url (one https URL) points this device alone
* at another one - a catalog branch with a package that is not live for everyone yet.
*/
private fun catalogUrl(): String {
val override = File(android.os.Environment.getExternalStorageDirectory(), "Download/droiddeck-catalog-url")
.takeIf { it.isFile }?.let { FileUtils.readString(it)?.trim()?.lineSequence()?.firstOrNull()?.trim() }
return override?.takeIf { it.startsWith("https://") }?.also { Log.i(TAG, "catalog override: $it") } ?: CATALOG_URL
}
fun fetch(context: Context? = null): List<Entry>? {
val body = Downloader.downloadString(catalogUrl()) ?: return null
val entries = parse(body)
if (entries != null && context != null) FileUtils.writeString(cacheFile(context), body)
return entries
}
private fun parse(body: String): List<Entry>? {
return try {
val json = JSONObject(body)
val arr = json.getJSONArray("packages")
@@ -44,6 +71,7 @@ object DesktopCatalog {
o.optString("kind", "tar"), file.getString("url"), file.optString("sha256", ""),
file.optLong("size", 0L), o.optString("notes", ""),
o.optString("icon", "applications-games"), o.optString("category", "Game"),
o.optJSONObject("frontend"),
)
}
} catch (e: Exception) {
@@ -964,7 +964,7 @@ private fun Content(
}
Rise(4) { SectionTitle("Emulators", "${s.emulators.count { it.installed }} installed · ${s.emulators.count { !it.installed }} available") }
Rise(5, Modifier.weight(1f).fillMaxWidth()) {
ArtGrid(s.emulators.map { e -> Tile(e.name, if (e.installed) (if (e.id == "retroarch") null else "${e.games.size} game${if (e.games.size == 1) "" else "s"}") else "Select to install", null, "emu:${e.id}", e.iconRes, dim = !e.installed) { onSelect("emu:${e.id}") } })
ArtGrid(s.emulators.map { e -> Tile(e.name, if (e.installed) (if (e.id == "retroarch") null else "${e.games.size} game${if (e.games.size == 1) "" else "s"}") else "Select to install", null, "emu:${e.id}", e.iconRes.takeIf { e.icon == null }, dim = !e.installed, iconBitmap = e.icon) { onSelect("emu:${e.id}") } })
}
}
selected == "setup" -> SetupPanel(s, a, onOpenDeveloperOptions, onRequestWirelessAdb)
@@ -983,7 +983,7 @@ private fun Content(
if (e.installed) {
Rise(3) {
Actions {
Image(painterResource(e.iconRes), null, modifier = Modifier.size(40.dp))
EmulatorIcon(e)
PrimaryButton("Open ${e.name}", enabled = s.ready && !s.busy, main = true) { a.onEmulator(e) }
SecondaryButton("ROMs folder", onClick = a.onRoms)
if (pkg != null) SecondaryButton(
@@ -1001,12 +1001,12 @@ private fun Content(
)
}
else Rise(5, Modifier.weight(1f).fillMaxWidth()) {
ArtGrid(e.games.mapIndexed { index, g -> Tile(g.name, if (g.art != null) "installed" else g.hostPath.extension.uppercase().ifEmpty { "folder" }, g.art, "rom:${e.id}:$index", e.iconRes) { onSelect("rom:${e.id}:$index") } }, wide = e.games.none { it.art != null })
ArtGrid(e.games.mapIndexed { index, g -> Tile(g.name, if (g.art != null) "installed" else g.hostPath.extension.uppercase().ifEmpty { "folder" }, g.art, "rom:${e.id}:$index", e.iconRes.takeIf { e.icon == null }, iconBitmap = e.icon) { onSelect("rom:${e.id}:$index") } }, wide = e.games.none { it.art != null })
}
} else {
if (pkg != null) Rise(2) {
Actions {
Image(painterResource(e.iconRes), null, modifier = Modifier.size(40.dp))
EmulatorIcon(e)
PrimaryButton(
if (s.packageBusyId == pkg.id) "Installing…" else "Install ${e.name}",
enabled = s.packageBusyId == null && s.ready && !s.packageCatalogLoading && !s.sessionRunning,
@@ -1060,7 +1060,7 @@ private fun Content(
Text(g.guestPath, fontSize = 12.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.clip(RoundedCornerShape(8.dp)).background(colors.surface).padding(horizontal = 10.dp, vertical = 6.dp))
Spacer(Modifier.height(14.dp))
Actions {
Image(painterResource(e.iconRes), null, modifier = Modifier.size(40.dp))
EmulatorIcon(e)
PrimaryButton("Launch in ${e.name}", enabled = s.ready && !s.busy, main = true) { a.onRom(g) }
Chip(g.hostPath.extension.uppercase().ifEmpty { "folder" }, ok = false)
}
@@ -1074,7 +1074,7 @@ private fun Content(
Rise(4, Modifier.weight(1f).fillMaxWidth()) {
ArtGrid(others.map { x ->
val index = e.games.indexOf(x)
Tile(x.name, if (x.art != null) "installed" else x.hostPath.extension.uppercase().ifEmpty { "folder" }, x.art, "rom:${e.id}:$index", e.iconRes) { onSelect("rom:${e.id}:$index") }
Tile(x.name, if (x.art != null) "installed" else x.hostPath.extension.uppercase().ifEmpty { "folder" }, x.art, "rom:${e.id}:$index", e.iconRes.takeIf { e.icon == null }, iconBitmap = e.icon) { onSelect("rom:${e.id}:$index") }
}, wide = others.none { it.art != null })
}
}
@@ -1444,6 +1444,14 @@ private fun CoverImage(art: File, modifier: Modifier) {
}
}
/** An emulator's icon in its page's header: the catalog's own, else the bundled one. */
@Composable
private fun EmulatorIcon(e: Library.Emulator) {
val icon = e.icon
if (icon != null) Image(bitmap = icon.asImageBitmap(), contentDescription = null, modifier = Modifier.size(40.dp))
else Image(painterResource(e.iconRes), null, modifier = Modifier.size(40.dp))
}
@Composable
private fun Art(art: File?, iconRes: Int?, label: String, modifier: Modifier, wide: Boolean = false, iconBitmap: Bitmap? = null) {
val colors = MaterialTheme.colorScheme
+36
View File
@@ -7,6 +7,42 @@ the timeline, then lessons and backlog. Companion to the README (what the app *d
---
## 2026-09-26 - `feat/ps4-shadps4` (PARKED): PS4 through shadPS4, emulators from the catalog
State: **branch `feat/ps4-shadps4` = main `c950d31` + 5 commits** (head `9daf41e`, not a PR yet). Parked by
the owner after the first game test: the DroidDeck side works; the emulator core aborts on this device.
- **shadPS4 package** (`tools/shadps4`, `.github/workflows/build-shadps4.yml`): the ARM64 shadPS4 core
(zenithblue-oss/shadps4-arm64 `be6bc2e9`, from the Bachata S4 project, GPL-2.0; FEXCore runs the PS4's
x86-64 code) cross-built in Debian trixie with a patch so it runs stand-alone: SDL controllers and SDL audio
(PulseAudio) when there is no Bachata control socket. Packed as `shadps4.AppImage` (AppRun: no arguments =
Big Picture game list; the first time, `ROMs/ps4` is added to its game folders). Published as a release
asset in winlator-contents `steamdeck-desktop-r1` (the org repo is private). Needs only the runtime's own
libraries (libc/libm/libstdc++/libgcc_s/libudev/libuuid).
- **Emulators described by the catalog**: a `desktop.json` row with a `"frontend"` block (system, folders,
file types or a game-folder file, title/art paths, args with `{game}`, icon URL) becomes a front-end tile
with no app change; the last catalog read is kept for offline use. shadPS4 is the first: a game is a
dumped folder with `eboot.bin` (title from `sce_sys/param.sfo`, art `sce_sys/icon0.png`, `-UPDATE` /
`-patch` folders left out), launched `-f true -g <eboot>`.
- **Per-device catalog override**: `Download/droiddeck-catalog-url` (one https URL) points one device at
another catalog (listed in the device report). The shadPS4 row lives on winlator-contents branch
`feat/shadps4`, not main; the test device reads it through the override.
- **Device test** (Pocket FIT, repacked build `9daf41e`): tile appears ("Select to install"), installs,
lists God of War III Remastered (CUSA01715) with its icon and title, launches full screen; controller
found, Turnip Adreno 750 Vulkan up, ~55 frames presented - then the game aborts (SIGABRT, exit 134;
the AppImage runtime reports 127, the session 0) right after the game starts a thread. Same with the 1.01
update's eboot. **Bachata S4 0.2.3's own app fails the same way on this device**, without proot:
`BACHATA_FEX_MAPPING_FAIL reason=invalid_proc_maps` / `FEX guest callback pthread start failed at stage 1:
14` - an emulator-core bug at FEX guest-thread start, not DroidDeck's.
- Games come from PKGs via the owner's separate app, PS4 PKG Extractor (private repo); GoW3 extracted and
verified (12,022 files, 54.25 GB).
- **Open when resumed**: prefer `<ID>-UPDATE/eboot.bin` when an update exists (shadPS4 applies an update
only when booted from it); tile name "shadPS4" (the row's name is long); try other games; report the
FEX thread-start failure upstream with the Bachata diagnostic; then the DroidDeck PR, and the catalog row
last (it goes live to every install).
---
## 2026-09-25 - main `8cdedbe`: melonDS out of the box, touch in Big Picture, log privacy, Decky
State: **main = `8cdedbe`** (PR #22 merge). Device-tested on the AYANEO Pocket FIT (repacked test builds)
+1 -1
View File
@@ -100,7 +100,7 @@ docker run --rm --platform linux/amd64 \
d=app/src/main/assets/linuxfs
mkdir -p "$d"
aarch64-linux-gnu-g++ -shared -fPIC -O2 -Wall -Wno-attributes -Wno-nonnull-compare \
-pthread -std=c++17 -static-libstdc++ -static-libgcc \
-pthread -std=c++17 -static-libstdc++ -static-libgcc -Wl,--exclude-libs,ALL \
-o "$d/libfakeinput.so" app/src/main/cpp/fakeinput_steam.cpp -ldl
aarch64-linux-gnu-strip --strip-unneeded "$d/libfakeinput.so"
aarch64-linux-gnu-gcc -shared -fPIC -O2 -Wall -pthread \
+127
View File
@@ -0,0 +1,127 @@
#!/usr/bin/env bash
# Runs INSIDE a debian:trixie container on an x86-64 runner. Cross-builds the ARM64 shadPS4 core
# (tools/shadps4/source.env) with its own scripts - FEXCore first, then shadPS4 linked against it -
# after applying ./patches, and packs the binary as shadps4.AppImage for /opt/appimages.
# Built against trixie's glibc 2.41; the runtime's Arch rootfs is newer, so its own X11, Vulkan
# loader, udev and C++ libraries are used and nothing is bundled.
set -euxo pipefail
WORK=/work
cd "$WORK"
. tools/shadps4/source.env
export DEBIAN_FRONTEND=noninteractive
# Downloaded packages are kept in the workspace (.apt-cache), which the workflow caches between
# runs; the image's docker-clean hook would otherwise delete them after every install.
rm -f /etc/apt/apt.conf.d/docker-clean
mkdir -p "$WORK/.apt-cache/archives/partial"
cat > /etc/apt/apt.conf.d/99droiddeck-cache <<'APT'
Dir::Cache::archives "/work/.apt-cache/archives";
Binary::apt::APT::Keep-Downloaded-Packages "true";
APT
dpkg --add-architecture arm64
apt-get update
# The Bachata runtime's cross set, plus PulseAudio and ALSA headers so SDL3 builds its audio
# drivers (it loads them at run time; the runtime plays through PulseAudio), and the Wayland, EGL
# and xkbcommon headers with the host's wayland-scanner so it builds its Wayland video driver.
apt-get install -y --no-install-recommends \
ca-certificates git curl xz-utils zstd file pkg-config dpkg-dev squashfs-tools ccache \
cmake ninja-build clang llvm lld gcc g++ make \
gcc-aarch64-linux-gnu g++-aarch64-linux-gnu binutils-aarch64-linux-gnu \
python3 nodejs \
libvulkan-dev libc6-dev \
libx11-dev:arm64 libxext-dev:arm64 libudev-dev:arm64 uuid-dev:arm64 \
libpulse-dev:arm64 libasound2-dev:arm64 \
libwayland-dev:arm64 libegl-dev:arm64 libxkbcommon-dev:arm64 libwayland-bin \
libc6:arm64 libgcc-s1:arm64 libstdc++6:arm64 libvulkan1:arm64 libudev1:arm64 libuuid1:arm64 \
libx11-6:arm64 libxext6:arm64
git config --global --add safe.directory '*'
SRC=$WORK/build/shadps4-arm64-src
rm -rf "$SRC"
git init -q "$SRC"
git -C "$SRC" remote add origin "$SHADPS4_REPO"
git -C "$SRC" fetch -q --depth 1 origin "$SHADPS4_REV"
git -C "$SRC" checkout -q --detach FETCH_HEAD
test "$(git -C "$SRC" rev-parse HEAD)" = "$SHADPS4_REV"
git -C "$SRC" submodule update --init --recursive --depth 1 --jobs 8
for p in $(ls tools/shadps4/patches/*.patch | sort); do
echo "applying $(basename "$p")"
git -C "$SRC" apply --whitespace=nowarn "$WORK/$p"
done
# pkg-config answers for the arm64 target, not the x86-64 host: SDL3 finds PulseAudio (libpulse)
# only through it, and without it builds ALSA alone.
export PKG_CONFIG_LIBDIR=/usr/lib/aarch64-linux-gnu/pkgconfig:/usr/share/pkgconfig
pkg-config --modversion libpulse
# FEXCore compiles through ccache on its own once it is installed, and patches/0005 hands
# shadPS4's configure the same launcher; the cache lives in the workspace (.ccache) and the
# workflow keeps it between runs.
export CCACHE_DIR=$WORK/.ccache CCACHE_BASEDIR=$WORK CCACHE_NOHASHDIR=1 \
CCACHE_COMPILERCHECK=content CCACHE_MAXSIZE=4G
ccache -z
bash "$SRC/runtime/scripts/build-shadps4-arm64.sh"
ccache -s
BIN=$SRC/runtime/build/shadps4-arm64-stage/bin/shadps4-arm64
test -x "$BIN"
cat "$SRC/runtime/build/shadps4-arm64-stage/needed.txt"
# SDL3 found the audio drivers the runtime needs.
grep -E 'SDL_AUDIO_DRIVER_(PULSEAUDIO|ALSA)' "$SRC"/runtime/build/shadps4-arm64/externals/sdl3/include-config-release/build_config/SDL_build_config.h
grep -q '#define SDL_AUDIO_DRIVER_PULSEAUDIO 1' "$SRC"/runtime/build/shadps4-arm64/externals/sdl3/include-config-release/build_config/SDL_build_config.h
# ... and both video drivers: X11 under gamescope, Wayland on the desktop.
grep -q '#define SDL_VIDEO_DRIVER_WAYLAND 1' "$SRC"/runtime/build/shadps4-arm64/externals/sdl3/include-config-release/build_config/SDL_build_config.h
grep -q '#define SDL_VIDEO_DRIVER_X11 1' "$SRC"/runtime/build/shadps4-arm64/externals/sdl3/include-config-release/build_config/SDL_build_config.h
# The patch is in.
grep -aq 'BACHATA_MANAGED' "$BIN"
APPDIR=$WORK/build/shadPS4.AppDir
rm -rf "$APPDIR"
install -Dm755 "$BIN" "$APPDIR/usr/bin/shadps4"
cat > "$APPDIR/AppRun" <<'RUN'
#!/bin/sh
# shadPS4 is a command-line emulator; opened from a menu with nothing to run, it shows its own
# Big Picture game list instead of a usage box.
# The first time, the ROMs folder's ps4 folder (any case) is added to its game folders so the
# list is not empty; a folder the player removes later stays removed.
# Under gamescope (a game session) it stays on gamescope's X server; on the desktop it draws
# through the compositor's Wayland directly. An SDL_VIDEODRIVER already set wins.
HERE=$(dirname "$(readlink -f "$0")")
if [ -z "$SDL_VIDEODRIVER" ]; then
if [ -n "$GAMESCOPE_WAYLAND_DISPLAY" ]; then
export SDL_VIDEODRIVER=x11
elif [ -n "$WAYLAND_DISPLAY" ]; then
export SDL_VIDEODRIVER=wayland
fi
fi
if [ $# -eq 0 ]; then
MARK="${XDG_DATA_HOME:-$HOME/.local/share}/shadPS4/.droiddeck-games-folder"
if [ ! -e "$MARK" ]; then
for d in /root/ROMs/*; do
case "$(basename "$d" | tr 'A-Z' 'a-z')" in
ps4) [ -d "$d" ] && "$HERE/usr/bin/shadps4" --add-game-folder "$d" && mkdir -p "$(dirname "$MARK")" && : > "$MARK" ;;
esac
done
fi
set -- --big-picture
fi
exec "$HERE/usr/bin/shadps4" "$@"
RUN
chmod 755 "$APPDIR/AppRun"
cat > "$APPDIR/shadps4.desktop" <<'DESK'
[Desktop Entry]
Type=Application
Name=shadPS4
Exec=shadps4
Icon=shadps4
Categories=Game;Emulator;
DESK
cp "$SRC/src/resources/shadps4.png" "$APPDIR/shadps4.png"
ln -sf shadps4.png "$APPDIR/.DirIcon"
curl -fsSL -o runtime-aarch64 "$APPIMAGE_RUNTIME_URL"
file runtime-aarch64 | grep -q 'ARM aarch64'
mksquashfs "$APPDIR" shadps4.squashfs -root-owned -noappend -comp zstd -Xcompression-level 19
cat runtime-aarch64 shadps4.squashfs > shadps4.AppImage
chmod 755 shadps4.AppImage
rm -f shadps4.squashfs runtime-aarch64
sha256sum shadps4.AppImage | tee shadps4.AppImage.sha256
ls -l shadps4.AppImage
@@ -0,0 +1,111 @@
diff --git a/src/core/libraries/audio/audioout.cpp b/src/core/libraries/audio/audioout.cpp
index 2e28f7d..91ec84f 100644
--- a/src/core/libraries/audio/audioout.cpp
+++ b/src/core/libraries/audio/audioout.cpp
@@ -1,6 +1,7 @@
// SPDX-FileCopyrightText: Copyright 2024-2026 shadPS4 Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
+#include <cstdlib>
#include <cstring>
#include <memory>
#include <mutex>
@@ -207,14 +208,15 @@ s32 PS4_SYSV_ABI sceAudioOutInit() {
}
#ifdef ENABLE_BACHATA_RUNTIME
- audio = std::make_unique<BachataAudioOut>();
-#else
+ if (std::getenv("BACHATA_ALSA_SOCKET") != nullptr) {
+ audio = std::make_unique<BachataAudioOut>();
+ } else
+#endif
if (EmulatorSettings.GetAudioBackend() == AudioBackend::OpenAL) {
audio = std::make_unique<OpenALAudioOut>();
} else {
audio = std::make_unique<SDLAudioOut>();
}
-#endif
LOG_INFO(Lib_AudioOut, "Audio system initialized");
return ORBIS_OK;
diff --git a/src/main.cpp b/src/main.cpp
index 13733ed..d28319e 100644
--- a/src/main.cpp
+++ b/src/main.cpp
@@ -298,6 +298,8 @@ int main(int argc, char* argv[]) {
return 1;
}
runtime_client = std::move(*connected);
+ // Stand-alone runs (no socket) keep SDL controllers and SDL audio.
+ setenv("BACHATA_MANAGED", "1", 1);
if (!runtime_client.SendStarting()) {
std::cerr << "Failed to send Bachata Starting event\n";
return 1;
diff --git a/src/sdl_window.cpp b/src/sdl_window.cpp
index ffa42d3..0785fc3 100644
--- a/src/sdl_window.cpp
+++ b/src/sdl_window.cpp
@@ -27,6 +27,8 @@
#include "input/input_handler.h"
#include "input/input_mouse.h"
#include "sdl_window.h"
+
+#include <cstdlib>
#include "video_core/renderdoc.h"
#ifdef __APPLE__
@@ -39,6 +41,14 @@ CMRC_DECLARE(res);
namespace Frontend {
+#ifdef ENABLE_BACHATA_RUNTIME
+// The Android runtime delivers controllers over its socket; a stand-alone run uses SDL like
+// the desktop build.
+static bool BachataManaged() {
+ return std::getenv("BACHATA_MANAGED") != nullptr;
+}
+#endif
+
using namespace Libraries::Pad;
static OrbisPadButtonDataOffset SDLGamepadToOrbisButton(u8 button) {
@@ -153,9 +163,10 @@ WindowSDL::WindowSDL(s32 width_, s32 height_, Input::GameControllers* controller
SDL_SetWindowFullscreen(window, EmulatorSettings.IsFullScreen());
SDL_SyncWindow(window);
-#ifndef ENABLE_BACHATA_RUNTIME
- SDL_InitSubSystem(SDL_INIT_GAMEPAD);
+#ifdef ENABLE_BACHATA_RUNTIME
+ if (!BachataManaged())
#endif
+ SDL_InitSubSystem(SDL_INIT_GAMEPAD);
#if defined(SDL_PLATFORM_WIN32)
window_info.type = WindowSystemType::Windows;
@@ -183,9 +194,10 @@ WindowSDL::WindowSDL(s32 width_, s32 height_, Input::GameControllers* controller
// input handler init-s
Input::ControllerOutput::LinkJoystickAxes();
Input::ParseInputConfig(std::string(Common::ElfInfo::Instance().GameSerial()));
-#ifndef ENABLE_BACHATA_RUNTIME
- controllers.TryOpenSDLControllers();
+#ifdef ENABLE_BACHATA_RUNTIME
+ if (!BachataManaged())
#endif
+ controllers.TryOpenSDLControllers();
if (EmulatorSettings.IsBackgroundControllerInput()) {
SDL_SetHint(SDL_HINT_JOYSTICK_ALLOW_BACKGROUND_EVENTS, "1");
@@ -266,9 +278,10 @@ void WindowSDL::WaitEvent() {
break;
case SDL_EVENT_GAMEPAD_ADDED:
case SDL_EVENT_GAMEPAD_REMOVED:
-#ifndef ENABLE_BACHATA_RUNTIME
- controllers.TryOpenSDLControllers();
+#ifdef ENABLE_BACHATA_RUNTIME
+ if (!BachataManaged())
#endif
+ controllers.TryOpenSDLControllers();
break;
case SDL_EVENT_GAMEPAD_BUTTON_DOWN:
case SDL_EVENT_GAMEPAD_BUTTON_UP:
@@ -0,0 +1,26 @@
diff --git a/runtime/scripts/build-shadps4-arm64.sh b/runtime/scripts/build-shadps4-arm64.sh
index 8739b7a..49c35fa 100644
--- a/runtime/scripts/build-shadps4-arm64.sh
+++ b/runtime/scripts/build-shadps4-arm64.sh
@@ -98,7 +98,8 @@ cmake -S "$project_root" -B "$build_dir" -G Ninja \
-DSDL_X11_XSHAPE=OFF \
-DSDL_X11_XSYNC=OFF \
-DSDL_X11_XTEST=OFF \
- -DSDL_WAYLAND=OFF
+ -DSDL_WAYLAND=ON \
+ -DSDL_WAYLAND_LIBDECOR=OFF
sdl_config="$build_dir/externals/sdl3/include-config-release/build_config/SDL_build_config.h"
if ! grep -Fq '#define SDL_VIDEO_DRIVER_X11_DYNAMIC_XEXT "libXext.so.6"' "$sdl_config"; then
@@ -106,6 +107,11 @@ if ! grep -Fq '#define SDL_VIDEO_DRIVER_X11_DYNAMIC_XEXT "libXext.so.6"' "$sdl_c
exit 1
fi
+if ! grep -Fq '#define SDL_VIDEO_DRIVER_WAYLAND_DYNAMIC "libwayland-client.so.0"' "$sdl_config"; then
+ echo "ARM64 SDL3 requires dynamic Wayland support; install libwayland-dev:arm64, libegl-dev:arm64 and libxkbcommon-dev:arm64" >&2
+ exit 1
+fi
+
cmake --build "$build_dir" --target shadps4
test -f "$binary"
@@ -0,0 +1,35 @@
diff --git a/src/core/memory.cpp b/src/core/memory.cpp
index 7e17cdc..26cac97 100644
--- a/src/core/memory.cpp
+++ b/src/core/memory.cpp
@@ -550,6 +550,30 @@ s32 MemoryManager::MapMemory(void** out_addr, VAddr virtual_addr, u64 size, Memo
}
}
+ // A 39-bit Android address space ends far below the top of the PS4's user area, where some
+ // engines ask for fixed mappings (EA's 'EAOSGlobal' at 0xfbffffc000). Place such a mapping at
+ // the top of the range this device has instead of asserting; the caller gets the new address.
+ if (True(flags & MemoryMapFlags::Fixed) && !IsValidMapping(virtual_addr, size)) {
+ const VAddr requested = virtual_addr;
+ const u64 align = alignment > 16_KB ? alignment : 16_KB;
+ const VAddr top = impl.UserVirtualBase() + impl.UserVirtualSize();
+ VAddr candidate = Common::AlignDown(top - size, align);
+ const auto it = FindVMA(candidate);
+ if (it == vma_map.end() || !it->second.IsFree() || !it->second.Contains(candidate, size)) {
+ candidate = SearchFree(DEFAULT_MAPPING_BASE, size, static_cast<u32>(align));
+ }
+ if (candidate == static_cast<VAddr>(-1)) {
+ LOG_ERROR(Kernel_Vmm, "No room to relocate fixed mapping {:#x} ({:#x} bytes)",
+ requested, size);
+ return ORBIS_KERNEL_ERROR_ENOMEM;
+ }
+ LOG_WARNING(Kernel_Vmm,
+ "Fixed mapping {:#x} ({:#x} bytes, '{}') is outside this device's address "
+ "space; relocated to {:#x}",
+ requested, size, name, candidate);
+ virtual_addr = candidate;
+ }
+
if (True(flags & MemoryMapFlags::Fixed) && True(flags & MemoryMapFlags::NoOverwrite)) {
// Perform necessary error checking for Fixed & NoOverwrite case
ASSERT_MSG(IsValidMapping(virtual_addr, size), "Attempted to access invalid address {:#x}",
@@ -0,0 +1,16 @@
diff --git a/src/core/fex/fex_guest_engine.cpp b/src/core/fex/fex_guest_engine.cpp
index 2271f97..71ddc6d 100644
--- a/src/core/fex/fex_guest_engine.cpp
+++ b/src/core/fex/fex_guest_engine.cpp
@@ -182,7 +182,10 @@ EngineResult<bool> ValidateHostMapping(const GuestExecutionRange& range) {
static_cast<unsigned long>(begin), static_cast<unsigned long>(end), permissions);
return Failure(EngineStage::Mapping, EACCES);
}
- if (range.Writable && (permissions[1] != 'w' || permissions[2] == 'x')) {
+ // A guard hole the guest protected itself (PROT_NONE, "---p") inside a writable range is
+ // the game's own doing, like a stack guard page; FEX never writes there on its behalf.
+ const bool guardHole = permissions[0] == '-' && permissions[1] == '-' && permissions[2] == '-';
+ if (range.Writable && !guardHole && (permissions[1] != 'w' || permissions[2] == 'x')) {
std::fprintf(stderr,
"BACHATA_FEX_MAPPING_FAIL reason=host_write_permission error=%d begin=%#lx "
"size=%#lx executable=%d writable=%d host_begin=%#lx host_end=%#lx "
@@ -0,0 +1,13 @@
diff --git a/runtime/scripts/build-shadps4-arm64.sh b/runtime/scripts/build-shadps4-arm64.sh
index 49c35fa..2bdcba0 100644
--- a/runtime/scripts/build-shadps4-arm64.sh
+++ b/runtime/scripts/build-shadps4-arm64.sh
@@ -74,6 +74,8 @@ cmake -S "$project_root" -B "$build_dir" -G Ninja \
-DXEXT_LIB:FILEPATH="$arm64_xext_lib" \
-DIMGUI_FONT_EMBED_EXECUTABLE="$host_font_embed" \
-DCMAKE_BUILD_TYPE=Release \
+ -DCMAKE_C_COMPILER_LAUNCHER=ccache \
+ -DCMAKE_CXX_COMPILER_LAUNCHER=ccache \
-DCMAKE_CXX_SCAN_FOR_MODULES=OFF \
-DCMAKE_AR="$llvm_ar" \
-DCMAKE_RANLIB="$llvm_ranlib" \
+6
View File
@@ -0,0 +1,6 @@
# The ARM64 shadPS4 core: upstream shadPS4 with FEXCore running the PS4's x86-64 code, published
# by the Bachata S4 project (GPL-2.0). Pinned; tools/shadps4/patches apply on top.
SHADPS4_REPO=https://github.com/zenithblue-oss/shadps4-arm64.git
SHADPS4_REV=be6bc2e9c60799e071dd2fafa6216e8d80ec619c
# AppImage type2 runtime for aarch64 (squashfs with zstd).
APPIMAGE_RUNTIME_URL=https://github.com/AppImage/type2-runtime/releases/download/continuous/runtime-aarch64