Browser roots: the USB and extended drives ShadowMountPlus mounts

ShadowMountPlus 1.7beta4 mounts the console's /mnt (and /data) into every
PPSA title's sandbox while it runs. The top of the file browser and Load
Content is now INTERNAL (/app0) and each drive actually mounted there: USB 0-7
(/mnt/usbN) and EXTENDED 0-1 (/mnt/extN). A slot counts as a drive when its
root is another filesystem than /mnt. With none, EXTERNAL (/mnt) stays, as
before. /data is never offered (owner's decision). The startup trace records
what the sandbox exposes: roots, drive slots and /mnt's mount names, no file
names.

tools/build-title.sh gains PS5_REUSE_STAGED_CORES (development builds only,
refused for a release): stage a core as its last build left it, to deploy a
title change while MAME's hour-long rebuild runs.

Verified on the base PS5 (13.40), ShadowMountPlus 1.7beta4, one USB drive:
run-PPSA99169-204255; trace: "directory /mnt opened=1", "storage /mnt/usb0
(USB 0) available", no DATA root (evidence/usb-storage-roots). Before,
/mnt and /mnt/usb0 failed with errno 2. The owner confirmed the drive is
listed and a game on it plays.
Gates: tools/verify.sh format, unit and evidence PASS (87 captures, 0
failed). build and integration not run: MAME's full rebuild is in progress.
This commit is contained in:
Mihawk committed 2026-10-06 20:44:15 -04:00
1 parent 1cdf298db1
commit e06c1b87c8
7 files changed
+173 -26

No files matched your search

+6 -3
View File
@@ -219,9 +219,12 @@ obtained backups**: games you own, and BIOS or firmware dumped from hardware you
own. Piracy is not condoned.
Ordinary scripted updates preserve content and saved settings. Back up your
files before replacing or removing a title. The file browser has **INTERNAL**
for the title folder and **EXTERNAL** for `/mnt`; the current title sandbox hides
external mounts, so EXTERNAL may be empty.
files before replacing or removing a title. The file browser opens on **INTERNAL**,
the title folder. With [ShadowMountPlus](https://github.com/drakmor/ShadowMountPlus)
1.7beta4 or later running, it also lists each USB drive (**USB 0** to **USB 7**,
`/mnt/usb0` onward) and extended storage (**EXTENDED 0** and **1**, `/mnt/ext0` and
`/mnt/ext1`), only when a drive is mounted there. Without it, the sandbox hides them
and **EXTERNAL** (`/mnt`) may be empty.
## What has been verified
+27
View File
@@ -0,0 +1,27 @@
{
"step": "usb-storage-roots",
"recorded": "2026-10-07T00:44:06Z",
"console_run": "base PS5 13.40, ShadowMountPlus 1.7beta4 loaded, one USB drive; run-PPSA99169-204255",
"revision": "working tree on 1cdf298 (this commit)",
"command": "PS5_FRONTENDS='es-de picker' PS5_REUSE_STAGED_CORES=mame tools/build-title.sh; tools/run-title.sh --no-build --watch 30; /app0/trace.txt over FTP",
"raw_capture": "klog/usb-storage-roots/trace-storage-204255.txt",
"raw_lines": 15,
"raw_bytes": 844,
"lines": [
"frontend ps5: directory /app0/cores opened=1 entries=32 errno=0",
"frontend ps5: directory /app0 opened=1 entries=35 errno=0",
"frontend ps5: directory /data opened=1 entries=12 errno=0",
"frontend ps5: directory /mnt opened=1 entries=16 errno=0",
"frontend ps5: /mnt holds disc system_data.tmp rnps ext0 sandbox rnps2.tmp rnps2 pfs usb1 usb0 usb3 usb2 usb5 usb4 usb7 usb6",
"frontend ps5: storage /mnt/ext0 (EXTENDED 0) absent",
"frontend ps5: storage /mnt/ext1 (EXTENDED 1) absent",
"frontend ps5: storage /mnt/usb0 (USB 0) available",
"frontend ps5: storage /mnt/usb1 (USB 1) absent",
"frontend ps5: storage /mnt/usb2 (USB 2) absent",
"frontend ps5: storage /mnt/usb3 (USB 3) absent",
"frontend ps5: storage /mnt/usb4 (USB 4) absent",
"frontend ps5: storage /mnt/usb5 (USB 5) absent",
"frontend ps5: storage /mnt/usb6 (USB 6) absent",
"frontend ps5: storage /mnt/usb7 (USB 7) absent"
]
}
@@ -0,0 +1,13 @@
{
"step": "usb-storage-roots",
"note": "With ShadowMountPlus 1.7beta4, which mounts /mnt and /data into every PPSA title's sandbox, the title opens /mnt and finds the USB drive at /mnt/usb0; before, /mnt and /mnt/usb0 failed with errno 2. The browser's roots are INTERNAL and each mounted drive; /data is never offered (owner).",
"must_contain": [
"frontend ps5: directory /mnt opened=1",
"frontend ps5: storage /mnt/usb0 (USB 0) available",
"frontend ps5: storage /mnt/ext0 (EXTENDED 0) absent"
],
"must_not_contain": [
"(DATA)"
],
"human_check": "Owner confirmed the USB drive is listed in the title and a game from it plays."
}
+2 -1
View File
@@ -195,7 +195,8 @@
0096-frontend-thread-stacks
# 0097 the browser's top is the frontend's roots, INTERNAL (the title's folder)
# and EXTERNAL (the console's mounts): Load Content lists them alone, a
# and the drives the sandbox exposes (src/frontend_ps5.cpp: USB and
# extended drives, or EXTERNAL): Load Content lists them alone, a
# root shows its name, and a root's parent is the roots, not the
# filesystem's /
# menu/menu_displaylist.c, menu/cbs/menu_cbs_get_value.c,
+80 -16
View File
@@ -14,6 +14,7 @@ extern "C"
#include <cstring>
#include <ps5platform/libc.h>
#include <initializer_list>
#include <string>
#include <sys/stat.h>
namespace
@@ -21,15 +22,58 @@ namespace
constexpr const char *saved_config = "/app0/config/retroarch.cfg";
// The top of the file browser, Load Content included (patches/series, 0097):
// INTERNAL, the title's own folder, and EXTERNAL, where the console mounts USB
// drives, an extended storage drive and any other external storage. Each shows
// its name, and opening it opens its folder.
// INTERNAL, the title's own folder, then the USB and extended storage drives
// mounted into the title's sandbox. A sandbox shows the drives under /mnt only
// when something mounts them into it: ShadowMountPlus 1.7beta4 and later does,
// for every PPSA title, for as long as it runs. A drive slot is offered only if a
// drive is mounted there; without any, the browser keeps EXTERNAL, the bare /mnt,
// as before. The console's own /data is never offered (owner's decision), though
// ShadowMountPlus mounts it too. Each shows its name.
struct Root
{
const char *path;
const char *name;
};
constexpr Root roots[] = {{"/app0", "INTERNAL"}, {"/mnt", "EXTERNAL"}};
constexpr Root internal_root{"/app0", "INTERNAL"};
constexpr Root storage_roots[] = {{"/mnt/usb0", "USB 0"}, {"/mnt/usb1", "USB 1"},
{"/mnt/usb2", "USB 2"}, {"/mnt/usb3", "USB 3"},
{"/mnt/usb4", "USB 4"}, {"/mnt/usb5", "USB 5"},
{"/mnt/usb6", "USB 6"}, {"/mnt/usb7", "USB 7"},
{"/mnt/ext0", "EXTENDED 0"}, {"/mnt/ext1", "EXTENDED 1"}};
constexpr Root external_root{"/mnt", "EXTERNAL"};
// Whether a directory opens, and how many entries it holds besides . and ..
bool opens(const char *path, size_t *entries = nullptr, int *error = nullptr)
{
errno = 0;
DIR *dir = ps5_opendir(path);
size_t count = 0;
if (dir)
{
while (const struct dirent *entry = ps5_readdir(dir))
if (std::strcmp(entry->d_name, ".") != 0 && std::strcmp(entry->d_name, ".."))
++count;
ps5_closedir(dir);
}
if (entries)
*entries = count;
if (error)
*error = dir ? 0 : errno;
return dir != nullptr;
}
// A drive slot holds a drive when its root is another filesystem than /mnt
// itself; an empty slot directory is the same filesystem.
bool available(const Root &root)
{
size_t entries = 0;
if (!opens(root.path, &entries))
return false;
struct stat slot{}, parent{};
if (stat(root.path, &slot) == 0 && stat("/mnt", &parent) == 0)
return slot.st_dev != parent.st_dev;
return entries != 0;
}
void set_directory(default_dirs slot, const char *path)
{
@@ -100,20 +144,28 @@ void initialize(void *)
set_directory(DEFAULT_DIR_OVERLAY, "/app0/overlays");
set_directory(DEFAULT_DIR_LOGS, "/app0");
std::fprintf(stderr, "frontend ps5: config=%s browser=/app0 cores=/app0/cores\n", saved_config);
// Startup summary: known roots only; never log the user's file names.
for (const char *path : {"/app0", "/app0/cores", "/mnt", "/mnt/usb0"})
// Startup summary: what this sandbox exposes, roots and mount names only;
// never the user's file names.
for (const char *path : {"/app0", "/app0/cores", "/data", "/mnt"})
{
errno = 0;
DIR *dir = ps5_opendir(path);
size_t entries = 0;
if (dir)
{
while (ps5_readdir(dir))
++entries;
ps5_closedir(dir);
}
int error = 0;
const bool opened = opens(path, &entries, &error);
std::fprintf(stderr, "frontend ps5: directory %s opened=%d entries=%zu errno=%d\n", path,
dir != nullptr, entries, errno);
opened, entries, error);
}
for (const Root &root : storage_roots)
std::fprintf(stderr, "frontend ps5: storage %s (%s) %s\n", root.path, root.name,
available(root) ? "available" : "absent");
if (DIR *dir = ps5_opendir("/mnt"))
{
std::string names;
while (const struct dirent *entry = ps5_readdir(dir))
if (entry->d_name[0] != '.' && names.size() < 400)
names += std::string(names.empty() ? "" : " ") + entry->d_name;
ps5_closedir(dir);
std::fprintf(stderr, "frontend ps5: /mnt holds %s\n",
names.empty() ? "nothing" : names.c_str());
}
}
@@ -128,9 +180,21 @@ int drives(void *data, bool content)
auto *list = static_cast<file_list_t *>(data);
const auto label = content ? MENU_ENUM_LABEL_FILE_DETECT_CORE_LIST_PUSH_DIR
: MENU_ENUM_LABEL_FILE_BROWSER_DIRECTORY;
for (const Root &root : roots)
const auto add = [&](const Root &root)
{
if (menu_entries_append(list, root.path, "", label, FILE_TYPE_DIRECTORY, 0, 0, nullptr))
file_list_set_alt_at_offset(list, list->size - 1, root.name);
};
add(internal_root);
bool any = false;
for (const Root &root : storage_roots)
if (available(root))
{
add(root);
any = true;
}
if (!any)
add(external_root);
return 0;
}
} // namespace
+26 -5
View File
@@ -11,11 +11,13 @@ namespace
std::string fixture;
std::vector<std::string> root_paths;
std::vector<std::string> root_names;
// Drive slots that hold a mounted drive: their stat reports another device.
std::vector<std::string> mounted;
std::string physical(const char *path)
{
std::string p(path);
if (p == "/" || p == "/app0" || p.find("/app0/") == 0 || p == "/data" ||
p.find("/mnt/usb") == 0)
p.find("/data/") == 0 || p == "/mnt" || p.find("/mnt/") == 0)
return fixture + p;
return p;
}
@@ -40,7 +42,11 @@ extern "C"
}
int __wrap_stat(const char *p, struct stat *s)
{
return __real_stat(physical(p).c_str(), s);
const int result = __real_stat(physical(p).c_str(), s);
for (const std::string &slot : mounted)
if (result == 0 && slot == p)
s->st_dev += 1;
return result;
}
int __wrap_mkdir(const char *p, mode_t mode)
{
@@ -129,11 +135,26 @@ int main(int argc, char **argv)
assert(frontend_ctx_ps5.environment_get);
frontend_ctx_ps5.environment_get(&count, argv, nullptr, &untouched);
assert(count == argc && untouched == 0x1234);
// The browser's top is two named roots, the title's folder and the mounts.
// Without anything mounted into the sandbox, the browser's top is the title's
// folder and the bare mounts, as before: an empty drive slot is not a drive.
file_list_t list{};
assert(frontend_ctx_ps5.parse_drive_list(&list, true) == 0);
assert((root_paths == std::vector<std::string>{"/app0", "/mnt"}));
assert((root_names == std::vector<std::string>{"INTERNAL", "EXTERNAL"}));
std::puts("frontend_ps5: config seed/preservation, directories, argv and the INTERNAL and "
"EXTERNAL roots PASS");
// With /data and a USB drive in the sandbox (ShadowMountPlus 1.7beta4), the
// drive is a root of its own, by name, and EXTERNAL gives way to it; /data is
// never offered.
std::filesystem::create_directories(fixture + "/data/roms");
std::ofstream(fixture + "/mnt/usb0/game.bin") << "x";
std::filesystem::create_directories(fixture + "/mnt/ext0");
mounted = {"/mnt/usb0"};
root_paths.clear();
root_names.clear();
list = {};
assert(frontend_ctx_ps5.parse_drive_list(&list, false) == 0);
assert((root_paths == std::vector<std::string>{"/app0", "/mnt/usb0"}));
assert((root_names == std::vector<std::string>{"INTERNAL", "USB 0"}));
frontend_ctx_ps5.init(nullptr); // the startup summary walks every root
std::puts("frontend_ps5: config seed/preservation, directories, argv, the INTERNAL and "
"EXTERNAL roots, and the mounted storage roots PASS");
}
+19 -1
View File
@@ -75,6 +75,16 @@ if [[ ${PS5_WITH_RPCS3:-0} == 1 ]]; then
core_names+=(rpcs3)
echo "==> [title] RPCS3 staged (PS5_WITH_RPCS3=1, your own build only)"
fi
# PS5_REUSE_STAGED_CORES="mame ..." (development builds only): stage those cores as
# their last build left them in build/cores/stage instead of building them again.
# For deploying a title change while a long core build (MAME's, over an hour) runs
# elsewhere; the reused core keeps the title's import table covering it. A release
# always builds every core.
reuse_cores=" ${PS5_REUSE_STAGED_CORES:-} "
if [[ -n ${PS5_REUSE_STAGED_CORES:-} && -n ${PS5_RELEASE_TAG:-} ]]; then
echo "error: PS5_REUSE_STAGED_CORES with release $PS5_RELEASE_TAG; a release builds every core" >&2
exit 2
fi
echo "==> [title] step 1/3: the frontend"
"$root/tools/build-retroarch.sh"
core_files=()
@@ -88,7 +98,15 @@ for core_name in "${core_names[@]}"; do
vice_x64sc) script=vice ;;
*) script=${core_name//_/-} ;;
esac
bash "$root/tools/build-$script.sh"
if [[ $reuse_cores == *" $core_name "* ]]; then
for staged in "$root/build/cores/stage/cores/${core_name}_libretro.so" \
"$root/build/cores/stage/info/${core_name}_libretro.info"; do
[[ -f $staged ]] || { echo "error: no staged $staged to reuse" >&2; exit 2; }
done
echo "==> [title] $core_name: reusing the staged core (PS5_REUSE_STAGED_CORES), not built"
else
bash "$root/tools/build-$script.sh"
fi
core_files+=("$root/build/cores/stage/cores/${core_name}_libretro.so")
done
python3 "$root/tools/core-imports.py" "${core_files[@]}"