1 Commits
Author SHA1 Message Date
TheRealRetro b75d1130e3 0.6.3: in-app updates install correctly
The updater wrote the new files with fopen(), which gives no execute
permission, so the console refused to start the updated eboot.bin
(CE-107750-0). 0.6.3 unpacks the update next to the app folder with 0777
permissions (as an FTP copy gets), closes, and its helper payload swaps the
folders so ShadowMountPlus installs the app again (ProsperoUpdateJob).

The release zip is now Mupen64PlusPS5.zip: 0.6.0-0.6.2 look for
PPSA99064.zip, so they never offer an update their updater would break.
2026-10-07 19:27:31 -04:00
16 changed files with 561 additions and 75 deletions

No files matched your search

+3
View File
@@ -23,3 +23,6 @@ github-token.txt
*.swp
.DS_Store
Thumbs.db
# Local test builds
test-updater/
+11 -6
View File
@@ -15,7 +15,7 @@ Title ID `PPSA99064`. No games are included: use ROMs you dumped yourself.
Requirements: a jailbroken PS5 with **kstuff** (or kstuff-lite), **ShadowMountPlus**, and an **ELF loader**
running (elfldr, etaHEN or PS5 Payload Manager's: the usual setup).
1. Download `PPSA99064.zip` from the [latest release](../../releases/latest).
1. Download `Mupen64PlusPS5.zip` from the [latest release](../../releases/latest).
2. Copy the `PPSA99064` folder to `/data/homebrew/` on the console (FTP...), or to `homebrew/` on an exFAT
USB drive. ShadowMountPlus adds **Mupen64Plus PS5** to the home screen.
3. Put your ROMs (`.z64 .n64 .v64 .rom`, zipped or not) in `/data/mupen64plus/roms`, or in `mupen64plus/roms`
@@ -27,9 +27,14 @@ which lets it out of its sandbox (`/data`, USB drives, executable memory for the
## Updates
The app checks this repository's latest release each time it starts. When a newer one exists it downloads
`PPSA99064.zip`, checks it against the SHA-256 GitHub publishes for it, and asks:
**✕ Update now / ○ Later**. On ✕ it writes the new files over its own folder and restarts. Saves, states,
covers and settings in `/data/mupen64plus` are kept. **Settings → Check for updates** turns the question off.
`Mupen64PlusPS5.zip`, checks it against the SHA-256 GitHub publishes for it, and asks:
**✕ Update now / ○ Later**. On ✕ it unpacks the update and closes; a background helper installs it, and a
notification says when to open the app again. Saves, states, covers and settings in `/data/mupen64plus` are
kept. **Settings → Check for updates** turns the question off.
**On 0.6.0, 0.6.1 or 0.6.2?** Their updater is broken (the updated app wouldn't start), so they don't see
newer releases. Install 0.6.3 or newer by hand once: delete `/data/homebrew/PPSA99064`, then copy the
`PPSA99064` folder from `Mupen64PlusPS5.zip`. Updates after that work from inside the app.
## Compatibility
@@ -47,7 +52,7 @@ For a crash or a bug, please [open an issue](../../issues) and attach `/data/mup
## Building and releasing (Windows + WSL Ubuntu 24.04)
```
build-native.bat build-native\PPSA99064\ + PPSA99064.zip (+ .debug.elf)
build-native.bat build-native\PPSA99064\ + Mupen64PlusPS5.zip (+ .debug.elf)
build-native.bat Ffpfsc also a compressed .ffpfsc image
build-native.bat Clean from scratch (nothing reused from earlier builds)
release.bat clean build, then publish a GitHub release
@@ -58,7 +63,7 @@ Releasing:
1. Raise `VERSION` in `ps5/Makefile`, and set the matching `contentVersion` in `ps5/app/sce_sys/param.json`
(0.7.0 → `00.007.000`; the build stops if they differ). That number is also the release tag.
2. Write `release-notes/<contentVersion>.md`: a few short lines, shown on the console in the update question.
3. Commit and push, then run `release.bat`. It builds, creates the release with `PPSA99064.zip`, and checks
3. Commit and push, then run `release.bat`. It builds, creates the release with `Mupen64PlusPS5.zip`, and checks
GitHub's digest against the local zip.
Never reuse or lower a version.
+17 -12
View File
@@ -4,7 +4,7 @@
# included) and stays running as the helper that lets the app reach /data
# make app -> build/app/$(TITLE_ID)/ the app folder on its own (eboot.bin, sce_module/, sce_sys/)
# make host -> build/host/ Linux builds of the same code (PS5 calls faked by host/sce_host.cpp)
# make native -> ../build-native/: $(TITLE_ID)/ (the app folder), $(TITLE_ID).zip (the release),
# make native -> ../build-native/: $(TITLE_ID)/ (the app folder), $(RELEASE_ZIP) (the release),
# $(TITLE_ID).debug.elf, and $(TITLE_ID).ffpfsc with FORMAT=Ffpfsc -- the layout of the Homebrew
# Browser's build-native.bat (../build-native.bat runs this)
# make dist -> make native + build/dist/Mupen64PS5-v$(VERSION).elf (the optional installer) and
@@ -32,13 +32,17 @@
PS5_PAYLOAD_SDK ?= /opt/ps5-payload-sdk
PS5_HOST ?= ps5
PS5_PORT ?= 9021
VERSION ?= 0.6.2
VERSION ?= 0.6.3
# param.json's contentVersion, NN.NNN.NNN (0.6.1 -> 00.006.001): the release tag, as in the Homebrew Browser; it
# must also be written in app/sce_sys/param.json (checked below)
CONTENT_VERSION := $(shell echo $(VERSION) | awk -F. '{printf "%02d.%03d.%03d", $$1, $$2, $$3}')
TITLE_ID ?= PPSA99064
# where the app looks for updates (fe_update.h) and `make release` publishes
GITHUB_REPO ?= TheRealRetro/mupen64plus-ps5
# The release zip's name, which the app's updater looks for. Up to 0.6.2 it was $(TITLE_ID).zip, but those
# versions' updater wrote files without execute permission and left the app unable to start: under this name
# they never find a newer release, so they can't break themselves (their users install 0.6.3 by hand once).
RELEASE_ZIP := Mupen64PlusPS5.zip
APP_OUT := build/app/$(TITLE_ID)
M64 := ../mupen64plus-core
@@ -94,13 +98,13 @@ EMU_SRC := coreorbis/main-boot.cpp \
coreorbis/orbis-shims/orbis_paths.cpp coreorbis/orbis-shims/ProsperoNotify.cpp \
coreorbis/orbis-shims/ProsperoVideo.cpp coreorbis/orbis-shims/ProsperoAudio.cpp \
coreorbis/orbis-shims/ProsperoInput.cpp coreorbis/orbis-shims/ProsperoJailbreak.cpp \
coreorbis/orbis-shims/ProsperoCrash.cpp \
coreorbis/orbis-shims/ProsperoCrash.cpp coreorbis/orbis-shims/ProsperoUpdateJob.cpp \
frontend/fe_emu.cpp frontend/fe_menu.cpp frontend/fe_settings.cpp frontend/fe_text.cpp \
frontend/fe_games.cpp frontend/fe_http.cpp frontend/fe_covers.cpp frontend/fe_shelf.cpp \
frontend/fe_prefetch.cpp frontend/fe_update.cpp
# the payloads: the helper, and the installer (= helper + install)
HELPER_SRC := coreorbis/orbis-shims/orbis_paths.cpp coreorbis/orbis-shims/ProsperoNotify.cpp \
coreorbis/orbis-shims/ProsperoHelper.cpp
coreorbis/orbis-shims/ProsperoHelper.cpp coreorbis/orbis-shims/ProsperoUpdateJob.cpp
INSTALLER_SRC := installer/installer_main.cpp $(HELPER_SRC) coreorbis/orbis-shims/ProsperoInstall.cpp
ZLIB_SRC := $(wildcard $(ZLIB)/*.c)
@@ -108,7 +112,8 @@ ZLIB_SRC := $(wildcard $(ZLIB)/*.c)
INCS := -I$(M64SRC) -I$(M64)/subprojects/minizip -I$(ZLIB) \
-Icoreorbis/include-orbis -Icoreorbis/orbis-shims -Ifrontend -In64 -In64/core
DEFS := -DN64PS5_VERSION='"$(VERSION)"' -DN64PS5_TITLE_ID='"$(TITLE_ID)"' \
-DN64PS5_CONTENT_VERSION='"$(CONTENT_VERSION)"' -DN64PS5_GITHUB_REPO='"$(GITHUB_REPO)"'
-DN64PS5_CONTENT_VERSION='"$(CONTENT_VERSION)"' -DN64PS5_GITHUB_REPO='"$(GITHUB_REPO)"' \
-DN64PS5_RELEASE_ZIP='"$(RELEASE_ZIP)"'
PORT_WARN := -Wall -Wextra -Werror -Wno-unused-parameter -Wno-missing-field-initializers
# generated: the game-name table (from the core's ROM catalog) built into fe_games.o
@@ -250,29 +255,29 @@ strip: $(PS5_OUT)/Mupen64PS5-stripped.elf
# The release, in the Homebrew Browser's layout (its native/build-native.sh), in ../build-native/:
# $(TITLE_ID)/ the app folder: copied to /data/homebrew/ (or a USB drive's homebrew/), ShadowMountPlus
# lists it
# $(TITLE_ID).zip the release artifact: just $(TITLE_ID)/ (the browser installs a release's
# $(TITLE_ID).zip; the tag is the contentVersion, $(CONTENT_VERSION))
# $(RELEASE_ZIP) the release artifact: just $(TITLE_ID)/ (the browser installs a release's
# $(RELEASE_ZIP); the tag is the contentVersion, $(CONTENT_VERSION))
# $(TITLE_ID).debug.elf eboot.bin's program with its symbols, for reading crash reports
# $(TITLE_ID).ffpfsc with FORMAT=Ffpfsc: a compressed image of the folder (the boilerplate's mkpfs)
# release-assets/$(CONTENT_VERSION)/$(TITLE_ID).zip a copy kept per version
# release-assets/$(CONTENT_VERSION)/$(RELEASE_ZIP) a copy kept per version
NATIVE_OUT := ../build-native
FORMAT ?= Folder
PS5_NATIVE_BOILERPLATE ?= /root/ps5-native
native: $(APP_FILES) build/app/mupen64plus-pie.elf
@mkdir -p "$(abspath $(NATIVE_OUT))"
rm -rf "$(abspath $(NATIVE_OUT))/$(TITLE_ID)" "$(abspath $(NATIVE_OUT))/$(TITLE_ID).zip"
rm -rf "$(abspath $(NATIVE_OUT))/$(TITLE_ID)" "$(abspath $(NATIVE_OUT))/$(RELEASE_ZIP)"
cp -r $(APP_OUT) "$(abspath $(NATIVE_OUT))/"
cd "$(abspath $(NATIVE_OUT))" && python3 -m zipfile -c $(TITLE_ID).zip $(TITLE_ID)
cd "$(abspath $(NATIVE_OUT))" && python3 -m zipfile -c $(RELEASE_ZIP) $(TITLE_ID)
cp build/app/mupen64plus-pie.elf "$(abspath $(NATIVE_OUT))/$(TITLE_ID).debug.elf"
mkdir -p "$(abspath $(NATIVE_OUT))/release-assets/$(CONTENT_VERSION)"
cp "$(abspath $(NATIVE_OUT))/$(TITLE_ID).zip" "$(abspath $(NATIVE_OUT))/release-assets/$(CONTENT_VERSION)/"
cp "$(abspath $(NATIVE_OUT))/$(RELEASE_ZIP)" "$(abspath $(NATIVE_OUT))/release-assets/$(CONTENT_VERSION)/"
@if [ "$$(echo $(FORMAT) | tr A-Z a-z)" = ffpfsc ]; then \
mkpfs=$$(bash $(PS5_NATIVE_BOILERPLATE)/tools/setup-packaging-dependencies.sh ffpfsc) && \
rm -f "$(abspath $(NATIVE_OUT))/$(TITLE_ID).ffpfsc" && \
"$$mkpfs" pack folder --no-adjust-output-file-extension --version PS5 --verify \
"$(abspath $(NATIVE_OUT))/$(TITLE_ID)" "$(abspath $(NATIVE_OUT))/$(TITLE_ID).ffpfsc"; \
fi
@python3 -m zipfile -l "$(abspath $(NATIVE_OUT))/$(TITLE_ID).zip"
@python3 -m zipfile -l "$(abspath $(NATIVE_OUT))/$(RELEASE_ZIP)"
@echo "Native app: $(abspath $(NATIVE_OUT))/$(TITLE_ID) (copy this folder to /data/homebrew/), version $(CONTENT_VERSION)"
# the extra release files: the optional installer payload and the sources
+18 -7
View File
@@ -15,7 +15,13 @@ its files): no payload to send.
The PS5 layer and the build come from **Snes9x PS5** (`../../snes9xPS5-2.0/ps5`), which follows the PS5SX2
model.
> **0.6:** released as the app folder (`PPSA99064.zip`): copy `PPSA99064/` to
> **0.6.3:** in-app updates work. Up to 0.6.2 the updater wrote the new files without execute permission, so
> the console refused to start the updated app ("Can't start the game or app", CE-107750-0). 0.6.3 unpacks the
> update next to the app folder with the permissions an FTP copy gets, closes, and its helper swaps the folders
> so ShadowMountPlus installs the app again. The release zip is now `Mupen64PlusPS5.zip`, so 0.6.0–0.6.2 never
> offer an update they would break: install 0.6.3 by hand once.
>
> **0.6:** released as the app folder (`Mupen64PlusPS5.zip`): copy `PPSA99064/` to
> `/data/homebrew/` or a USB drive's `homebrew/`, and ShadowMountPlus lists it. No `.elf` to send: eboot.bin
> starts the helper it carries through the ELF loader by itself, and now prefers it over etaHEN's daemon.
> The app updates itself from the GitHub releases ([Updates](#updates)).
@@ -87,7 +93,7 @@ angrylion renders on the CPU, which the PS5's eight Zen 2 cores can afford.
## Install and play
1. **Copy the `PPSA99064` folder** from `PPSA99064.zip` (the folder itself, not just its
1. **Copy the `PPSA99064` folder** from `Mupen64PlusPS5.zip` (the folder itself, not just its
contents) to one of:
- `/data/homebrew/PPSA99064` on the console (FTP, PS5Upload...);
- `homebrew/PPSA99064` on an exFAT USB drive.
@@ -170,12 +176,17 @@ At every start, before it asks for `/data` (the console's HTTPS works only then,
`api.github.com` for the latest release of `TheRealRetro/mupen64plus-ps5` (`GITHUB_REPO` in the Makefile).
- **When the release's tag is newer** than the app's `contentVersion`, it downloads the release's
`PPSA99064.zip` and checks its size and SHA-256 against the release (`frontend/fe_update.cpp`).
`Mupen64PlusPS5.zip` and checks its size and SHA-256 against the release (`frontend/fe_update.cpp`).
- **Once `/data` is visible,** it shows "Mupen64Plus PS5 X is available", with the release notes:
- ✕ writes every file over each copy of the app folder it finds (`/data/homebrew/PPSA99064`, a USB drive's
`homebrew/PPSA99064`...), each through a temporary `.new` name, and restarts the app;
- ✕ unpacks the new version next to each copy of the app folder it finds (`/data/homebrew/PPSA99064`, a USB
drive's `homebrew/PPSA99064`...), in `.mupen64plus-update/new/`, with the permissions an FTP copy gets
(0777: the console doesn't start an `eboot.bin` without execute permission), writes a job file, starts its
helper through the ELF loader and closes. The helper waits for the app to exit, moves the app folder aside,
asks ShadowMountPlus to rescan (its API, 127.0.0.1:10101) until it has dropped the app, moves the new
version in, rescans until it is installed again, and says so in a notification
(`coreorbis/orbis-shims/ProsperoUpdateJob.cpp`; its steps go to `logs/helper.log`);
- ○ carries on (asked again next start).
- **A release that was installed but didn't take** (the restart still ran an older eboot) isn't offered
- **A release that was installed but didn't take** (the next start still ran an older eboot) isn't offered
again (`/data/mupen64plus/update/installed.txt`).
- **Settings → Check for updates: No** skips the question.
- **An app running from a `.ffpfsc` image** can't be updated this way (read-only): copy the new folder by hand.
@@ -222,7 +233,7 @@ build-native.bat Ffpfsc also a compressed .ffpfsc image
Output in `build-native/` (next to `ps5/`):
- `PPSA99064/`: the app folder to copy to `/data/homebrew/`.
- `PPSA99064.zip`: the release artifact (just the folder). Its tag is the `contentVersion`, made from
- `Mupen64PlusPS5.zip`: the release artifact (just the folder). Its tag is the `contentVersion`, made from
`VERSION` in the Makefile (0.6.0 -> `00.006.000`); a copy is kept in `release-assets/<contentVersion>/`.
- `PPSA99064.debug.elf`: the program with its symbols, for reading crash reports.
- `PPSA99064.ffpfsc`: with `Ffpfsc`, a compressed image ShadowMountPlus can mount.
+1 -1
View File
@@ -10,7 +10,7 @@
"conceptId": "99064",
"contentBadgeType": 1,
"contentId": "UP9000-PPSA99064_00-MUPEN64PS5000001",
"contentVersion": "00.006.002",
"contentVersion": "00.006.003",
"downloadDataSize": 256,
"gameIntent": {
"permittedIntents": [
@@ -199,6 +199,11 @@ bool FetchWantedCovers(std::string& text)
return r == 1;
}
bool StartHelper()
{
return SendHelper();
}
bool RequestForSelf(std::string& how)
{
const int own = PortFromEnv("N64PS5_HELPER_PORT", kHelperPort);
@@ -48,6 +48,10 @@ constexpr int32_t kRetUntouched = -1337;
// Every step goes to OrbisLog (kept in memory until boot.log can be opened).
bool RequestForSelf(std::string& how);
// The app side: sends the helper built into the app to the ELF loader, even when one is running (a new copy
// runs a pending update job, then leaves). True when every byte went out.
bool StartHelper();
// The app side, before the jailbreak: the wanted-covers list from the helper (starting the helper through the
// ELF loader if none answers). False when no helper could be reached; `text` may be empty (nothing wanted).
bool FetchWantedCovers(std::string& text);
@@ -0,0 +1,368 @@
// Mupen64Plus PS5: installing a downloaded update (ProsperoUpdateJob.h).
//
// SPDX-License-Identifier: MIT
#include "ProsperoUpdateJob.h"
#include "OrbisPaths.h"
#include "ProsperoNotify.h"
#include <arpa/inet.h>
#include <cerrno>
#include <csignal>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <ctime>
#include <dirent.h>
#include <netinet/in.h>
#include <sys/socket.h>
#include <sys/stat.h>
#include <sys/time.h>
#include <unistd.h>
#ifndef N64PS5_TITLE_ID
#define N64PS5_TITLE_ID "PPSA99064"
#endif
namespace updatejob
{
namespace
{
constexpr const char* kUpdateDir = ".mupen64plus-update";
bool Exists(const std::string& path)
{
struct stat st;
return lstat(path.c_str(), &st) == 0;
}
// /user/app/<TITLE>/mount.lnk: ShadowMountPlus's record of where an installed folder app lives
// (N64PS5_APP_LINK_DIR replaces /user/app in the host tests).
std::string MountLink()
{
const char* dir = getenv("N64PS5_APP_LINK_DIR");
return std::string(dir && *dir ? dir : "/user/app") + "/" N64PS5_TITLE_ID "/mount.lnk";
}
// POST /api/v1/scan to ShadowMountPlus (N64PS5_SMP_PORT replaces 10101 in the host tests). The answer's
// status line goes to the log; false when nobody listens.
bool AskForScan()
{
const char* p = getenv("N64PS5_SMP_PORT");
const int port = p && *p ? atoi(p) : 10101;
const int fd = socket(AF_INET, SOCK_STREAM, 0);
if (fd < 0)
return false;
timeval tv = {3, 0};
setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv));
setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, &tv, sizeof(tv));
sockaddr_in sa;
memset(&sa, 0, sizeof(sa));
#ifdef __FreeBSD__
sa.sin_len = sizeof(sa);
#endif
sa.sin_family = AF_INET;
sa.sin_port = htons(uint16_t(port));
sa.sin_addr.s_addr = htonl(0x7F000001);
if (connect(fd, reinterpret_cast<sockaddr*>(&sa), sizeof(sa)) != 0)
{
close(fd);
OrbisLog("[update-job] ShadowMountPlus API (port %d): not there (errno %d)", port, errno);
return false;
}
static const char req[] = "POST /api/v1/scan HTTP/1.0\r\nHost: 127.0.0.1\r\nContent-Type: application/json\r\n"
"Content-Length: 2\r\nConnection: close\r\n\r\n{}";
bool ok = send(fd, req, sizeof(req) - 1, 0) == ssize_t(sizeof(req) - 1);
char buf[256] = {};
const ssize_t n = ok ? recv(fd, buf, sizeof(buf) - 1, 0) : -1;
close(fd);
if (n > 0)
{
buf[n] = 0;
buf[strcspn(buf, "\r\n")] = 0;
}
OrbisLog("[update-job] ShadowMountPlus rescan: %s", n > 0 ? buf : "no answer");
return n > 0;
}
// Waits (asking for rescans every 15 s) until the mount link exists == want. False after `seconds`.
bool WaitForLink(bool want, int seconds)
{
const std::string link = MountLink();
for (int i = 0; i <= seconds; i++)
{
if (Exists(link) == want)
{
OrbisLog("[update-job] mount link %s after %d s", want ? "back" : "gone", i);
return true;
}
if (i % 15 == 0)
AskForScan();
sleep(1);
}
OrbisLog("[update-job] mount link still %s after %d s", want ? "missing" : "there", seconds);
return false;
}
bool ProcessAlive(int pid)
{
return pid > 0 && (kill(pid, 0) == 0 || errno != ESRCH);
}
bool RemoveTreeRaw(const std::string& path)
{
struct stat st;
if (lstat(path.c_str(), &st) != 0)
return errno == ENOENT;
if (!S_ISDIR(st.st_mode))
return unlink(path.c_str()) == 0;
bool ok = true;
if (DIR* d = opendir(path.c_str()))
{
while (dirent* e = readdir(d))
{
if (strcmp(e->d_name, ".") == 0 || strcmp(e->d_name, "..") == 0)
continue;
ok = RemoveTreeRaw(path + "/" + e->d_name) && ok;
}
closedir(d);
}
return rmdir(path.c_str()) == 0 && ok;
}
bool Read(Job& job)
{
FILE* f = fopen(JobPath().c_str(), "r");
if (!f)
return false;
char line[1024];
while (fgets(line, sizeof(line), f))
{
line[strcspn(line, "\r\n")] = 0;
const std::string s = line;
if (s.rfind("pid=", 0) == 0)
job.pid = atoi(s.c_str() + 4);
else if (s.rfind("tag=", 0) == 0)
job.tag = s.substr(4);
else if (s.rfind("version=", 0) == 0)
job.version = s.substr(8);
else if (s.rfind("target=", 0) == 0)
{
const std::string v = s.substr(7);
const size_t a = v.find('|'), b = v.find('|', a == std::string::npos ? a : a + 1);
if (a == std::string::npos || b == std::string::npos)
continue;
Target t;
t.dir = v.substr(0, a);
t.staging = v.substr(a + 1, b - a - 1);
t.old = v.substr(b + 1);
job.targets.push_back(t);
}
}
fclose(f);
return !job.targets.empty();
}
} // namespace
Target TargetFor(const std::string& app_dir)
{
Target t;
t.dir = app_dir;
const size_t slash = app_dir.rfind('/');
const std::string parent = slash == std::string::npos ? "." : app_dir.substr(0, slash);
const std::string name = slash == std::string::npos ? app_dir : app_dir.substr(slash + 1);
t.staging = parent + "/" + kUpdateDir + "/new/" + name;
t.old = parent + "/" + kUpdateDir + "/old/" + name;
return t;
}
std::string JobPath()
{
return OrbisDir("update") + "/job.txt";
}
bool Write(const Job& job, std::string& why)
{
OrbisMkdirs(OrbisDir("update"));
const std::string path = JobPath(), tmp = path + ".part";
FILE* f = fopen(tmp.c_str(), "w");
if (!f)
{
why = "can't write " + tmp;
return false;
}
fprintf(f, "pid=%d\ntag=%s\nversion=%s\n", job.pid, job.tag.c_str(), job.version.c_str());
for (const Target& t : job.targets)
fprintf(f, "target=%s|%s|%s\n", t.dir.c_str(), t.staging.c_str(), t.old.c_str());
const bool ok = fclose(f) == 0;
if (!ok || rename(tmp.c_str(), path.c_str()) != 0)
{
unlink(tmp.c_str());
why = "can't write " + path;
return false;
}
return true;
}
int MakeRunnable(const std::string& tree)
{
struct stat st;
if (lstat(tree.c_str(), &st) != 0 || S_ISLNK(st.st_mode))
return 0;
int changed = 0;
if ((st.st_mode & 07777) != 0777)
changed += chmod(tree.c_str(), 0777) == 0;
if (S_ISDIR(st.st_mode))
if (DIR* d = opendir(tree.c_str()))
{
while (dirent* e = readdir(d))
if (strcmp(e->d_name, ".") != 0 && strcmp(e->d_name, "..") != 0)
changed += MakeRunnable(tree + "/" + e->d_name);
closedir(d);
}
return changed;
}
std::string EbootMode(const std::string& app_dir)
{
struct stat st;
if (stat((app_dir + "/eboot.bin").c_str(), &st) != 0)
return "missing";
char buf[16];
snprintf(buf, sizeof(buf), "%04o", unsigned(st.st_mode & 07777));
return buf;
}
bool RemoveUpdateTree(const std::string& path)
{
if (path.find(std::string("/") + kUpdateDir + "/") == std::string::npos)
return false; // never anything else
return RemoveTreeRaw(path);
}
void RunIfPending()
{
Job job;
if (!Read(job))
return;
// take the job first: a second helper started meanwhile must not run it too
const std::string taken = JobPath() + ".running";
if (rename(JobPath().c_str(), taken.c_str()) != 0)
return;
OrbisLog("[update-job] installing %s (%s) for pid %d, %zu folder(s)", job.version.c_str(), job.tag.c_str(),
job.pid, job.targets.size());
// 1. the app closes itself right after starting this payload
int waited = 0;
while (ProcessAlive(job.pid) && waited < 60)
{
sleep(1);
waited++;
}
if (ProcessAlive(job.pid))
{
OrbisLog("[update-job] pid %d still running after 60 s: giving up", job.pid);
ProsperoNotify("Mupen64Plus PS5: the update to %s was not installed (the app didn't close). Open the app to "
"try again.",
job.version.c_str());
unlink(taken.c_str());
return;
}
sleep(2); // ShadowMountPlus releases the app's runtime mounts after it exits
ProsperoNotify("Mupen64Plus PS5: installing version %s...", job.version.c_str());
const bool tracked = Exists(MountLink());
OrbisLog("[update-job] ShadowMountPlus mount link %s: %s", MountLink().c_str(),
tracked ? "present" : "absent (folders are swapped without waiting for it)");
// 2. the app folders aside
std::vector<Target> moved;
std::string why;
for (const Target& t : job.targets)
{
if (!Exists(t.staging))
{
why = "the new version is missing (" + t.staging + ")";
break;
}
const std::string before = EbootMode(t.staging);
const int changed = MakeRunnable(t.staging);
OrbisLog("[update-job] %s: eboot.bin %s, installed copy %s; new version's permissions set (%d changed): "
"eboot.bin now %s",
t.dir.c_str(), before.c_str(), EbootMode(t.dir).c_str(), changed, EbootMode(t.staging).c_str());
RemoveUpdateTree(t.old);
OrbisMkdirs(t.old.substr(0, t.old.rfind('/')));
if (rename(t.dir.c_str(), t.old.c_str()) != 0)
{
why = "can't move " + t.dir + " aside (errno " + std::to_string(errno) + ")";
break;
}
moved.push_back(t);
}
auto roll_back = [&]() {
for (const Target& t : moved)
{
if (!Exists(t.dir))
rename(t.old.c_str(), t.dir.c_str());
}
};
if (!why.empty())
{
OrbisLog("[update-job] %s: putting everything back", why.c_str());
roll_back();
if (tracked)
AskForScan();
ProsperoNotify("Mupen64Plus PS5: the update to %s failed (%s).", job.version.c_str(), why.c_str());
unlink(taken.c_str());
return;
}
// 3. ShadowMountPlus forgets the old installation
if (tracked)
WaitForLink(false, 90);
// 4. the new version in place, installed again
for (const Target& t : moved)
{
if (rename(t.staging.c_str(), t.dir.c_str()) != 0)
{
why = "can't move the new version into " + t.dir + " (errno " + std::to_string(errno) + ")";
break;
}
}
if (!why.empty())
{
OrbisLog("[update-job] %s: putting the old version back", why.c_str());
for (const Target& t : moved)
if (Exists(t.dir) && !Exists(t.staging))
rename(t.dir.c_str(), t.staging.c_str()); // the new copy back out of the way
roll_back();
if (tracked)
WaitForLink(true, 120);
ProsperoNotify("Mupen64Plus PS5: the update to %s failed (%s).", job.version.c_str(), why.c_str());
unlink(taken.c_str());
return;
}
const bool installed = !tracked || WaitForLink(true, 120);
// 5. tidy up
for (const Target& t : moved)
{
RemoveUpdateTree(t.old);
const std::string root = t.old.substr(0, t.old.rfind("/old/"));
rmdir((root + "/old").c_str());
rmdir((root + "/new").c_str());
rmdir(root.c_str());
}
unlink(taken.c_str());
OrbisLog("[update-job] version %s installed%s", job.version.c_str(),
installed ? "" : " (ShadowMountPlus hasn't confirmed it yet)");
if (installed)
ProsperoNotify("Mupen64Plus PS5 updated to %s. Open it from the home screen.", job.version.c_str());
else
ProsperoNotify("Mupen64Plus PS5 updated to %s. Wait a minute before opening it (or restart the PS5 if it "
"doesn't start).",
job.version.c_str());
}
} // namespace updatejob
@@ -0,0 +1,60 @@
// Mupen64Plus PS5: installing a downloaded update (frontend/fe_update.h) so the console starts it.
//
// Replacing the files of an installed app in place leaves it unable to start ("Can't start the game or app",
// confirmed on the console with 0.6.0 -> 0.6.1 and 0.6.1 -> 0.6.2, whether the app or an FTP client wrote
// them): ShadowMountPlus keeps the installation it made from the old files. A fresh copy works because
// ShadowMountPlus sees the folder disappear ("source removed", its mount link /user/app/<TITLE>/mount.lnk goes)
// and installs it again when it comes back ("Installed NEW!"). The update does the same:
// 1. the app unpacks the new version into a staging folder next to the app folder
// (<parent>/.mupen64plus-update/new/PPSA99064, too deep for ShadowMountPlus's scan), writes a job file,
// starts its helper payload through the ELF loader, and closes;
// 2. the helper (RunIfPending) waits for the app to exit, moves the app folder aside (.../old/PPSA99064),
// asks ShadowMountPlus to rescan (its HTTP API, 127.0.0.1:10101) until the mount link is gone, moves the
// new folder in, rescans until the link is back (installed again), removes the old copy and says so in
// a notification. Anything failing puts the old folder back.
//
// SPDX-License-Identifier: MIT
#pragma once
#include <string>
#include <vector>
namespace updatejob
{
struct Target
{
std::string dir; // the app folder, e.g. /data/homebrew/PPSA99064
std::string staging; // the new version, unpacked
std::string old; // where the app folder goes while ShadowMountPlus forgets it
};
struct Job
{
int pid = 0; // the app, which must have exited before the folders move
std::string tag; // 00.006.003
std::string version; // 0.6.3
std::vector<Target> targets;
};
// The staging and old paths for an app folder.
Target TargetFor(const std::string& app_dir);
// /data/mupen64plus/update/job.txt
std::string JobPath();
bool Write(const Job& job, std::string& why);
// Deletes a folder tree, only inside a ".mupen64plus-update" folder (staging and old copies).
bool RemoveUpdateTree(const std::string& path);
// Gives every file and folder in a tree the permissions an FTP copy gets (0777). Files a program writes with
// fopen() have no execute permission (0666 at most), and the console doesn't start an eboot.bin without it:
// that, not the files' contents, is why every in-app update so far left the app unable to start
// ("Can't start the game or app", CE-107750-0). The number of entries changed.
int MakeRunnable(const std::string& tree);
// eboot.bin's permissions in a folder, for the logs ("0777", or "missing").
std::string EbootMode(const std::string& app_dir);
// The helper payload, at start: performs a pending job (and deletes it). Returns at once when there is none.
void RunIfPending();
} // namespace updatejob
+48 -36
View File
@@ -8,7 +8,9 @@
#include "fe_menu.h"
#include "OrbisPaths.h"
#include "ProsperoJailbreak.h"
#include "ProsperoNotify.h"
#include "ProsperoUpdateJob.h"
#include "ProsperoSce.h"
#include "unzip.h"
@@ -32,6 +34,9 @@
#ifndef N64PS5_TITLE_ID
#define N64PS5_TITLE_ID "PPSA99064"
#endif
#ifndef N64PS5_RELEASE_ZIP
#define N64PS5_RELEASE_ZIP "Mupen64PlusPS5.zip"
#endif
#ifndef N64PS5_GITHUB_REPO
#define N64PS5_GITHUB_REPO "TheRealRetro/mupen64plus-ps5"
#endif
@@ -43,7 +48,7 @@ namespace
constexpr size_t kMaxReleaseJson = 1u << 20;
constexpr size_t kMaxZip = 64u << 20;
constexpr size_t kMaxFile = 32u << 20;
const char* const kAsset = N64PS5_TITLE_ID ".zip";
const char* const kAsset = N64PS5_RELEASE_ZIP; // not PPSA99064.zip: see RELEASE_ZIP in the Makefile
// ---- a small JSON reader (the release object) --------------------------------------------------------------
struct Json
@@ -564,32 +569,24 @@ bool WriteFile(const std::string& path, const std::vector<uint8_t>& data)
return fclose(f) == 0 && ok;
}
// The new files over one app folder: all written as "<file>.new" first, then renamed into place (the
// running eboot.bin keeps its old copy open until the restart).
bool InstallInto(const std::string& dir, const std::map<std::string, std::vector<uint8_t>>& files, std::string& why)
// The new version, complete, in the staging folder next to an app folder (ProsperoUpdateJob.h): the installed
// files themselves are never written over (the console then can't start the app: 0.6.1 and 0.6.2).
bool StageInto(const updatejob::Target& t, const std::map<std::string, std::vector<uint8_t>>& files, std::string& why)
{
updatejob::RemoveUpdateTree(t.staging);
for (const auto& f : files)
{
const std::string path = dir + "/" + f.first;
const size_t slash = path.rfind('/');
OrbisMkdirs(path.substr(0, slash));
if (!WriteFile(path + ".new", f.second))
const std::string path = t.staging + "/" + f.first;
OrbisMkdirs(path.substr(0, path.rfind('/')));
if (!WriteFile(path, f.second))
{
why = "can't write " + path + ".new";
for (const auto& g : files)
unlink((dir + "/" + g.first + ".new").c_str());
return false;
}
}
for (const auto& f : files)
{
const std::string path = dir + "/" + f.first;
if (rename((path + ".new").c_str(), path.c_str()) != 0)
{
why = "can't replace " + path;
why = "can't write " + path;
updatejob::RemoveUpdateTree(t.staging);
return false;
}
}
// fopen() gives no execute permission, and the console doesn't start an eboot.bin without it
updatejob::MakeRunnable(t.staging);
return true;
}
@@ -598,21 +595,19 @@ std::string StampPath()
return OrbisDir("update") + "/installed.txt";
}
void Restart(const UpdateOffer& offer)
// The helper installs the staged version once the app has closed (ProsperoUpdateJob.h): say so, then close
// the way main-boot.cpp's ExitApp does.
[[noreturn]] void CloseForUpdate(const UpdateOffer& offer)
{
const char* path = "/data/homebrew/" N64PS5_TITLE_ID "/eboot.bin";
if (access(path, F_OK) != 0)
path = "/app0/eboot.bin";
OrbisLog("[update] installed %s: restarting %s", offer.tag.c_str(), path);
MessageBox("Installing Mupen64Plus PS5 " + offer.version,
"The app closes now. Open it again from the home screen when the notification says the update is installed.");
OrbisLog("[update] closing so the helper can install %s", offer.tag.c_str());
ProsperoNotifyFlush();
OrbisLogClose();
const int rc = sceSystemServiceLoadExec(path, nullptr);
if (rc == 0)
if (sceSystemServiceLoadExec("exit", nullptr) == 0)
for (int i = 0; i < 100; i++)
usleep(100 * 1000);
OrbisLogOpen("boot-after-update");
OrbisLog("[update] sceSystemServiceLoadExec(%s) -> %x", path, unsigned(rc));
MessageBox("Mupen64Plus PS5 " + offer.version + " is installed", "Close the app and open it again to use it.");
_exit(0);
}
} // namespace
@@ -760,14 +755,18 @@ void OfferUpdate(UpdateOffer& offer, bool ask)
"copy the new folder by hand)";
}
}
int done = 0;
updatejob::Job job;
job.pid = int(getpid());
job.tag = offer.tag;
job.version = offer.version;
for (const std::string& dir : folders)
{
const updatejob::Target t = updatejob::TargetFor(dir);
std::string err;
if (InstallInto(dir, files, err))
if (StageInto(t, files, err))
{
done++;
OrbisLog("[update] %s: %zu files written", dir.c_str(), files.size());
job.targets.push_back(t);
OrbisLog("[update] %s: %zu files staged in %s", dir.c_str(), files.size(), t.staging.c_str());
}
else
{
@@ -775,9 +774,22 @@ void OfferUpdate(UpdateOffer& offer, bool ask)
why = err;
}
}
if (!ok || done == 0)
if (ok && job.targets.empty())
ok = false;
if (ok && !updatejob::Write(job, why))
ok = false;
// the helper that installs it: a fresh copy through the ELF loader (one already running only lets apps out)
if (ok && !jailbreak::StartHelper())
{
ok = false;
why = "the ELF loader (port 9021) didn't answer, so the update can't be installed now";
}
if (!ok)
{
OrbisLog("[update] %s failed: %s", offer.tag.c_str(), why.c_str());
unlink(updatejob::JobPath().c_str());
for (const updatejob::Target& t : job.targets)
updatejob::RemoveUpdateTree(t.staging);
MessageBox("The update failed", why);
return;
}
@@ -787,6 +799,6 @@ void OfferUpdate(UpdateOffer& offer, bool ask)
fclose(f);
}
offer.zip.clear();
Restart(offer);
CloseForUpdate(offer);
}
} // namespace fe
+8 -7
View File
@@ -1,15 +1,16 @@
// Mupen64Plus PS5: updates from the project's GitHub releases (github.com/TheRealRetro/mupen64plus-ps5).
//
// A release is tagged with param.json's contentVersion (NN.NNN.NNN, 0.6.0 -> 00.006.000) and carries
// PPSA99064.zip (the PPSA99064/ app folder), as the Homebrew Browser's releases do; GitHub publishes each
// asset's SHA-256 ("digest") with the release.
// Mupen64PlusPS5.zip (the PPSA99064/ app folder; RELEASE_ZIP in the Makefile, PPSA99064.zip up to 0.6.2);
// GitHub publishes each asset's SHA-256 ("digest") with the release.
//
// Two steps, because of where HTTPS works (fe_prefetch.h: before the app asks for /data, not after):
// 1. CheckForUpdate, at start-up before the jailbreak: asks api.github.com for the latest release; when its
// tag is newer than this build, downloads PPSA99064.zip and checks its size and SHA-256 (in memory).
// tag is newer than this build, downloads Mupen64PlusPS5.zip and checks its size and SHA-256 (in memory).
// 2. OfferUpdate, once /data is visible: "Version X is available" with the release notes; on Cross it
// writes the new files over every copy of the app folder it finds (/data/homebrew/PPSA99064, the USB
// drives' homebrew/PPSA99064...), each file through a temporary name, and starts the app again.
// unpacks the new version next to every copy of the app folder it finds (/data/homebrew/PPSA99064, the
// USB drives' homebrew/PPSA99064...) and closes; its helper payload then swaps the folders so
// ShadowMountPlus installs the app again (ProsperoUpdateJob.h), and a notification says when to reopen it.
// The setting "Check for updates" (Off) skips the question; the download in step 1 still happens, since the
// settings can't be read before the jailbreak.
//
@@ -34,8 +35,8 @@ struct UpdateOffer
// Step 1 (before the jailbreak). Never blocks for long when the console is offline.
UpdateOffer CheckForUpdate();
// Step 2 (with /data). Returns when there is nothing to do, the user said later, or the install failed;
// after a successful install it restarts the app and doesn't return (unless the restart fails).
// Step 2 (with /data). Returns when there is nothing to do, the user said later, or staging failed; once the
// update is staged it closes the app and doesn't return.
void OfferUpdate(UpdateOffer& offer, bool ask);
// "00.006.000" -> {0, 6, 0}; also accepts "v00.006.000" and "0.6.0". False when it isn't a version.
+4
View File
@@ -16,6 +16,7 @@
#include "OrbisPaths.h"
#include "ProsperoJailbreak.h"
#include "ProsperoNotify.h"
#include "ProsperoUpdateJob.h"
#ifndef N64PS5_HELPER_ONLY
#include "ProsperoInstall.h"
#endif
@@ -85,6 +86,9 @@ int main()
g_ready_message = msg;
#endif
// an update the app staged before closing (ProsperoUpdateJob.h)
updatejob::RunIfPending();
// the helper: runs until the console is turned off, or returns at once when one already runs
if (!jailbreak::ServeHelper(OnReady))
{
+1 -1
View File
@@ -8,7 +8,7 @@
# (release.bat): 0.6.1 was published from an incremental build that didn't start on the console, while a clean
# build of the same source did.
#
# Output in build-native/ (next to ps5/): PPSA99064/, PPSA99064.zip, PPSA99064.debug.elf, and PPSA99064.ffpfsc
# Output in build-native/ (next to ps5/): PPSA99064/, Mupen64PlusPS5.zip, PPSA99064.debug.elf, and PPSA99064.ffpfsc
# with Ffpfsc. The version (param.json's contentVersion) comes from VERSION in ps5/Makefile.
#
# Environment:
+5 -4
View File
@@ -1,11 +1,11 @@
# release.ps1 - publishes a Mupen64Plus PS5 release on GitHub (run from Windows: release.bat).
#
# 1. builds the app from scratch (build-native.bat Clean): build-native\PPSA99064\ and PPSA99064.zip. Always
# 1. builds the app from scratch (build-native.bat Clean): build-native\PPSA99064\ and Mupen64PlusPS5.zip. Always
# clean: 0.6.1 was published from an incremental build that didn't start on the console;
# 2. checks that everything is committed and pushed, that release-notes\<contentVersion>.md exists and that
# the tag isn't taken;
# 3. creates the GitHub release: tag = contentVersion (NN.NNN.NNN, from VERSION in ps5\Makefile), asset
# PPSA99064.zip, notes from release-notes\<contentVersion>.md (the app shows them when it offers the
# Mupen64PlusPS5.zip, notes from release-notes\<contentVersion>.md (the app shows them when it offers the
# update);
# 4. reads the release back and compares GitHub's SHA-256 of the asset with the local zip (the app checks
# the download against that digest).
@@ -17,6 +17,7 @@ $ErrorActionPreference = 'Stop'
$root = Resolve-Path (Join-Path $PSScriptRoot '..\..')
$repo = 'TheRealRetro/mupen64plus-ps5'
$title = 'PPSA99064'
$asset = 'Mupen64PlusPS5.zip' # RELEASE_ZIP in ps5\Makefile; PPSA99064.zip up to 0.6.2
$gh = (Get-Command gh -ErrorAction SilentlyContinue).Source
if (-not $gh) { $gh = 'C:\Program Files\GitHub CLI\gh.exe' }
if (-not (Test-Path $gh)) { throw 'GitHub CLI not found: winget install GitHub.cli, then gh auth login' }
@@ -25,7 +26,7 @@ Push-Location $root
try {
& cmd /c "`"$root\build-native.bat`" Clean"
if ($LASTEXITCODE -ne 0) { throw 'build failed' }
$zip = Join-Path $root "build-native\$title.zip"
$zip = Join-Path $root "build-native\$asset"
$param = Get-Content (Join-Path $root "build-native\$title\sce_sys\param.json") -Raw | ConvertFrom-Json
$cv = $param.contentVersion
if ($cv -notmatch '^\d{2}\.\d{3}\.\d{3}$') { throw "contentVersion '$cv' is not NN.NNN.NNN" }
@@ -55,7 +56,7 @@ try {
$local = (Get-FileHash $zip -Algorithm SHA256).Hash.ToLower()
# parsed here: PowerShell 5.1 strips the quotes a --jq filter would need
$release = (& $gh api "repos/$repo/releases/tags/$cv") -join "`n" | ConvertFrom-Json
$remote = ($release.assets | Where-Object { $_.name -eq "$title.zip" }).digest
$remote = ($release.assets | Where-Object { $_.name -eq $asset }).digest
if ($remote -ne "sha256:$local") { throw "GitHub's digest '$remote' doesn't match the local zip (sha256:$local)" }
Write-Host "Done: https://github.com/$repo/releases/tag/$cv (sha256 $local)"
Write-Host 'Consoles running an older version will offer it the next time Mupen64Plus PS5 starts.'
+7
View File
@@ -0,0 +1,7 @@
## Fixes
- In-app updates now install correctly. Before, the updated app wouldn't start ("Can't start the game or app", CE-107750-0): the updater wrote the new files without execute permission.
- The update is unpacked next to the app, the app closes, and a background helper installs it. A notification says when to open the app again.
## If you have 0.6.0, 0.6.1 or 0.6.2
- Install this version by hand once: delete /data/homebrew/PPSA99064, then copy the PPSA99064 folder from Mupen64PlusPS5.zip to /data/homebrew/.
- Those versions don't offer this update themselves (their updater would break the app). Updates after this one install from inside the app.
+1 -1
View File
@@ -1,6 +1,6 @@
@echo off
rem Publishes a Mupen64Plus PS5 release on GitHub (TheRealRetro/mupen64plus-ps5).
rem release.bat clean build, then publish build-native\PPSA99064.zip as release <contentVersion>
rem release.bat clean build, then publish build-native\Mupen64PlusPS5.zip as release <contentVersion>
rem Before: raise VERSION in ps5\Makefile, write release-notes\<contentVersion>.md, commit and push.
rem See ps5\tools\release.ps1.
setlocal