mirror of
https://github.com/s0liton/ps5-romm.git
synced 2026-10-28 20:02:09 +01:00
Compare commits
67
Commits
v1.1.0
...
recompstore-qr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2c7b069290 | ||
|
|
f2c0c9bbd1 | ||
|
|
804dea1e50 | ||
|
|
833307403a | ||
|
|
63e5884f68 | ||
|
|
37714135c5 | ||
|
|
a1981fa5fb | ||
|
|
a35d80210b | ||
|
|
92237e603e | ||
|
|
f78a02db27 | ||
|
|
77d81e4476 | ||
|
|
b738d7f470 | ||
|
|
5194001e91 | ||
|
|
217a9b6991 | ||
|
|
3421c41003 | ||
|
|
920fe9e819 | ||
|
|
b37cd7f290 | ||
|
|
224923aaae | ||
|
|
57c1d05847 | ||
|
|
382e799a4a | ||
|
|
6818392c80 | ||
|
|
acc15db685 | ||
|
|
b8748872b6 | ||
|
|
2d42c0578d | ||
|
|
5c63aa5352 | ||
|
|
35ecb09a09 | ||
|
|
c65f6b178c | ||
|
|
e87b3f82a6 | ||
|
|
7e9ba03ccd | ||
|
|
fa0b13b028 | ||
|
|
1d8eeca542 | ||
|
|
2e35b40138 | ||
|
|
9b8a7bd4b1 | ||
|
|
48f1db129f | ||
|
|
04007c2f25 | ||
|
|
17917e30f7 | ||
|
|
ceebe293aa | ||
|
|
03998081fa | ||
|
|
d2c8abc8e1 | ||
|
|
fe9276a617 | ||
|
|
ae29a28855 | ||
|
|
109f471900 | ||
|
|
e45be32af8 | ||
|
|
2e8e0d9c89 | ||
|
|
129b587558 | ||
|
|
59d8e31d43 | ||
|
|
569382b240 | ||
|
|
3f602735b8 | ||
|
|
bb8b71b21d | ||
|
|
1d2462eb0d | ||
|
|
5421f7b09d | ||
|
|
78d0c98a38 | ||
|
|
ac725f3458 | ||
|
|
b2bd33a9c4 | ||
|
|
d8a595e50b | ||
|
|
024e610cfc | ||
|
|
68275e8740 | ||
|
|
43c8d94c15 | ||
|
|
cbfc79ba1e | ||
|
|
659005ce58 | ||
|
|
a1446f4706 | ||
|
|
b5d3e954f8 | ||
|
|
9066fb38ac | ||
|
|
46aa1710d2 | ||
|
|
bf479d045b | ||
|
|
7a5a07a13f | ||
|
|
7b426b8273 |
No files matched your search
@@ -0,0 +1 @@
|
||||
* @s0liton
|
||||
@@ -0,0 +1,36 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: Create a report to help us improve
|
||||
title: ''
|
||||
labels: ''
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Describe the bug**
|
||||
A clear and concise description of what the bug is.
|
||||
|
||||
**To Reproduce**
|
||||
Steps to reproduce the behavior:
|
||||
1. Go to '...'
|
||||
2. Click on '....'
|
||||
3. Scroll down to '....'
|
||||
4. See error
|
||||
|
||||
**Expected behavior**
|
||||
A clear and concise description of what you expected to happen.
|
||||
|
||||
**Screenshots**
|
||||
If applicable, add screenshots to help explain your problem.
|
||||
|
||||
**Firmware Version:**
|
||||
Which firmware version is your console on? (e.g 13.62)
|
||||
|
||||
**PS5 Hardware Model:**
|
||||
Which console revision do you have? (e.g CFI-1215A)
|
||||
|
||||
**Additional context**
|
||||
Add any other context about the problem here.
|
||||
|
||||
**Diagnostic Log**
|
||||
- Retrieve a diagnostic log from settings after replicating the issue and attach it to the ticket.
|
||||
@@ -0,0 +1,38 @@
|
||||
---
|
||||
name: Emulator/App Submission
|
||||
about: Request that RommPS includes an Emulator/App profile for your PS4/PS5 application
|
||||
title: '[App Profile] Create Sync Profile for "Your App"'
|
||||
labels: ''
|
||||
assignees: s0liton
|
||||
type: Task
|
||||
|
||||
---
|
||||
|
||||
# Application Details
|
||||
### Application Name
|
||||
- What do you call it?
|
||||
|
||||
### Title ID
|
||||
- What is your app's title ID?
|
||||
|
||||
### GitHub URL
|
||||
- The URL of your app in GitHub or other hosting platform.
|
||||
|
||||
### Describe Your App
|
||||
- Give a brief description of what your app does.
|
||||
|
||||
### Logo URL
|
||||
- We will soon start including a square icon for each profile.
|
||||
|
||||
# Directories
|
||||
### ROMs Directory
|
||||
- Where would ROMs/Games be synced to?
|
||||
|
||||
### Saves Directory
|
||||
- Where game saves and memory cards should be synced to?
|
||||
|
||||
### Save States Directory
|
||||
- Where save states should be synced to?
|
||||
|
||||
### BIOS/Required Files Directory
|
||||
- Where to send any system files for the given platform?
|
||||
@@ -0,0 +1,20 @@
|
||||
---
|
||||
name: Feature request
|
||||
about: Suggest an idea for this project
|
||||
title: ''
|
||||
labels: ''
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Is your feature request related to a problem? Please describe.**
|
||||
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
|
||||
|
||||
**Describe the solution you'd like**
|
||||
A clear and concise description of what you want to happen.
|
||||
|
||||
**Describe alternatives you've considered**
|
||||
A clear and concise description of any alternative solutions or features you've considered.
|
||||
|
||||
**Additional context**
|
||||
Add any other context or screenshots about the feature request here.
|
||||
@@ -14,3 +14,4 @@ jobs:
|
||||
- run: sudo apt-get update && sudo apt-get install -y libcurl4-openssl-dev zlib1g-dev
|
||||
- run: make host tools
|
||||
- run: tests/e2e.sh
|
||||
- run: tests/autostart.sh
|
||||
@@ -3,6 +3,12 @@
|
||||
# - merging a PR into main from a branch named with a version, e.g.
|
||||
# release/v1.0.2 or v1.0.2-some-fix (the tag is created on the merge commit)
|
||||
# - pushing a version tag manually: git tag v1.0.0 && git push origin v1.0.0
|
||||
#
|
||||
# Run by hand (Actions > Release > Run workflow, or
|
||||
# gh workflow run release.yml -f tag=v1.3.0 -f ref=release/v1.3.0), it instead
|
||||
# rebuilds only the PS4 payload from ref and swaps it into the existing tag's
|
||||
# release: a PS4 fix that doesn't make the PS5 download anything. Those already
|
||||
# on that version reinstall it by hand; the in-app update only offers newer ones.
|
||||
name: Release
|
||||
|
||||
on:
|
||||
@@ -11,6 +17,14 @@ on:
|
||||
pull_request:
|
||||
types: [closed]
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: "Existing release whose PS4 payload is replaced (v1.3.0)"
|
||||
required: true
|
||||
ref:
|
||||
description: "Branch or commit the PS4 payload is built from"
|
||||
required: true
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -26,6 +40,7 @@ jobs:
|
||||
- name: Resolve version
|
||||
id: version
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
HEAD_REF: ${{ github.event.pull_request.head.ref }}
|
||||
run: |
|
||||
if [ "$GITHUB_EVENT_NAME" = push ]; then
|
||||
@@ -39,6 +54,12 @@ jobs:
|
||||
echo "Branch '$HEAD_REF' has no vX.Y.Z version; skipping release."
|
||||
exit 0
|
||||
fi
|
||||
# A release branch merged again after its release went out (a fix
|
||||
# shipped with the manual PS4 run below, say).
|
||||
if gh release view "$tag" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
|
||||
echo "Release $tag already exists; skipping."
|
||||
exit 0
|
||||
fi
|
||||
echo "tag=$tag" >> "$GITHUB_OUTPUT"
|
||||
echo "sha=$sha" >> "$GITHUB_OUTPUT"
|
||||
|
||||
@@ -81,7 +102,7 @@ jobs:
|
||||
if: steps.version.outputs.tag
|
||||
with:
|
||||
context: platform/ps4/docker
|
||||
tags: romm-sync-ps4sdk:v0.9-oo0.5.4
|
||||
tags: romm-sync-ps4sdk:v0.9-crt1352-oo0.5.4
|
||||
load: true
|
||||
cache-from: type=gha,scope=ps4
|
||||
cache-to: type=gha,mode=max,scope=ps4,ignore-error=true
|
||||
@@ -140,3 +161,61 @@ jobs:
|
||||
fi
|
||||
gh release create "$TAG" dist/* \
|
||||
--target "$SHA" --title "$TAG" --notes-file "$notes"
|
||||
|
||||
replace-ps4:
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
GH_REPO: ${{ github.repository }}
|
||||
TAG: ${{ inputs.tag }}
|
||||
steps:
|
||||
- name: Check the release
|
||||
run: |
|
||||
if ! [[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||
echo "::error::'$TAG' isn't a vX.Y.Z tag"
|
||||
exit 1
|
||||
fi
|
||||
gh release view "$TAG" >/dev/null || { echo "::error::no release $TAG to update"; exit 1; }
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ inputs.ref }}
|
||||
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
|
||||
- uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: platform/ps4/docker
|
||||
tags: romm-sync-ps4sdk:v0.9-crt1352-oo0.5.4
|
||||
load: true
|
||||
cache-from: type=gha,scope=ps4
|
||||
cache-to: type=gha,mode=max,scope=ps4,ignore-error=true
|
||||
|
||||
- name: Build the PS4 payload
|
||||
run: tools/ps4-build.sh ps4-pkg VERSION="${TAG#v}"
|
||||
|
||||
- name: Sign
|
||||
env:
|
||||
RELEASE_SIGNING_KEY: ${{ secrets.RELEASE_SIGNING_KEY }}
|
||||
run: |
|
||||
if [ -z "$RELEASE_SIGNING_KEY" ]; then
|
||||
echo "::error::RELEASE_SIGNING_KEY secret is not set; in-app updates need a signed release."
|
||||
exit 1
|
||||
fi
|
||||
make tools
|
||||
mkdir dist
|
||||
cp build/ps4/romm-sync-ps4.elf build/ps4/romm-sync-ps4.pkg dist/
|
||||
build/host/release-sign manifest "$TAG" dist/romm-sync-ps4.elf > dist/manifest-ps4.json
|
||||
build/host/release-sign sign dist/manifest-ps4.json > dist/manifest-ps4.sig
|
||||
build/host/release-sign check dist/manifest-ps4.json dist/manifest-ps4.sig
|
||||
|
||||
# The PS5's files and their checksums stay as they are.
|
||||
- name: Replace the PS4 files
|
||||
run: |
|
||||
cd dist
|
||||
ps4="romm-sync-ps4.elf romm-sync-ps4.pkg manifest-ps4.json manifest-ps4.sig"
|
||||
gh release download "$TAG" --pattern SHA256SUMS --output old-sums
|
||||
grep -vE ' (romm-sync-ps4\.(elf|pkg)|manifest-ps4\.(json|sig))$' old-sums > SHA256SUMS || true
|
||||
sha256sum $ps4 >> SHA256SUMS
|
||||
gh release upload "$TAG" $ps4 SHA256SUMS --clobber
|
||||
@@ -4,9 +4,3 @@ host-data/
|
||||
.DS_Store
|
||||
.vscode/
|
||||
*.elf
|
||||
|
||||
# MGS interface sounds: used with permission in release builds, never published
|
||||
mgs-licensed-sounds.zip
|
||||
mgs-licensed-sounds/
|
||||
mgs1-menu-move.mp3
|
||||
platform/ps5/app/assets/sounds/
|
||||
@@ -9,6 +9,7 @@
|
||||
# make ps4-pkg PS4 home screen app (OpenOrbis), with the payload inside
|
||||
# make install-ps4 PS4_HOST=<ip> copy the PS4 payload to /data/payloads over FTP (run it from GoldHEN's payload menu)
|
||||
# make ps5 VERSION=1.2.3 override the version baked into the build
|
||||
# make ps4-pkg NETLOG=1 test build that broadcasts its log on the LAN (tools/netlog.py)
|
||||
# make tools release-sign, for signing releases (tools/release-sign.c)
|
||||
#
|
||||
# Code shared by every console is in src/. Each console has its own folder in
|
||||
@@ -19,11 +20,11 @@
|
||||
CHDR_SRCS := $(filter-out %microflac.cpp,$(wildcard third_party/libchdr/src/*.c)) \
|
||||
third_party/libchdr/deps/lzma/src/LzmaDec.c third_party/libchdr/deps/zstd/zstddeclib.c
|
||||
SRCS := src/main.c src/util.c src/http.c src/romm.c src/config.c src/state.c \
|
||||
src/profiles.c src/sync.c src/watch.c src/detect.c src/autostart.c src/memcard.c src/zip.c src/bundle.c src/gameid.c src/covers.c src/library.c src/pair.c src/web.c src/update.c \
|
||||
src/profiles.c src/sync.c src/watch.c src/detect.c src/ra2.c src/playlist.c src/autostart.c src/memcard.c src/zip.c src/bundle.c src/gcmem.c src/statefmt.c src/events.c src/gameid.c src/covers.c src/library.c src/firmware.c src/pair.c src/web.c src/update.c src/jsonlean.c \
|
||||
third_party/cJSON.c third_party/md5.c third_party/monocypher.c third_party/monocypher-ed25519.c \
|
||||
$(CHDR_SRCS)
|
||||
HDRS := $(wildcard src/*.h) third_party/cJSON.h third_party/md5.h third_party/monocypher.h third_party/monocypher-ed25519.h
|
||||
UI_HEADER := build/gen/ui_index.h build/gen/icon_png.h
|
||||
GEN_HEADERS := build/gen/ui_index.h build/gen/retroarch_cores_json.h
|
||||
COMMON_CFLAGS := -std=gnu11 -O2 -Wall -Wextra -Wno-unused-parameter -Wno-unknown-warning-option -Wno-unreachable-code-generic-assoc -Isrc -Ithird_party -Ibuild/gen \
|
||||
-Ithird_party/libchdr/include -DCHDR_SYSTEM_ZLIB
|
||||
ifdef VERSION
|
||||
@@ -35,9 +36,14 @@ BUILD_ID ?= $(shell git describe --always --dirty 2>/dev/null)
|
||||
ifneq ($(BUILD_ID),)
|
||||
COMMON_CFLAGS += -DAPP_BUILD='"$(BUILD_ID)"'
|
||||
endif
|
||||
# Test builds: NETLOG=1 also broadcasts the log on the LAN, curl's details
|
||||
# included. tools/netlog.py shows it. Never in a release.
|
||||
ifdef NETLOG
|
||||
COMMON_CFLAGS += -DROMM_NETLOG
|
||||
endif
|
||||
COMMON_CFLAGS += $(EXTRA_CFLAGS)
|
||||
|
||||
.PHONY: all host ps5 ps4 tools deploy install install-ps4 run clean
|
||||
.PHONY: unit all host ps5 ps4 tools deploy install install-ps4 run clean
|
||||
|
||||
all: host
|
||||
|
||||
@@ -45,15 +51,17 @@ build/gen/ui_index.h: ui/index.html ui/style.css $(wildcard ui/themes/*.css) bui
|
||||
@mkdir -p $(dir $@)
|
||||
python3 tools/embed.py $< $@ UI_INDEX_HTML
|
||||
|
||||
# What RetroArch's cores need beyond the defaults (src/detect.c), on every console.
|
||||
build/gen/retroarch_cores_json.h: platform/retroarch_cores.json tools/embed.py
|
||||
@mkdir -p $(dir $@)
|
||||
python3 -c "import json,sys; json.load(open(sys.argv[1]))" $<
|
||||
python3 tools/embed.py $< $@ RETROARCH_CORES_JSON
|
||||
|
||||
# Inter (SIL Open Font License, third_party/Inter-LICENSE.txt), inlined into the UI.
|
||||
build/gen/inter-font.css: third_party/inter-latin.woff2 tools/font_css.py
|
||||
@mkdir -p $(dir $@)
|
||||
python3 tools/font_css.py $< $@ Inter
|
||||
|
||||
build/gen/icon_png.h: assets/icon0.png tools/embed.py
|
||||
@mkdir -p $(dir $@)
|
||||
python3 tools/embed.py $< $@ ICON_PNG
|
||||
|
||||
# host build
|
||||
HOST_CC ?= cc
|
||||
HOST_BIN := build/host/romm-sync
|
||||
@@ -61,10 +69,23 @@ HOST_BIN := build/host/romm-sync
|
||||
host: $(HOST_BIN)
|
||||
|
||||
# Host builds also trust the test key in tests/, so the e2e test can sign updates.
|
||||
$(HOST_BIN): $(SRCS) platform/host/platform.c $(HDRS) $(UI_HEADER)
|
||||
$(HOST_BIN): $(SRCS) platform/host/platform.c $(HDRS) $(GEN_HEADERS)
|
||||
@mkdir -p $(dir $@)
|
||||
$(HOST_CC) $(COMMON_CFLAGS) -DROMM_UPDATE_TEST_KEY -o $@ $(SRCS) platform/host/platform.c -lcurl -lz -lpthread
|
||||
|
||||
# Unit tests for the parts with file formats of their own.
|
||||
unit: build/host/gcmem_test build/host/jsonlean_cli
|
||||
build/host/gcmem_test
|
||||
python3 tests/jsonlean_test.py build/host/jsonlean_cli
|
||||
|
||||
build/host/jsonlean_cli: tests/jsonlean_cli.c src/jsonlean.c src/jsonlean.h
|
||||
@mkdir -p $(dir $@)
|
||||
$(HOST_CC) -std=gnu11 -g -Wall -Wextra -fsanitize=address,undefined -Isrc -o $@ tests/jsonlean_cli.c src/jsonlean.c
|
||||
|
||||
build/host/gcmem_test: tests/gcmem_test.c src/gcmem.c src/gcmem.h
|
||||
@mkdir -p $(dir $@)
|
||||
$(HOST_CC) -std=gnu11 -g -Wall -Wextra -fsanitize=address,undefined -Isrc -o $@ tests/gcmem_test.c src/gcmem.c
|
||||
|
||||
tools: build/host/release-sign
|
||||
|
||||
build/host/release-sign: tools/release-sign.c src/update_keys.h third_party/monocypher.c third_party/monocypher-ed25519.c
|
||||
|
||||
@@ -4,69 +4,48 @@
|
||||
|
||||
A [RomM](https://romm.app) companion client for jailbroken PS5 consoles (etaHEN or onionHEN, with kstuff) and PS4 consoles (GoldHEN). Through my insatiable need to mod everything electronic I encounter, I had my 12.70 PS5 jailbroken the minute that P2JB was out. I maintain a RomM server to centralize all my _legal_ ROMs, saves, and savestates, which all my devices sync with, and the PS5 needs similar treatment.
|
||||
|
||||
- **Background save sync**
|
||||
- Monitors save file changes in the background, and uploads them as soon as they've changed. Periodic syncs also ensure that your PS5 always has the latest saves and savestates.
|
||||
- **Save and state sync**
|
||||
- Monitors save file changes in the background, and uploads them as soon as they've changed. Periodic syncs also ensure that your PS4/5 always has the latest saves and save states.
|
||||
- A game's saves come down the moment it finishes downloading, so it's ready to play.
|
||||
- Save states only come down to an emulator that can load them (see [Save states](#save-states)).
|
||||
- Optional: turn save sync off in setup or Settings to use RomM just for games.
|
||||
- **See what synced**
|
||||
- An activity log and every save and state kept in sync, by platform, in the app and the Web UI. Settle conflicts and restore older versions from either.
|
||||
- Settings > Diagnostic bundle saves the logs for a bug report, with your server, names and addresses left out.
|
||||
- **Interactive library**
|
||||
- Browse your RomM library from RommPS, the native app (or some other device), download games and BIOS files into the right emulator folders, and resolve save conflicts.
|
||||
- Browse your RomM library from the RommPS app (or some other device via the Web UI), download games and BIOS files into the right emulator folders, and resolve save conflicts.
|
||||
- **Pluggable emulator profiles and multiple emulators per platform**
|
||||
- Fully customizable sync destinations, but includes frequently updated preset profiles for existing homebrew emulators.
|
||||
- Can sync games and saves and states for multiple emulators per platform (PS2 on RetroArch and PS5SX2 as an example) using hardlinks.
|
||||
- **Beautiful and Responsive UI**
|
||||
- Based on mihawk-99's Vulkan homebrew apps and BlackBearReloaded's UI kit, we get a nice, responsive feel. So worth it, it looks 1000x better.
|
||||
- **Easter Egg**
|
||||
- Check in the Settings, scroll the settings list, see if you find the X.
|
||||
|
||||
## Supported Emulators and Games
|
||||
|
||||
Technically, this RomM client will work with all emulators, but I have included profiles for the emulators that exist for the console as of Oct 2026.
|
||||
|
||||
### Supported out of the box
|
||||
|
||||
Setup finds these on its own. Anything else can still sync by picking its folders during setup.
|
||||
|
||||
**PS5**
|
||||
|
||||
| Emulator | Systems | Saves |
|
||||
| --- | --- | --- |
|
||||
| RetroArch (mihawk-99's port) | NES, Famicom Disk System, SNES, Game Boy, Game Boy Color, Game Boy Advance, Genesis/Mega Drive, Master System, Game Gear, Sega CD, SG-1000, arcade (FinalBurn Neo), PS2 (LRPS2), PSP (PPSSPP), GameCube and Wii (Dolphin) | Saves and states |
|
||||
| PSXS5 / SwanStationPS5 | PS1 | One memory card per game, and states |
|
||||
| PS5SX2 | PS2 | Its two shared memory cards, backed up to RomM (5.3 or newer) |
|
||||
| PS5N64 | N64 | Saves and states |
|
||||
|
||||
PS5NES and Porpoise don't have presets yet, because neither works properly on my console right now:
|
||||
|
||||
- **PS5NES** v7 looks for its games in PS5SX2's folder instead of its own.
|
||||
- **Porpoise** crashes the moment it leaves the app sandbox, with both OnionHEN and Lapy freeing it, so it fails to launch. Issue reported to the dev. In the meantime, you can sync files into the sandbox, but you won't have access to /data.
|
||||
|
||||
|
||||
**PS4** (experimental)
|
||||
|
||||
| Emulator | Systems |
|
||||
| --- | --- |
|
||||
| RetroArch | The RetroArch systems above, wherever the PS4 build has the core |
|
||||
| pSNES, pNES, pGEN, pGBA, pFBN | SNES; NES; Genesis, Master System, Game Gear, Sega CD, SG-1000; GBA, GBC, GB; arcade |
|
||||
|
||||
Only RetroArch has been tested on the PS4 so far.
|
||||
|
||||
## PS4/PS5 Games
|
||||
|
||||
If you are interested in downloading PS4 and PS5 games from your RomM server, RomM has direct FPKGi support, simply add it as a library. This sync client does NOT handle PS4/PS5 save syncing at this time, but is being researched for potential future inclusion.
|
||||
- Based on mihawk-99's Vulkan homebrew apps and BlackBearReloaded's UI kit, we get a nice, responsive feel. So worth it, it looks 1000x better. 15+ different themes to pick from.
|
||||
- **Background Service**
|
||||
- All operations are handled by a backend payload, which means syncing, downloading, and updating keep working in the background, so you can keep **gaming**.
|
||||
- **RomM API Compliant**
|
||||
- RommPS is meant to strictly stick to RomM's API and method of syncing saves and states, ensuring we don't break anything and support RomM's latest features.
|
||||
- **Secure and Human-Coded**
|
||||
- Each release is signed. The payload checks the signature before it updates itself, and ProsperoStore checks every download against the release's checksum. This app was primarily coded by a **human**, and will be frequently updated and maintained. AI was primarily used for explaining concepts, creating documentation, and converting data. Ya know, the tedious stuff.
|
||||
- **Easter Eggs**
|
||||
- Check in the Settings, scroll the settings list, see if you find the X. This **hint** won't be here forever.
|
||||
|
||||
## Requirements
|
||||
|
||||
- A PS5 running etaHEN or onionHEN, or a PS4 running GoldHEN
|
||||
- RomM 5.2.0 or newer (5.3.0 if you want to do shared memory card syncing instead of per-game)
|
||||
- RetroArch installed
|
||||
- RomM 5.2.0 or newer (may work on older versions)
|
||||
- Whichever other emulators you want
|
||||
|
||||
|
||||
## Install
|
||||
|
||||
Install **RommPS** from ProsperoStore and open it. That's it: RommPS carries the RomM Sync payload and starts it for you, then walks you through setup. Its last step makes RomM Sync start with every jailbreak, so you don't have to open the app for syncing to work.
|
||||
### PS5
|
||||
|
||||
Not using the store? Download `PPSA76677.zip` from the releases page, unzip it and copy the `PPSA76677` folder to `/data/homebrew/` on the console.
|
||||
| ProsperoStore | RecompStore |
|
||||
| :---: | :---: |
|
||||
| <img src="/assets/prospero-store-rommps.png" width="300" alt="ProsperoStore Install QR"> | <img src="/assets/recomp-store-rommps.png" width="300" alt="RecompStore Install QR"> |
|
||||
|
||||
### Manual Install
|
||||
Download `PPSA76677.zip` from the releases page, unzip it and copy the `PPSA76677` folder to `/data/homebrew/` on the console. Launch RommPS and it will deploy the background service, and set itself to autostart with etaHen or onionHen.
|
||||
|
||||
If you'd rather run just the payload, without the app:
|
||||
|
||||
1. Download the latest `romm-sync.elf` from the releases page.
|
||||
2. Send it to the console (or use whichever favorite tool you wish):
|
||||
```sh
|
||||
@@ -74,35 +53,32 @@ If you'd rather run just the payload, without the app:
|
||||
```
|
||||
3. Set it up at `http://<ps5-ip>:8780` from another device.
|
||||
|
||||
To start RomM Sync with the console, let your HEN start it: the last setup step does this for you. Both HENs start `payloads/romm-sync.elf` when `romm-sync.elf.auto_start` sits next to it, in `/data/etaHEN/payloads` or `/data/OnionHEN/payloads` (onionHEN's Toolbox has a toggle for it under Payloads). RomM Sync works out which HEN is running. If you build it yourself, `make install PS5_HOST=<ps5-ip>` copies it there over FTP (add `PS5_FTP=ftp://<ps5-ip>:1337 PS5_HEN_DIR=/data/OnionHEN` for onionHEN's FTP plugin).
|
||||
|
||||
### PS4
|
||||
|
||||
> **Experimental.** The PS4 build has only been tested with RetroArch so far. Standalone emulators may work, but haven't been confirmed. I spent all my focus on the PS5 build, so I didn't get to test as much, so consider it experimental and there may be gremlins for now.
|
||||
|
||||
> **Experimental.** The PS4 is currently considered experimental
|
||||
Needs GoldHEN 2.4b18.5 or newer, whose payload menu runs ELF payloads.
|
||||
|
||||
R
|
||||
1. Download the latest `romm-sync-ps4.pkg` from the releases page and install it with your package installer.
|
||||
2. Open **RomM Sync** from the home screen. It copies its payload, `romm-sync-ps4.elf`, to `/data/payloads/`.
|
||||
2. Open **RommPS** from the home screen. It copies its payload, `romm-sync-ps4.elf`, to `/data/payloads/`.
|
||||
3. In GoldHEN Settings, open the payloader, pick `romm-sync-ps4.elf` and run it. Add it to the AutoRun queue there and it starts on every jailbreak.
|
||||
4. Open **RomM Sync** again: it shows the web UI. It's also at `http://<ps4-ip>:8780` from another device.
|
||||
4. Open **RommPS** again: it shows the web UI. It's also at `http://<ps4-ip>:8780` from another device.
|
||||
|
||||
The app only installs the payload and shows the web UI; the payload does the syncing, in the background, whether or not the app is open. Without the package, copy `romm-sync-ps4.elf` to `/data/payloads/` yourself (GoldHEN's FTP server, or `make install-ps4 PS4_HOST=<ps4-ip>`).
|
||||
|
||||
Don't send the payload to GoldHEN's network loader on port 9090: GoldHEN 2.4 crashes on ELFs sent there. Make sure the console's date and time are set (GoldHEN can keep them updated), or HTTPS won't work. RetroArch is found in `/data/retroarch`, with its cores in `/data/self/retroarch/cores`, as its `retroarch.cfg` says.
|
||||
**Note:** Don't send the payload to GoldHEN's network loader on port 9090: GoldHEN 2.4 crashes on ELFs sent there. Make sure the console's date and time are set (GoldHEN can keep them updated), or HTTPS won't work.
|
||||
|
||||
### How it runs
|
||||
|
||||
RommPS starts with your HEN, and setup turns this on for you. Both HENs start `payloads/romm-sync.elf` when `romm-sync.elf.auto_start` sits next to it, and RommPS puts both in every HEN's folder on the console (`/data/etaHEN/payloads` and `/data/OnionHEN/payloads`), so switching HENs keeps working. If a payload manager like pldmgr has its own copy, that copy is kept up to date too. If you build it yourself, `make install PS5_HOST=<ps5-ip>` copies it over FTP (add `PS5_FTP=ftp://<ps5-ip>:1337 PS5_HEN_DIR=/data/OnionHEN` for onionHEN's FTP plugin).
|
||||
|
||||
## Updates
|
||||
|
||||
There are two pieces to keep updated, and they update separately:
|
||||
- On the PS5, just update RommPS: from ProsperoStore (or other homebrew storefronts eventually), or by copying the new zip's folder over the old one. The background service comes with it, and RommPS swaps it in the next time you open it, once nothing is syncing or downloading. An older version never replaces a newer one.
|
||||
- On the PS4, the payload updates itself from Settings.
|
||||
|
||||
- **RomM Sync (the payload)** checks GitHub once a day (you can turn that off) and tells you when there's a new version. Settings can install it for you, but nothing installs on its own. Each payload release is signed, and RomM Sync only installs a file whose signature matches the key built into it, so nobody can slip a modified payload in through the updater.
|
||||
- **RommPS (the app)** updates through ProsperoStore, like any other homebrew in its catalog. If the payload ever falls behind what the app needs, RommPS tells you to update it.
|
||||
## Setup Wizard
|
||||
|
||||
You can always update either one by hand from the releases page.
|
||||
|
||||
## Setup
|
||||
|
||||
The setup walks you through five steps:
|
||||
The setup wizard walks you through five steps:
|
||||
|
||||
1. Connecting to your server.
|
||||
2. Approving the console in RomM, by scanning a QR code or entering a code.
|
||||
@@ -110,40 +86,50 @@ The setup walks you through five steps:
|
||||
4. Checking what the first sync will do.
|
||||
5. Choosing when to sync in the background.
|
||||
|
||||
**Back up your saves before the first sync.** Copy your emulator save folders to a computer or USB drive. RomM Sync keeps a local copy before replacing any file, but your own backup is the safest option.
|
||||
**Back up your saves before the first sync.** Copy your emulator save folders to a computer or USB drive. RommPS keeps a local copy before replacing any file, but your own backup is the safest option.
|
||||
|
||||
## PS2 Memory Cards
|
||||
## Emulators
|
||||
RommPS has built-in profiles for most known emulators on the PS5, and will set the correct default game, save/save state, and bios directories for them. Since there is a huge influx of new emulators popping up, I will be doing my very best to keep up with them and add profiles accordingly.
|
||||
|
||||
LRPS2 keeps every game on one shared memory card by default. RomM stores saves per game, so pick one of these during setup:
|
||||
Note: RommPS relies on your emulator **NOT** being sandboxed. Some emulators require a separate payload that must be launched to be able to access outside of the sandbox, so make sure you've followed their instructions to properly install. Sandboxed emulators will not be able to read games or save files.
|
||||
|
||||
- **One card per game.** Works with any RomM version. In RetroArch, open the LRPS2 core options and turn off _Shared Memory Cards_. After that, each game's card syncs like any other save. Saves already on the shared card stay there.
|
||||
- **Back up the shared card.** Needs RomM 5.3.0 or newer. The whole card is uploaded when it changes, and you can restore it from Settings. It isn't merged with other devices.
|
||||
> The emulators listed below are those I've tested and have created profiles for to auto-detect them. You can also manually set the directories for each emulator/platform via the PS5 app or or the Web UI; you can use whichever emulators you wish.
|
||||
|
||||
PSP, GameCube and Wii saves are synced as one zip per game, the same format other RomM clients use. Depending on the client, they may or may not support unzipping the saves (I don't truly know), but this client does!
|
||||
### PS5
|
||||
|
||||
My opinion? One card per game is better since 8MB memory cards fill up quick.
|
||||
| Emulator | Systems | Game Downloads | Saves | Comments |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| [RetroArch](https://github.com/mihawk-99/PS5_RetroArch) | NES, Famicom Disk System, SNES, Game Boy, Game Boy Color, Game Boy Advance, Genesis/Mega Drive, Master System, Game Gear, Sega CD, SG-1000, arcade (FinalBurn Neo), PS2 (LRPS2), PSP (PPSSPP), GameCube and Wii (Dolphin) | Full Support | Full Support | - |
|
||||
| [PSXS5](https://github.com/SynoPiia/PSXS5) | PS1 | Full Support | One memory card per game, and states | States work with RomM's web player (PCSX-ReARMed or Beetle, whichever PSXS5 is set to) |
|
||||
| [SwanStationPS5](https://github.com/darkxex/SwanStationPS5) | PS1 | Full Support | One memory card per game, and states | Plays PSXS5's games, keeps its own saves in `/data/SwanStationPS5`. Its states only load in SwanStationPS5 |
|
||||
| [PS5SX2](https://github.com/Swordpdf/PS5SX2) | PS2 | Full Support | Its two shared memory cards, backed up to RomM (5.3 or newer) | - |
|
||||
| [PS5N64](https://github.com/Globaleliteee/PS5N64) | N64 | Full Support | Full Support | - |
|
||||
| [PS5NES](https://github.com/Globaleliteee/PS5NES) | NES | Untested | Untested | Preset uses `/data/PS5NES/games` and `/data/PS5NES/saves`. GH page gone |
|
||||
| [Porpoise](https://github.com/elripalda/Porpoise-Dolphin-Emulator-for-PS5) | Wii/GameCube | Full Support | N/A | Couldn't exit sandbox w/o crash. [Issue](https://github.com/elripalda/Porpoise-Dolphin-Emulator-for-PS5/issues/18) reported. Downloading games should work if the emulator is actually working for you.|
|
||||
| [ProsperoEden](https://github.com/blackbearreloaded/ProsperoEden) | Switch | Manual Support | Researching | - |
|
||||
| [PS5X360](https://github.com/BrinooTk/PS5X360) | Xbox 360 | Manual Support | Researching | - |
|
||||
|
||||
### PS4
|
||||
|
||||
| Emulator | Systems |
|
||||
| --- | --- |
|
||||
| RetroArch | The RetroArch systems above, wherever the PS4 build has the core |
|
||||
| pSNES, pNES, pGEN, pGBA, pFBN | SNES; NES; Genesis, Master System, Game Gear, Sega CD, SG-1000; GBA, GBC, GB; arcade |
|
||||
|
||||
### Save states
|
||||
Some users have recently reported issues with being able to pull down save states from their servers. They don't see them on the destination save folder for that emulator, or if they do, they don't work. Save states typically cannot be used across different emulators (cores), and even within the same core, the definition of a save state may different with the file version, and version of the app. In my opinion, save states should be for quick resume or to save you when you forgot to save your game, so they truly don't need to be synced across devices.
|
||||
|
||||
## PS4/PS5 Games
|
||||
|
||||
If you are interested in downloading PS4 and PS5 games from your RomM server, RomM has direct FPKGi support, simply add it as a library. This sync client does NOT handle PS4/PS5 save syncing at this time, but is being researched for potential future inclusion.
|
||||
|
||||
## Web UI
|
||||
|
||||
RommPS exposes a separate Web UI at http://your-ps5-ip:8780 where you can access all the same features present in the native app.
|
||||
|
||||
## Emulator Profiles
|
||||
|
||||
I've included direct support for most of the emulators that are out right now, except for the ones that won't even launch... you can sync games, states, and saves anywhere you want, but the presets should do it for most people.
|
||||
|
||||
Settings has a JSON editor for emulator profiles, and a table showing which folders are used for each platform. A profile lists an emulator's folders and the RomM platforms it plays:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "retroarch",
|
||||
"name": "RetroArch",
|
||||
"root": "/data/homebrew/PPSA99169",
|
||||
"retroarch_cfg": "{root}/config/retroarch.cfg",
|
||||
"rom_dir": "{root}/content/{platform}",
|
||||
"save_dir": "{root}/savefiles",
|
||||
"state_dir": "{root}/savestates",
|
||||
"bios_dir": "{root}/system",
|
||||
"save_exts": ".srm,.sav",
|
||||
"platforms": [{ "romm": ["snes", "sfam"], "dir": "snes", "emulator": "snes9x", "core_name": "Snes9x" }]
|
||||
}
|
||||
```
|
||||
|
||||
I've included direct support for most of the emulators that are out right now, except for the ones that won't even launch... you can sync games, states, and saves anywhere you want, but the presets should do it for most people. You can also configure the paths for all platforms manually. The web UI has a JSON editor for emulator profiles.
|
||||
|
||||
## Building
|
||||
|
||||
@@ -153,6 +139,7 @@ tools/ps5-build.sh # PS5 build in Docker, output in build/ps5/
|
||||
tools/ps4-build.sh # PS4 payload in Docker, output in build/ps4/
|
||||
tools/ps4-build.sh ps4-pkg # plus the PS4 home screen app (platform/ps4/app)
|
||||
tests/e2e.sh # end-to-end tests against a mock RomM server
|
||||
tests/autostart.sh # autostart, version checks and HEN folders
|
||||
```
|
||||
|
||||
Both consoles build from the same code in `src/`. What differs lives in `platform/<console>/`: `platform.c` (the `src/platform.h` interface), the make rules and the Docker SDK image. `platform/console/` has the parts the PS4 and PS5 share, and `platform/host/` is the development build.
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 1.5 MiB After Width: | Height: | Size: 1.6 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 8.9 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 794 KiB |
@@ -0,0 +1,240 @@
|
||||
# Emulators
|
||||
|
||||
Generated by `tools/emulators-doc.py` from `platform/*/emulators.json` and `platform/retroarch_cores.json`: edit those, then run it again.
|
||||
`{root}` is the emulator's folder, `{game_id}` the disc's id, `{serial}` a PS1 disc's serial, `{rom_stem}` the game file's name without its extension.
|
||||
|
||||
Memory cards: one method per system, never both. A card of each game's own syncs as that game's save. Shared cards hold every game's saves at once: they sync whole, as RomM memory cards, between the consoles that keep them (RetroArch's cores on cards of their own); when both sides changed, the newer wins and the other is kept.
|
||||
|
||||
## PS5
|
||||
|
||||
### PSXS5
|
||||
|
||||
One memory card per game, named after the game file (after the disc with PCSX-ReARMed).
|
||||
|
||||
| System | Games | Saves | Save files | Saved as | States | State files | Memory cards |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| psx, ps1, playstation | `{root}/games` | `{root}/saves` | `.mcd` | `{serial}_1{ext}` | `{root}/states` | `.state*` | one per game |
|
||||
|
||||
### SwanStationPS5
|
||||
|
||||
Plays the games in PSXS5's folder. One memory card per game, named after the game file. Its save states load only in SwanStationPS5.
|
||||
|
||||
| System | Games | Saves | Save files | Saved as | States | State files | Memory cards |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| psx, ps1, playstation | `/data/PSXS5/games` | `{root}/saves` | `.mcd` | `{rom_stem}_1{ext}` | `{root}/states` | `.state*` | one per game |
|
||||
|
||||
### PSSwanStation
|
||||
|
||||
One memory card per game, named after the game file. Its save states load only in PSSwanStation.
|
||||
|
||||
| System | Games | Saves | Save files | Saved as | States | State files | Memory cards |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| psx, ps1, playstation | `{root}/games` | `{root}/data/saves` | `.mcd` | `{rom_stem}_1{ext}` | `{root}/data/states` | `.state` | one per game |
|
||||
|
||||
### PS64
|
||||
|
||||
Games save into their own disk images. Save Disks sync; there are no save states.
|
||||
|
||||
| System | Games | Saves | Save files | Saved as | States | State files | Memory cards |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| c64 | `{root}/games` | `{root}/emulator/saves` | `.d64` | `{rom_stem}.save0{ext}` | `{root}/emulator/saves` | `.vsf` | |
|
||||
|
||||
### PS5SX2
|
||||
|
||||
Two memory cards for every game, backed up to RomM (5.3 or newer).
|
||||
|
||||
| System | Games | Saves | Save files | Saved as | States | State files | Memory cards |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| ps2 | `{root}/games` | not synced | | | not synced | | shared by every game. Shared cards (synced whole, while the system uses shared cards): `{root}/Mcd001.ps2`, `{root}/Mcd002.ps2` |
|
||||
|
||||
### PS5N64
|
||||
|
||||
Saves and states are named after a hash of the game file.
|
||||
|
||||
| System | Games | Saves | Save files | Saved as | States | State files | Memory cards |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| n64 | `{root}/games` | `{root}/saves` | `.sav` | `{rom_fnv64}{ext}` | `{root}/saves` | `.state` | one per game |
|
||||
|
||||
### PS5NES
|
||||
|
||||
Untested: v7 may look for games in PS5SX2's folder.
|
||||
|
||||
| System | Games | Saves | Save files | Saved as | States | State files | Memory cards |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| nes, famicom | `{root}/games` | `{root}/saves` | `.sav` | `{rom_fnv64}{ext}` | `{root}/saves` | `.state*` | |
|
||||
|
||||
### Porpoise
|
||||
|
||||
Dolphin, with its user folder in {root}/saves/User.
|
||||
|
||||
| System | Games | Saves | Save files | Saved as | States | State files | Memory cards |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| ngc, gamecube | `{root}/games` | `{root}/saves/User` | a zip per game (gci), from `{root}/saves/User/GC` | | `{root}/states/{game_id}` | `.state` | one per game |
|
||||
| wii | `{root}/games` | `{root}/saves/User` | a zip per game (wii), from `{root}/saves/User/Wii` | | `{root}/states/{game_id}` | `.state` | |
|
||||
|
||||
### PSFlyCast
|
||||
|
||||
Each game's own memory card syncs as its save; the shared ones (Own memory card for each game off) are backed up as RomM memory cards. Save states don't sync yet.
|
||||
|
||||
| System | Games | Saves | Save files | Saved as | States | State files | Memory cards |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| dc, dreamcast | `{root}/games` | `{root}/data` | `.bin` | `{game_id}_vmu_save_A1{ext}` | not synced | | one per game; can be shared by every game. One per game: Turn on Own memory card for each game in its settings (players 2 to 4 always share theirs) (read from `{root}/emu.cfg`, `PerGameVmu`). Shared cards (synced whole, while the system uses shared cards): `{root}/data/vmu_save_A1.bin`, `{root}/data/vmu_save_B1.bin`, `{root}/data/vmu_save_C1.bin`, `{root}/data/vmu_save_D1.bin` |
|
||||
|
||||
### Seedee5
|
||||
|
||||
Games and BIOS files only: saved games are kept as folders, and only after a clean exit.
|
||||
|
||||
| System | Games | Saves | Save files | Saved as | States | State files | Memory cards |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| philips-cd-i | `{root}/roms` | not synced | | | not synced | | |
|
||||
|
||||
### XPSemu
|
||||
|
||||
Games and BIOS files only: saves live inside its hard disk image.
|
||||
|
||||
| System | Games | Saves | Save files | Saved as | States | State files | Memory cards |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| xbox | `{root}/games` | not synced | | | not synced | | |
|
||||
|
||||
### PS5CEMU-HAR
|
||||
|
||||
Wii U (Cemu), 3DS (Azahar) and DS (melonDS). Wii U and 3DS saves sync; 3DS save states load only in PS5CEMU-HAR. DS: games and BIOS files only for now.
|
||||
|
||||
| System | Games | Saves | Save files | Saved as | States | State files | Memory cards |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| wiiu | `{root}/games` | `{rom_dir}` | a zip per game (wiiu), from `{root}/mlc01/usr/save` | | `{rom_dir}` | `.state*` | |
|
||||
| 3ds | `{root}/azahar/games` | `{rom_dir}` | a zip per game (3ds), from `{root}/azahar/sdmc/Nintendo 3DS/00000000000000000000000000000000/00000000000000000000000000000000/title` | | `{root}/azahar/states` | `.cst` | |
|
||||
| nds | `{root}/melonds/games` | not synced | | | not synced | | |
|
||||
|
||||
### ProsperoEden
|
||||
|
||||
Games (with their updates and DLC) and keys only: saves are kept per Switch user profile. Its Switch UI app shares the same folders.
|
||||
|
||||
| System | Games | Saves | Save files | Saved as | States | State files | Memory cards |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| switch | `{root}/roms` | not synced | | | not synced | | |
|
||||
|
||||
## PS4
|
||||
|
||||
### pSNES
|
||||
|
||||
| System | Games | Saves | Save files | Saved as | States | State files | Memory cards |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| snes, sfam | `{root}/roms` | `{root}/sram` | `.srm` | `{rom_stem}{ext}` | `{root}/saves` | `.state` | |
|
||||
|
||||
### pNES
|
||||
|
||||
| System | Games | Saves | Save files | Saved as | States | State files | Memory cards |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| nes, famicom | `{root}/roms` | `{root}/save` | `.sav` | `{rom_stem}{ext}` | `{root}/saves` | `.state` | |
|
||||
| fds | `{root}/roms` | `{root}/save` | `.sav` | `{rom_stem}{ext}` | `{root}/saves` | `.state` | |
|
||||
|
||||
### pGEN
|
||||
|
||||
| System | Games | Saves | Save files | Saved as | States | State files | Memory cards |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| genesis, genesis-slash-megadrive, megadrive | `{root}/{platform}` | `{root}/rams` | `.srm` | `{rom_stem}{ext}` | `{root}/saves` | `.state` | |
|
||||
| sms, mastersystem | `{root}/{platform}` | `{root}/rams` | `.srm` | `{rom_stem}{ext}` | `{root}/saves` | `.state` | |
|
||||
| gamegear, gg | `{root}/{platform}` | `{root}/rams` | `.srm` | `{rom_stem}{ext}` | `{root}/saves` | `.state` | |
|
||||
| segacd, sega-cd | `{root}/{platform}` | `{root}/rams` | `.brm` | `{rom_stem}{ext}` | `{root}/saves` | `.state` | |
|
||||
| sg1000 | `{root}/{platform}` | `{root}/rams` | `.srm` | `{rom_stem}{ext}` | `{root}/saves` | `.state` | |
|
||||
|
||||
### pGBA
|
||||
|
||||
Saves and states share the saves folder.
|
||||
|
||||
| System | Games | Saves | Save files | Saved as | States | State files | Memory cards |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| gba | `{root}/{platform}` | `{root}/saves` | `.sav` | `{rom_stem}{ext}` | `{root}/saves` | `.state` | |
|
||||
| gbc | `{root}/{platform}` | `{root}/saves` | `.sav` | `{rom_stem}{ext}` | `{root}/saves` | `.state` | |
|
||||
| gb | `{root}/{platform}` | `{root}/saves` | `.sav` | `{rom_stem}{ext}` | `{root}/saves` | `.state` | |
|
||||
|
||||
### pFBN
|
||||
|
||||
Arcade saves are the games' own memory (eeproms), backed up as they are.
|
||||
|
||||
| System | Games | Saves | Save files | Saved as | States | State files | Memory cards |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| arcade, fbneo, neogeoaes, neogeomvs, cps1, cps2, cps3 | `{root}/{platform}` | `{root}/eeproms` | `.fs` | `{rom_stem}{ext}` | `{root}/saves` | `.state` | |
|
||||
| neo-geo-pocket-color, neo-geo-pocket, ngpc, ngp | `{root}/{platform}` | `{root}/eeproms` | `.fs` | `{rom_stem}{ext}` | `{root}/saves` | `.state` | |
|
||||
| colecovision | `{root}/{platform}` | `{root}/eeproms` | `.fs` | `{rom_stem}{ext}` | `{root}/saves` | `.state` | |
|
||||
| msx | `{root}/{platform}` | `{root}/eeproms` | `.fs` | `{rom_stem}{ext}` | `{root}/saves` | `.state` | |
|
||||
| zxs, zx-spectrum | `{root}/{platform}` | `{root}/eeproms` | `.fs` | `{rom_stem}{ext}` | `{root}/saves` | `.state` | |
|
||||
| supergrafx | `{root}/{platform}` | `{root}/eeproms` | `.fs` | `{rom_stem}{ext}` | `{root}/saves` | `.state` | |
|
||||
| channel-f, fairchild-channel-f | `{root}/{platform}` | `{root}/eeproms` | `.fs` | `{rom_stem}{ext}` | `{root}/saves` | `.state` | |
|
||||
|
||||
### EmuSuNi
|
||||
|
||||
Set EmuSuNi's N64 folder to /data/EmuSuNi/n64 in its settings.
|
||||
|
||||
| System | Games | Saves | Save files | Saved as | States | State files | Memory cards |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| snes, sfam | `{root}/games` | `{root}/saves` | `.srm` | `{rom_stem}{ext}` | `{root}/states` | `.state*` | |
|
||||
| nes, famicom | `{root}/nes` | `{root}/saves/nes` | `.srm` | `{rom_stem}{ext}` | `{root}/states/nes` | `.state*` | |
|
||||
| n64 | `{root}/n64` | `{root}/saves/n64` | `.srm` | `{rom_stem}{ext}` | `{root}/states/n64` | `.state*` | one per game |
|
||||
|
||||
### DolphinPS4
|
||||
|
||||
Dolphin, with its user folder in {root}/User. States are named after the disc's id.
|
||||
|
||||
| System | Games | Saves | Save files | Saved as | States | State files | Memory cards |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| ngc, gamecube | `{root}/games` | `{root}/User` | a zip per game (gci), from `{root}/User/GC` | | `{root}/User/StateSaves` | `.s0*` | one per game |
|
||||
| wii | `{root}/games` | `{root}/User` | a zip per game (wii), from `{root}/User/Wii` | | `{root}/User/StateSaves` | `.s0*` | |
|
||||
|
||||
### Resonance
|
||||
|
||||
Each game's own memory card syncs as its save; the shared ones are backed up as RomM memory cards.
|
||||
|
||||
| System | Games | Saves | Save files | Saved as | States | State files | Memory cards |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| dc, dreamcast | `{root}/games` | `{root}/data` | `.bin` | `{game_id}_vmu_save_A1{ext}` | `{root}/data` | `.state` | one per game; can be shared by every game. One per game: Turn on Own memory card for each game in its settings (players 2 to 4 always share theirs) (read from `{root}/emu.cfg`, `PerGameVmu`). Shared cards (synced whole, while the system uses shared cards): `{root}/data/vmu_save_A1.bin`, `{root}/data/vmu_save_B1.bin`, `{root}/data/vmu_save_C1.bin`, `{root}/data/vmu_save_D1.bin` |
|
||||
|
||||
### PS4SX2
|
||||
|
||||
Two memory cards for every game, backed up to RomM (5.3 or newer). No save states yet.
|
||||
|
||||
| System | Games | Saves | Save files | Saved as | States | State files | Memory cards |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| ps2 | `{root}/roms` | not synced | | | not synced | | shared by every game. Shared cards (synced whole, while the system uses shared cards): `{root}/memcards/Mcd001.ps2`, `{root}/memcards/Mcd002.ps2` |
|
||||
|
||||
### NPS64
|
||||
|
||||
Saves and states are named after a hash of the game file (.z64 dumps).
|
||||
|
||||
| System | Games | Saves | Save files | Saved as | States | State files | Memory cards |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| n64 | `{root}/games` | `{root}/saves` | `.srm` | `rom-{rom_fnv64}{ext}` | `{root}/saves` | `.state` | one per game |
|
||||
|
||||
### mGBA
|
||||
|
||||
| System | Games | Saves | Save files | Saved as | States | State files | Memory cards |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| gba | `{root}/roms` | `{root}/saves` | `.sav` | `{rom_stem}{ext}` | `{root}/states` | `.ss*` | |
|
||||
| gbc | `{root}/roms` | `{root}/saves` | `.sav` | `{rom_stem}{ext}` | `{root}/states` | `.ss*` | |
|
||||
| gb | `{root}/roms` | `{root}/saves` | `.sav` | `{rom_stem}{ext}` | `{root}/states` | `.ss*` | |
|
||||
|
||||
### SNESUI
|
||||
|
||||
| System | Games | Saves | Save files | Saved as | States | State files | Memory cards |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| snes, sfam | `{root}/juegos` | `{root}/guardados` | `.srm` | `{rom_stem}{ext}` | `{root}/guardados` | `.st0,.st1,.st2,.st3` | |
|
||||
|
||||
## RetroArch (both consoles)
|
||||
|
||||
From `platform/retroarch_cores.json`. Saves default to `<game>.srm` (`.sav`, `.rtc`) in RetroArch's save folder and states to `<game>.state*`, unless a rule says otherwise.
|
||||
|
||||
| Core | System | Save files | Saved as | Memory cards | Notes |
|
||||
|---|---|---|---|---|---|
|
||||
| LRPS2, PCSX2 | ps2 | `.ps2` | | shared by every game; can be one per game. One per game: Turn off Shared Memory Cards in LRPS2's core options. Shared cards (synced whole, while the system uses shared cards): `{bios}/pcsx2/memcards/Mcd001.ps2`, `{bios}/pcsx2/memcards/Mcd002.ps2` | Per game: <save folder>/<game>.ps2 (slot 1 only). Shared (the default): <system>/pcsx2/memcards/Mcd001.ps2 and Mcd002.ps2. |
|
||||
| PS5SX2 | ps2 | | | shared by every game. Shared cards (synced whole, while the system uses shared cards): `{bios}/pcsx2/memcards/Mcd001.ps2`, `{bios}/pcsx2/memcards/Mcd002.ps2` | Shared only: <system>/pcsx2/memcards/Mcd001.ps2 and Mcd002.ps2. |
|
||||
| SwanStation | psx | `.srm,.mcd` | | one per game; can be shared by every game. One per game: Set Memory Card 1 Type to Libretro in SwanStation's core options | Card 1: <game>.srm (Libretro, the default), <serial>_1.mcd (per game by code) or <game>_1.mcd (by title). Shared: duckstation_shared_card_1.mcd. |
|
||||
| *Beetle PSX* | psx | | | one per game; can be shared by every game. One per game: Turn off Shared Memory Cards in the core options | Card 1: <game>.srm. Shared cards (mednafen_psx_libretro_shared.N.mcr) need the Mednafen card method. |
|
||||
| PCSX-ReARMed | psx | | | one per game | Card 1: <game>.srm. |
|
||||
| Flycast | dc | `.bin` | `{game_id}.A1{ext}` | shared by every game; can be one per game. One per game: Set Per-Game VMUs to VMU A1 in Flycast's core options. Shared cards (synced whole, while the system uses shared cards): `{bios}/dc/vmu_save_A1.bin`, `{bios}/dc/vmu_save_B1.bin`, `{bios}/dc/vmu_save_C1.bin`, `{bios}/dc/vmu_save_D1.bin` | Shared (the default): <system>/dc/vmu_save_<port>.bin. Per-Game VMUs on VMU A1 or All VMUs: <save folder>/<disc id>.A1.bin (<game>.A1.bin before late 2024), the same raw 128 KB card as PSFlyCast's. |
|
||||
| any | saturn | `.srm,.bcr,.bkr` | | one per game; can be shared by every game. One per game: Turn off Shared Internal and Backup Memory in Beetle Saturn's core options | Beetle Saturn: internal backup memory as <game>.srm (its Libretro save method, the default) or <game>.bkr; the cartridge's as <game>.bcr. |
|
||||
| *ParaLLEl*, *Mupen64Plus* | n64 | | | one per game | Controller paks live in each game's .srm (EEPROM, four paks, SRAM, flash): there's no shared one. |
|
||||
| PPSSPP | any | a zip per game (psp) | | | |
|
||||
| Dolphin | wii | a zip per game (wii) | | | |
|
||||
| Dolphin | any | a zip per game (gci) | | one per game | A GCI folder: one file per save, so each game's own; synced as a zip of the game's .gci files. |
|
||||
| Azahar, Citra | any | a zip per game (3ds) | | | |
|
||||
@@ -0,0 +1,49 @@
|
||||
# Release checklist
|
||||
|
||||
Run all of this on a real console before tagging. Tests in CI (`tests/e2e.sh`, `tests/autostart.sh`) cover the logic, not the console.
|
||||
|
||||
## Before
|
||||
|
||||
- `kMinPayload` in `rommps_app.hpp`, `contentVersion` in RommPS's `param.json` and `APP_VERSION` in `src/util.h` all match the tag.
|
||||
- `docs/releases/<tag>.md` is written. ProsperoStore shows it as the release notes.
|
||||
- If this release changes who owns something on the console (the home screen icon, starting the payload, the autostart files, updates, the data folder), the old one gets removed or migrated in this same release.
|
||||
|
||||
## Upgrades
|
||||
|
||||
For each one, start from the old version actually installed and set up, then update. Check afterwards:
|
||||
|
||||
- one icon on the home screen (RommPS), no "RomM Sync" tile
|
||||
- one RomM Sync running, the new version (`/api/status`), lock file says the same
|
||||
- every HEN folder that exists has the new `romm-sync.elf` and `.auto_start` (`/api/autostart` lists the copies and their versions)
|
||||
- a pldmgr copy, if there was one, is the new version
|
||||
- no `.bak` files left in the payload folders
|
||||
- a sync runs and finishes without errors
|
||||
- no RomM Sync notifications while RommPS is installed
|
||||
|
||||
| From | How | Also check |
|
||||
| --- | --- | --- |
|
||||
| Nothing | ProsperoStore install, open RommPS | autostart is on, with no switch to turn it off |
|
||||
| Nothing | zip copied to `/data/homebrew` | same as above |
|
||||
| 1.0.2 payload only, tile, etaHEN folder | in-app update from the web UI | tile stays (no RommPS), "Install RommPS" notice |
|
||||
| 1.0.2 as above | install RommPS, open it | tile goes away |
|
||||
| previous RommPS + payload | store update of RommPS, open it | payload swapped once idle, copies updated |
|
||||
| previous RommPS + payload | payload's own update first, RommPS later | "RommPS is out of date" notice |
|
||||
| any, autostart off or its flag renamed to `.off` | update, reboot | autostart is back on in every HEN folder, no `.off` left |
|
||||
| any, old copy in pldmgr's autoload | update | the pldmgr copy is updated, its autoload list untouched |
|
||||
|
||||
## Boot and rest mode
|
||||
|
||||
- Reboot, jailbreak with onionHEN only, don't open RommPS: RomM Sync starts by itself, one copy.
|
||||
- Same with etaHEN.
|
||||
- Rest mode, then wake: same process keeps running (uptime carries on), web UI answers.
|
||||
- Open RommPS right after a jailbreak: it waits for RomM Sync instead of starting a second copy.
|
||||
|
||||
## Never downgrade
|
||||
|
||||
- Send an older `romm-sync.elf` to port 9021 while a newer one runs: it exits, the newer one keeps running.
|
||||
- Upload an older payload in the web UI's autostart: the newer copies on disk stay.
|
||||
|
||||
## After
|
||||
|
||||
- The release workflow published the payloads, then `tools/release-app.sh <tag> --upload` attached `PPSA76677.zip`.
|
||||
- The catalog bot's pull request for the new version shows up the next day.
|
||||
@@ -0,0 +1,20 @@
|
||||
Update RommPS and open it once. It takes care of the rest.
|
||||
|
||||
### Fixed
|
||||
|
||||
- The extra "RomM Sync" icon is gone.
|
||||
- RomM Sync now starts with onionHEN too.
|
||||
- Old copies of RomM Sync no longer start or replace newer ones.
|
||||
- RommPS shows the right time for the last sync.
|
||||
- Saving settings in the web UI no longer undoes changes made in RommPS.
|
||||
- The library picks up emulator changes right away, without restarting RommPS.
|
||||
|
||||
### Added
|
||||
|
||||
- RomM Sync starts with the console by default.
|
||||
- New home screen art.
|
||||
|
||||
### Changed
|
||||
|
||||
- RomM Sync updates come with RommPS.
|
||||
- No more RomM Sync notifications while RommPS is installed, except sync results.
|
||||
@@ -0,0 +1,25 @@
|
||||
Update RommPS and open it once. It updates everything else.
|
||||
|
||||
### New
|
||||
|
||||
- Saves come down as soon as a game finishes downloading, so it's ready to play.
|
||||
- Save states only come down to an emulator that can load them.
|
||||
- Save sync is optional. Use RomM just for games, or pick per platform: saves and states, saves only, or off.
|
||||
- Home has Conflicts, Activity (what synced and why) and Tracked saves. Each game has its own saves view, with restore.
|
||||
- Settings > Emulators is organized by platform: its emulator, folders, save sync and settings in one place.
|
||||
- GameCube saves keep the form RomM has them in: a zip, a single .gci, or a whole memory card. Other saves on a card are left alone.
|
||||
- Porpoise is supported, including its save states. SwanStationPS5 and PS5NES are offered too.
|
||||
- Settings > Diagnostic bundle, for bug reports and troubleshooting.
|
||||
- Hold Options to close RommPS gracefully, avoiding any weird behaviors that you often see when hard closing homebrew apps.
|
||||
|
||||
### Fixed
|
||||
|
||||
- A save or state deleted from the console comes back from RomM.
|
||||
- Older saves from before RomM's save slots now reach the console.
|
||||
- A blank PS1 memory card from RomM never replaces one with saves on it.
|
||||
- Games in folders download with their file names.
|
||||
- Notifications say what moved and for which games, and every download says when it's done.
|
||||
|
||||
### Good to know
|
||||
|
||||
- RomM's web player and the PS5 emulators don't always use the same core. A state only comes down where it can load.
|
||||
@@ -0,0 +1,15 @@
|
||||
Update RommPS and open it once. It updates everything else.
|
||||
|
||||
### New
|
||||
|
||||
- RetroArch 2.0 support. Its games live in new folders, named after each system (`content/Nintendo - Game Boy Advance`). New downloads go there, and RommPS offers to move the games already in the old folders. Saves and states stay where they are.
|
||||
- RetroArch 2.0 plays PS2 with PS5SX2. Its two shared memory cards are backed up to RomM.
|
||||
- Covers keep their own shape. Square and wide boxes (SNES, N64 and others) are no longer squeezed into portrait cards.
|
||||
|
||||
### Fixed
|
||||
|
||||
- PS2 BIOS files for RetroArch go to `system/pcsx2/bios`, where its PS2 cores look for them.
|
||||
|
||||
### Good to know
|
||||
|
||||
- PS2 save states made with LRPS2 don't load in PS5SX2. Save in-game before upgrading RetroArch.
|
||||
@@ -0,0 +1,52 @@
|
||||
Update RommPS and open it once. It updates everything else.
|
||||
|
||||
### New
|
||||
|
||||
- Extra emulators no longer get their own copy of every game. Their games folder is mounted from the system's main emulator while they run (PSSwanStation, PSXS5, SwanStationPS5), and the old copies are removed.
|
||||
- Groundwork for keeping all your games, saves and states in one RommPS folder, mounted into every emulator.
|
||||
- Changing a system's main emulator moves its RomM games to the new one. If other games are in the old one's folder, RommPS asks what to do with them.
|
||||
- BIOS files sync from RomM to every emulator that needs them, with the name each one expects. PS2 emulators get a single BIOS. Your own BIOS files are never replaced.
|
||||
- Shared memory cards (PS2, Dreamcast) now sync both ways between your consoles. Each system uses either shared cards or one card per game.
|
||||
- New emulators: PS64 (C64), PSSwanStation, PSFlyCast (Dreamcast), Seedee5 (CD-i), XPSemu (Xbox), PS5CEMU-HAR (Wii U, 3DS, DS) and ProsperoEden (Switch).
|
||||
- Switch games download with their latest update and DLC, into the folders ProsperoEden reads. Each game gets one card in the library, and its details show the title ID of the game, its update and each DLC.
|
||||
- New saves that sync: Wii U and 3DS (with 3DS save states), Dreamcast VMUs in PSFlyCast and RetroArch's Flycast core, Beetle Saturn cartridge saves and SwanStation .mcd cards.
|
||||
- RetroArch recognizes 14 more systems, including 3DS, Jaguar, 3DO, PC-FX and Neo Geo CD.
|
||||
- Redesigned connection handling: RommPS keeps its connections to RomM open instead of opening a new one for every cover and page, so it's much lighter on your server. When RomM stops answering, RommPS says so and waits instead of retrying over and over.
|
||||
- Downloads cut off halfway carry on where they stopped, and ones that failed while RomM was away start again once it's back. Failed or cancelled downloads can be retried from the Queue.
|
||||
- Emulators installed after setup are announced once. Turn them on in Emulator Settings.
|
||||
- Archived games (.rar, or any archive for Switch) aren't downloaded. RommPS asks you to unpack them on the server.
|
||||
- The Downloads tab is now Queue. A new Downloads tab lists the games on the console and replaces the library's "On this console" row. Same in the web UI.
|
||||
- Game details show the rating, and button hints stay readable over any cover.
|
||||
- Interface sounds can be turned off in Settings.
|
||||
- Activity shows BIOS files and moved games.
|
||||
|
||||
### PS4
|
||||
|
||||
- Works on firmware 13.52 and 14.00.
|
||||
- New emulators: pSNES, pNES, pGEN, pGBA and pFBN (now with save states), EmuSuNi, DolphinPS4, NPS64, SNESUI, PS4SX2, Resonance and RArch.
|
||||
- GameCube saves are shared with the PS5. N64 saves are shared between NPS64, EmuSuNi and RetroArch.
|
||||
- Resonance's memory cards sync.
|
||||
- When the sync service isn't running, RommPS says why.
|
||||
- Big libraries no longer run the sync service out of memory while browsing, which could fail downloads.
|
||||
- The PS4 app is now called RommPS and installs over the old one.
|
||||
- The web UI is faster.
|
||||
|
||||
### Fixed
|
||||
|
||||
- PSXS5 2.0 save states and memory cards sync again.
|
||||
- Large save states (GameCube) sync both ways.
|
||||
- PS1 and PS2 game lists with lots of info load again.
|
||||
- Turning on many emulators at once no longer stops the sync service.
|
||||
- A game deleted outside RommPS no longer shows as downloaded.
|
||||
- Each console shows on RomM under its own name.
|
||||
- Accented letters in names (Pokémon) show as they should, instead of "?".
|
||||
- A game whose file was moved or renamed on the RomM server says so, instead of a bare HTTP error.
|
||||
- General fixes and improvements.
|
||||
|
||||
### Good to know
|
||||
|
||||
- Update all your consoles. A console still on 1.2 can't read Wii saves made with 1.3.
|
||||
- Shared memory cards become one card on RomM for all your consoles. The first sync keeps the card played last, and a blank card never replaces one with saves. The other stays in your backups.
|
||||
- RommPS now always starts with the console, and the setting to turn that off is gone.
|
||||
- Re-run setup wizard moved to Settings > RomM.
|
||||
- 3DS save states only load in PS5CEMU-HAR.
|
||||
+181
-11
@@ -4,9 +4,12 @@
|
||||
* single-instance check (ftpsrv main-orbis.c and main-prospero.c, which use the
|
||||
* same kinfo_proc offsets), notifications and the LAN address (websrv). */
|
||||
#include "console.h"
|
||||
#include "util.h"
|
||||
|
||||
#include <arpa/inet.h>
|
||||
#include <errno.h>
|
||||
#include <pthread.h>
|
||||
#include <fcntl.h>
|
||||
#include <ifaddrs.h>
|
||||
#include <netinet/in.h>
|
||||
#include <signal.h>
|
||||
@@ -15,8 +18,10 @@
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <time.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#include <sys/file.h>
|
||||
#include <sys/socket.h>
|
||||
#include <sys/stat.h>
|
||||
#include <sys/sysctl.h>
|
||||
@@ -94,13 +99,10 @@ static pid_t find_pid(const char *name) {
|
||||
return c.found;
|
||||
}
|
||||
|
||||
void console_single_instance(void) {
|
||||
/* Kills every other process called CONSOLE_PROC_NAME, giving up after 10 s. */
|
||||
static void kill_by_name(void) {
|
||||
pid_t pid;
|
||||
int tries = 0;
|
||||
|
||||
/* Name the main thread so find_pid() of the next instance sees us. */
|
||||
syscall(SYS_thr_set_name, -1, CONSOLE_PROC_NAME);
|
||||
|
||||
while ((pid = find_pid(CONSOLE_PROC_NAME)) > 0) {
|
||||
if (kill(pid, SIGKILL) && errno != ESRCH) break;
|
||||
sleep(1);
|
||||
@@ -108,6 +110,121 @@ void console_single_instance(void) {
|
||||
}
|
||||
}
|
||||
|
||||
struct pid_ctx { pid_t pid; };
|
||||
|
||||
static int has_pid_cb(pid_t pid, const char *tdname, void *vctx) {
|
||||
struct pid_ctx *c = vctx;
|
||||
return pid == c->pid && !strcmp(tdname, CONSOLE_PROC_NAME);
|
||||
}
|
||||
|
||||
/* "1.2.3" into numbers; anything after the patch is ignored. */
|
||||
static int parse_version(const char *s, int v[3]) {
|
||||
v[0] = v[1] = v[2] = 0;
|
||||
if (*s == 'v') s++;
|
||||
return sscanf(s, "%d.%d.%d", &v[0], &v[1], &v[2]) >= 2 ? 0 : -1;
|
||||
}
|
||||
|
||||
/* Whether this copy should take over from one running `have`. A version that
|
||||
* doesn't parse (a hand-made build) is sent on purpose, so it takes over. */
|
||||
static int takes_over(const char *mine, const char *have) {
|
||||
int x[3], y[3];
|
||||
if (parse_version(mine, x) || parse_version(have, y)) return 1;
|
||||
for (int i = 0; i < 3; i++)
|
||||
if (x[i] != y[i]) return x[i] > y[i];
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* Held for as long as this copy runs; the kernel lets go when the process
|
||||
* ends, however it ends. The file says which version holds it and its pid. */
|
||||
static int g_lock_fd = -1;
|
||||
|
||||
/* One line in the log, written before the log is set up: a copy that quits
|
||||
* here would otherwise leave no trace. */
|
||||
static void note(const char *fmt, ...) {
|
||||
char line[256], when[32];
|
||||
time_t now = time(NULL);
|
||||
struct tm tm;
|
||||
localtime_r(&now, &tm);
|
||||
strftime(when, sizeof when, "%Y-%m-%d %H:%M:%S", &tm);
|
||||
int n = snprintf(line, sizeof line, "%s [INFO] ", when);
|
||||
va_list ap;
|
||||
va_start(ap, fmt);
|
||||
n += vsnprintf(line + n, sizeof line - (size_t)n - 1, fmt, ap);
|
||||
va_end(ap);
|
||||
if (n > (int)sizeof line - 2) n = (int)sizeof line - 2;
|
||||
line[n++] = '\n';
|
||||
int fd = open(CONSOLE_DATA_DIR "/romm-sync.log", O_WRONLY | O_APPEND | O_CREAT, 0666);
|
||||
if (fd < 0) return;
|
||||
write(fd, line, (size_t)n);
|
||||
close(fd);
|
||||
}
|
||||
|
||||
static int lock_wait(int fd, int seconds) {
|
||||
for (int i = 0; i <= seconds * 4; i++) {
|
||||
if (!flock(fd, LOCK_EX | LOCK_NB)) return 0;
|
||||
usleep(250 * 1000);
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
int console_single_instance(const char *version) {
|
||||
console_mkdir_p(CONSOLE_DATA_DIR);
|
||||
int fd = open(CONSOLE_LOCK_FILE, O_RDWR | O_CREAT, 0644);
|
||||
if (fd < 0) {
|
||||
/* No lock to be had: run, as versions before the lock did. */
|
||||
kill_by_name();
|
||||
syscall(SYS_thr_set_name, -1, CONSOLE_PROC_NAME);
|
||||
return 0;
|
||||
}
|
||||
if (flock(fd, LOCK_EX | LOCK_NB)) {
|
||||
char have[32] = "";
|
||||
pid_t holder = 0;
|
||||
int alive = 0;
|
||||
/* A copy that took the lock a moment ago may not have said who it is
|
||||
* yet; until it does, the file still names the copy before it. */
|
||||
for (int tries = 0; tries < 8 && !alive; tries++) {
|
||||
char buf[64] = "";
|
||||
ssize_t n = pread(fd, buf, sizeof buf - 1, 0);
|
||||
if (n > 0) buf[n] = 0;
|
||||
have[0] = 0;
|
||||
holder = 0;
|
||||
sscanf(buf, "%31s %d", have, &holder);
|
||||
struct pid_ctx c = {holder};
|
||||
alive = holder > 0 && console_for_each_proc(has_pid_cb, &c) == 1;
|
||||
if (!alive) usleep(250 * 1000);
|
||||
}
|
||||
/* Held, but by no copy of ours: RommPS checking whether one runs.
|
||||
* It lets go at once. */
|
||||
if (!alive && !lock_wait(fd, 3)) goto locked;
|
||||
if (!takes_over(version, have)) {
|
||||
note("romm-sync %s not started: %s is already running (pid %d)", version, have, (int)holder);
|
||||
close(fd);
|
||||
return 1;
|
||||
}
|
||||
/* Older: stop it, but only if that pid is still RomM Sync. */
|
||||
note("romm-sync %s replacing %s (pid %d)", version, have, (int)holder);
|
||||
struct pid_ctx c = {holder};
|
||||
if (holder > 0 && console_for_each_proc(has_pid_cb, &c) == 1) kill(holder, SIGKILL);
|
||||
if (lock_wait(fd, 10)) {
|
||||
note("romm-sync %s not started: %s didn't stop", version, have);
|
||||
close(fd);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
locked:;
|
||||
/* Say who holds the lock first, for any copy starting right now. */
|
||||
char line[64];
|
||||
int len = snprintf(line, sizeof line, "%s %d\n", version, (int)getpid());
|
||||
if (!ftruncate(fd, 0) && len > 0) pwrite(fd, line, (size_t)len, 0);
|
||||
fchmod(fd, 0644);
|
||||
g_lock_fd = fd;
|
||||
syscall(SYS_thr_set_name, -1, CONSOLE_PROC_NAME);
|
||||
/* Copies from before the lock don't hold it: find them by name. A copy
|
||||
* still deciding hasn't named itself yet, so it's safe from this. */
|
||||
kill_by_name();
|
||||
return 0;
|
||||
}
|
||||
|
||||
int console_send_elf(int port, const void *elf, size_t len) {
|
||||
struct sockaddr_in sa;
|
||||
memset(&sa, 0, sizeof sa);
|
||||
@@ -159,15 +276,54 @@ void console_describe(char *buf, size_t n, const char *host_title) {
|
||||
(int)getpid(), (int)getuid(), host_title && host_title[0] ? host_title : "none");
|
||||
}
|
||||
|
||||
void plat_notify(const char *fmt, ...) {
|
||||
notify_request_t req;
|
||||
va_list ap;
|
||||
/* Notifications go through the shell, and a request waits until the shell
|
||||
* takes it: with the shell hung (it has been, with the HEN's hooks in it), the
|
||||
* caller would hang too. So they're handed to a thread of their own, a few
|
||||
* at a time; when the shell isn't taking them, the newest are dropped, and
|
||||
* nothing else ever waits on it (the mount guard least of all). */
|
||||
#define NOTIFY_QUEUE 8
|
||||
static pthread_mutex_t g_notify_lock = PTHREAD_MUTEX_INITIALIZER;
|
||||
static pthread_cond_t g_notify_cond = PTHREAD_COND_INITIALIZER;
|
||||
static char g_notify_queue[NOTIFY_QUEUE][256];
|
||||
static int g_notify_head, g_notify_count, g_notify_started;
|
||||
|
||||
memset(&req, 0, sizeof req);
|
||||
static void *notify_thread(void *arg) {
|
||||
(void)arg;
|
||||
for (;;) {
|
||||
notify_request_t req;
|
||||
memset(&req, 0, sizeof req);
|
||||
pthread_mutex_lock(&g_notify_lock);
|
||||
while (!g_notify_count) pthread_cond_wait(&g_notify_cond, &g_notify_lock);
|
||||
snprintf(req.message, sizeof req.message, "%s", g_notify_queue[g_notify_head]);
|
||||
g_notify_head = (g_notify_head + 1) % NOTIFY_QUEUE;
|
||||
g_notify_count--;
|
||||
pthread_mutex_unlock(&g_notify_lock);
|
||||
/* The shell can turn one down; without this a lost notice leaves no trace. */
|
||||
const int rc = sceKernelSendNotificationRequest(0, &req, sizeof req, 0);
|
||||
if (rc) LOGW("notification not shown (0x%08x): %s", (unsigned)rc, req.message);
|
||||
else LOGD("notification shown: %s", req.message);
|
||||
}
|
||||
return NULL;
|
||||
}
|
||||
|
||||
void plat_notify(const char *fmt, ...) {
|
||||
char msg[256];
|
||||
va_list ap;
|
||||
va_start(ap, fmt);
|
||||
vsnprintf(req.message, sizeof req.message, fmt, ap);
|
||||
vsnprintf(msg, sizeof msg, fmt, ap);
|
||||
va_end(ap);
|
||||
sceKernelSendNotificationRequest(0, &req, sizeof req, 0);
|
||||
pthread_mutex_lock(&g_notify_lock);
|
||||
if (!g_notify_started) {
|
||||
pthread_t t;
|
||||
g_notify_started = pthread_create(&t, NULL, notify_thread, NULL) == 0;
|
||||
if (g_notify_started) pthread_detach(t);
|
||||
}
|
||||
if (g_notify_started && g_notify_count < NOTIFY_QUEUE) {
|
||||
snprintf(g_notify_queue[(g_notify_head + g_notify_count) % NOTIFY_QUEUE], sizeof g_notify_queue[0], "%s", msg);
|
||||
g_notify_count++;
|
||||
pthread_cond_signal(&g_notify_cond);
|
||||
}
|
||||
pthread_mutex_unlock(&g_notify_lock);
|
||||
}
|
||||
|
||||
const char *plat_data_dir(void) {
|
||||
@@ -183,6 +339,20 @@ const char *plat_ca_bundle(void) {
|
||||
|
||||
const char *plat_name(void) { return plat_info()->name; }
|
||||
|
||||
/* libSceSystemService, the same on both consoles (OpenOrbis SystemService.h). */
|
||||
#define SYSTEM_SERVICE_PARAM_ID_SYSTEM_NAME 6
|
||||
int sceSystemServiceParamGetString(int param_id, char *buf, size_t n);
|
||||
|
||||
void plat_console_name(char *buf, size_t n) {
|
||||
char name[128] = "";
|
||||
buf[0] = 0;
|
||||
if (sceSystemServiceParamGetString(SYSTEM_SERVICE_PARAM_ID_SYSTEM_NAME, name, sizeof name) != 0) return;
|
||||
name[sizeof name - 1] = 0;
|
||||
for (const char *c = name; *c; c++)
|
||||
if ((unsigned char)*c < 0x20) return; /* not a name */
|
||||
snprintf(buf, n, "%s", name);
|
||||
}
|
||||
|
||||
void plat_local_ip(char *buf, size_t n) {
|
||||
struct ifaddrs *ifaddr = NULL;
|
||||
char ip[INET_ADDRSTRLEN];
|
||||
|
||||
@@ -11,7 +11,8 @@
|
||||
#define CONSOLE_PROC_NAME "romm-sync.elf"
|
||||
#define CONSOLE_DATA_DIR "/data/romm-sync"
|
||||
#define CONSOLE_CA_BUNDLE CONSOLE_DATA_DIR "/cacert.pem"
|
||||
#define CONSOLE_ICON CONSOLE_DATA_DIR "/icon0.png"
|
||||
/* Held by the running copy; holds "<version> <pid>". RommPS reads it too. */
|
||||
#define CONSOLE_LOCK_FILE CONSOLE_DATA_DIR "/romm-sync.lock"
|
||||
|
||||
int console_mkdir_p(const char *path);
|
||||
|
||||
@@ -20,8 +21,11 @@ int console_mkdir_p(const char *path);
|
||||
typedef int (*console_proc_cb)(pid_t pid, const char *tdname, void *ctx);
|
||||
int console_for_each_proc(console_proc_cb cb, void *ctx);
|
||||
|
||||
/* Names the main thread CONSOLE_PROC_NAME and kills any older copy. */
|
||||
void console_single_instance(void);
|
||||
/* Makes this the only copy running. A copy holding the lock keeps running if
|
||||
* it's the same version or newer, and 1 is returned: this copy should quit.
|
||||
* Otherwise it's stopped, as is any copy from before the lock, and this one
|
||||
* takes the lock and the name CONSOLE_PROC_NAME. */
|
||||
int console_single_instance(const char *version);
|
||||
|
||||
/* Sends an ELF to a loader listening on 127.0.0.1:port. -1 with errno set. */
|
||||
int console_send_elf(int port, const void *elf, size_t len);
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
/* platform.h for macOS and Linux, used for development and tests.
|
||||
* ROMM_SYNC_DATA sets the data folder (default ./host-data). Writing a title id
|
||||
* to <data>/foreground pretends a game is running. It presents itself as a PS5,
|
||||
* so the end-to-end tests cover the PS5 paths and release assets. */
|
||||
* so the end-to-end tests cover the PS5 paths and release assets.
|
||||
* ROMM_SYNC_MOUNTS=<folder> makes it a console that can mount, with apps in
|
||||
* that folder; plat_mounts_set writes its list to <data>/mounts.txt. */
|
||||
#include <arpa/inet.h>
|
||||
#include <ifaddrs.h>
|
||||
#include <netinet/in.h>
|
||||
@@ -17,10 +19,10 @@
|
||||
|
||||
static char g_data[PATH_MAX_LEN];
|
||||
|
||||
static const char *const HOMEBREW_DIRS[] = {NULL}; /* ROMM_SYNC_HOMEBREW */
|
||||
static const char *HOMEBREW_DIRS[] = {NULL, NULL}; /* ROMM_SYNC_HOMEBREW; ROMM_SYNC_MOUNTS */
|
||||
static const char *const EMULATOR_DIRS[] = {NULL};
|
||||
|
||||
static const plat_info_t INFO = {
|
||||
static plat_info_t INFO = {
|
||||
.name = "host",
|
||||
.console = "PlayStation 5",
|
||||
.client = "romm-sync-ps5",
|
||||
@@ -35,12 +37,20 @@ static const plat_info_t INFO = {
|
||||
.retroarch_root = "/data/homebrew/PPSA99169",
|
||||
.mednafen_root = "/data/homebrew/Mednafen",
|
||||
.can_relaunch = 1,
|
||||
.self_update = 1, /* ROMM_SYNC_SELF_UPDATE=0 tests the PS5's way */
|
||||
};
|
||||
|
||||
const plat_info_t *plat_info(void) { return &INFO; }
|
||||
|
||||
int plat_init(void) {
|
||||
signal(SIGPIPE, SIG_IGN);
|
||||
const char *su = getenv("ROMM_SYNC_SELF_UPDATE");
|
||||
if (su && *su) INFO.self_update = atoi(su);
|
||||
const char *mounts = getenv("ROMM_SYNC_MOUNTS");
|
||||
if (mounts && *mounts) {
|
||||
INFO.can_mount = 1;
|
||||
HOMEBREW_DIRS[0] = mounts;
|
||||
}
|
||||
const char *d = getenv("ROMM_SYNC_DATA");
|
||||
str_copy(g_data, sizeof g_data, d && *d ? d : "./host-data");
|
||||
mkdir_p(g_data);
|
||||
@@ -74,9 +84,16 @@ const char *plat_ca_bundle(void) {
|
||||
return c && *c ? c : NULL;
|
||||
}
|
||||
|
||||
int plat_install_tile(int port) {
|
||||
(void)port;
|
||||
return -1;
|
||||
void plat_remove_old_tile(void) {}
|
||||
|
||||
int plat_app_installed(void) {
|
||||
const char *e = getenv("ROMM_SYNC_APP_INSTALLED");
|
||||
return e && *e == '1';
|
||||
}
|
||||
|
||||
void plat_app_version(char *out, size_t n) {
|
||||
const char *e = getenv("ROMM_SYNC_APP_VERSION");
|
||||
str_copy(out, n, e ? e : "");
|
||||
}
|
||||
|
||||
int plat_launch_elf(const void *elf, size_t len) {
|
||||
@@ -85,6 +102,19 @@ int plat_launch_elf(const void *elf, size_t len) {
|
||||
return write_file_atomic(p, elf, len);
|
||||
}
|
||||
|
||||
int plat_is_mount(const char *path) { (void)path; return 0; }
|
||||
|
||||
void plat_mounts_set(const plat_mount *mounts, int n) {
|
||||
char p[PATH_MAX_LEN], tmp[PATH_MAX_LEN + 8];
|
||||
path_join(p, sizeof p, g_data, "mounts.txt");
|
||||
snprintf(tmp, sizeof tmp, "%s.tmp", p);
|
||||
FILE *f = fopen(tmp, "w");
|
||||
if (!f) return;
|
||||
for (int i = 0; i < n; i++) fprintf(f, "%s|%s|%s\n", mounts[i].title, mounts[i].from, mounts[i].into);
|
||||
fclose(f);
|
||||
rename(tmp, p);
|
||||
}
|
||||
|
||||
const char *plat_name(void) { return INFO.name; }
|
||||
|
||||
void plat_describe(char *buf, size_t n) {
|
||||
@@ -93,6 +123,11 @@ void plat_describe(char *buf, size_t n) {
|
||||
else snprintf(buf, n, "host build on %s %s %s", u.sysname, u.release, u.machine);
|
||||
}
|
||||
|
||||
void plat_console_name(char *buf, size_t n) {
|
||||
const char *env = getenv("ROMM_SYNC_CONSOLE_NAME");
|
||||
snprintf(buf, n, "%s", env ? env : "");
|
||||
}
|
||||
|
||||
void plat_local_ip(char *buf, size_t n) {
|
||||
buf[0] = 0;
|
||||
struct ifaddrs *ifs, *i;
|
||||
|
||||
+337
-57
@@ -1,24 +1,30 @@
|
||||
/* RomM Sync for PS4: the home screen app. The sync itself is the payload,
|
||||
/* RommPS for PS4: the home screen app. The sync itself is the payload,
|
||||
* which GoldHEN runs from /data/payloads (platform/ps4/platform.c); this app
|
||||
* only installs that payload and shows its web UI, so it never needs to stay
|
||||
* running. Built with OpenOrbis (platform/ps4/ps4.mk, "make ps4-pkg").
|
||||
*
|
||||
* When opened it:
|
||||
* 1. copies the payload bundled in the package to /data/payloads when it's
|
||||
* missing or older than the bundled one. Versions come from the
|
||||
* ROMM_SYNC_VERSION= tag in each payload (src/main.c), so an older app
|
||||
* never undoes a newer payload from an update or a manual copy. This app
|
||||
* runs as a regular user, so it only relies on /data/payloads being
|
||||
* writable. Files are read in full: stat() is unreliable in the sandbox;
|
||||
* missing, older, or the same version with different bytes (a test
|
||||
* build). Versions come from the ROMM_SYNC_VERSION= tag in each payload
|
||||
* (src/main.c), so an older app never undoes a newer payload from an
|
||||
* update or a manual copy. Other romm-sync ELFs in /data/payloads (old
|
||||
* names, PS5 builds copied over by mistake) are removed, and an AutoRun
|
||||
* entry for one moves to this payload. This app runs as a regular user,
|
||||
* so it only relies on /data/payloads being writable. Files are read in
|
||||
* full: stat() is unreliable in the sandbox;
|
||||
* 2. checks that RomM Sync answers on its web port;
|
||||
* 3. shows the web UI in the system web browser dialog, or the Browser app
|
||||
* if the dialog won't open; if RomM Sync isn't running, says how to start it.
|
||||
* if the dialog won't open; if RomM Sync isn't running, says why, from
|
||||
* what it left in its logs, and how to start it.
|
||||
*
|
||||
* Each step goes to /data/romm-sync/launcher.log, for bug reports.
|
||||
* Each step goes to /data/romm-sync/launcher.log, for bug reports. NETLOG=1
|
||||
* builds also broadcast it, with the payload's own logs, to tools/netlog.py.
|
||||
*
|
||||
* The web browser dialog has no OpenOrbis types; its layout is Sony's
|
||||
* SceWebBrowserDialogParam as used by Nuvio-PS5 and EVO Player. */
|
||||
#include <arpa/inet.h>
|
||||
#include <dirent.h>
|
||||
#include <netinet/in.h>
|
||||
#include <stdarg.h>
|
||||
#include <stdint.h>
|
||||
@@ -45,9 +51,13 @@
|
||||
#define PAYLOAD_PATH PAYLOAD_DIR "/romm-sync-ps4.elf"
|
||||
#define DATA_DIR "/data/romm-sync"
|
||||
#define LOG_PATH DATA_DIR "/launcher.log"
|
||||
#define BOOT_LOG DATA_DIR "/boot.log" /* the payload's start steps, from its last start */
|
||||
#define PAYLOAD_LOG DATA_DIR "/romm-sync.log"
|
||||
#define AUTORUN_INI "/data/GoldHEN/payloads.ini" /* "[AutoRun]" then "<payload path> = 1" */
|
||||
#define CONFIG_PATH DATA_DIR "/config.json"
|
||||
#define VERSION_TAG "ROMM_SYNC_VERSION="
|
||||
#define DEFAULT_PORT 8780
|
||||
#define NETLOG_PORT 8781 /* ROMM_NETLOG_PORT in src/util.h */
|
||||
|
||||
/* libSceWebBrowserDialog */
|
||||
typedef struct {
|
||||
@@ -77,18 +87,43 @@ int sceWebBrowserDialogTerminate(void);
|
||||
/* OpenOrbis declares it without parameters; this is the real signature. */
|
||||
int launch_web_browser(const char *uri, void *param) __asm__("sceSystemServiceLaunchWebBrowser");
|
||||
|
||||
#ifdef ROMM_NETLOG
|
||||
/* A UDP broadcast per line, as the payload does (src/util.c). */
|
||||
static void netlog(const char *tag, const char *text, size_t len) {
|
||||
static int fd = -1;
|
||||
static struct sockaddr_in to;
|
||||
char pkt[1200];
|
||||
if (fd < 0) {
|
||||
int on = 1;
|
||||
if ((fd = socket(AF_INET, SOCK_DGRAM, 0)) < 0) return;
|
||||
setsockopt(fd, SOL_SOCKET, SO_BROADCAST, &on, sizeof on);
|
||||
memset(&to, 0, sizeof to);
|
||||
to.sin_family = AF_INET;
|
||||
to.sin_port = htons(NETLOG_PORT);
|
||||
to.sin_addr.s_addr = htonl(INADDR_BROADCAST);
|
||||
}
|
||||
int n = snprintf(pkt, sizeof pkt, "[%s] %.*s\n", tag, (int)(len > 1100 ? 1100 : len), text);
|
||||
sendto(fd, pkt, (size_t)n, 0, (struct sockaddr *)&to, sizeof to);
|
||||
usleep(2000); /* a burst of broadcasts gets dropped */
|
||||
}
|
||||
#endif
|
||||
|
||||
static void logf_(const char *fmt, ...) {
|
||||
FILE *f = fopen(LOG_PATH, "a");
|
||||
char ts[32];
|
||||
char ts[32], line[1024];
|
||||
time_t now = time(NULL);
|
||||
va_list ap;
|
||||
if (!f) return;
|
||||
strftime(ts, sizeof ts, "%Y-%m-%d %H:%M:%S", localtime(&now));
|
||||
fprintf(f, "%s ", ts);
|
||||
va_start(ap, fmt);
|
||||
vfprintf(f, fmt, ap);
|
||||
vsnprintf(line, sizeof line, fmt, ap);
|
||||
va_end(ap);
|
||||
fputc('\n', f);
|
||||
strftime(ts, sizeof ts, "%Y-%m-%d %H:%M:%S", localtime(&now));
|
||||
#ifdef ROMM_NETLOG
|
||||
char out[1100];
|
||||
snprintf(out, sizeof out, "%s %s", ts, line);
|
||||
netlog("launcher", out, strlen(out));
|
||||
#endif
|
||||
if (!f) return;
|
||||
fprintf(f, "%s %s\n", ts, line);
|
||||
fclose(f);
|
||||
}
|
||||
|
||||
@@ -107,7 +142,7 @@ static unsigned char *slurp(const char *path, size_t *len) {
|
||||
if (!f) return NULL;
|
||||
do {
|
||||
if (n == cap) {
|
||||
unsigned char *nb = realloc(buf, cap = cap ? cap * 2 : 1 << 20);
|
||||
unsigned char *nb = realloc(buf, cap = cap ? cap * 2 : 64 * 1024);
|
||||
if (!nb) {
|
||||
free(buf);
|
||||
fclose(f);
|
||||
@@ -124,17 +159,60 @@ static unsigned char *slurp(const char *path, size_t *len) {
|
||||
}
|
||||
|
||||
/* The version in a payload's ROMM_SYNC_VERSION= tag, or "" if it has none
|
||||
* (payloads older than the tag). */
|
||||
static void payload_tag(const unsigned char *elf, size_t len, char *out, size_t n) {
|
||||
size_t tl = strlen(VERSION_TAG);
|
||||
* (payloads older than the tag). Read in chunks: a payload is megabytes, and
|
||||
* this app's heap doesn't take a buffer that big well. 0 if it can't be read. */
|
||||
static int payload_tag(const char *path, char *out, size_t n) {
|
||||
static unsigned char buf[64 * 1024 + 128];
|
||||
size_t tl = strlen(VERSION_TAG), keep = 0, got;
|
||||
FILE *f = fopen(path, "rb");
|
||||
out[0] = 0;
|
||||
for (size_t i = 0; i + tl < len; i++) {
|
||||
if (elf[i] != 'R' || memcmp(elf + i, VERSION_TAG, tl) != 0) continue;
|
||||
size_t j = 0;
|
||||
for (const unsigned char *v = elf + i + tl; v < elf + len && *v != '\n' && *v && j + 1 < n; v++) out[j++] = (char)*v;
|
||||
out[j] = 0;
|
||||
return;
|
||||
if (!f) return 0;
|
||||
while ((got = fread(buf + keep, 1, sizeof buf - keep, f)) > 0) {
|
||||
size_t len = keep + got;
|
||||
for (size_t i = 0; i + tl < len; i++) {
|
||||
if (buf[i] != 'R' || memcmp(buf + i, VERSION_TAG, tl) != 0) continue;
|
||||
size_t j = 0;
|
||||
for (const unsigned char *v = buf + i + tl; v < buf + len && *v != '\n' && *v && j + 1 < n; v++)
|
||||
out[j++] = (char)*v;
|
||||
out[j] = 0;
|
||||
fclose(f);
|
||||
return 1;
|
||||
}
|
||||
/* The tail again, in case the tag straddles two reads. */
|
||||
keep = len < 128 ? len : 128;
|
||||
memmove(buf, buf + len - keep, keep);
|
||||
}
|
||||
fclose(f);
|
||||
return 1;
|
||||
}
|
||||
|
||||
/* The two files hold the same bytes. */
|
||||
static int same_file(const char *a, const char *b) {
|
||||
static unsigned char x[64 * 1024], y[64 * 1024];
|
||||
FILE *fa = fopen(a, "rb"), *fb = fopen(b, "rb");
|
||||
int same = fa && fb;
|
||||
size_t na, nb;
|
||||
while (same) {
|
||||
na = fread(x, 1, sizeof x, fa);
|
||||
nb = fread(y, 1, sizeof y, fb);
|
||||
if (na != nb || memcmp(x, y, na) != 0) same = 0;
|
||||
else if (na == 0) break;
|
||||
}
|
||||
if (fa) fclose(fa);
|
||||
if (fb) fclose(fb);
|
||||
return same;
|
||||
}
|
||||
|
||||
/* Copies src to dst; 0 when every byte got there. */
|
||||
static int copy_to(const char *src, const char *dst) {
|
||||
static unsigned char buf[64 * 1024];
|
||||
FILE *in = fopen(src, "rb"), *out = in ? fopen(dst, "wb") : NULL;
|
||||
size_t got;
|
||||
int ok = in && out;
|
||||
while (ok && (got = fread(buf, 1, sizeof buf, in)) > 0) ok = fwrite(buf, 1, got, out) == got;
|
||||
if (in) fclose(in);
|
||||
if (out && fclose(out) != 0) ok = 0;
|
||||
return ok ? 0 : -1;
|
||||
}
|
||||
|
||||
/* "1.2.3" or "1.2.3-ps4-prototype.6": the three numbers, and the last number
|
||||
@@ -161,45 +239,129 @@ static int compare_versions(const int a[4], const int b[4]) {
|
||||
|
||||
/* 1 if installed or replaced, 0 if left as it is, -1 on failure. */
|
||||
static int install_payload(void) {
|
||||
size_t want_len = 0, have_len = 0;
|
||||
unsigned char *want = slurp(BUNDLED_PAYLOAD, &want_len), *have;
|
||||
char want_ver[64], have_ver[64];
|
||||
int wv[4], hv[4], rc = -1;
|
||||
if (!want || want_len == 0) {
|
||||
if (!payload_tag(BUNDLED_PAYLOAD, want_ver, sizeof want_ver)) {
|
||||
logf_("can't read the bundled payload %s", BUNDLED_PAYLOAD);
|
||||
free(want);
|
||||
return -1;
|
||||
}
|
||||
payload_tag(want, want_len, want_ver, sizeof want_ver);
|
||||
have = slurp(PAYLOAD_PATH, &have_len);
|
||||
if (have) payload_tag(have, have_len, have_ver, sizeof have_ver);
|
||||
else have_ver[0] = 0;
|
||||
const int have = payload_tag(PAYLOAD_PATH, have_ver, sizeof have_ver);
|
||||
int replace;
|
||||
if (!have) replace = 1;
|
||||
else if (have_len == want_len && !memcmp(have, want, want_len)) replace = 0;
|
||||
else if (same_file(PAYLOAD_PATH, BUNDLED_PAYLOAD)) replace = 0;
|
||||
else if (!parse_version(want_ver, wv)) replace = 0; /* an untagged test build: never overwrite */
|
||||
else if (!parse_version(have_ver, hv)) replace = 1; /* older than version tags */
|
||||
else replace = compare_versions(wv, hv) > 0;
|
||||
else replace = compare_versions(wv, hv) >= 0; /* the same version can be a different build */
|
||||
if (!replace) {
|
||||
logf_("payload %s left as it is (installed %s, this app has %s)", PAYLOAD_PATH, have_ver[0] ? have_ver : "?",
|
||||
want_ver[0] ? want_ver : "?");
|
||||
rc = 0;
|
||||
} else {
|
||||
FILE *f;
|
||||
mkdir(PAYLOAD_DIR, 0777);
|
||||
if ((f = fopen(PAYLOAD_PATH ".tmp", "wb"))) {
|
||||
int ok = fwrite(want, 1, want_len, f) == want_len;
|
||||
if (fclose(f) == 0 && ok) rc = rename(PAYLOAD_PATH ".tmp", PAYLOAD_PATH) == 0 ? 1 : -1;
|
||||
}
|
||||
logf_("%s %s with %s (was %s): %s", have ? "replaced" : "installed", PAYLOAD_PATH,
|
||||
want_ver[0] ? want_ver : "?", have ? (have_ver[0] ? have_ver : "untagged") : "missing",
|
||||
rc == 1 ? "ok" : "failed");
|
||||
return 0;
|
||||
}
|
||||
free(want);
|
||||
free(have);
|
||||
mkdir(PAYLOAD_DIR, 0777);
|
||||
if (copy_to(BUNDLED_PAYLOAD, PAYLOAD_PATH ".tmp") == 0) rc = rename(PAYLOAD_PATH ".tmp", PAYLOAD_PATH) == 0 ? 1 : -1;
|
||||
logf_("%s %s with %s (was %s): %s", have ? "replaced" : "installed", PAYLOAD_PATH, want_ver[0] ? want_ver : "?",
|
||||
have ? (have_ver[0] ? have_ver : "untagged") : "missing", rc == 1 ? "ok" : "failed");
|
||||
return rc;
|
||||
}
|
||||
|
||||
/* Points an AutoRun entry for a removed copy at this payload, unless it's
|
||||
* already queued. GoldHEN only reads the file at jailbreak, so this takes
|
||||
* effect on the next one. */
|
||||
static void move_autorun(const char *name) {
|
||||
size_t len = 0;
|
||||
char *ini = (char *)slurp(AUTORUN_INI, &len), *at, path[300];
|
||||
if (!ini) return;
|
||||
ini = realloc(ini, len + 1);
|
||||
ini[len] = 0;
|
||||
snprintf(path, sizeof path, PAYLOAD_DIR "/%s", name);
|
||||
if ((at = strstr(ini, path)) && !strstr(ini, "romm-sync-ps4.elf")) {
|
||||
FILE *f = fopen(AUTORUN_INI ".tmp", "wb");
|
||||
int ok = f != NULL;
|
||||
if (f) {
|
||||
ok = fwrite(ini, 1, (size_t)(at - ini), f) == (size_t)(at - ini) && fputs(PAYLOAD_PATH, f) >= 0 &&
|
||||
fputs(at + strlen(path), f) >= 0;
|
||||
if (fclose(f) != 0) ok = 0;
|
||||
}
|
||||
ok = ok && rename(AUTORUN_INI ".tmp", AUTORUN_INI) == 0;
|
||||
logf_("AutoRun entry for %s moved to %s: %s", path, PAYLOAD_PATH, ok ? "ok" : "failed");
|
||||
}
|
||||
free(ini);
|
||||
}
|
||||
|
||||
/* Other romm-sync ELFs in /data/payloads: older names, a PS5 build, a test
|
||||
* build's leftovers. GoldHEN lists them all in its payload menu, and running
|
||||
* the wrong one looks just like RomM Sync failing to start. The payload's own
|
||||
* update in progress (romm-sync-ps4.elf.tmp) is left alone. */
|
||||
static void remove_stale_payloads(void) {
|
||||
DIR *d = opendir(PAYLOAD_DIR);
|
||||
struct dirent *e;
|
||||
char names[16][256];
|
||||
int n = 0;
|
||||
if (!d) {
|
||||
logf_("can't list %s", PAYLOAD_DIR);
|
||||
return;
|
||||
}
|
||||
while ((e = readdir(d)) && n < 16) {
|
||||
size_t l = strlen(e->d_name);
|
||||
if (strncmp(e->d_name, "romm-sync", 9) != 0 || !strcmp(e->d_name, "romm-sync-ps4.elf") ||
|
||||
!strcmp(e->d_name, "romm-sync-ps4.elf.tmp"))
|
||||
continue;
|
||||
if (!(l > 4 && !strcmp(e->d_name + l - 4, ".elf")) && !(l > 8 && !strcmp(e->d_name + l - 8, ".elf.tmp"))) continue;
|
||||
snprintf(names[n++], sizeof names[0], "%s", e->d_name);
|
||||
}
|
||||
closedir(d);
|
||||
for (int i = 0; i < n; i++) {
|
||||
char path[300];
|
||||
snprintf(path, sizeof path, PAYLOAD_DIR "/%s", names[i]);
|
||||
int rc = unlink(path);
|
||||
logf_("removed %s: %s", path, rc == 0 ? "ok" : "failed");
|
||||
if (rc == 0) move_autorun(names[i]);
|
||||
}
|
||||
}
|
||||
|
||||
#ifdef ROMM_NETLOG
|
||||
/* What a bug report would carry, sent line by line: the payload's last start,
|
||||
* its log, GoldHEN's AutoRun queue and what's in /data/payloads. */
|
||||
static char *slurp_text(const char *path);
|
||||
|
||||
static void netlog_file(const char *path, size_t tail) {
|
||||
char *t = slurp_text(path), *p, *end;
|
||||
size_t len = t ? strlen(t) : 0;
|
||||
char head[300];
|
||||
snprintf(head, sizeof head, "----- %s (%s) -----", path, t ? "" : "missing");
|
||||
netlog("file", head, strlen(head));
|
||||
if (!t) return;
|
||||
p = len > tail ? (char *)memchr(t + len - tail, '\n', tail) : t;
|
||||
p = p ? (p == t ? t : p + 1) : t;
|
||||
for (end = t + len; p < end;) {
|
||||
char *nl = memchr(p, '\n', (size_t)(end - p));
|
||||
size_t l = nl ? (size_t)(nl - p) : (size_t)(end - p);
|
||||
if (l) netlog("file", p, l);
|
||||
p += l + 1;
|
||||
}
|
||||
free(t);
|
||||
}
|
||||
|
||||
static void netlog_report(void) {
|
||||
char line[400], ver[64];
|
||||
DIR *d = opendir(PAYLOAD_DIR);
|
||||
struct dirent *e;
|
||||
netlog("file", "----- " PAYLOAD_DIR " -----", strlen("----- " PAYLOAD_DIR " -----"));
|
||||
while (d && (e = readdir(d))) {
|
||||
char path[300];
|
||||
if (e->d_name[0] == '.') continue;
|
||||
snprintf(path, sizeof path, PAYLOAD_DIR "/%s", e->d_name);
|
||||
payload_tag(path, ver, sizeof ver);
|
||||
snprintf(line, sizeof line, "%s %s", e->d_name, ver[0] ? ver : "(no version tag)");
|
||||
netlog("file", line, strlen(line));
|
||||
}
|
||||
if (d) closedir(d);
|
||||
netlog_file(AUTORUN_INI, 4096);
|
||||
netlog_file(BOOT_LOG, 8192);
|
||||
netlog_file(PAYLOAD_LOG, 64 * 1024);
|
||||
}
|
||||
#endif
|
||||
|
||||
/* The web_port from RomM Sync's config.json, if it was changed. */
|
||||
static int web_port(void) {
|
||||
size_t len = 0;
|
||||
@@ -241,6 +403,117 @@ static int payload_version(int port, char *version, size_t n) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
/* The end of a text file, at most TEXT_TAIL bytes of it (RomM Sync's log
|
||||
* only grows, and its last start is near the end). Read straight through:
|
||||
* sizes can't be trusted in the sandbox. */
|
||||
#define TEXT_TAIL (256 * 1024)
|
||||
static char *slurp_text(const char *path) {
|
||||
FILE *f = fopen(path, "rb");
|
||||
char *t = f ? malloc(TEXT_TAIL + 64 * 1024 + 1) : NULL;
|
||||
size_t len = 0, got;
|
||||
if (!t) {
|
||||
if (f) fclose(f);
|
||||
return NULL;
|
||||
}
|
||||
while ((got = fread(t + len, 1, 64 * 1024, f)) > 0) {
|
||||
len += got;
|
||||
if (len > TEXT_TAIL) { /* keep the newest TEXT_TAIL bytes */
|
||||
memmove(t, t + len - TEXT_TAIL, TEXT_TAIL);
|
||||
len = TEXT_TAIL;
|
||||
}
|
||||
}
|
||||
fclose(f);
|
||||
t[len] = 0;
|
||||
return t;
|
||||
}
|
||||
|
||||
/* GoldHEN runs the payload on every jailbreak: it's in the AutoRun queue. */
|
||||
static int in_autorun(void) {
|
||||
char *ini = slurp_text(AUTORUN_INI), *line;
|
||||
int on = 0;
|
||||
for (line = ini ? strstr(ini, "romm-sync-ps4.elf") : NULL; line; line = strstr(line + 1, "romm-sync-ps4.elf")) {
|
||||
char *eq = strchr(line, '='), *eol = strchr(line, '\n');
|
||||
if (eq && (!eol || eq < eol) && atoi(eq + 1) == 1) on = 1;
|
||||
}
|
||||
free(ini);
|
||||
return on;
|
||||
}
|
||||
|
||||
/* The part of RomM Sync's log since it last started, or NULL. */
|
||||
static char *last_start(char *log) {
|
||||
char *since = NULL;
|
||||
for (char *p = log; p && (p = strstr(p, " starting (")); p++) since = p;
|
||||
if (since)
|
||||
while (since > log && since[-1] != '\n') since--;
|
||||
return since;
|
||||
}
|
||||
|
||||
/* RomM Sync started and hasn't opened its web page yet, nor failed: it's
|
||||
* still starting (or stuck doing so). */
|
||||
static int starting(void) {
|
||||
char *boot = slurp_text(BOOT_LOG), *log = slurp_text(PAYLOAD_LOG), *since = last_start(log);
|
||||
int yes = boot && strstr(boot, "platform ready") && !strstr(boot, "already running") && since &&
|
||||
!strstr(since, "web UI on") && !strstr(since, "crashed with signal") && !strstr(since, "cannot listen on port");
|
||||
free(boot);
|
||||
free(log);
|
||||
return yes;
|
||||
}
|
||||
|
||||
/* Why RomM Sync isn't answering, from what it left behind: its start steps
|
||||
* (boot.log, rewritten at each start) and its log since it last started. */
|
||||
static void diagnose(int installed, int port, char *out, size_t n) {
|
||||
char *boot = slurp_text(BOOT_LOG), *log = slurp_text(PAYLOAD_LOG), *since = last_start(log), when[20] = "";
|
||||
const int autorun = in_autorun();
|
||||
const char *how = autorun ? "In GoldHEN Settings > Payloader, run romm-sync-ps4.elf, then open RommPS again."
|
||||
: "In GoldHEN Settings > Payloader, run romm-sync-ps4.elf and add it to the AutoRun queue. "
|
||||
"Then open RommPS again.";
|
||||
logf_("in GoldHEN's AutoRun queue: %s", autorun ? "yes" : "no");
|
||||
if (since) snprintf(when, sizeof when, "%.19s", since); /* the line's "2026-10-16 09:30:00" */
|
||||
if (boot && strstr(boot, "already running")) {
|
||||
logf_("diagnosis: a copy holds the lock but doesn't answer");
|
||||
snprintf(out, n, "RommPS's sync service is stuck. Restart the console, jailbreak, and open RommPS again.");
|
||||
} else if (since && strstr(since, "crashed with signal")) {
|
||||
logf_("diagnosis: crashed after starting at %s", when);
|
||||
snprintf(out, n,
|
||||
"RommPS's sync service stopped with an error (started %s).\n\nRestart the console and jailbreak again. "
|
||||
"If it keeps happening, send /data/romm-sync/romm-sync.log with a bug report.",
|
||||
when);
|
||||
} else if (since && strstr(since, "cannot listen on port")) {
|
||||
logf_("diagnosis: port %d is taken", port);
|
||||
snprintf(out, n,
|
||||
"RommPS's sync service is running, but another app uses port %d.\n\nClose other homebrew servers, "
|
||||
"or change web_port in /data/romm-sync/config.json.",
|
||||
port);
|
||||
} else if (since && !strstr(since, "web UI on")) {
|
||||
const char *last = since + strlen(since);
|
||||
while (last > since && last[-1] == '\n') last--;
|
||||
const char *bol = last;
|
||||
while (bol > since && bol[-1] != '\n') bol--;
|
||||
logf_("diagnosis: started at %s, no web page yet; it last logged: %.*s", when, (int)(last - bol), bol);
|
||||
snprintf(out, n, "RommPS's sync service started (%s) but hasn't opened its web page yet.\n\nGive it a moment "
|
||||
"and open RommPS again. If this keeps happening, send /data/romm-sync/romm-sync.log with a bug "
|
||||
"report.", when);
|
||||
} else if (since) {
|
||||
logf_("diagnosis: last started at %s, not running now", when);
|
||||
if (autorun)
|
||||
snprintf(out, n, "RommPS's sync service isn't running. It's in GoldHEN's AutoRun queue but didn't start "
|
||||
"this time (last start %s).\n\n%s", when, how);
|
||||
else
|
||||
snprintf(out, n, "RommPS's sync service isn't running. It last started %s, and won't start after a reboot "
|
||||
"unless it's in GoldHEN's AutoRun queue.\n\n%s", when, how);
|
||||
} else if (boot) {
|
||||
logf_("diagnosis: started but stopped before its log opened");
|
||||
snprintf(out, n, "RommPS's sync service started but stopped right away.\n\nRestart the console and jailbreak "
|
||||
"again. If it keeps happening, send /data/romm-sync/boot.log with a bug report.");
|
||||
} else {
|
||||
logf_("diagnosis: never started");
|
||||
snprintf(out, n, "RommPS's sync service hasn't been started yet.\n\n%s%s",
|
||||
installed > 0 ? "It's now in /data/payloads. " : "", how);
|
||||
}
|
||||
free(boot);
|
||||
free(log);
|
||||
}
|
||||
|
||||
static void message(const char *text) {
|
||||
OrbisMsgDialogParam param;
|
||||
OrbisMsgDialogUserMessageParam user;
|
||||
@@ -312,40 +585,47 @@ static void show_web_ui(const char *url) {
|
||||
logf_("Browser app: 0x%08x", (unsigned)rc);
|
||||
if (rc != 0) {
|
||||
char text[256];
|
||||
snprintf(text, sizeof text, "Open %s in the web browser to use RomM Sync.", url);
|
||||
snprintf(text, sizeof text, "Open %s in the web browser to use RommPS.", url);
|
||||
message(text);
|
||||
}
|
||||
}
|
||||
|
||||
int main(void) {
|
||||
char url[64], running[32], text[512];
|
||||
char url[64], running[32], text[640];
|
||||
int installed, port;
|
||||
|
||||
sceSystemServiceHideSplashScreen();
|
||||
mkdir(DATA_DIR, 0777);
|
||||
logf_("RomM Sync launcher %s", APP_VERSION);
|
||||
logf_("RommPS launcher %s", APP_VERSION);
|
||||
sceUserServiceInitialize(NULL);
|
||||
sceSysmoduleLoadModule(ORBIS_SYSMODULE_MESSAGE_DIALOG);
|
||||
sceCommonDialogInitialize();
|
||||
|
||||
installed = install_payload();
|
||||
/* Only with ours in place: AutoRun entries move to it. */
|
||||
if (installed >= 0 && access(PAYLOAD_PATH, F_OK) == 0) remove_stale_payloads();
|
||||
#ifdef ROMM_NETLOG
|
||||
netlog_report();
|
||||
#endif
|
||||
port = web_port();
|
||||
snprintf(url, sizeof url, "http://127.0.0.1:%d/", port);
|
||||
int up = payload_version(port, running, sizeof running);
|
||||
/* Opened just after GoldHEN launched it: give it time to open its web page. */
|
||||
for (int i = 0; !up && i < 20 && starting(); i++) {
|
||||
if (i == 0) logf_("RomM Sync is starting; waiting for it");
|
||||
usleep(500 * 1000);
|
||||
up = payload_version(port, running, sizeof running);
|
||||
}
|
||||
logf_("RomM Sync on port %d: %s%s", port, up ? "running " : "not running", running);
|
||||
|
||||
if (installed < 0) {
|
||||
message("RomM Sync couldn't copy its payload to /data/payloads. Copy romm-sync-ps4.elf there yourself over FTP, then run it from GoldHEN Settings > Payloader.");
|
||||
message("RommPS couldn't copy its sync service to /data/payloads. Copy romm-sync-ps4.elf there over FTP, then run it from GoldHEN Settings > Payloader.");
|
||||
} else if (!up) {
|
||||
snprintf(text, sizeof text,
|
||||
"RomM Sync isn't running.\n\n%sIn GoldHEN Settings > Payloader, run romm-sync-ps4.elf, then add it to the "
|
||||
"AutoRun queue so it starts on every jailbreak. Open this app again afterwards.",
|
||||
installed ? "Its payload is now in /data/payloads. " : "");
|
||||
diagnose(installed, port, text, sizeof text);
|
||||
message(text);
|
||||
} else {
|
||||
if (installed)
|
||||
message("A new version of RomM Sync was copied to /data/payloads. It takes over after the next jailbreak; "
|
||||
"until then the running version stays in use.");
|
||||
message("A new version of RommPS's sync service is in /data/payloads. It takes over after the next jailbreak.");
|
||||
show_web_ui(url);
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,10 @@
|
||||
FROM --platform=linux/amd64 ubuntu:24.04
|
||||
|
||||
ARG SDK_VERSION=v0.9
|
||||
# The SDK's startup code (crt1.o) from a later commit: v0.9 can't start on
|
||||
# firmware 13.52 or 14.00, whose kernel offsets came after it (crt/kernel.c,
|
||||
# crt/patch.c). Drop this once a release has them.
|
||||
ARG SDK_CRT_COMMIT=573b4a0d0d5fa8695cfeee5f5e2f0447c550035d
|
||||
ARG PACBREW_VERSION=v0.3.3
|
||||
ARG OPENORBIS_VERSION=v0.5.4
|
||||
|
||||
@@ -21,6 +25,11 @@ RUN wget -q -O /tmp/sdk.zip \
|
||||
&& rm /tmp/sdk.zip \
|
||||
&& chmod +x /opt/ps4-payload-sdk/bin/*
|
||||
|
||||
RUN git clone -q --filter=blob:none https://github.com/ps4-payload-dev/sdk /tmp/sdk-src \
|
||||
&& git -C /tmp/sdk-src checkout -q ${SDK_CRT_COMMIT} \
|
||||
&& make -C /tmp/sdk-src/crt LLVM_CONFIG=llvm-config-18 >/dev/null \
|
||||
&& install -m 644 /tmp/sdk-src/crt/crt1.o /opt/ps4-payload-sdk/target/lib/crt1.o \
|
||||
&& rm -rf /tmp/sdk-src
|
||||
# Libraries land in the SDK's target/user/homebrew, next to the SDK itself.
|
||||
RUN wget -q -O /tmp/pb.tar.gz \
|
||||
https://github.com/ps4-payload-dev/pacbrew/releases/download/${PACBREW_VERSION}/ps4-payload-dev.tar.gz \
|
||||
|
||||
+115
-7
@@ -3,7 +3,10 @@
|
||||
"id": "psnes", "name": "pSNES", "title_ids": ["PSNE00001"], "detect": ["/data/psnes"],
|
||||
"profile": {
|
||||
"root": "/data/psnes", "rom_dir": "{root}/roms", "save_dir": "{root}/sram", "bios_dir": "{root}/bios",
|
||||
"save_exts": ".srm", "state_dir": "{root}/saves", "state_exts": "",
|
||||
"save_exts": ".srm",
|
||||
"state_dir": "{root}/saves", "state_exts": ".state", "state_format": "raw",
|
||||
"state_stems": "{rom_stem}-0,{rom_stem}-1,{rom_stem}-2,{rom_stem}-3", "state_name": "{rom_stem}-0{ext}",
|
||||
"firmware": {"from": {"satellaview": ["BS-X.bin"], "sufami-turbo": ["STBIOS.bin"]}},
|
||||
"platforms": [{"romm": ["snes", "sfam"], "dir": "snes"}]
|
||||
}
|
||||
},
|
||||
@@ -11,7 +14,10 @@
|
||||
"id": "pnes", "name": "pNES", "title_ids": ["PNES00001"], "detect": ["/data/pnes"],
|
||||
"profile": {
|
||||
"root": "/data/pnes", "rom_dir": "{root}/roms", "save_dir": "{root}/save", "bios_dir": "{root}/bios",
|
||||
"save_exts": ".sav", "state_dir": "{root}/saves", "state_exts": "",
|
||||
"save_exts": ".sav",
|
||||
"state_dir": "{root}/saves", "state_exts": ".state", "state_format": "raw",
|
||||
"state_stems": "{rom_stem}-0,{rom_stem}-1,{rom_stem}-2,{rom_stem}-3", "state_name": "{rom_stem}-0{ext}",
|
||||
"firmware": {"dir": "{root}"},
|
||||
"platforms": [{"romm": ["nes", "famicom"], "dir": "nes"}, {"romm": ["fds"], "dir": "fds"}]
|
||||
}
|
||||
},
|
||||
@@ -19,7 +25,9 @@
|
||||
"id": "pgen", "name": "pGEN", "title_ids": ["PGEN00001"], "detect": ["/data/pgen"],
|
||||
"profile": {
|
||||
"root": "/data/pgen", "rom_dir": "{root}/{platform}", "save_dir": "{root}/rams", "bios_dir": "{root}/bios",
|
||||
"save_exts": ".srm", "state_dir": "{root}/saves", "state_exts": "",
|
||||
"save_exts": ".srm",
|
||||
"state_dir": "{root}/saves", "state_exts": ".state", "state_format": "raw",
|
||||
"state_stems": "{rom_stem}-0,{rom_stem}-1,{rom_stem}-2,{rom_stem}-3", "state_name": "{rom_stem}-0{ext}",
|
||||
"platforms": [
|
||||
{"romm": ["genesis", "genesis-slash-megadrive", "megadrive"], "dir": "megadrive"},
|
||||
{"romm": ["sms", "mastersystem"], "dir": "mastersystem"},
|
||||
@@ -31,9 +39,12 @@
|
||||
},
|
||||
{
|
||||
"id": "pgba", "name": "pGBA", "title_ids": ["PGBA00001"], "detect": ["/data/pgba"],
|
||||
"note": "Saves and states share the saves folder.",
|
||||
"profile": {
|
||||
"root": "/data/pgba", "rom_dir": "{root}/{platform}", "save_dir": "{root}/saves", "bios_dir": "{root}/bios",
|
||||
"save_exts": ".sav", "state_dir": "{root}/saves", "state_exts": "",
|
||||
"save_exts": ".sav",
|
||||
"state_dir": "{root}/saves", "state_exts": ".state", "state_format": "raw",
|
||||
"state_stems": "{rom_stem}-0,{rom_stem}-1,{rom_stem}-2,{rom_stem}-3", "state_name": "{rom_stem}-0{ext}",
|
||||
"platforms": [
|
||||
{"romm": ["gba"], "dir": "gba"},
|
||||
{"romm": ["gbc"], "dir": "gbc"},
|
||||
@@ -43,10 +54,107 @@
|
||||
},
|
||||
{
|
||||
"id": "pfbn", "name": "pFBN", "title_ids": ["PFBN00001"], "detect": ["/data/pfba"],
|
||||
"note": "Arcade saves are the games' own memory (eeproms), backed up as they are.",
|
||||
"profile": {
|
||||
"root": "/data/pfba", "rom_dir": "{root}/arcade", "save_dir": "{root}/eeproms", "bios_dir": "{root}/arcade",
|
||||
"save_exts": ".fs", "state_dir": "{root}/saves", "state_exts": "",
|
||||
"platforms": [{"romm": ["arcade", "fbneo", "neogeoaes", "neogeomvs", "cps1", "cps2", "cps3"], "dir": "arcade"}]
|
||||
"root": "/data/pfba", "rom_dir": "{root}/{platform}", "save_dir": "{root}/eeproms", "bios_dir": "{root}/arcade",
|
||||
"save_exts": ".fs",
|
||||
"state_dir": "{root}/saves", "state_exts": ".state", "state_format": "raw",
|
||||
"state_stems": "{rom_stem}-0,{rom_stem}-1,{rom_stem}-2,{rom_stem}-3", "state_name": "{rom_stem}-0{ext}",
|
||||
"firmware": {"dir": "{rom_dir}"},
|
||||
"platforms": [
|
||||
{"romm": ["arcade", "fbneo", "neogeoaes", "neogeomvs", "cps1", "cps2", "cps3"], "dir": "arcade"},
|
||||
{"romm": ["neo-geo-pocket-color", "neo-geo-pocket", "ngpc", "ngp"], "dir": "ngp"},
|
||||
{"romm": ["colecovision"], "dir": "coleco"},
|
||||
{"romm": ["msx"], "dir": "msx"},
|
||||
{"romm": ["zxs", "zx-spectrum"], "dir": "spectrum"},
|
||||
{"romm": ["supergrafx"], "dir": "sgx"},
|
||||
{"romm": ["channel-f", "fairchild-channel-f"], "dir": "channelf"}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "emusuni", "name": "EmuSuNi", "title_ids": ["EMSN00001"], "detect": ["/data/EmuSuNi"],
|
||||
"note": "Set EmuSuNi's N64 folder to /data/EmuSuNi/n64 in its settings.",
|
||||
"profile": {
|
||||
"root": "/data/EmuSuNi", "bios_dir": "{root}/system",
|
||||
"save_exts": ".srm",
|
||||
"state_exts": ".state*", "state_format": "raw", "state_slot0": ".state1",
|
||||
"platforms": [
|
||||
{"romm": ["snes", "sfam"], "dir": "snes", "rom_dir": "{root}/games", "save_dir": "{root}/saves", "state_dir": "{root}/states"},
|
||||
{"romm": ["nes", "famicom"], "dir": "nes", "rom_dir": "{root}/nes", "save_dir": "{root}/saves/nes", "state_dir": "{root}/states/nes"},
|
||||
{"romm": ["n64"], "dir": "n64", "rom_dir": "{root}/n64", "save_dir": "{root}/saves/n64", "state_dir": "{root}/states/n64", "save_format": "n64-srm", "cards": {"default": "per_game", "modes": "per_game"}}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "dolphinps4", "name": "DolphinPS4", "title_ids": ["DLPH00010"], "detect": ["/data/DolphinPS4"],
|
||||
"note": "Dolphin, with its user folder in {root}/User. States are named after the disc's id.",
|
||||
"profile": {
|
||||
"root": "/data/DolphinPS4", "rom_dir": "{root}/games", "save_dir": "{root}/User",
|
||||
"state_dir": "{root}/User/StateSaves", "state_exts": ".s0*", "state_format": "raw",
|
||||
"state_stems": "{game_id}", "state_name": "{game_id}{ext}",
|
||||
"firmware": {"dir": "{root}/User/GC", "only_named": true, "names": {"b17148254a5799684c7d783206504926": "USA/IPL.bin", "fc924a7c879b661abc37cec4f018fdf3": "USA/IPL.bin", "019e39822a9ca3029124f74dd4d55ac4": "USA/IPL.bin", "db92574caab77a7ec99d4605fd6f2450": "EUR/IPL.bin", "339848a0b7c2124cf155276c1e79cbd0": "EUR/IPL.bin", "0cdda509e2da83c85bfe423dd87346cc": "EUR/IPL.bin"}},
|
||||
"platforms": [
|
||||
{"romm": ["ngc", "gamecube"], "dir": "gamecube", "emulator": "dolphin", "save_layout": "gci", "bundle_dirs": "{root}/User/GC", "cards": {"default": "per_game", "modes": "per_game"}},
|
||||
{"romm": ["wii"], "dir": "wii", "emulator": "dolphin", "save_layout": "wii", "bundle_dirs": "{root}/User/Wii"}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "resonance", "name": "Resonance", "title_ids": ["DCST00001"], "detect": ["/data/Resonance"],
|
||||
"note": "Each game's own memory card syncs as its save; the shared ones are backed up as RomM memory cards.",
|
||||
"profile": {
|
||||
"root": "/data/Resonance", "rom_dir": "{root}/games", "save_dir": "{root}/data", "bios_dir": "{root}/data",
|
||||
"save_exts": ".bin", "disc_id": "dreamcast",
|
||||
"save_stems": "{game_id}_vmu_save_A1", "save_name": "{game_id}_vmu_save_A1{ext}",
|
||||
"memcards": "{root}/data/vmu_save_A1.bin,{root}/data/vmu_save_B1.bin,{root}/data/vmu_save_C1.bin,{root}/data/vmu_save_D1.bin",
|
||||
"state_dir": "{root}/data", "state_exts": ".state", "state_format": "raw",
|
||||
"state_stems": "{rom_stem}_1,{rom_stem}_2,{rom_stem}_3", "state_name": "{rom_stem}{ext}",
|
||||
"firmware": {"names": {"e10c53c2f8b90bab96ead2d368858623": "dc_boot.bin"}, "from": {"arcade": ["naomi.zip", "naomi2.zip", "awbios.zip"]}},
|
||||
"platforms": [{"romm": ["dc", "dreamcast"], "dir": "dreamcast", "save_format": "dc-vmu", "cards": {"default": "per_game", "modes": "per_game,shared", "change": "Turn on Own memory card for each game in its settings (players 2 to 4 always share theirs)", "setting": {"file": "{root}/emu.cfg", "key": "PerGameVmu", "per_game": "yes,true", "shared": "no,false"}}}]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "ps4sx2", "name": "PS4SX2", "title_ids": ["PCSX20007"], "detect": ["/data/PS4SX2"],
|
||||
"note": "Two memory cards for every game, backed up to RomM (5.3 or newer). No save states yet.",
|
||||
"profile": {
|
||||
"root": "/data/PS4SX2", "rom_dir": "{root}/roms", "bios_dir": "{root}/bios",
|
||||
"sync_saves": false, "memcards": "{root}/memcards/Mcd001.ps2,{root}/memcards/Mcd002.ps2",
|
||||
"firmware": {"pick_one": ["21038400dc633070a78ad53090c53017", "dc69f0643a3030aaa4797501b483d6c4", "80ac46fa7e77b8ab4366e86948e54f83", "93ea3bcee4252627919175ff1b16a1d9", "d3e81e95db25f5a86a7b7474550a2155"]},
|
||||
"platforms": [{"romm": ["ps2"], "dir": "ps2", "cards": {"default": "shared", "modes": "shared"}}]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "nps64", "name": "NPS64", "title_ids": ["NPSX00064"], "detect": ["/data/NPS64"],
|
||||
"note": "Saves and states are named after a hash of the game file (.z64 dumps).",
|
||||
"profile": {
|
||||
"root": "/data/NPS64", "rom_dir": "{root}/games", "save_dir": "{root}/saves", "state_dir": "{root}/saves",
|
||||
"bios_dir": "{root}/system", "fixed": ["rom_dir", "save_dir", "state_dir"],
|
||||
"save_exts": ".srm", "save_name": "rom-{rom_fnv64}{ext}", "save_stems": "rom-{rom_fnv64}",
|
||||
"state_exts": ".state", "state_stems": "rom-{rom_fnv64}", "state_name": "rom-{rom_fnv64}.slot1{ext}", "state_format": "raw",
|
||||
"firmware": {"skip": true},
|
||||
"platforms": [{"romm": ["n64"], "dir": "n64", "save_format": "n64-srm", "cards": {"default": "per_game", "modes": "per_game"}}]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "mgbaps4", "name": "mGBA", "title_ids": ["MGBA00001"], "detect": ["/data/mgba"],
|
||||
"profile": {
|
||||
"root": "/data/mgba", "rom_dir": "{root}/roms", "save_dir": "{root}/saves", "state_dir": "{root}/states",
|
||||
"save_exts": ".sav", "state_exts": ".ss*", "state_slot0": ".ss1", "state_format": "raw",
|
||||
"platforms": [
|
||||
{"romm": ["gba"], "dir": "gba"},
|
||||
{"romm": ["gbc"], "dir": "gbc"},
|
||||
{"romm": ["gb"], "dir": "gb"}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "snesui", "name": "SNESUI", "title_ids": ["SNES00001", "SNES00009"], "detect": ["/data/snes-ps4"],
|
||||
"profile": {
|
||||
"root": "/data/snes-ps4", "rom_dir": "{root}/juegos", "save_dir": "{root}/guardados", "state_dir": "{root}/guardados",
|
||||
"save_exts": ".srm", "state_exts": ".st0,.st1,.st2,.st3", "state_slot0": ".st0", "state_format": "raw",
|
||||
"firmware": {"skip": true},
|
||||
"platforms": [{"romm": ["snes", "sfam"], "dir": "snes"}]
|
||||
}
|
||||
}
|
||||
]
|
||||
+20
-10
@@ -24,9 +24,10 @@
|
||||
#include <sys/stat.h>
|
||||
|
||||
#include "console.h"
|
||||
#include "util.h"
|
||||
#include "emulators_json.h" /* generated from platform/ps4/emulators.json */
|
||||
|
||||
#define LOCK_FILE CONSOLE_DATA_DIR "/romm-sync.lock"
|
||||
#define LOCK_FILE CONSOLE_LOCK_FILE
|
||||
|
||||
|
||||
/* libkernel: layout from sdk/samples/ps/main.c (title_id is wider than the PS5's) */
|
||||
@@ -46,7 +47,8 @@ static const char *const HOMEBREW_DIRS[] = {
|
||||
"/data/homebrew", "/mnt/usb0/homebrew", "/mnt/usb1/homebrew", NULL};
|
||||
/* RetroArch keeps its config, saves and core .info files here; the cores
|
||||
* themselves are in /data/self/retroarch/cores (see retroarch.cfg). */
|
||||
static const char *const EMULATOR_DIRS[] = {"/data/retroarch", NULL};
|
||||
/* RetroArch builds: OsirizX's (/data/retroarch) and gen04177's RArch (/data/rarch). */
|
||||
static const char *const EMULATOR_DIRS[] = {"/data/retroarch", "/data/rarch", NULL};
|
||||
|
||||
static const plat_info_t INFO = {
|
||||
.name = "ps4",
|
||||
@@ -67,10 +69,9 @@ static const plat_info_t INFO = {
|
||||
.retroarch_root = "/data/retroarch",
|
||||
.mednafen_root = "/data/mednafen",
|
||||
.emulator_catalog = EMULATORS_JSON,
|
||||
/* PS4 tiles need an installed package; there's no URL shortcut. */
|
||||
.has_tile = 0,
|
||||
/* GoldHEN runs the payload on a thread of its own process. */
|
||||
.detach = 1,
|
||||
.self_update = 1,
|
||||
};
|
||||
|
||||
const plat_info_t *plat_info(void) { return &INFO; }
|
||||
@@ -80,12 +81,14 @@ const plat_info_t *plat_info(void) { return &INFO; }
|
||||
* hangs or dies inside the loader still says how far it got. */
|
||||
static void boot_step(const char *fmt, ...) {
|
||||
FILE *f = fopen(CONSOLE_DATA_DIR "/boot.log", "a");
|
||||
char line[256] = "boot: ";
|
||||
va_list ap;
|
||||
if (!f) return;
|
||||
va_start(ap, fmt);
|
||||
vfprintf(f, fmt, ap);
|
||||
vsnprintf(line + 6, sizeof line - 6, fmt, ap);
|
||||
va_end(ap);
|
||||
fputc('\n', f);
|
||||
netlog_send(line);
|
||||
if (!f) return;
|
||||
fprintf(f, "%s\n", line + 6);
|
||||
fclose(f);
|
||||
}
|
||||
|
||||
@@ -148,9 +151,16 @@ void plat_describe(char *buf, size_t n) {
|
||||
console_describe(buf, n, host);
|
||||
}
|
||||
|
||||
int plat_install_tile(int port) {
|
||||
(void)port;
|
||||
return -1;
|
||||
void plat_remove_old_tile(void) {}
|
||||
|
||||
int plat_is_mount(const char *path) { (void)path; return 0; }
|
||||
|
||||
void plat_mounts_set(const plat_mount *mounts, int n) { (void)mounts; (void)n; }
|
||||
|
||||
int plat_app_installed(void) { return 0; }
|
||||
|
||||
void plat_app_version(char *out, size_t n) {
|
||||
if (n) out[0] = 0;
|
||||
}
|
||||
|
||||
/* Not used (can_relaunch is 0): GoldHEN's network loader (9090) crashes on
|
||||
|
||||
+9
-4
@@ -33,7 +33,7 @@ build/ps4/gen/emulators_json.h: platform/ps4/emulators.json tools/embed.py
|
||||
python3 -c "import json,sys; json.load(open(sys.argv[1]))" $<
|
||||
python3 tools/embed.py $< $@ EMULATORS_JSON
|
||||
|
||||
$(PS4_ELF): $(SRCS) $(PS4_PLAT) platform/console/console.h $(HDRS) $(UI_HEADER) build/ps4/gen/cacert_pem.h build/ps4/gen/emulators_json.h
|
||||
$(PS4_ELF): $(SRCS) $(PS4_PLAT) platform/console/console.h $(HDRS) $(GEN_HEADERS) build/ps4/gen/cacert_pem.h build/ps4/gen/emulators_json.h
|
||||
@mkdir -p $(dir $@)
|
||||
$(PS4_CC) $(COMMON_CFLAGS) -Ibuild/ps4/gen -Iplatform/console -DROMM_EMBED_CA $$($(PS4_PKGCONF) --cflags libcurl) -DCURL_STATICLIB \
|
||||
-o $@ $(SRCS) $(PS4_PLAT) \
|
||||
@@ -46,7 +46,12 @@ install-ps4: $(PS4_ELF)
|
||||
OO ?= $(OO_PS4_TOOLCHAIN)
|
||||
PS4_TITLE_ID := ROMM00002
|
||||
PS4_CONTENT_ID := IV0000-$(PS4_TITLE_ID)_00-ROMMSYNCPS400000
|
||||
PS4_PKG_VER := 1.00
|
||||
# The package's version follows the release, so a newer one installs as an
|
||||
# update: 1.2.2 is 01.22 (one digit each for minor and patch), dev builds 01.00.
|
||||
PS4_PKG_VER := $(shell echo '$(or $(VERSION),1.0.0)' | awk -F'[.-]' '{ if ($$2 > 9 || $$3 > 9) exit 1; printf "%02d.%d%d", $$1, $$2, $$3 }')
|
||||
ifeq ($(PS4_PKG_VER),)
|
||||
$(error VERSION $(VERSION) doesn't fit the package's NN.NN version)
|
||||
endif
|
||||
PS4_PKG_DIR := build/ps4/pkg
|
||||
PS4_PKG := build/ps4/romm-sync-ps4.pkg
|
||||
PS4_APP_LIBS := -lc -lkernel -lSceSysmodule -lSceCommonDialog -lSceMsgDialog -lSceWebBrowserDialog \
|
||||
@@ -59,7 +64,7 @@ ps4-pkg: $(PS4_PKG)
|
||||
build/ps4/launcher.elf: platform/ps4/app/launcher.c
|
||||
@mkdir -p $(dir $@)
|
||||
clang --target=x86_64-pc-freebsd12-elf -fPIC -funwind-tables -O2 -Wall -Wextra -Wno-unused-parameter \
|
||||
-isysroot $(OO) -isystem $(OO)/include -DAPP_VERSION='"$(or $(VERSION),dev)"' -c -o build/ps4/launcher.o $<
|
||||
-isysroot $(OO) -isystem $(OO)/include -DAPP_VERSION='"$(or $(VERSION),dev)"' $(if $(NETLOG),-DROMM_NETLOG) -c -o build/ps4/launcher.o $<
|
||||
ld.lld -m elf_x86_64 -pie --script $(OO)/link.x --eh-frame-hdr -L$(OO)/lib $(PS4_APP_LIBS) $(OO)/lib/crt1.o \
|
||||
-o $@ build/ps4/launcher.o
|
||||
|
||||
@@ -80,7 +85,7 @@ $(PS4_PKG): build/ps4/launcher.elf $(PS4_ELF) assets/icon0.png
|
||||
&& $(PKGTOOL) sfo_setentry $$f CONTENT_ID --type Utf8 --maxsize 48 --value '$(PS4_CONTENT_ID)' \
|
||||
&& $(PKGTOOL) sfo_setentry $$f DOWNLOAD_DATA_SIZE --type Integer --maxsize 4 --value 0 \
|
||||
&& $(PKGTOOL) sfo_setentry $$f SYSTEM_VER --type Integer --maxsize 4 --value 0 \
|
||||
&& $(PKGTOOL) sfo_setentry $$f TITLE --type Utf8 --maxsize 128 --value 'RomM Sync' \
|
||||
&& $(PKGTOOL) sfo_setentry $$f TITLE --type Utf8 --maxsize 128 --value 'RommPS' \
|
||||
&& $(PKGTOOL) sfo_setentry $$f TITLE_ID --type Utf8 --maxsize 12 --value '$(PS4_TITLE_ID)' \
|
||||
&& $(PKGTOOL) sfo_setentry $$f VERSION --type Utf8 --maxsize 8 --value '$(PS4_PKG_VER)'
|
||||
cd $(PS4_PKG_DIR) && $(OO)/bin/linux/create-gp4 -out pkg.gp4 --content-id=$(PS4_CONTENT_ID) \
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -14,7 +14,7 @@ tools/ps5-app-build.sh bash -c 'cd /repo/platform/ps5/app/rommps && bash ps5/too
|
||||
tools/release-app.sh v1.2.3 # the release zip
|
||||
```
|
||||
|
||||
The menu sounds aren't in git. Put them in `platform/ps5/app/assets/sounds/` (`focus_01.wav`, `back_01.wav` and so on, named after the kit's cues) before building; without them the kit's own sounds are used.
|
||||
The menu sounds are in `platform/ps5/app/assets/primary-menu-sounds/`, one WAV per kit cue (`focus_01.wav`, `back_01.wav` and so on).
|
||||
|
||||
## Licences
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
#include "avatar_png.h"
|
||||
#include "core/tween.hpp"
|
||||
#include "qrcodegen.h"
|
||||
#include "rommps_api.hpp"
|
||||
#include "stb_image.h" // implemented by base/VulkanglTFModel.cpp
|
||||
|
||||
#include <algorithm>
|
||||
@@ -238,7 +239,7 @@ void CodecCall::go(int step, hui::ui::Feedback &feedback)
|
||||
{
|
||||
// The payload's page hosting YouTube's player, filling the browser
|
||||
// (an embed opened on its own fails with YouTube's error 153).
|
||||
const std::string page = std::string("http://127.0.0.1:8780/video?v=") + s.video;
|
||||
const std::string page = "http://127.0.0.1:" + std::to_string(payload_port()) + "/video?v=" + s.video;
|
||||
opened_ = open_in_browser(page.c_str());
|
||||
feedback.play(Cue::launch);
|
||||
}
|
||||
|
||||
@@ -26,14 +26,17 @@ using hui::gfx::Rect;
|
||||
|
||||
constexpr float kMargin = 96.0f;
|
||||
|
||||
// "On this console"
|
||||
constexpr float kRecentW = 150.0f, kRecentH = 200.0f, kRecentGap = 22.0f;
|
||||
// The platform tiles start right under the page's heading.
|
||||
constexpr float kPlatformTop = 196.0f;
|
||||
|
||||
// Platform tiles
|
||||
constexpr int kTileCols = 4;
|
||||
constexpr float kTileW = 408.0f, kTileH = 220.0f, kTileGap = 32.0f, kTilePitch = kTileH + kTileGap;
|
||||
// A grid of games
|
||||
constexpr int kCols = 8;
|
||||
constexpr float kGridTop = 400.0f, kCardW = 176.0f, kCardH = 235.0f, kColPitch = 200.0f, kRowPitch = 260.0f;
|
||||
// A grid of games: its columns and cards follow the shape of its covers
|
||||
constexpr int kMinCols = 4, kMaxCols = 8;
|
||||
constexpr float kGridTop = 400.0f, kGridW = 1620.0f, kCardTallest = 235.0f, kColGap = 24.0f, kRowGap = 25.0f;
|
||||
// Search results, in a row
|
||||
constexpr float kResultH = 224.0f, kResultGap = 24.0f, kResultsEnd = 1824.0f;
|
||||
constexpr float kBarX = 1726.0f, kBarW = 98.0f, kBarTop = 392.0f, kBarH = 600.0f;
|
||||
// The A to Z bar's slots: # (digits and the rest) then A to Z.
|
||||
constexpr int kSlots = 27;
|
||||
@@ -80,10 +83,41 @@ void pop_utf8(std::string &s)
|
||||
// The end of a text that fits a width.
|
||||
std::string tail(const hui::gfx::Font &font, const std::string &text, float size, float width)
|
||||
{
|
||||
// A character at a time: a cut inside one would draw as "?"
|
||||
std::size_t from = 0;
|
||||
while (from < text.size() && font.measure(std::string_view(text).substr(from), size) > width)
|
||||
++from;
|
||||
return from ? "\xE2\x80\xA6" + text.substr(from + 1) : text;
|
||||
from = char_boundary(text, from + 1);
|
||||
return from ? "\xE2\x80\xA6" + text.substr(char_boundary(text, from + 1)) : text;
|
||||
}
|
||||
|
||||
struct GridLayout
|
||||
{
|
||||
float shape; // of the cards, width over height
|
||||
int cols;
|
||||
float card_w, card_h, col_pitch, row_pitch;
|
||||
};
|
||||
|
||||
// As many columns as cards of the grid's shape fit across (8 of 3:4 covers,
|
||||
// 6 of square ones, 5 of the wide SNES and N64 boxes), the cards a little
|
||||
// shorter when that fills the width better. Every card the same size.
|
||||
GridLayout grid_layout(const GameList &l)
|
||||
{
|
||||
const float shape = std::clamp(l.shape > 0 ? l.shape : kPortrait, 0.7f, kWidest);
|
||||
const int cols = std::clamp(static_cast<int>(std::lround((kGridW + kColGap) / (kCardTallest * shape + kColGap))),
|
||||
kMinCols, kMaxCols);
|
||||
const float w = std::min(kCardTallest * shape, (kGridW - kColGap * static_cast<float>(cols - 1)) / static_cast<float>(cols));
|
||||
return {shape, cols, w, w / shape, w + kColGap, w / shape + kRowGap};
|
||||
}
|
||||
|
||||
// A cover in a grid's card: its own shape, centred. One much wider than the
|
||||
// card keeps most of the card's height, cropped at the sides.
|
||||
Rect in_card(float shape, const Rect &card)
|
||||
{
|
||||
Rect r = fit_cover(shape, card);
|
||||
const float least = card.h * 0.75f;
|
||||
if (r.h < least)
|
||||
r.y = card.y + (card.h - least) * 0.5f, r.h = least;
|
||||
return r;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
@@ -131,13 +165,50 @@ std::string play_time(double ms)
|
||||
return text;
|
||||
}
|
||||
|
||||
float cover_shape(App &app, const Game *g, float fallback)
|
||||
{
|
||||
double since = 0;
|
||||
const float own = g && !g->cover.empty() ? app.cover_aspect(g->cover, &since) : 0.0f;
|
||||
if (own <= 0)
|
||||
return fallback;
|
||||
const float t = tween::smoothstep(tween::clamp01(static_cast<float>(app.clock() - since) / 0.3f));
|
||||
return fallback + (std::clamp(own, kNarrowest, kWidest) - fallback) * t;
|
||||
}
|
||||
|
||||
Rect fit_cover(float shape, const Rect &box)
|
||||
{
|
||||
shape = std::clamp(shape, kNarrowest, kWidest);
|
||||
float w = box.h * shape, h = box.h;
|
||||
if (w > box.w)
|
||||
w = box.w, h = box.w / shape;
|
||||
return {box.x + (box.w - w) * 0.5f, box.y + (box.h - h) * 0.5f, w, h};
|
||||
}
|
||||
|
||||
bool draw_logo(hui::gfx::DrawList &list, App &app, const std::string &slug, const Rect &box, float alpha)
|
||||
{
|
||||
const Cover &logo = app.platform_logo(slug);
|
||||
if (!logo.texture)
|
||||
return false;
|
||||
list.image(logo.texture, fit_cover(logo.aspect > 0 ? logo.aspect : 1.0f, box), hui::gfx::kFullUv,
|
||||
Color::rgb(0xFFFFFF, alpha));
|
||||
return true;
|
||||
}
|
||||
|
||||
void draw_cover(hui::gfx::DrawList &list, const hui::ui::Fonts &fonts, const Look &look, App &app, const Game *g,
|
||||
const Rect &rect, float alpha, float radius, bool name)
|
||||
{
|
||||
const std::uint32_t texture = g ? app.cover(g->cover).texture : 0;
|
||||
if (texture)
|
||||
static const Cover kNone;
|
||||
const Cover &c = g ? app.cover(g->cover) : kNone;
|
||||
if (c.texture)
|
||||
{
|
||||
list.image(texture, rect, hui::gfx::kFullUv, Color::rgb(0xffffff, alpha), radius);
|
||||
// The middle of the cover in the rectangle's shape: cropped, never stretched.
|
||||
Rect uv = hui::gfx::kFullUv;
|
||||
const float want = rect.h > 0 ? rect.w / rect.h : 1.0f;
|
||||
if (c.aspect > want)
|
||||
uv.w = want / c.aspect, uv.x = (1.0f - uv.w) * 0.5f;
|
||||
else if (c.aspect > 0 && c.aspect < want)
|
||||
uv.h = c.aspect / want, uv.y = (1.0f - uv.h) * 0.5f;
|
||||
list.image(c.texture, rect, uv, Color::rgb(0xffffff, alpha), radius);
|
||||
return;
|
||||
}
|
||||
// No cover (yet): a tinted card with the name.
|
||||
@@ -159,7 +230,7 @@ void draw_badge(hui::gfx::DrawList &list, const Look &look, App &app, const Game
|
||||
list.rounded_rect(bar, 4, Color::rgb(0x000000, 0.55f));
|
||||
list.rounded_rect({bar.x, bar.y, std::max(8.0f, bar.w * d->progress()), bar.h}, 4, accent);
|
||||
}
|
||||
else if (g->installed || (d && d->status == "done"))
|
||||
else if (app.on_console(*g))
|
||||
{
|
||||
const float cx = rect.x + rect.w - 24, cy = rect.y + 24;
|
||||
list.circle(cx, cy, 14, look.success);
|
||||
@@ -201,10 +272,9 @@ LibraryPage::LibraryPage()
|
||||
keys_.set_bounds({360, 610, 1200, 370});
|
||||
}
|
||||
|
||||
void LibraryPage::enter(App &app)
|
||||
void LibraryPage::enter(App &)
|
||||
{
|
||||
mode_age_ = 0.0f;
|
||||
app.refresh_installed();
|
||||
if (mode_ == kSearch)
|
||||
mode_ = before_search_;
|
||||
}
|
||||
@@ -218,13 +288,6 @@ void LibraryPage::refuse(hui::ui::Feedback &feedback)
|
||||
|
||||
const Game *LibraryPage::focused(App &app) const
|
||||
{
|
||||
if (mode_ == kPlatforms)
|
||||
{
|
||||
const auto &recent = app.installed();
|
||||
if (section_ == 0 && recent_ >= 0 && recent_ < static_cast<int>(recent.size()))
|
||||
return &recent[static_cast<std::size_t>(recent_)];
|
||||
return nullptr;
|
||||
}
|
||||
if (mode_ == kGrid)
|
||||
return grid(app).at(index_);
|
||||
return nullptr;
|
||||
@@ -234,7 +297,7 @@ std::string LibraryPage::backdrop_cover(App &app) const
|
||||
{
|
||||
if (const Game *g = focused(app))
|
||||
return g->cover;
|
||||
if (mode_ == kPlatforms && section_ == 1)
|
||||
if (mode_ == kPlatforms)
|
||||
{
|
||||
const auto &platforms = app.platforms();
|
||||
if (tile_ >= 0 && tile_ < static_cast<int>(platforms.size()))
|
||||
@@ -253,7 +316,7 @@ bool LibraryPage::update(App &app, const hui::InputFrame &input, float dt, hui::
|
||||
switch (mode_)
|
||||
{
|
||||
case kPlatforms:
|
||||
update_platforms(app, input, feedback, details);
|
||||
update_platforms(app, input, feedback);
|
||||
break;
|
||||
case kGrid:
|
||||
update_grid(app, input, feedback, details);
|
||||
@@ -263,17 +326,15 @@ bool LibraryPage::update(App &app, const hui::InputFrame &input, float dt, hui::
|
||||
break;
|
||||
}
|
||||
// Scrolling.
|
||||
const int tile_rows = std::max(1, static_cast<int>((1000.0f - platforms_top(app) - 24.0f) / kTilePitch));
|
||||
const int tile_rows = std::max(1, static_cast<int>((1000.0f - kPlatformTop - 24.0f) / kTilePitch));
|
||||
const int tile_row = tile_ / kTileCols;
|
||||
if (tile_row < tiles_scroll_.target)
|
||||
tiles_scroll_.target = static_cast<float>(tile_row);
|
||||
if (tile_row > tiles_scroll_.target + static_cast<float>(tile_rows - 1))
|
||||
tiles_scroll_.target = static_cast<float>(tile_row - tile_rows + 1);
|
||||
tiles_scroll_.update(dt, 14.0f);
|
||||
recent_scroll_.reveal(static_cast<float>(recent_) * (kRecentW + kRecentGap),
|
||||
static_cast<float>(recent_) * (kRecentW + kRecentGap) + kRecentW, 1728.0f, 120.0f);
|
||||
recent_scroll_.update(dt, 14.0f);
|
||||
grid_scroll_.target = static_cast<float>(std::max(0, index_ / kCols - 1));
|
||||
if (mode_ == kGrid)
|
||||
grid_scroll_.target = static_cast<float>(std::max(0, index_ / grid_layout(grid(app)).cols - 1));
|
||||
grid_scroll_.update(dt, 14.0f);
|
||||
const Game *g = focused(app);
|
||||
const int id = g ? g->id : -1;
|
||||
@@ -287,19 +348,10 @@ bool LibraryPage::update(App &app, const hui::InputFrame &input, float dt, hui::
|
||||
|
||||
// ------------------------------------------------------------------ platforms
|
||||
|
||||
float LibraryPage::platforms_top(App &app) const
|
||||
void LibraryPage::update_platforms(App &app, const hui::InputFrame &input, hui::ui::Feedback &feedback)
|
||||
{
|
||||
return app.installed().empty() ? 196.0f : 486.0f;
|
||||
}
|
||||
|
||||
void LibraryPage::update_platforms(App &app, const hui::InputFrame &input, hui::ui::Feedback &feedback, bool &details)
|
||||
{
|
||||
const auto &recent = app.installed();
|
||||
const auto &platforms = app.platforms();
|
||||
const int nrecent = static_cast<int>(recent.size()), ntiles = static_cast<int>(platforms.size());
|
||||
if (nrecent == 0)
|
||||
section_ = 1;
|
||||
recent_ = std::clamp(recent_, 0, std::max(0, nrecent - 1));
|
||||
const int ntiles = static_cast<int>(platforms.size());
|
||||
tile_ = std::clamp(tile_, 0, std::max(0, ntiles - 1));
|
||||
|
||||
// The covers of the tiles in view.
|
||||
@@ -308,99 +360,52 @@ void LibraryPage::update_platforms(App &app, const hui::InputFrame &input, hui::
|
||||
app.ensure(app.games(platforms[static_cast<std::size_t>(i)].id, ""), 0, 0);
|
||||
|
||||
const bool repeat = input.nav_repeat;
|
||||
if (section_ == 0)
|
||||
switch (input.nav)
|
||||
{
|
||||
switch (input.nav)
|
||||
case Direction::left:
|
||||
case Direction::right:
|
||||
{
|
||||
const int col = tile_ % kTileCols;
|
||||
const int next = tile_ + (input.nav == Direction::right ? 1 : -1);
|
||||
if ((input.nav == Direction::right ? col < kTileCols - 1 : col > 0) && next >= 0 && next < ntiles)
|
||||
{
|
||||
case Direction::left:
|
||||
case Direction::right:
|
||||
{
|
||||
const int next = recent_ + (input.nav == Direction::right ? 1 : -1);
|
||||
if (next >= 0 && next < nrecent)
|
||||
{
|
||||
recent_ = next;
|
||||
feedback.play(Cue::focus);
|
||||
}
|
||||
else if (!repeat)
|
||||
refuse(feedback);
|
||||
break;
|
||||
}
|
||||
case Direction::down:
|
||||
if (ntiles > 0)
|
||||
{
|
||||
section_ = 1;
|
||||
feedback.play(Cue::tab);
|
||||
}
|
||||
else if (!repeat)
|
||||
refuse(feedback);
|
||||
break;
|
||||
case Direction::up:
|
||||
if (!repeat)
|
||||
refuse(feedback);
|
||||
break;
|
||||
case Direction::none:
|
||||
break;
|
||||
}
|
||||
if (input.is_pressed(Action::confirm) && nrecent > 0)
|
||||
{
|
||||
details = true;
|
||||
feedback.play(Cue::open);
|
||||
tile_ = next;
|
||||
feedback.play(Cue::focus);
|
||||
}
|
||||
else if (!repeat)
|
||||
refuse(feedback);
|
||||
break;
|
||||
}
|
||||
else
|
||||
case Direction::up:
|
||||
if (tile_ - kTileCols >= 0)
|
||||
{
|
||||
tile_ -= kTileCols;
|
||||
feedback.play(Cue::focus);
|
||||
}
|
||||
else if (!repeat)
|
||||
refuse(feedback);
|
||||
break;
|
||||
case Direction::down:
|
||||
if (tile_ + kTileCols < ntiles)
|
||||
{
|
||||
tile_ += kTileCols;
|
||||
feedback.play(Cue::focus);
|
||||
}
|
||||
else if (tile_ / kTileCols < (ntiles - 1) / kTileCols)
|
||||
{
|
||||
tile_ = ntiles - 1;
|
||||
feedback.play(Cue::focus);
|
||||
}
|
||||
else if (!repeat)
|
||||
refuse(feedback);
|
||||
break;
|
||||
case Direction::none:
|
||||
break;
|
||||
}
|
||||
if (input.is_pressed(Action::confirm) && ntiles > 0)
|
||||
{
|
||||
switch (input.nav)
|
||||
{
|
||||
case Direction::left:
|
||||
case Direction::right:
|
||||
{
|
||||
const int col = tile_ % kTileCols;
|
||||
const int next = tile_ + (input.nav == Direction::right ? 1 : -1);
|
||||
if ((input.nav == Direction::right ? col < kTileCols - 1 : col > 0) && next >= 0 && next < ntiles)
|
||||
{
|
||||
tile_ = next;
|
||||
feedback.play(Cue::focus);
|
||||
}
|
||||
else if (!repeat)
|
||||
refuse(feedback);
|
||||
break;
|
||||
}
|
||||
case Direction::up:
|
||||
if (tile_ - kTileCols >= 0)
|
||||
{
|
||||
tile_ -= kTileCols;
|
||||
feedback.play(Cue::focus);
|
||||
}
|
||||
else if (nrecent > 0)
|
||||
{
|
||||
section_ = 0;
|
||||
feedback.play(Cue::tab);
|
||||
}
|
||||
else if (!repeat)
|
||||
refuse(feedback);
|
||||
break;
|
||||
case Direction::down:
|
||||
if (tile_ + kTileCols < ntiles)
|
||||
{
|
||||
tile_ += kTileCols;
|
||||
feedback.play(Cue::focus);
|
||||
}
|
||||
else if (tile_ / kTileCols < (ntiles - 1) / kTileCols)
|
||||
{
|
||||
tile_ = ntiles - 1;
|
||||
feedback.play(Cue::focus);
|
||||
}
|
||||
else if (!repeat)
|
||||
refuse(feedback);
|
||||
break;
|
||||
case Direction::none:
|
||||
break;
|
||||
}
|
||||
if (input.is_pressed(Action::confirm) && ntiles > 0)
|
||||
{
|
||||
feedback.play(Cue::open);
|
||||
open_grid(app, platforms[static_cast<std::size_t>(tile_)].id, "");
|
||||
}
|
||||
feedback.play(Cue::open);
|
||||
open_grid(app, platforms[static_cast<std::size_t>(tile_)].id, "");
|
||||
}
|
||||
if (input.is_pressed(Action::north))
|
||||
{
|
||||
@@ -412,51 +417,16 @@ void LibraryPage::update_platforms(App &app, const hui::InputFrame &input, hui::
|
||||
void LibraryPage::draw_platforms(hui::gfx::DrawList &list, const hui::ui::Fonts &fonts, const Look &look, App &app,
|
||||
Color accent, float clock, float age) const
|
||||
{
|
||||
const auto &recent = app.installed();
|
||||
const auto &platforms = app.platforms();
|
||||
const float shake = ui::shake(nudge_.value, clock, 10.0f, 9.0f);
|
||||
|
||||
// On this console
|
||||
if (!recent.empty())
|
||||
{
|
||||
const float in = tween::stagger(age, 1, 0.08f, 0.6f);
|
||||
list.push_opacity(in);
|
||||
ui::text(list, fonts.semibold, "On this console", kMargin, 190, 24, section_ == 0 ? look.text : look.muted);
|
||||
list.push_clip({0, 150, 1920, 330});
|
||||
for (int i = 0; i < static_cast<int>(recent.size()); ++i)
|
||||
{
|
||||
const Game &g = recent[static_cast<std::size_t>(i)];
|
||||
Rect r{kMargin + static_cast<float>(i) * (kRecentW + kRecentGap) - recent_scroll_.offset(), 214, kRecentW, kRecentH};
|
||||
if (r.x > 1920 || r.x + r.w < 0)
|
||||
continue;
|
||||
const bool on = section_ == 0 && i == recent_;
|
||||
if (on)
|
||||
{
|
||||
r.x += shake;
|
||||
list.glow(r, 18, 22, accent.with_alpha(0.45f));
|
||||
}
|
||||
draw_cover(list, fonts, look, app, &g, r, on || section_ == 0 ? 1.0f : 0.8f, 16);
|
||||
if (on)
|
||||
list.bordered_rect(r.inset(-5), 20, Color::rgb(0x000000, 0.0f), 4, look.focus.with_alpha(1.0f));
|
||||
}
|
||||
list.pop_clip();
|
||||
if (section_ == 0)
|
||||
{
|
||||
const Game &g = recent[static_cast<std::size_t>(recent_)];
|
||||
const Platform *p = app.platform_by_id(g.platform_id);
|
||||
std::string line = g.name + (p ? " \xC2\xB7 " + p->name : "");
|
||||
ui::text(list, fonts.regular, fonts.regular.font->fit(line, 22, 1700), kMargin, 452, 22, look.muted);
|
||||
}
|
||||
list.pop_opacity();
|
||||
}
|
||||
|
||||
// Platforms
|
||||
const float top = platforms_top(app);
|
||||
const float in = tween::stagger(age, 2, 0.08f, 0.6f);
|
||||
const float top = kPlatformTop;
|
||||
const float in = tween::stagger(age, 1, 0.08f, 0.6f);
|
||||
list.push_opacity(in);
|
||||
char label[64];
|
||||
std::snprintf(label, sizeof label, "Platforms \xC2\xB7 %d", static_cast<int>(platforms.size()));
|
||||
ui::text(list, fonts.semibold, label, kMargin, top, 24, section_ == 1 ? look.text : look.muted);
|
||||
ui::text(list, fonts.semibold, label, kMargin, top, 24, look.text);
|
||||
if (platforms.empty())
|
||||
ui::text(list, fonts.regular, "Loading your platforms", kMargin, top + 60, 26, look.muted);
|
||||
const float grid_top = top + 24;
|
||||
@@ -468,7 +438,7 @@ void LibraryPage::draw_platforms(hui::gfx::DrawList &list, const hui::ui::Fonts
|
||||
const float y = grid_top + (static_cast<float>(row) - tiles_scroll_.value) * kTilePitch;
|
||||
if (y > 1080 || y + kTileH < grid_top - 20)
|
||||
continue;
|
||||
const bool on = section_ == 1 && i == tile_;
|
||||
const bool on = i == tile_;
|
||||
Rect r{kMargin + static_cast<float>(col) * (kTileW + kTileGap) + (on ? shake : 0.0f), y, kTileW, kTileH};
|
||||
if (on)
|
||||
list.glow(r, 26, 26, accent.with_alpha(0.4f));
|
||||
@@ -480,11 +450,15 @@ void LibraryPage::draw_platforms(hui::gfx::DrawList &list, const hui::ui::Fonts
|
||||
const Game *g = games.at(k);
|
||||
if (!g)
|
||||
continue;
|
||||
const Rect c{r.x + r.w - 128 - static_cast<float>(k) * 40, r.y + 28 + static_cast<float>(k) * 10, 100,
|
||||
133};
|
||||
// Each its own shape, as tall as fits 120 wide, standing on the same line.
|
||||
const float shape = cover_shape(app, g);
|
||||
const float h = std::min(133.0f, 120.0f / shape), w = h * shape;
|
||||
const Rect c{r.x + r.w - 28 - w - static_cast<float>(k) * 40, r.y + 161 + static_cast<float>(k) * 10 - h, w, h};
|
||||
list.shadow({c.x, c.y + 8, c.w, c.h}, 10, 16, Color::rgb(0x000000, 0.35f));
|
||||
draw_cover(list, fonts, look, app, g, c, 1.0f - 0.15f * static_cast<float>(k), 10, false);
|
||||
}
|
||||
// The system's logo, small, in the corner under the covers.
|
||||
draw_logo(list, app, p.slug, {r.x + r.w - 28 - 30, r.y + 172, 30, 30}, 0.9f);
|
||||
ui::paragraph(list, fonts.semibold, p.name, r.x + 28, r.y + 56, 28, 165, 34, look.panel_text, 2);
|
||||
char count[48];
|
||||
std::snprintf(count, sizeof count, p.rom_count == 1 ? "%d game" : "%d games", p.rom_count);
|
||||
@@ -510,7 +484,7 @@ void LibraryPage::open_grid(App &app, int platform_id, const std::string &search
|
||||
index_ = 0;
|
||||
on_letters_ = false;
|
||||
grid_scroll_.snap(0.0f);
|
||||
app.ensure(grid(app), 0, kCols * 4);
|
||||
app.ensure(grid(app), 0, kMaxCols * 4);
|
||||
}
|
||||
|
||||
int LibraryPage::letter_at(const GameList &l, int index) const
|
||||
@@ -546,6 +520,7 @@ void LibraryPage::update_grid(App &app, const hui::InputFrame &input, hui::ui::F
|
||||
index_ = l.shown(focus_raw_);
|
||||
if (l.ready())
|
||||
index_ = std::min(index_, std::max(0, l.total - 1)); // the list can come out shorter than first thought
|
||||
settle_shape(app, l);
|
||||
move_in_grid(app, input, feedback, details);
|
||||
GameList &now = grid(app);
|
||||
focus_list_ = &now;
|
||||
@@ -553,13 +528,39 @@ void LibraryPage::update_grid(App &app, const hui::InputFrame &input, hui::ui::F
|
||||
focus_hidden_ = now.hidden.size();
|
||||
}
|
||||
|
||||
void LibraryPage::settle_shape(App &app, GameList &l) const
|
||||
{
|
||||
if (l.shape_settled || !l.ready())
|
||||
return;
|
||||
// The covers of the first rows: the grid takes their middle shape.
|
||||
std::vector<float> seen;
|
||||
int wanted = 0;
|
||||
for (int i = 0; i < std::min(l.total, 2 * kMaxCols); ++i)
|
||||
if (const Game *g = l.at(i); g && !g->cover.empty())
|
||||
{
|
||||
++wanted;
|
||||
if (const float a = app.cover_aspect(g->cover); a > 0)
|
||||
seen.push_back(std::clamp(a, kNarrowest, kWidest));
|
||||
}
|
||||
if (!seen.empty())
|
||||
{
|
||||
std::nth_element(seen.begin(), seen.begin() + static_cast<std::ptrdiff_t>(seen.size() / 2), seen.end());
|
||||
l.shape = seen[seen.size() / 2];
|
||||
}
|
||||
// It settles once those covers are all here, or enough of them are and
|
||||
// the grid is in use, so it never changes under the focus.
|
||||
const int known = static_cast<int>(seen.size());
|
||||
l.shape_settled = known >= wanted || (known >= 4 && (index_ > 0 || mode_age_ > 1.5f)) || mode_age_ > 4.0f;
|
||||
}
|
||||
|
||||
void LibraryPage::move_in_grid(App &app, const hui::InputFrame &input, hui::ui::Feedback &feedback, bool &details)
|
||||
{
|
||||
GameList &l = grid(app);
|
||||
const int cols = grid_layout(l).cols;
|
||||
// The games around where the grid is going, not the rows it scrolls past
|
||||
// on the way (a jump to a letter animates through everything between).
|
||||
const int target_row = std::max(0, index_ / kCols - 2);
|
||||
app.ensure(l, target_row * kCols, (target_row + 7) * kCols);
|
||||
const int target_row = std::max(0, index_ / cols - 2);
|
||||
app.ensure(l, target_row * cols, (target_row + 7) * cols);
|
||||
const int total = std::max(0, l.total);
|
||||
const Slots slots = slots_of(l);
|
||||
const bool repeat = input.nav_repeat;
|
||||
@@ -612,7 +613,7 @@ void LibraryPage::move_in_grid(App &app, const hui::InputFrame &input, hui::ui::
|
||||
switch (input.nav)
|
||||
{
|
||||
case Direction::left:
|
||||
if (index_ % kCols > 0)
|
||||
if (index_ % cols > 0)
|
||||
{
|
||||
--index_;
|
||||
feedback.play(Cue::focus);
|
||||
@@ -621,7 +622,7 @@ void LibraryPage::move_in_grid(App &app, const hui::InputFrame &input, hui::ui::
|
||||
refuse(feedback);
|
||||
break;
|
||||
case Direction::right:
|
||||
if (index_ % kCols < kCols - 1 && index_ + 1 < total)
|
||||
if (index_ % cols < cols - 1 && index_ + 1 < total)
|
||||
{
|
||||
++index_;
|
||||
feedback.play(Cue::focus);
|
||||
@@ -636,21 +637,21 @@ void LibraryPage::move_in_grid(App &app, const hui::InputFrame &input, hui::ui::
|
||||
refuse(feedback);
|
||||
break;
|
||||
case Direction::up:
|
||||
if (index_ - kCols >= 0)
|
||||
if (index_ - cols >= 0)
|
||||
{
|
||||
index_ -= kCols;
|
||||
index_ -= cols;
|
||||
feedback.play(Cue::focus);
|
||||
}
|
||||
else if (!repeat)
|
||||
refuse(feedback);
|
||||
break;
|
||||
case Direction::down:
|
||||
if (index_ + kCols < total)
|
||||
if (index_ + cols < total)
|
||||
{
|
||||
index_ += kCols;
|
||||
index_ += cols;
|
||||
feedback.play(Cue::focus);
|
||||
}
|
||||
else if (index_ / kCols < (total - 1) / kCols)
|
||||
else if (index_ / cols < (total - 1) / cols)
|
||||
{
|
||||
index_ = total - 1;
|
||||
feedback.play(Cue::focus);
|
||||
@@ -705,8 +706,9 @@ void LibraryPage::draw_grid(hui::gfx::DrawList &list, const hui::ui::Fonts &font
|
||||
char count[48] = "";
|
||||
if (l.ready())
|
||||
std::snprintf(count, sizeof count, l.total == 1 ? "1 game" : "%d games", l.total);
|
||||
ui::text(list, fonts.semibold, ui::upper(fonts.semibold.font->fit(where, 20, 1300)), kMargin, 196, 20, accent,
|
||||
Align::left, 4.0f);
|
||||
// What was typed, in curly quotes (plain_text turns them straight in a font without them)
|
||||
ui::text(list, fonts.semibold, ui::upper(fonts.semibold.font->fit(plain_text(where.c_str()), 20, 1300)), kMargin, 196, 20,
|
||||
accent, Align::left, 4.0f);
|
||||
ui::text(list, fonts.regular, count, 1824, 196, 22, look.muted, Align::right);
|
||||
const float fade = tween::smoothstep(tween::clamp01(shown_age_ / 0.25f));
|
||||
if (g)
|
||||
@@ -737,15 +739,17 @@ void LibraryPage::draw_grid(hui::gfx::DrawList &list, const hui::ui::Fonts &font
|
||||
const Rect bar{kMargin, 336, 420, 8};
|
||||
list.rounded_rect(bar, 4, look.muted.with_alpha(0.25f));
|
||||
list.rounded_rect({bar.x, bar.y, std::max(8.0f, bar.w * d->progress()), bar.h}, 4, accent);
|
||||
if (d->syncing())
|
||||
ui::text(list, fonts.semibold, "Syncing saves", bar.x + bar.w + 20, 344, 20, look.muted);
|
||||
}
|
||||
else
|
||||
{
|
||||
const float w = ui::text(list, fonts.semibold, g->installed ? "Downloaded" : "In your RomM library", kMargin,
|
||||
352, 20, g->installed ? look.success : look.muted);
|
||||
// With the details on the line above, the file and emulator follow the state.
|
||||
// "Downloaded" when it is; with the details on the line above, the file and emulator follow.
|
||||
const bool here = app.on_console(*g);
|
||||
const float w = here ? ui::text(list, fonts.semibold, "Downloaded", kMargin, 352, 20, look.success) + 14 : 0.0f;
|
||||
if (!about.empty())
|
||||
ui::text(list, fonts.regular, fonts.regular.font->fit("\xC2\xB7 " + meta, 20, 1200), kMargin + w + 14,
|
||||
352, 20, look.muted);
|
||||
ui::text(list, fonts.regular, fonts.regular.font->fit((here ? "\xC2\xB7 " : "") + meta, 20, 1200),
|
||||
kMargin + w, 352, 20, look.muted);
|
||||
}
|
||||
list.pop_opacity();
|
||||
}
|
||||
@@ -768,26 +772,33 @@ void LibraryPage::draw_grid(hui::gfx::DrawList &list, const hui::ui::Fonts &font
|
||||
list.push_opacity(appear);
|
||||
const float shake = ui::shake(nudge_.value, clock, 10.0f, 9.0f);
|
||||
const int total = std::max(0, l.total);
|
||||
const GridLayout layout = grid_layout(l);
|
||||
const int cols = layout.cols;
|
||||
const int first_row = std::max(0, static_cast<int>(grid_scroll_.value) - 1);
|
||||
const int rows = static_cast<int>((1080.0f - kGridTop) / layout.row_pitch) + 3;
|
||||
list.push_clip({0, kGridTop - 30, kBarX - 10, 1080 - kGridTop + 30});
|
||||
Rect focus_rect{};
|
||||
for (int row = first_row; row < first_row + 5; ++row)
|
||||
for (int col = 0; col < kCols; ++col)
|
||||
for (int row = first_row; row < first_row + rows; ++row)
|
||||
for (int col = 0; col < cols; ++col)
|
||||
{
|
||||
const int i = row * kCols + col;
|
||||
const int i = row * cols + col;
|
||||
if (i >= total)
|
||||
break;
|
||||
const float y = kGridTop + (static_cast<float>(row) - grid_scroll_.value) * kRowPitch + 24 * (1.0f - appear);
|
||||
if (y > 1080 || y + kCardH < kGridTop - 40)
|
||||
const float y = kGridTop + (static_cast<float>(row) - grid_scroll_.value) * layout.row_pitch + 24 * (1.0f - appear);
|
||||
if (y > 1080 || y + layout.card_h < kGridTop - 40)
|
||||
continue;
|
||||
const Rect r{kMargin + static_cast<float>(col) * kColPitch, y, kCardW, kCardH};
|
||||
const Game *game = l.at(i);
|
||||
const Rect card{kMargin + static_cast<float>(col) * layout.col_pitch, y, layout.card_w, layout.card_h};
|
||||
const Rect r = game ? in_card(cover_shape(app, game, layout.shape), card) : card;
|
||||
if (i == index_)
|
||||
{
|
||||
focus_rect = r;
|
||||
continue; // drawn last, on top
|
||||
}
|
||||
const Game *game = l.at(i);
|
||||
const float dim = y < kGridTop - 4 ? 0.35f : 0.85f;
|
||||
// Rows above where the grid is going are dimmed: one scrolling in
|
||||
// brightens as soon as the move starts, not once it has settled.
|
||||
const float above = tween::clamp01(grid_scroll_.target - static_cast<float>(row));
|
||||
const float dim = 0.85f - 0.5f * above;
|
||||
if (game)
|
||||
{
|
||||
draw_cover(list, fonts, look, app, game, r, dim, 14);
|
||||
@@ -814,7 +825,7 @@ void LibraryPage::draw_grid(hui::gfx::DrawList &list, const hui::ui::Fonts &font
|
||||
list.pop_clip();
|
||||
// The covers of the two rows below are asked for too (after the ones on
|
||||
// screen), so scrolling down finds them ready.
|
||||
for (int i = (first_row + 5) * kCols; i < (first_row + 7) * kCols && i < total; ++i)
|
||||
for (int i = (first_row + rows) * cols; i < (first_row + rows + 2) * cols && i < total; ++i)
|
||||
if (const Game *game = l.at(i))
|
||||
app.cover(game->cover);
|
||||
// The row below fades out under the button hints.
|
||||
@@ -924,12 +935,16 @@ void LibraryPage::draw_search(hui::gfx::DrawList &list, const hui::ui::Fonts &fo
|
||||
Align::right);
|
||||
if (l)
|
||||
{
|
||||
float x = kMargin;
|
||||
for (int i = 0; i < 9; ++i)
|
||||
{
|
||||
const Game *g = l->at(i);
|
||||
if (!g)
|
||||
break;
|
||||
const Rect r{kMargin + static_cast<float>(i) * 192.0f, 340, 168, 224};
|
||||
const Rect r{x, 340, kResultH * cover_shape(app, g), kResultH};
|
||||
if (r.x + r.w > kResultsEnd)
|
||||
break;
|
||||
x += r.w + kResultGap;
|
||||
draw_cover(list, fonts, look, app, g, r, 1.0f, 14);
|
||||
draw_badge(list, look, app, g, r, accent);
|
||||
draw_version(list, fonts, look, g, r);
|
||||
@@ -967,7 +982,7 @@ std::vector<hui::ui::Hint> LibraryPage::hints() const
|
||||
case kPlatforms:
|
||||
return {{Button::l1, "Pages", Button::r1},
|
||||
{Button::triangle, "Search"},
|
||||
{Button::cross, section_ == 0 ? "Details" : "Open"}};
|
||||
{Button::cross, "Open"}};
|
||||
case kGrid:
|
||||
if (on_letters_)
|
||||
return {{Button::cross, "Jump"}, {Button::circle, "Back"}};
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
// RommPS: the Library tab.
|
||||
//
|
||||
// - Platforms: "On this console" (the games here, most recently played
|
||||
// first) above a grid of RomM's platforms, each with a few of its covers.
|
||||
// - Platforms: a grid of RomM's platforms, each with a few of its covers.
|
||||
// Cross opens one, Triangle searches every platform.
|
||||
// - A platform (or a search's results): the focused game's details above a
|
||||
// grid of covers that loads as it scrolls, and an A to Z bar on the right
|
||||
@@ -29,9 +28,20 @@
|
||||
namespace rommps
|
||||
{
|
||||
|
||||
// Covers keep their own shape (width over height) within these: narrower or
|
||||
// wider ones are cropped, never stretched.
|
||||
constexpr float kPortrait = 0.75f, kNarrowest = 0.6f, kWidest = 1.45f;
|
||||
// A game's cover shape: fallback until its cover has been loaded, then its
|
||||
// own, eased in over a moment so what's around it slides rather than jumps.
|
||||
float cover_shape(App &app, const Game *g, float fallback = kPortrait);
|
||||
// The largest rectangle of a shape that fits in box, centred in it.
|
||||
hui::gfx::Rect fit_cover(float shape, const hui::gfx::Rect &box);
|
||||
// A game's cover, or a card with its name while there's none.
|
||||
void draw_cover(hui::gfx::DrawList &list, const hui::ui::Fonts &fonts, const Look &look, App &app, const Game *g,
|
||||
const hui::gfx::Rect &rect, float alpha, float radius, bool name = true);
|
||||
// A platform's logo from RomM, as large as fits box; nothing while it loads or
|
||||
// when RomM has none. Whether one was drawn.
|
||||
bool draw_logo(hui::gfx::DrawList &list, App &app, const std::string &slug, const hui::gfx::Rect &box, float alpha);
|
||||
// "Downloaded", or a running download's progress, on a card.
|
||||
void draw_badge(hui::gfx::DrawList &list, const Look &look, App &app, const Game *g, const hui::gfx::Rect &rect,
|
||||
Color accent);
|
||||
@@ -72,13 +82,14 @@ class LibraryPage
|
||||
};
|
||||
|
||||
// ---- platforms
|
||||
void update_platforms(App &app, const hui::InputFrame &input, hui::ui::Feedback &feedback, bool &details);
|
||||
void update_platforms(App &app, const hui::InputFrame &input, hui::ui::Feedback &feedback);
|
||||
void draw_platforms(hui::gfx::DrawList &list, const hui::ui::Fonts &fonts, const Look &look, App &app, Color accent,
|
||||
float clock, float age) const;
|
||||
float platforms_top(App &app) const;
|
||||
// ---- a grid of games
|
||||
void open_grid(App &app, int platform_id, const std::string &search);
|
||||
void update_grid(App &app, const hui::InputFrame &input, hui::ui::Feedback &feedback, bool &details);
|
||||
// Picks the grid's shape from its first covers, until it settles.
|
||||
void settle_shape(App &app, GameList &l) const;
|
||||
void move_in_grid(App &app, const hui::InputFrame &input, hui::ui::Feedback &feedback, bool &details);
|
||||
void draw_grid(hui::gfx::DrawList &list, const hui::ui::Fonts &fonts, const Look &look, App &app, Color accent,
|
||||
float clock, float age) const;
|
||||
@@ -98,10 +109,8 @@ class LibraryPage
|
||||
float mode_age_ = 0.0f;
|
||||
hui::ui::Pulse nudge_;
|
||||
|
||||
// platforms: section 0 is "On this console", 1 the platform grid
|
||||
int section_ = 1;
|
||||
int recent_ = 0, tile_ = 0;
|
||||
hui::ui::Scroller recent_scroll_;
|
||||
// platforms: the tiles
|
||||
int tile_ = 0;
|
||||
hui::tween::Spring tiles_scroll_; // in rows
|
||||
|
||||
// a grid
|
||||
|
||||
@@ -4,11 +4,15 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#include "rommps_app.hpp"
|
||||
|
||||
#include "ui/fonts.hpp"
|
||||
|
||||
#include "cJSON.h"
|
||||
#include "stb_image.h" // implemented by base/VulkanglTFModel.cpp
|
||||
|
||||
#include <arpa/inet.h>
|
||||
#include <fcntl.h>
|
||||
#include <netinet/in.h>
|
||||
#include <sys/file.h>
|
||||
#include <sys/socket.h>
|
||||
#include <unistd.h>
|
||||
|
||||
@@ -29,8 +33,9 @@ namespace
|
||||
{
|
||||
|
||||
constexpr float kStatusEvery = 3.0f;
|
||||
constexpr float kDownloadsEvery = 1.0f; // while one is running
|
||||
constexpr float kDownloadsIdleEvery = 5.0f; // otherwise
|
||||
constexpr float kQueueEvery = 1.0f; // while one is running
|
||||
constexpr float kQueueIdleEvery = 5.0f; // otherwise
|
||||
constexpr int kCoverRetries = 3; // a failed cover is tried again after 15, 30, 60 s
|
||||
constexpr int kDecodesPerFrame = 2; // covers turned into textures each frame
|
||||
constexpr std::size_t kCoverTextures = 360; // covers kept as textures (about 130 MB); the
|
||||
// least recently drawn go, and come back from
|
||||
@@ -39,6 +44,44 @@ constexpr std::size_t kListsKept = 24; // game lists kept at once: every tile on
|
||||
|
||||
void *g_renderer = nullptr;
|
||||
|
||||
// -1, 0 or 1 as a is older, the same as or newer than b ("1.2.3"); one that
|
||||
// isn't a version ("dev", "") counts as 0.0.0.
|
||||
int version_cmp(const std::string &a, const std::string &b)
|
||||
{
|
||||
int x[3] = {}, y[3] = {};
|
||||
std::sscanf(a.c_str(), "%d.%d.%d", &x[0], &x[1], &x[2]);
|
||||
std::sscanf(b.c_str(), "%d.%d.%d", &y[0], &y[1], &y[2]);
|
||||
for (int i = 0; i < 3; ++i)
|
||||
if (x[i] != y[i])
|
||||
return x[i] < y[i] ? -1 : 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
// RomM's platform logos are .ico files holding a PNG (256x256): bytes and len
|
||||
// are moved onto the biggest PNG inside, which stb_image reads. Anything else
|
||||
// is left as it is.
|
||||
static void ico_png(const unsigned char *&bytes, int &len)
|
||||
{
|
||||
auto u16 = [&](int at) { return bytes[at] | bytes[at + 1] << 8; };
|
||||
auto u32 = [&](int at) { return static_cast<unsigned>(u16(at)) | static_cast<unsigned>(u16(at + 2)) << 16; };
|
||||
if (len < 6 || u16(0) != 0 || u16(2) != 1)
|
||||
return;
|
||||
const int n = u16(4);
|
||||
unsigned best_size = 0, best_at = 0, best_area = 0;
|
||||
for (int i = 0; i < n && 6 + 16 * (i + 1) <= len; ++i)
|
||||
{
|
||||
const int e = 6 + 16 * i;
|
||||
const unsigned w = bytes[e] ? bytes[e] : 256, h = bytes[e + 1] ? bytes[e + 1] : 256;
|
||||
const unsigned size = u32(e + 8), at = u32(e + 12);
|
||||
if (at + size > static_cast<unsigned>(len) || size < 8 || std::memcmp(bytes + at, "\x89PNG", 4) != 0)
|
||||
continue;
|
||||
if (w * h > best_area)
|
||||
best_area = w * h, best_size = size, best_at = at;
|
||||
}
|
||||
if (best_size)
|
||||
bytes += best_at, len = static_cast<int>(best_size);
|
||||
}
|
||||
|
||||
std::string url_escape(const std::string &s)
|
||||
{
|
||||
static const char hex[] = "0123456789ABCDEF";
|
||||
@@ -70,40 +113,266 @@ double num(const cJSON *o, const char *key, double fallback = 0)
|
||||
return cJSON_IsNumber(v) ? v->valuedouble : fallback;
|
||||
}
|
||||
|
||||
// The kit's fonts are baked for ASCII: accented letters fold to their plain
|
||||
// ones ("Pok\xC3\xA9mon" reads "Pokemon"), typographic quotes and dashes to
|
||||
// their ASCII forms, and anything else to "?".
|
||||
// The faces the screen draws text with (set_text_fonts). plain() keeps a
|
||||
// character only when all of them have it.
|
||||
const hui::gfx::Font *g_text_fonts[4] = {};
|
||||
|
||||
// What ps5/ui/setup-kit.sh bakes into them: the kit's "european" set and its
|
||||
// symbols. Only asked before the fonts are loaded.
|
||||
bool baked(unsigned cp)
|
||||
{
|
||||
if ((cp >= 0xA1 && cp <= 0x17F) || (cp >= 0x410 && cp <= 0x44F))
|
||||
return true;
|
||||
switch (cp)
|
||||
{
|
||||
case 0x401: case 0x451: case 0x2013: case 0x2014: case 0x2018: case 0x2019: case 0x201C: case 0x201D:
|
||||
case 0x201E: case 0x2022: case 0x2026: case 0x20AC: case 0x2122: case 0x2190: case 0x2191: case 0x2192:
|
||||
case 0x2193: case 0x2713:
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
bool drawable(unsigned cp)
|
||||
{
|
||||
if (!g_text_fonts[0])
|
||||
return baked(cp);
|
||||
for (const hui::gfx::Font *f : g_text_fonts)
|
||||
if (f && !f->has_glyph(cp))
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
void put_utf8(std::string &out, unsigned cp)
|
||||
{
|
||||
if (cp < 0x80)
|
||||
out += static_cast<char>(cp);
|
||||
else if (cp < 0x800)
|
||||
out += static_cast<char>(0xC0 | cp >> 6), out += static_cast<char>(0x80 | (cp & 0x3F));
|
||||
else if (cp < 0x10000)
|
||||
out += static_cast<char>(0xE0 | cp >> 12), out += static_cast<char>(0x80 | (cp >> 6 & 0x3F)),
|
||||
out += static_cast<char>(0x80 | (cp & 0x3F));
|
||||
else
|
||||
out += static_cast<char>(0xF0 | cp >> 18), out += static_cast<char>(0x80 | (cp >> 12 & 0x3F)),
|
||||
out += static_cast<char>(0x80 | (cp >> 6 & 0x3F)), out += static_cast<char>(0x80 | (cp & 0x3F));
|
||||
}
|
||||
|
||||
constexpr unsigned kNothing = 0xFFFFFFFFu;
|
||||
|
||||
// The next character of text, advancing p. Bytes that aren't UTF-8 are read
|
||||
// as Latin-1 (old file names are), a stray continuation byte as nothing; a
|
||||
// broken sequence never becomes a control character.
|
||||
unsigned next_char(const unsigned char *&p)
|
||||
{
|
||||
const unsigned lead = *p++;
|
||||
if (lead < 0x80)
|
||||
return lead;
|
||||
const int more = lead >= 0xC2 && lead <= 0xDF ? 1 : lead >= 0xE0 && lead <= 0xEF ? 2 : lead >= 0xF0 && lead <= 0xF4 ? 3 : 0;
|
||||
if (!more)
|
||||
return lead >= 0xC0 ? lead : kNothing;
|
||||
unsigned cp = lead & (0x3F >> more);
|
||||
for (int i = 0; i < more; ++i)
|
||||
{
|
||||
if ((p[i] & 0xC0) != 0x80)
|
||||
return lead; // the bytes after it stay, as characters of their own
|
||||
cp = cp << 6 | (p[i] & 0x3F);
|
||||
}
|
||||
p += more;
|
||||
// Too long a form, a surrogate, past Unicode: nothing to show
|
||||
static const unsigned kLeast[] = {0, 0x80, 0x800, 0x10000};
|
||||
if (cp < kLeast[more] || (cp >= 0xD800 && cp <= 0xDFFF) || cp > 0x10FFFF)
|
||||
return kNothing;
|
||||
return cp;
|
||||
}
|
||||
|
||||
// Marks that sit on the letter before them (decomposed text: "e" then U+0301).
|
||||
bool combining(unsigned cp)
|
||||
{
|
||||
return (cp >= 0x300 && cp <= 0x36F) || (cp >= 0x1AB0 && cp <= 0x1AFF) || (cp >= 0x1DC0 && cp <= 0x1DFF) ||
|
||||
(cp >= 0x20D0 && cp <= 0x20FF) || (cp >= 0xFE20 && cp <= 0xFE2F);
|
||||
}
|
||||
|
||||
// What a name can carry that shows as nothing: control codes, a soft hyphen, zero widths,
|
||||
// direction marks, variation selectors, the byte order mark.
|
||||
bool invisible(unsigned cp)
|
||||
{
|
||||
return (cp >= 0x80 && cp < 0xA0) || cp == 0xAD || (cp >= 0x200B && cp <= 0x200F) || (cp >= 0x202A && cp <= 0x202E) ||
|
||||
(cp >= 0x2060 && cp <= 0x2064) || (cp >= 0xFE00 && cp <= 0xFE0F) || cp == 0xFEFF;
|
||||
}
|
||||
|
||||
// A letter and the mark after it, as one character: 0 when there's none.
|
||||
unsigned compose(char base, unsigned mark)
|
||||
{
|
||||
static const struct
|
||||
{
|
||||
unsigned mark;
|
||||
const char *bases;
|
||||
const char16_t *made;
|
||||
} kMarks[] = {
|
||||
{0x300, "AEIOUaeiou", u"ÀÈÌÒÙàèìòù"},
|
||||
{0x301, "AEIOUYaeiouyCcLlNnRrSsZz",
|
||||
u"ÁÉÍÓÚÝáéíóúý"
|
||||
u"ĆćĹĺŃńŔŕŚśŹź"},
|
||||
{0x302, "AEIOUaeiouCcGgHhJjSsWwYy",
|
||||
u"ÂÊÎÔÛâêîôû"
|
||||
u"ĈĉĜĝĤĥĴĵŜŝŴŵŶŷ"},
|
||||
{0x303, "ANOanoIiUu", u"ÃÑÕãñõĨĩŨũ"},
|
||||
{0x304, "AEIOUaeiou", u"ĀĒĪŌŪāēīōū"},
|
||||
{0x306, "AEGIOUaegiou", u"ĂĔĞĬŎŬăĕğĭŏŭ"},
|
||||
{0x307, "CEGIZcegz", u"ĊĖĠİŻċėġż"},
|
||||
{0x308, "AEIOUYaeiouy", u"ÄËÏÖÜŸäëïöüÿ"},
|
||||
{0x30A, "AaUu", u"ÅåŮů"},
|
||||
{0x30B, "OoUu", u"ŐőŰű"},
|
||||
{0x30C, "CDELNRSTZcdelnrstz",
|
||||
u"ČĎĚĽŇŘŠŤŽčďěľňřšťž"},
|
||||
{0x327, "CcGgKkLlNnRrSsTt",
|
||||
u"ÇçĢģĶķĻļŅņŖŗŞşŢţ"},
|
||||
{0x328, "AEIUaeiu", u"ĄĘĮŲąęįų"},
|
||||
};
|
||||
for (const auto &m : kMarks)
|
||||
if (m.mark == mark)
|
||||
for (int i = 0; m.bases[i]; ++i)
|
||||
if (m.bases[i] == base)
|
||||
return m.made[i];
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Appends what a character the fonts lack reads as, in ASCII; false for one
|
||||
// with no stand-in (Japanese, say).
|
||||
bool fold(unsigned cp, std::string &out)
|
||||
{
|
||||
static const char *const kSymbols[] = {// U+00A0 to U+00BF
|
||||
" ", "", "c", "GBP", "", "JPY", "|", "S", "", "(C)", "a", "\"", "-", "", "(R)", "",
|
||||
"o", "+/-", "2", "3", "'", "u", "", ".", "", "1", "o", "\"", "1/4", "1/2", "3/4", ""};
|
||||
static const char kLatin1[] = // U+00C0 to U+00FF; Æ, Þ, ß, æ and þ are below
|
||||
"AAAAAAACEEEEIIII" "DNOOOOOxOUUUUYTs" "aaaaaaaceeeeiiii" "dnooooo/ouuuuyty";
|
||||
static const char kExtendedA[] = // U+0100 to U+017F; IJ, ij, Œ and œ are below
|
||||
"AaAaAaCcCcCcCcDd" "DdEeEeEeEeEeGgGg" "GgGgHhHhIiIiIiIi" "IiIiJjKkkLlLlLlL"
|
||||
"lLlNnNnNnnNnOoOo" "OoOoRrRrRrSsSsSs" "SsTtTtTtUuUuUuUu" "UuUuWwYyYZzZzZzs";
|
||||
static const char kPinyin[] = "AaIiOoUuUuUuUuUu"; // U+01CD to U+01DC: Ǎ ǎ Ǐ ǐ ... ǜ
|
||||
// Greek and Cyrillic capitals in Latin letters; the small ones are these in lower case
|
||||
static const char *const kGreek[] = {// U+0391 to U+03A9 (U+03A2, unused, as final sigma's slot)
|
||||
"A", "B", "G", "D", "E", "Z", "I", "Th", "I", "K", "L", "M", "N", "X", "O", "P", "R", "s", "S",
|
||||
"T", "Y", "F", "Ch", "Ps", "O"};
|
||||
static const char *const kCyrillic[] = {// U+0410 to U+042F
|
||||
"A", "B", "V", "G", "D", "E", "Zh", "Z", "I", "Y", "K", "L", "M", "N", "O", "P",
|
||||
"R", "S", "T", "U", "F", "Kh", "Ts", "Ch", "Sh", "Shch", "", "Y", "", "E", "Yu", "Ya"};
|
||||
auto lower = [&out](const char *s) {
|
||||
for (; *s; ++s)
|
||||
out += static_cast<char>(std::tolower(static_cast<unsigned char>(*s)));
|
||||
return true;
|
||||
};
|
||||
auto put = [&out](const char *s) { return out += s, true; };
|
||||
auto one = [&out](char c) { return out += c, true; };
|
||||
if (cp >= 0xA0 && cp <= 0xBF)
|
||||
return put(kSymbols[cp - 0xA0]);
|
||||
if (cp >= 0xC0 && cp <= 0xFF)
|
||||
return cp == 0xC6 ? put("AE") : cp == 0xDE ? put("Th") : cp == 0xDF ? put("ss") : cp == 0xE6 ? put("ae")
|
||||
: cp == 0xFE ? put("th") : one(kLatin1[cp - 0xC0]);
|
||||
if (cp >= 0x100 && cp <= 0x17F)
|
||||
return cp == 0x132 ? put("IJ") : cp == 0x133 ? put("ij") : cp == 0x152 ? put("OE") : cp == 0x153 ? put("oe")
|
||||
: one(kExtendedA[cp - 0x100]);
|
||||
if (cp >= 0x1CD && cp <= 0x1DC)
|
||||
return one(kPinyin[cp - 0x1CD]);
|
||||
if (cp >= 0x391 && cp <= 0x3A9)
|
||||
return put(kGreek[cp - 0x391]);
|
||||
if (cp >= 0x3B1 && cp <= 0x3C9)
|
||||
return lower(kGreek[cp - 0x3B1]);
|
||||
if (cp >= 0x410 && cp <= 0x42F)
|
||||
return put(kCyrillic[cp - 0x410]);
|
||||
if (cp >= 0x430 && cp <= 0x44F)
|
||||
return lower(kCyrillic[cp - 0x430]);
|
||||
if (cp >= 0xFF01 && cp <= 0xFF5E) // full-width ASCII
|
||||
return one(static_cast<char>(cp - 0xFEE0));
|
||||
if ((cp >= 0x2000 && cp <= 0x200A) || cp == 0x2028 || cp == 0x2029 || cp == 0x202F || cp == 0x205F || cp == 0x3000)
|
||||
return one(' ');
|
||||
switch (cp)
|
||||
{
|
||||
case 0x192: return put("f");
|
||||
case 0x1A0: return put("O");
|
||||
case 0x1A1: return put("o");
|
||||
case 0x1AF: return put("U");
|
||||
case 0x1B0: return put("u");
|
||||
case 0x218: return put("S");
|
||||
case 0x219: return put("s");
|
||||
case 0x21A: return put("T");
|
||||
case 0x21B: return put("t");
|
||||
case 0x2BC: return put("'");
|
||||
case 0x2C6: return put("^");
|
||||
case 0x2DC: return put("~");
|
||||
case 0x386: return put("A");
|
||||
case 0x388: return put("E");
|
||||
case 0x389: case 0x38A: return put("I");
|
||||
case 0x38C: case 0x38F: return put("O");
|
||||
case 0x38E: return put("Y");
|
||||
case 0x390: case 0x3AE: case 0x3AF: case 0x3CA: return put("i");
|
||||
case 0x3AC: return put("a");
|
||||
case 0x3AD: return put("e");
|
||||
case 0x3CB: case 0x3CD: return put("y");
|
||||
case 0x3CC: case 0x3CE: return put("o");
|
||||
case 0x401: return put("Yo");
|
||||
case 0x451: return put("yo");
|
||||
case 0x2010: case 0x2011: case 0x2012: case 0x2013: case 0x2014: case 0x2015: case 0x2212: return put("-");
|
||||
case 0x2018: case 0x2019: case 0x201A: case 0x201B: case 0x2032: return put("'");
|
||||
case 0x201C: case 0x201D: case 0x201E: case 0x201F: case 0x2033: return put("\"");
|
||||
case 0x2039: return put("<");
|
||||
case 0x203A: return put(">");
|
||||
case 0x2020: return put("+");
|
||||
case 0x2022: case 0x2605: case 0x2606: return put("*");
|
||||
case 0x2024: return put(".");
|
||||
case 0x2025: return put("..");
|
||||
case 0x2026: return put("...");
|
||||
case 0x2030: return put("%");
|
||||
case 0x2044: case 0x2215: return put("/");
|
||||
case 0x20AC: return put("EUR");
|
||||
case 0x2116: return put("No");
|
||||
case 0x2122: return put("TM");
|
||||
case 0x2190: return put("<-");
|
||||
case 0x2192: return put("->");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Text the screen's fonts can draw: what they have stays ("Pokémon",
|
||||
// "Luigi’s Mansion"), anything else folds to its nearest ASCII ("Ō" reads "O",
|
||||
// "ß" "ss", "™" "TM" in a font without them); a decomposed accent joins its
|
||||
// letter, or goes. Only a character with no stand-in at all is "?".
|
||||
std::string plain(const char *text)
|
||||
{
|
||||
static const char *const kLatin1 = // U+00C0 to U+00FF
|
||||
"AAAAAAACEEEEIIII" "DNOOOOOxOUUUUYTs" "aaaaaaaceeeeiiii" "dnooooo/ouuuuyty";
|
||||
std::string out;
|
||||
const auto *p = reinterpret_cast<const unsigned char *>(text);
|
||||
std::size_t base_at = 0; // where the last plain letter is in out, for a mark after it
|
||||
char base = 0;
|
||||
while (*p)
|
||||
{
|
||||
unsigned cp = *p++;
|
||||
int more = cp >= 0xF0 ? 3 : cp >= 0xE0 ? 2 : cp >= 0xC0 ? 1 : 0;
|
||||
if (more)
|
||||
cp &= 0x3F >> more;
|
||||
while (more-- > 0 && (*p & 0xC0) == 0x80)
|
||||
cp = (cp << 6) | (*p++ & 0x3F);
|
||||
const unsigned cp = next_char(p);
|
||||
if (cp == kNothing || invisible(cp))
|
||||
continue;
|
||||
if (cp < 0x80)
|
||||
{
|
||||
base = std::isalpha(static_cast<int>(cp)) ? static_cast<char>(cp) : 0;
|
||||
base_at = out.size();
|
||||
out += static_cast<char>(cp);
|
||||
else if (cp >= 0xC0 && cp <= 0xFF)
|
||||
out += kLatin1[cp - 0xC0];
|
||||
else if (cp == 0x2018 || cp == 0x2019)
|
||||
out += '\'';
|
||||
else if (cp == 0x201C || cp == 0x201D)
|
||||
out += '"';
|
||||
else if (cp == 0x2013 || cp == 0x2014)
|
||||
out += '-';
|
||||
else if (cp == 0x2026)
|
||||
out += "...";
|
||||
else if (cp == 0xA0)
|
||||
out += ' ';
|
||||
else if (cp == 0xB7)
|
||||
out += "\xC2\xB7"; // the middle dot is in the fonts: the app uses it too
|
||||
else
|
||||
continue;
|
||||
}
|
||||
if (combining(cp))
|
||||
{
|
||||
const unsigned made = base ? compose(base, cp) : 0;
|
||||
if (made && drawable(made))
|
||||
{
|
||||
out.resize(base_at);
|
||||
put_utf8(out, made);
|
||||
}
|
||||
base = 0; // a second mark on the same letter goes
|
||||
continue;
|
||||
}
|
||||
base = 0;
|
||||
if (cp != 0xA0 && drawable(cp))
|
||||
{
|
||||
put_utf8(out, cp);
|
||||
continue;
|
||||
}
|
||||
if (!fold(cp, out))
|
||||
out += '?';
|
||||
}
|
||||
return out;
|
||||
@@ -155,20 +424,32 @@ Palette palette_of(const unsigned char *rgba, int w, int h)
|
||||
|
||||
} // namespace
|
||||
|
||||
std::string plain_text(const char *text)
|
||||
{
|
||||
return plain(text ? text : "");
|
||||
}
|
||||
|
||||
void set_text_fonts(const hui::ui::Fonts &fonts)
|
||||
{
|
||||
const hui::ui::FontRef *faces[] = {&fonts.regular, &fonts.semibold, &fonts.display, &fonts.mono};
|
||||
for (int i = 0; i < 4; ++i)
|
||||
g_text_fonts[i] = faces[i]->font;
|
||||
}
|
||||
|
||||
App &app()
|
||||
{
|
||||
static App instance;
|
||||
return instance;
|
||||
}
|
||||
|
||||
App::App() : api_("127.0.0.1", 8780)
|
||||
App::App() : api_("127.0.0.1", payload_port())
|
||||
{
|
||||
refresh_status();
|
||||
}
|
||||
|
||||
void App::say(const std::string &message)
|
||||
{
|
||||
toast_ = message;
|
||||
toast_ = plain(message.c_str()); // the service's errors carry names and paths
|
||||
toast_age_ = 0;
|
||||
}
|
||||
|
||||
@@ -176,6 +457,7 @@ void App::tick(float dt, hui::gfx::Renderer *renderer)
|
||||
{
|
||||
clock_ += dt;
|
||||
api_.poll();
|
||||
slow_api_.poll();
|
||||
for (Api &a : covers_api_)
|
||||
a.poll();
|
||||
for (Api &a : pages_api_)
|
||||
@@ -193,6 +475,8 @@ void App::tick(float dt, hui::gfx::Renderer *renderer)
|
||||
if (covers_in_flight_ >= kCoversInFlight)
|
||||
break;
|
||||
Cover &c = covers_[path];
|
||||
if (c.failed && c.retry_at > 0 && clock_ >= c.retry_at)
|
||||
c.requested = c.failed = false, c.retry_at = 0; // try again
|
||||
if (c.requested)
|
||||
continue;
|
||||
c.requested = true;
|
||||
@@ -203,7 +487,14 @@ void App::tick(float dt, hui::gfx::Renderer *renderer)
|
||||
if (r.ok() && !r.body.empty())
|
||||
to_decode_.push_back({path, r.body});
|
||||
else
|
||||
covers_[path].failed = true;
|
||||
{
|
||||
Cover &c = covers_[path];
|
||||
c.failed = true;
|
||||
// RomM has no cover: that stays. Anything else (the service
|
||||
// busy, a network hiccup): again in a little while.
|
||||
const bool again = r.status != 404 && c.retries < kCoverRetries;
|
||||
c.retry_at = again ? clock_ + 15.0 * (1 << c.retries++) : 0;
|
||||
}
|
||||
});
|
||||
}
|
||||
wanted_.clear();
|
||||
@@ -217,10 +508,10 @@ void App::tick(float dt, hui::gfx::Renderer *renderer)
|
||||
update_timer_ += dt;
|
||||
if (update_.busy() && !update_busy_ && update_timer_ >= 1.0f)
|
||||
refresh_update();
|
||||
downloads_timer_ += dt;
|
||||
if (status_.paired && !downloads_busy_ &&
|
||||
downloads_timer_ >= (active_downloads_ ? kDownloadsEvery : kDownloadsIdleEvery))
|
||||
refresh_downloads();
|
||||
queue_timer_ += dt;
|
||||
if (status_.paired && !queue_busy_ &&
|
||||
queue_timer_ >= (active_queue_ ? kQueueEvery : kQueueIdleEvery))
|
||||
refresh_queue();
|
||||
|
||||
if (renderer)
|
||||
evict_covers(renderer);
|
||||
@@ -233,7 +524,7 @@ void App::tick(float dt, hui::gfx::Renderer *renderer)
|
||||
else if (start_state_ == 3)
|
||||
{
|
||||
start_state_ = 0;
|
||||
say("Couldn't start RomM Sync: " + start_error_);
|
||||
say("Couldn't start RommPS's background service: " + start_error_);
|
||||
}
|
||||
|
||||
// A few covers a frame become textures; the rest wait their turn, so a
|
||||
@@ -244,14 +535,19 @@ void App::tick(float dt, hui::gfx::Renderer *renderer)
|
||||
to_decode_.pop_front();
|
||||
Cover &cover = covers_[job.path];
|
||||
int w = 0, h = 0, channels = 0;
|
||||
unsigned char *rgba = stbi_load_from_memory(reinterpret_cast<const unsigned char *>(job.bytes.data()),
|
||||
static_cast<int>(job.bytes.size()), &w, &h, &channels, 4);
|
||||
const auto *bytes = reinterpret_cast<const unsigned char *>(job.bytes.data());
|
||||
int len = static_cast<int>(job.bytes.size());
|
||||
ico_png(bytes, len);
|
||||
unsigned char *rgba = stbi_load_from_memory(bytes, len, &w, &h, &channels, 4);
|
||||
if (!rgba)
|
||||
{
|
||||
cover.failed = true;
|
||||
continue;
|
||||
}
|
||||
cover.palette = palette_of(rgba, w, h);
|
||||
cover.aspect = h > 0 ? static_cast<float>(w) / static_cast<float>(h) : 0.0f;
|
||||
if (shapes_[job.path].aspect != cover.aspect)
|
||||
shapes_[job.path] = {cover.aspect, clock_};
|
||||
if (renderer)
|
||||
cover.texture = renderer->create_texture(w, h, rgba);
|
||||
stbi_image_free(rgba);
|
||||
@@ -301,7 +597,7 @@ void App::start_payload()
|
||||
const std::string &elf = bundled_payload();
|
||||
if (elf.size() < 4 || elf.compare(0, 4, "\x7f" "ELF") != 0)
|
||||
{
|
||||
start_error_ = "this RommPS doesn't carry the RomM Sync payload";
|
||||
start_error_ = "this RommPS doesn't carry its background service";
|
||||
start_state_ = 3;
|
||||
return;
|
||||
}
|
||||
@@ -331,6 +627,23 @@ void App::start_payload()
|
||||
}).detach();
|
||||
}
|
||||
|
||||
void App::repair_autostart()
|
||||
{
|
||||
api_.get("/api/autostart", [this](const Response &r) {
|
||||
std::unique_ptr<cJSON, void (*)(cJSON *)> j(r.json(), cJSON_Delete);
|
||||
if (!r.ok() || !j || !flag(j.get(), "supported"))
|
||||
return;
|
||||
// RommPS needs RomM Sync running, so it starts with the console, from
|
||||
// a copy no older than the one RommPS carries.
|
||||
bool old = !flag(j.get(), "enabled");
|
||||
const cJSON *c;
|
||||
cJSON_ArrayForEach(c, cJSON_GetObjectItemCaseSensitive(j.get(), "copies"))
|
||||
old |= version_cmp(str(c, "version"), kMinPayload) < 0;
|
||||
if (old)
|
||||
install_autostart(nullptr);
|
||||
});
|
||||
}
|
||||
|
||||
void App::install_autostart(Api::Callback done)
|
||||
{
|
||||
api_.post("/api/autostart/upload", bundled_payload(), [this, done](const Response &r) {
|
||||
@@ -357,6 +670,20 @@ bool App::payload_too_old() const
|
||||
return false;
|
||||
}
|
||||
|
||||
// RomM Sync holds a lock on this file for as long as it runs (1.1.1 on); a
|
||||
// copy can be running and not answering yet, after a start or a wake.
|
||||
int App::payload_lock()
|
||||
{
|
||||
const int fd = ::open("/data/romm-sync/romm-sync.lock", O_RDONLY);
|
||||
if (fd < 0)
|
||||
return errno == ENOENT ? 0 : -1;
|
||||
int held = 0;
|
||||
if (::flock(fd, LOCK_SH | LOCK_NB) != 0)
|
||||
held = errno == EWOULDBLOCK ? 1 : -1;
|
||||
::close(fd);
|
||||
return held;
|
||||
}
|
||||
|
||||
void App::refresh_status()
|
||||
{
|
||||
status_timer_ = 0;
|
||||
@@ -365,16 +692,26 @@ void App::refresh_status()
|
||||
status_busy_ = false;
|
||||
status_.known = true;
|
||||
status_.reachable = r.status != 0;
|
||||
// Not running: RommPS starts the payload it carries, once by itself
|
||||
// (Cross on the screen tries again).
|
||||
// Not answering: RommPS starts the payload it carries, once by itself
|
||||
// (Cross on the screen tries again), but only when none is running.
|
||||
// One that's running is left to come up; with no way to tell, it
|
||||
// waits a few tries first.
|
||||
unanswered_ = status_.reachable ? 0 : unanswered_ + 1;
|
||||
payload_waiting_ = false;
|
||||
if (!status_.reachable && !tried_start_)
|
||||
{
|
||||
tried_start_ = true;
|
||||
start_payload();
|
||||
const int lock = payload_lock();
|
||||
if (lock == 1)
|
||||
payload_waiting_ = true;
|
||||
else if (lock == 0 || unanswered_ >= 3)
|
||||
{
|
||||
tried_start_ = true;
|
||||
start_payload();
|
||||
}
|
||||
}
|
||||
if (!r.ok())
|
||||
{
|
||||
status_.error = r.message();
|
||||
status_.error = plain(r.message().c_str());
|
||||
return;
|
||||
}
|
||||
std::unique_ptr<cJSON, void (*)(cJSON *)> j(r.json(), cJSON_Delete);
|
||||
@@ -382,31 +719,33 @@ void App::refresh_status()
|
||||
return;
|
||||
status_.error.clear();
|
||||
status_.version = str(j.get(), "version");
|
||||
// An older payload is replaced by the one RommPS carries, once it's
|
||||
// idle: its autostart copy first, if it has one, then the running one.
|
||||
if (payload_too_old() && !updated_payload_ && !flag(cJSON_GetObjectItemCaseSensitive(j.get(), "sync"), "running") &&
|
||||
active_downloads_ == 0)
|
||||
// An older payload is replaced by the one RommPS carries once it's
|
||||
// idle: the new one takes over from it and then, being this
|
||||
// RommPS's own, updates the copies on disk (below). A newer one is
|
||||
// left alone.
|
||||
const bool idle = !flag(cJSON_GetObjectItemCaseSensitive(j.get(), "sync"), "running") && active_queue_ == 0;
|
||||
if (payload_too_old() && !updated_payload_ && idle)
|
||||
{
|
||||
updated_payload_ = true;
|
||||
say("Updating RomM Sync to " + std::string(kMinPayload));
|
||||
api_.get("/api/autostart", [this](const Response &a) {
|
||||
std::unique_ptr<cJSON, void (*)(cJSON *)> as(a.json(), cJSON_Delete);
|
||||
if (a.ok() && flag(as.get(), "installed"))
|
||||
install_autostart([this](const Response &) { start_payload(); });
|
||||
else
|
||||
start_payload();
|
||||
});
|
||||
say("Updating RommPS's background service to " + std::string(kMinPayload));
|
||||
start_payload();
|
||||
}
|
||||
else if (!repaired_ && version_cmp(status_.version, kMinPayload) == 0)
|
||||
{
|
||||
repaired_ = true;
|
||||
repair_autostart();
|
||||
}
|
||||
status_.ip = str(j.get(), "ip");
|
||||
status_.port = static_cast<int>(num(j.get(), "web_port", 8780));
|
||||
status_.paired = flag(j.get(), "paired");
|
||||
status_.setup_complete = flag(j.get(), "setup_complete");
|
||||
status_.server = str(j.get(), "server_url");
|
||||
status_.user = str(j.get(), "username");
|
||||
status_.user = plain(str(j.get(), "username"));
|
||||
status_.server_trouble = plain(str(j.get(), "server_trouble"));
|
||||
status_.uptime = num(j.get(), "uptime");
|
||||
const cJSON *sync = cJSON_GetObjectItemCaseSensitive(j.get(), "sync");
|
||||
status_.syncing = flag(sync, "running");
|
||||
status_.phase = str(sync, "phase");
|
||||
status_.phase = plain(str(sync, "phase"));
|
||||
status_.last_run = num(sync, "last_run");
|
||||
status_.sync_count = static_cast<int>(num(sync, "sync_count"));
|
||||
status_.tracked_saves = static_cast<int>(num(sync, "tracked_saves"));
|
||||
@@ -414,14 +753,30 @@ void App::refresh_status()
|
||||
// The payload adds each sync at the end of its history: the last one is the newest.
|
||||
const cJSON *history = cJSON_GetObjectItemCaseSensitive(sync, "history");
|
||||
const cJSON *last = cJSON_GetArrayItem(history, cJSON_GetArraySize(history) - 1);
|
||||
status_.last_result = last ? (str(last, "error")[0] ? str(last, "error") : "OK") : "";
|
||||
status_.last_result = last ? (str(last, "error")[0] ? plain(str(last, "error")) : "OK") : "";
|
||||
status_.last_up = static_cast<int>(num(last, "uploaded"));
|
||||
status_.last_down = static_cast<int>(num(last, "downloaded"));
|
||||
status_.server_version = str(j.get(), "server_version");
|
||||
const cJSON *move = cJSON_GetObjectItemCaseSensitive(j.get(), "retroarch_move"), *folder;
|
||||
status_.retroarch_move = static_cast<int>(num(move, "games"));
|
||||
status_.retroarch_folders.clear();
|
||||
cJSON_ArrayForEach(folder, cJSON_GetObjectItemCaseSensitive(move, "folders"))
|
||||
{
|
||||
const std::string from = str(folder, "from");
|
||||
status_.retroarch_folders += (status_.retroarch_folders.empty() ? "" : ", ") + plain(from.substr(from.find_last_of('/') + 1).c_str());
|
||||
}
|
||||
const cJSON *covers = cJSON_GetObjectItemCaseSensitive(j.get(), "covers");
|
||||
status_.covers_state = str(covers, "state");
|
||||
status_.covers_done = static_cast<int>(num(covers, "done"));
|
||||
status_.covers_total = static_cast<int>(num(covers, "total"));
|
||||
// A game came or went (deleted by hand included): the lists load again.
|
||||
const double sig = num(j.get(), "installed_sig", -1);
|
||||
if (sig != installed_sig_ && installed_sig_ >= 0 && sig >= 0)
|
||||
{
|
||||
lists_.clear();
|
||||
refresh_installed();
|
||||
}
|
||||
installed_sig_ = sig;
|
||||
const cJSON *pairing = cJSON_GetObjectItemCaseSensitive(j.get(), "pairing");
|
||||
if (status_.pairing == "starting")
|
||||
pairing = nullptr; // the answer to /api/pair/start says how it went
|
||||
@@ -429,7 +784,7 @@ void App::refresh_status()
|
||||
status_.pairing = str(pairing, "status");
|
||||
if (pairing)
|
||||
{
|
||||
status_.pairing_message = str(pairing, "message");
|
||||
status_.pairing_message = plain(str(pairing, "message"));
|
||||
status_.user_code = str(pairing, "user_code");
|
||||
status_.verification_url = str(pairing, "verification_url");
|
||||
}
|
||||
@@ -464,7 +819,7 @@ void App::refresh_platforms()
|
||||
plat.id = static_cast<int>(num(p, "id"));
|
||||
plat.name = plain(str(p, "name"));
|
||||
plat.slug = str(p, "slug");
|
||||
plat.profile = str(p, "profile");
|
||||
plat.profile = plain(str(p, "profile"));
|
||||
plat.rom_count = static_cast<int>(num(p, "rom_count"));
|
||||
if (plat.rom_count > 0)
|
||||
list.push_back(std::move(plat));
|
||||
@@ -554,7 +909,7 @@ void App::send_page(const std::string &key, int page)
|
||||
GameList &l = *it->second.list;
|
||||
if (!r.ok())
|
||||
{
|
||||
l.error = r.message();
|
||||
l.error = plain(r.message().c_str());
|
||||
if (static_cast<int>(l.pages.size()) > page)
|
||||
l.pages[static_cast<std::size_t>(page)] = 0; // asked again when looked at
|
||||
return;
|
||||
@@ -593,6 +948,7 @@ void App::send_page(const std::string &key, int page)
|
||||
game.installed = flag(g, "installed");
|
||||
game.multi = flag(g, "multi");
|
||||
game.not_game = flag(g, "not_game");
|
||||
game.archived = flag(g, "archived");
|
||||
game.version = plain(str(g, "version"));
|
||||
game.versions = static_cast<int>(num(g, "versions"));
|
||||
}
|
||||
@@ -634,17 +990,37 @@ const Details &App::details(int rom_id)
|
||||
out.sessions = static_cast<int>(num(j.get(), "sessions"));
|
||||
out.completion = static_cast<int>(num(j.get(), "completion"));
|
||||
out.my_rating = static_cast<int>(num(j.get(), "my_rating"));
|
||||
out.archived = plain(str(j.get(), "archived"));
|
||||
out.updates = static_cast<int>(num(j.get(), "updates"));
|
||||
out.dlc = static_cast<int>(num(j.get(), "dlc"));
|
||||
out.title_id = str(j.get(), "title_id");
|
||||
const auto addon = [](const cJSON *a) {
|
||||
const char *k = str(a, "kind");
|
||||
return Details::Addon{!std::strcmp(k, "update") ? Details::Addon::update
|
||||
: !std::strcmp(k, "dlc") ? Details::Addon::dlc
|
||||
: Details::Addon::game,
|
||||
plain(str(a, "name")), str(a, "title_id"), num(a, "size")};
|
||||
};
|
||||
const cJSON *a;
|
||||
cJSON_ArrayForEach(a, cJSON_GetObjectItemCaseSensitive(j.get(), "addons"))
|
||||
out.addons.push_back(addon(a));
|
||||
cJSON_ArrayForEach(a, cJSON_GetObjectItemCaseSensitive(j.get(), "bundle"))
|
||||
{
|
||||
out.addons.push_back(addon(a));
|
||||
out.bundle++;
|
||||
}
|
||||
});
|
||||
return d;
|
||||
}
|
||||
|
||||
void App::refresh_installed()
|
||||
{
|
||||
api_.get("/api/installed?limit=30", [this](const Response &r) {
|
||||
api_.get("/api/installed?limit=5000", [this](const Response &r) { // every one: the Downloads tab counts them
|
||||
if (!r.ok())
|
||||
return;
|
||||
std::unique_ptr<cJSON, void (*)(cJSON *)> j(r.json(), cJSON_Delete);
|
||||
std::vector<Game> list;
|
||||
std::unordered_set<int> ids;
|
||||
const cJSON *g;
|
||||
cJSON_ArrayForEach(g, j.get())
|
||||
{
|
||||
@@ -654,13 +1030,49 @@ void App::refresh_installed()
|
||||
game.name = plain(str(g, "name"));
|
||||
game.fs_name = str(g, "fs_name");
|
||||
game.cover = str(g, "cover");
|
||||
game.size = num(g, "size");
|
||||
game.installed = true;
|
||||
ids.insert(game.id);
|
||||
list.push_back(std::move(game));
|
||||
}
|
||||
installed_ = std::move(list);
|
||||
on_console_ = std::move(ids);
|
||||
installed_known_ = true;
|
||||
});
|
||||
}
|
||||
|
||||
void App::check_on_console(int rom_id)
|
||||
{
|
||||
char path[64];
|
||||
std::snprintf(path, sizeof path, "/api/roms/%d/on_console", rom_id);
|
||||
api_.get(path, [this, rom_id](const Response &r) {
|
||||
std::unique_ptr<cJSON, void (*)(cJSON *)> j(r.json(), cJSON_Delete);
|
||||
if (!r.ok() || !j || !installed_known_) // an older payload doesn't know it
|
||||
return;
|
||||
const bool here = flag(j.get(), "installed");
|
||||
if (here == (on_console_.count(rom_id) > 0))
|
||||
return;
|
||||
if (here)
|
||||
{
|
||||
refresh_installed(); // its size and cover for the shelf
|
||||
on_console_.insert(rom_id);
|
||||
return;
|
||||
}
|
||||
on_console_.erase(rom_id);
|
||||
installed_.erase(std::remove_if(installed_.begin(), installed_.end(),
|
||||
[rom_id](const Game &g) { return g.id == rom_id; }),
|
||||
installed_.end());
|
||||
});
|
||||
}
|
||||
|
||||
const Cover &App::platform_logo(const std::string &slug)
|
||||
{
|
||||
std::string lower = slug;
|
||||
for (char &c : lower)
|
||||
c = static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
|
||||
return cover(slug.empty() ? std::string() : "/assets/platforms/" + lower + ".ico");
|
||||
}
|
||||
|
||||
const Cover &App::cover(const std::string &path)
|
||||
{
|
||||
static const Cover kNone;
|
||||
@@ -674,12 +1086,22 @@ const Cover &App::cover(const std::string &path)
|
||||
return c;
|
||||
}
|
||||
|
||||
void App::refresh_downloads()
|
||||
float App::cover_aspect(const std::string &path, double *since) const
|
||||
{
|
||||
downloads_timer_ = 0;
|
||||
downloads_busy_ = true;
|
||||
auto found = shapes_.find(path);
|
||||
if (found == shapes_.end())
|
||||
return 0.0f;
|
||||
if (since)
|
||||
*since = found->second.since;
|
||||
return found->second.aspect;
|
||||
}
|
||||
|
||||
void App::refresh_queue()
|
||||
{
|
||||
queue_timer_ = 0;
|
||||
queue_busy_ = true;
|
||||
api_.get("/api/downloads", [this](const Response &r) {
|
||||
downloads_busy_ = false;
|
||||
queue_busy_ = false;
|
||||
if (!r.ok())
|
||||
return;
|
||||
std::unique_ptr<cJSON, void (*)(cJSON *)> j(r.json(), cJSON_Delete);
|
||||
@@ -695,19 +1117,23 @@ void App::refresh_downloads()
|
||||
dl.name = plain(str(d, "name"));
|
||||
dl.platform = plain(str(d, "platform"));
|
||||
dl.status = str(d, "status");
|
||||
dl.error = str(d, "error");
|
||||
dl.error = plain(str(d, "error"));
|
||||
dl.done = num(d, "done");
|
||||
dl.total = num(d, "total");
|
||||
dl.speed = num(d, "speed");
|
||||
dl.saves = static_cast<int>(num(d, "saves"));
|
||||
dl.states = static_cast<int>(num(d, "states"));
|
||||
dl.save_conflicts = static_cast<int>(num(d, "save_conflicts"));
|
||||
dl.saves_failed = flag(d, "saves_failed");
|
||||
active += dl.active();
|
||||
// A game that just finished shows as downloaded on its shelf.
|
||||
for (const Download &old : downloads_)
|
||||
for (const Download &old : queue_)
|
||||
if (old.id == dl.id && old.active() && dl.status == "done")
|
||||
finished_one = true;
|
||||
list.push_back(std::move(dl));
|
||||
}
|
||||
downloads_ = std::move(list);
|
||||
active_downloads_ = active;
|
||||
queue_ = std::move(list);
|
||||
active_queue_ = active;
|
||||
// A game that finished shows as downloaded: the lists load again.
|
||||
if (finished_one)
|
||||
{
|
||||
@@ -717,10 +1143,25 @@ void App::refresh_downloads()
|
||||
});
|
||||
}
|
||||
|
||||
std::string Download::saves_note() const
|
||||
{
|
||||
if (saves_failed)
|
||||
return "Saves couldn't sync";
|
||||
if (save_conflicts)
|
||||
return "Save conflict to resolve";
|
||||
auto count = [](int n, const char *what) { return std::to_string(n) + " " + what + (n == 1 ? "" : "s"); };
|
||||
std::string got;
|
||||
if (saves)
|
||||
got = count(saves, "save");
|
||||
if (states)
|
||||
got += (got.empty() ? "" : " and ") + count(states, "state");
|
||||
return got.empty() ? "" : got + " synced";
|
||||
}
|
||||
|
||||
const Download *App::download_for(int rom_id) const
|
||||
{
|
||||
const Download *found = nullptr;
|
||||
for (const Download &d : downloads_)
|
||||
for (const Download &d : queue_)
|
||||
if (d.rom_id == rom_id)
|
||||
found = &d; // the newest one wins
|
||||
return found;
|
||||
@@ -738,16 +1179,16 @@ void App::download(int rom_id, const std::string &name)
|
||||
api_.post("/api/download", body, [this](const Response &r) {
|
||||
if (!r.ok())
|
||||
say("Couldn't start the download: " + r.message());
|
||||
refresh_downloads();
|
||||
refresh_queue();
|
||||
});
|
||||
}
|
||||
|
||||
void App::clear_finished_downloads()
|
||||
void App::clear_finished_queue()
|
||||
{
|
||||
api_.post("/api/downloads/clear", "{}", [this](const Response &r) {
|
||||
if (!r.ok())
|
||||
say("Couldn't clear the list: " + r.message());
|
||||
refresh_downloads();
|
||||
refresh_queue();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -758,7 +1199,35 @@ void App::cancel_download(int job_id)
|
||||
api_.post("/api/downloads/cancel", body, [this](const Response &r) {
|
||||
if (!r.ok())
|
||||
say("Couldn't cancel: " + r.message());
|
||||
refresh_downloads();
|
||||
refresh_queue();
|
||||
});
|
||||
}
|
||||
|
||||
void App::retry_download(int job_id)
|
||||
{
|
||||
char body[64];
|
||||
std::snprintf(body, sizeof body, "{\"id\":%d}", job_id);
|
||||
api_.post("/api/downloads/retry", body, [this](const Response &r) {
|
||||
if (!r.ok())
|
||||
say("Couldn't retry: " + r.message());
|
||||
refresh_queue();
|
||||
});
|
||||
}
|
||||
|
||||
void App::remove_game(int rom_id)
|
||||
{
|
||||
char body[64];
|
||||
std::snprintf(body, sizeof body, "{\"rom_id\":%d}", rom_id);
|
||||
api_.post("/api/delete", body, [this](const Response &r) {
|
||||
if (!r.ok())
|
||||
{
|
||||
say("Couldn't remove the game: " + r.message());
|
||||
return;
|
||||
}
|
||||
say("Game removed. Its saves are kept.");
|
||||
lists_.clear();
|
||||
refresh_installed();
|
||||
refresh_queue();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -786,6 +1255,7 @@ void App::refresh_config()
|
||||
c.sync_on_game_exit = flag(j.get(), "sync_on_game_exit");
|
||||
c.sync_on_game_start = flag(j.get(), "sync_on_game_start");
|
||||
c.sync_on_change = flag(j.get(), "sync_on_change");
|
||||
c.save_sync = !cJSON_IsFalse(cJSON_GetObjectItemCaseSensitive(j.get(), "save_sync")); // older payloads: on
|
||||
c.notify = flag(j.get(), "notify");
|
||||
c.update_check = flag(j.get(), "update_check");
|
||||
c.states = str(j.get(), "states", "off");
|
||||
@@ -825,12 +1295,15 @@ void App::refresh_update()
|
||||
Update u;
|
||||
u.known = true;
|
||||
u.state = str(j.get(), "state");
|
||||
u.message = str(j.get(), "message");
|
||||
u.error = str(j.get(), "error");
|
||||
u.message = plain(str(j.get(), "message"));
|
||||
u.error = plain(str(j.get(), "error"));
|
||||
u.current = str(j.get(), "current");
|
||||
u.latest = str(j.get(), "latest");
|
||||
u.available = flag(j.get(), "available");
|
||||
u.autostart = flag(j.get(), "autostart");
|
||||
// Payloads before 1.1.1 don't say: they update themselves.
|
||||
const cJSON *su = cJSON_GetObjectItemCaseSensitive(j.get(), "self_update");
|
||||
u.self_update = !cJSON_IsBool(su) || cJSON_IsTrue(su);
|
||||
u.done = num(j.get(), "done");
|
||||
u.total = num(j.get(), "total");
|
||||
update_ = u;
|
||||
@@ -839,9 +1312,10 @@ void App::refresh_update()
|
||||
if (u.state == "error")
|
||||
say("Update: " + (u.error.empty() ? u.message : u.error));
|
||||
else if (u.available)
|
||||
say("RomM Sync " + u.latest + " is available");
|
||||
say(u.self_update ? "RommPS " + u.latest + " is available"
|
||||
: "RommPS " + u.latest + " is available in ProsperoStore");
|
||||
else if (u.state == "idle")
|
||||
say("RomM Sync is up to date");
|
||||
say("RommPS is up to date");
|
||||
else if (!u.message.empty())
|
||||
say(u.message);
|
||||
}
|
||||
@@ -883,12 +1357,12 @@ void App::pair_start(const std::string &server_url, bool tls_verify)
|
||||
if (!r.ok())
|
||||
{
|
||||
status_.pairing = "error";
|
||||
status_.pairing_message = r.message();
|
||||
status_.pairing_message = plain(r.message().c_str());
|
||||
return;
|
||||
}
|
||||
std::unique_ptr<cJSON, void (*)(cJSON *)> j(r.json(), cJSON_Delete);
|
||||
status_.pairing = str(j.get(), "status");
|
||||
status_.pairing_message = str(j.get(), "message");
|
||||
status_.pairing_message = plain(str(j.get(), "message"));
|
||||
status_.user_code = str(j.get(), "user_code");
|
||||
status_.verification_url = str(j.get(), "verification_url");
|
||||
config_.known = false; // the server's address changed
|
||||
|
||||
@@ -20,8 +20,14 @@
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <unordered_map>
|
||||
#include <unordered_set>
|
||||
#include <vector>
|
||||
|
||||
namespace hui::ui
|
||||
{
|
||||
struct Fonts;
|
||||
}
|
||||
|
||||
namespace rommps
|
||||
{
|
||||
|
||||
@@ -38,10 +44,13 @@ struct Palette
|
||||
struct Cover
|
||||
{
|
||||
std::uint32_t texture = 0; // 0 until it's loaded (or if there is none)
|
||||
float aspect = 0; // its width over its height, 0 until it's loaded
|
||||
Palette palette;
|
||||
bool requested = false;
|
||||
bool failed = false;
|
||||
double used = 0; // when it was last drawn
|
||||
double retry_at = 0; // a fetch that failed (not a missing cover) is tried again then
|
||||
int retries = 0; // tries again so far this launch (kCoverRetries at most)
|
||||
};
|
||||
|
||||
struct Game
|
||||
@@ -52,7 +61,8 @@ struct Game
|
||||
double size = 0;
|
||||
bool installed = false;
|
||||
bool multi = false;
|
||||
bool not_game = false; // a file RomM scanned beside the games (metadata.txt...): hidden
|
||||
bool not_game = false; // a file RomM scanned beside the games (metadata.txt...), or a Switch update or DLC: hidden
|
||||
bool archived = false; // a .rar (or any archive, for Switch) nothing here plays: not downloaded
|
||||
std::string version; // what sets this version apart, when RomM has others of the game
|
||||
int versions = 0; // how many versions RomM has, this one included; 0 if just one
|
||||
};
|
||||
@@ -68,6 +78,19 @@ struct Details
|
||||
double main_story_s = 0; // How Long to Beat, the main story
|
||||
double play_ms = 0; // every device's sessions RomM has
|
||||
int sessions = 0, completion = 0, my_rating = 0;
|
||||
std::string archived; // why it can't be downloaded: it's an archive
|
||||
int updates = 0, dlc = 0; // a Switch game's, downloaded with it
|
||||
std::string title_id; // a Switch game's ("0100ABF008968000"), "" otherwise
|
||||
struct Addon
|
||||
{
|
||||
enum Kind { update, dlc, game } kind = update; // game: another one in its folder (a collection)
|
||||
std::string name, title_id;
|
||||
double size = 0;
|
||||
};
|
||||
// The update first, then the DLC; then the other games of a collection's
|
||||
// folder and theirs, which come with the download but aren't this game's.
|
||||
std::vector<Addon> addons;
|
||||
int bundle = 0; // how many of addons are those
|
||||
};
|
||||
|
||||
struct Platform
|
||||
@@ -92,6 +115,10 @@ struct GameList
|
||||
std::vector<std::pair<std::string, int>> raw_letters; // the same, by RomM's offset
|
||||
std::vector<int> hidden; // RomM offsets of the files that aren't games, sorted
|
||||
std::string error;
|
||||
// The shape of the grid's cards (width over height), from the covers at
|
||||
// the top; 0 until the library has seen some. Settled: it stays.
|
||||
float shape = 0;
|
||||
bool shape_settled = false;
|
||||
bool ready() const { return total >= 0; }
|
||||
// Positions in the list as shown, which skips what isn't a game, and
|
||||
// RomM's offsets. What isn't a game is known once its page is here.
|
||||
@@ -136,11 +163,19 @@ struct Download
|
||||
int id = 0, rom_id = 0;
|
||||
std::string name, platform, status, error;
|
||||
double done = 0, total = 0, speed = 0;
|
||||
// What the sync after the download brought.
|
||||
int saves = 0, states = 0, save_conflicts = 0;
|
||||
bool saves_failed = false;
|
||||
float progress() const { return total > 0 ? static_cast<float>(done / total) : 0.0f; }
|
||||
// The payload's states: queued, downloading, cancelling, then done, error or cancelled.
|
||||
// The payload's states: queued, downloading, cancelling, syncing (the
|
||||
// game's saves, once it's downloaded), then done, error or cancelled.
|
||||
bool running() const { return status == "downloading"; }
|
||||
bool active() const { return status == "queued" || running() || status == "cancelling"; }
|
||||
bool syncing() const { return status == "syncing"; }
|
||||
bool active() const { return status == "queued" || running() || status == "cancelling" || syncing(); }
|
||||
bool cancellable() const { return active() && !syncing(); }
|
||||
bool finished() const { return !active(); }
|
||||
bool retryable() const { return status == "error" || status == "cancelled"; }
|
||||
std::string saves_note() const;
|
||||
};
|
||||
|
||||
struct Status
|
||||
@@ -149,6 +184,7 @@ struct Status
|
||||
bool reachable = false; // the payload answered the last time
|
||||
bool paired = false, setup_complete = false;
|
||||
std::string version, server, user, ip, error;
|
||||
std::string server_trouble; // RomM isn't answering: what the payload says, "" while it does
|
||||
int port = 8780;
|
||||
int tracked_saves = 0, conflicts = 0, sync_count = 0;
|
||||
bool syncing = false;
|
||||
@@ -164,6 +200,10 @@ struct Status
|
||||
// Pairing in progress (/api/pair/start): "pending" while RomM waits for
|
||||
// the code to be approved, "error" when it failed, "" otherwise.
|
||||
std::string pairing, pairing_message, user_code, verification_url;
|
||||
// Games left in the folders RetroArch used before 2.0, and those
|
||||
// folders' names (/api/retroarch/move moves them to the new ones).
|
||||
int retroarch_move = 0;
|
||||
std::string retroarch_folders;
|
||||
};
|
||||
|
||||
// The payload's settings that RommPS shows (/api/config).
|
||||
@@ -173,6 +213,7 @@ struct Config
|
||||
std::string server_url;
|
||||
bool tls_verify = true;
|
||||
int sync_interval_min = 0;
|
||||
bool save_sync = true; // false: RomM only as a game source
|
||||
bool sync_on_game_exit = true, sync_on_game_start = false, sync_on_change = false;
|
||||
bool notify = true, update_check = true;
|
||||
std::string states = "off"; // off, upload, sync
|
||||
@@ -188,6 +229,8 @@ struct Update
|
||||
bool known = false;
|
||||
std::string state, message, error, current, latest;
|
||||
bool available = false, autostart = false;
|
||||
// false: RomM Sync doesn't update itself; a new RommPS brings it (PS5).
|
||||
bool self_update = true;
|
||||
double done = 0, total = 0;
|
||||
bool busy() const { return state == "checking" || state == "downloading" || state == "installing" || state == "restarting"; }
|
||||
};
|
||||
@@ -204,16 +247,19 @@ class App
|
||||
const Status &status() const { return status_; }
|
||||
// RommPS carries the RomM Sync payload of its own release
|
||||
// (assets/rommps/romm-sync.elf, version kMinPayload): it starts it when
|
||||
// it isn't running, and puts it in place of an older one.
|
||||
static constexpr const char *kMinPayload = "1.1.0";
|
||||
// none is running, puts it in place of an older one (never of a newer
|
||||
// one), and keeps the copies the console starts it from up to date.
|
||||
static constexpr const char *kMinPayload = "1.3.0";
|
||||
bool payload_too_old() const;
|
||||
void start_payload(); // sends the payload to the HEN's loader
|
||||
bool payload_starting() const { return start_state_ == 1; }
|
||||
// Sending it, or RomM Sync is running and not answering yet (starting up,
|
||||
// or waking from rest mode): either way it'll answer in a moment.
|
||||
bool payload_starting() const { return start_state_ == 1 || payload_waiting_; }
|
||||
const std::string &payload_error() const { return start_error_; }
|
||||
// The payload into the HEN's autostart folder, so it starts with the console.
|
||||
void install_autostart(Api::Callback done);
|
||||
const std::vector<Platform> &platforms() const { return platforms_; }
|
||||
const std::vector<Download> &downloads() const { return downloads_; }
|
||||
const std::vector<Download> &queue() const { return queue_; }
|
||||
const Download *download_for(int rom_id) const;
|
||||
|
||||
const Platform *platform_by_id(int id) const;
|
||||
@@ -225,15 +271,36 @@ class App
|
||||
|
||||
// The games on this console, most recently played first.
|
||||
const std::vector<Game> &installed() const { return installed_; }
|
||||
// Whether a game is on the console now. A list's copy can be older than
|
||||
// a game deleted by hand, so this goes by the latest installed list.
|
||||
bool on_console(const Game &g) const { return installed_known_ ? on_console_.count(g.id) > 0 : g.installed; }
|
||||
// Asks the payload about one game, so a sheet's actions are right even
|
||||
// before the next look at every game (the payload does that sparingly).
|
||||
void check_on_console(int rom_id);
|
||||
// A game's details; asks for them the first time.
|
||||
const Details &details(int rom_id);
|
||||
void refresh_installed();
|
||||
// Asks for the platforms again: which emulator plays each, and its
|
||||
// folder, change when emulators are set up here or in the web UI.
|
||||
void reload_platforms()
|
||||
{
|
||||
if (!platforms_busy_)
|
||||
refresh_platforms();
|
||||
}
|
||||
// A cover's texture and palette; asks for it the first time.
|
||||
const Cover &cover(const std::string &path);
|
||||
// A platform's logo from RomM, by its slug ("psx"), as a cover.
|
||||
const Cover &platform_logo(const std::string &slug);
|
||||
// A cover's width over its height, kept after its texture is let go;
|
||||
// 0 until it has been loaded once. since: when it became known.
|
||||
float cover_aspect(const std::string &path, double *since = nullptr) const;
|
||||
double clock() const { return clock_; }
|
||||
|
||||
void download(int rom_id, const std::string &name);
|
||||
void cancel_download(int job_id);
|
||||
void clear_finished_downloads();
|
||||
void retry_download(int job_id);
|
||||
void remove_game(int rom_id);
|
||||
void clear_finished_queue();
|
||||
void sync_now();
|
||||
|
||||
const Config &config() const { return config_; }
|
||||
@@ -255,8 +322,12 @@ class App
|
||||
// Asks for the status now rather than at the next tick.
|
||||
void refresh_now() { refresh_status(); }
|
||||
|
||||
// Any other call to the payload, for the setup's steps.
|
||||
void get(const std::string &path, Api::Callback done) { api_.get(path, std::move(done)); }
|
||||
// Any other call to the payload, for the setup's steps. A long one (the
|
||||
// sync preview) goes on a worker of its own, so the status never waits.
|
||||
void get(const std::string &path, Api::Callback done, int timeout_ms = 15000)
|
||||
{
|
||||
(timeout_ms > 15000 ? slow_api_ : api_).get(path, std::move(done), timeout_ms);
|
||||
}
|
||||
void post(const std::string &path, const std::string &body, Api::Callback done)
|
||||
{
|
||||
api_.post(path, body, std::move(done));
|
||||
@@ -269,17 +340,24 @@ class App
|
||||
|
||||
private:
|
||||
void refresh_status();
|
||||
// Whether a RomM Sync is running, from the lock it holds while it runs:
|
||||
// 1 yes, 0 no, -1 can't tell.
|
||||
static int payload_lock();
|
||||
// Turns autostart on wherever it's off and brings the copies the console
|
||||
// starts RomM Sync from up to the one RommPS carries.
|
||||
void repair_autostart();
|
||||
void evict_covers(hui::gfx::Renderer *renderer);
|
||||
void send_page(const std::string &key, int page);
|
||||
void refresh_platforms();
|
||||
void refresh_downloads();
|
||||
void refresh_queue();
|
||||
|
||||
Api api_;
|
||||
Api slow_api_; // calls that may take minutes
|
||||
// Covers on workers of their own, so lists never wait behind them; asked
|
||||
// for only while they're on screen (wanted_), newest first.
|
||||
static constexpr int kCoverWorkers = 3;
|
||||
static constexpr int kCoversInFlight = 6;
|
||||
Api covers_api_[kCoverWorkers]; // each to the payload on 127.0.0.1:8780
|
||||
Api covers_api_[kCoverWorkers]; // each to the payload on 127.0.0.1
|
||||
std::vector<std::string> wanted_; // drawn since the last tick, in draw order
|
||||
// Pages of games, on workers of their own too; asked for at the tick if
|
||||
// the screen still wants them.
|
||||
@@ -290,8 +368,11 @@ class App
|
||||
int covers_in_flight_ = 0, next_cover_worker_ = 0;
|
||||
Status status_;
|
||||
std::vector<Platform> platforms_;
|
||||
std::vector<Download> downloads_;
|
||||
std::vector<Download> queue_;
|
||||
std::vector<Game> installed_;
|
||||
std::unordered_set<int> on_console_; // installed_'s ids
|
||||
bool installed_known_ = false;
|
||||
double installed_sig_ = -1; // the payload's, from /api/status: changes when a game comes or goes
|
||||
std::unordered_map<int, Details> details_;
|
||||
struct CachedList
|
||||
{
|
||||
@@ -305,21 +386,29 @@ class App
|
||||
float update_timer_ = 0;
|
||||
bool update_busy_ = false;
|
||||
std::unordered_map<std::string, Cover> covers_;
|
||||
struct Shape
|
||||
{
|
||||
float aspect = 0;
|
||||
double since = 0;
|
||||
};
|
||||
std::unordered_map<std::string, Shape> shapes_; // every cover loaded this launch
|
||||
struct Pending
|
||||
{
|
||||
std::string path, bytes;
|
||||
};
|
||||
std::deque<Pending> to_decode_;
|
||||
float status_timer_ = 0, downloads_timer_ = 0;
|
||||
bool status_busy_ = false, downloads_busy_ = false, platforms_busy_ = false;
|
||||
float status_timer_ = 0, queue_timer_ = 0;
|
||||
bool status_busy_ = false, queue_busy_ = false, platforms_busy_ = false;
|
||||
bool was_paired_ = false;
|
||||
const std::string &bundled_payload();
|
||||
std::string bundled_;
|
||||
bool bundled_read_ = false;
|
||||
std::atomic<int> start_state_{0}; // 0 idle, 1 sending, 2 sent, 3 failed
|
||||
std::string start_error_; // written before start_state_ becomes 3
|
||||
bool tried_start_ = false, updated_payload_ = false;
|
||||
int active_downloads_ = 0;
|
||||
bool tried_start_ = false, updated_payload_ = false, repaired_ = false;
|
||||
bool payload_waiting_ = false;
|
||||
int unanswered_ = 0; // status requests in a row RomM Sync didn't answer
|
||||
int active_queue_ = 0;
|
||||
std::string toast_;
|
||||
float toast_age_ = 0;
|
||||
};
|
||||
@@ -327,6 +416,24 @@ class App
|
||||
// The program's one App, created on first use.
|
||||
App &app();
|
||||
|
||||
// Text the screen's fonts can draw: the characters they have stay ("Pokémon",
|
||||
// "Luigi’s Mansion"), the others fold to their nearest ASCII ("Ō" reads "O" in
|
||||
// a font without it), and only one with no stand-in at all reads "?".
|
||||
std::string plain_text(const char *text);
|
||||
|
||||
// The faces plain_text asks: the ones the screen draws text with. Set once the
|
||||
// kit has loaded them, before the payload's first answer is read.
|
||||
void set_text_fonts(const hui::ui::Fonts &fonts);
|
||||
|
||||
// The first byte at or after at that starts a character of UTF-8 text: where
|
||||
// to cut it without halving one.
|
||||
inline std::size_t char_boundary(const std::string &text, std::size_t at)
|
||||
{
|
||||
while (at < text.size() && (static_cast<unsigned char>(text[at]) & 0xC0) == 0x80)
|
||||
++at;
|
||||
return at;
|
||||
}
|
||||
|
||||
} // namespace rommps
|
||||
|
||||
// The program hands the kit's renderer over (rommps.cpp is compiled in a
|
||||
|
||||
@@ -26,6 +26,7 @@ namespace
|
||||
|
||||
const char *const kFolder = PS5_APP_ROOT "/hui";
|
||||
const char *const kPath = PS5_APP_ROOT "/hui/rommps-look.txt";
|
||||
const char *const kSoundsPath = PS5_APP_ROOT "/hui/rommps-sounds.txt";
|
||||
|
||||
const hui::ui::Theme *theme_named(const char *id)
|
||||
{
|
||||
@@ -112,6 +113,7 @@ const std::vector<Look> &all()
|
||||
}
|
||||
|
||||
int g_current = -1; // -1: not read yet
|
||||
int g_sounds = -1; // the same
|
||||
|
||||
} // namespace
|
||||
|
||||
@@ -159,6 +161,27 @@ void set_look(int index)
|
||||
chmod(kPath, 0666);
|
||||
}
|
||||
|
||||
bool ui_sounds()
|
||||
{
|
||||
if (g_sounds < 0)
|
||||
{
|
||||
std::string v;
|
||||
g_sounds = ps5TestRun || !hui::save::read_file(kSoundsPath, &v, 16) || v.rfind("off", 0) != 0;
|
||||
}
|
||||
return g_sounds != 0;
|
||||
}
|
||||
|
||||
void set_ui_sounds(bool on)
|
||||
{
|
||||
g_sounds = on;
|
||||
if (ps5TestRun)
|
||||
return;
|
||||
hui::save::ensure_directory(kFolder);
|
||||
chmod(kFolder, 0777);
|
||||
if (hui::save::write_atomic(kSoundsPath, on ? "on\n" : "off\n").empty())
|
||||
chmod(kSoundsPath, 0666);
|
||||
}
|
||||
|
||||
void draw_panel(hui::gfx::DrawList &list, const Look &look, const hui::gfx::Rect &r, float radius)
|
||||
{
|
||||
if (radius < 0.0f)
|
||||
|
||||
@@ -54,6 +54,11 @@ int current_look();
|
||||
// Uses the look from now on and saves the choice (silently, a test run excepted).
|
||||
void set_look(int index);
|
||||
|
||||
// The interface's sounds (focus, select, back...): on unless turned off in
|
||||
// Settings, kept beside the theme. Rumble isn't affected.
|
||||
bool ui_sounds();
|
||||
void set_ui_sounds(bool on);
|
||||
|
||||
// A panel in the look: its shadow, body and edge.
|
||||
void draw_panel(hui::gfx::DrawList &list, const Look &look, const hui::gfx::Rect &r, float radius = -1.0f);
|
||||
|
||||
|
||||
@@ -3,8 +3,11 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#include "rows.hpp"
|
||||
|
||||
#include "rommps_app.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstdio>
|
||||
#include <cctype>
|
||||
|
||||
namespace rommps
|
||||
{
|
||||
@@ -35,13 +38,28 @@ RowEvent RowList::handle(const std::vector<Row> &rows, const hui::InputFrame &in
|
||||
if (count == 0)
|
||||
return event;
|
||||
focus_ = std::clamp(focus_, 0, count - 1);
|
||||
// A section's name can't hold the focus: the first row after it does
|
||||
// (or, at the end of the list, the last one before it).
|
||||
if (rows[static_cast<std::size_t>(focus_)].kind == RowKind::header)
|
||||
{
|
||||
int at = focus_;
|
||||
while (at < count && rows[static_cast<std::size_t>(at)].kind == RowKind::header)
|
||||
++at;
|
||||
if (at == count)
|
||||
for (at = focus_; at > 0 && rows[static_cast<std::size_t>(at)].kind == RowKind::header;)
|
||||
--at;
|
||||
focus_ = at;
|
||||
}
|
||||
const Row &row = rows[static_cast<std::size_t>(focus_)];
|
||||
switch (input.nav)
|
||||
{
|
||||
case Direction::up:
|
||||
case Direction::down:
|
||||
{
|
||||
const int next = focus_ + (input.nav == Direction::down ? 1 : -1);
|
||||
const int step = input.nav == Direction::down ? 1 : -1;
|
||||
int next = focus_ + step;
|
||||
while (next >= 0 && next < count && rows[static_cast<std::size_t>(next)].kind == RowKind::header)
|
||||
next += step;
|
||||
if (next >= 0 && next < count)
|
||||
{
|
||||
focus_ = next;
|
||||
@@ -118,6 +136,14 @@ void RowList::draw(hui::gfx::DrawList &list, const hui::ui::Fonts &fonts, const
|
||||
const float y = bounds.y + static_cast<float>(i) * kRowHeight - scroll_.value;
|
||||
if (y + kRowHeight < bounds.y || y > bounds.y + bounds.h)
|
||||
continue;
|
||||
if (row.kind == RowKind::header)
|
||||
{
|
||||
std::string caps = plain_text(row.label.c_str());
|
||||
for (char &ch : caps)
|
||||
ch = static_cast<char>(std::toupper(static_cast<unsigned char>(ch)));
|
||||
ui::text(list, fonts.semibold, caps, bounds.x + 32, y + kRowHeight - 20, 18, look.muted);
|
||||
continue;
|
||||
}
|
||||
const bool focused = i == focus_;
|
||||
const float dim = row.enabled ? 1.0f : 0.45f;
|
||||
const Color ink = focused ? look.on_highlight : row.danger ? look.danger : look.text;
|
||||
@@ -140,7 +166,7 @@ void RowList::draw(hui::gfx::DrawList &list, const hui::ui::Fonts &fonts, const
|
||||
{
|
||||
const float size = 24.0f;
|
||||
const float chevrons = row.kind == RowKind::choice ? 30.0f : 0.0f;
|
||||
const std::string value = fonts.semibold.font->fit(row.value, size, bounds.w * 0.45f);
|
||||
const std::string value = fonts.semibold.font->fit(plain_text(row.value.c_str()), size, bounds.w * 0.45f);
|
||||
const float w = ui::text(list, fonts.semibold, value, right - chevrons, y + kRowHeight * 0.5f + 9, size,
|
||||
(row.kind == RowKind::info ? soft : ink).with_alpha(dim), hui::gfx::Align::right);
|
||||
if (row.kind == RowKind::choice && row.enabled)
|
||||
@@ -155,10 +181,42 @@ void RowList::draw(hui::gfx::DrawList &list, const hui::ui::Fonts &fonts, const
|
||||
}
|
||||
used = w + chevrons + (row.kind == RowKind::choice ? 50.0f : 24.0f);
|
||||
}
|
||||
const float room = std::max(200.0f, bounds.w - 64 - used);
|
||||
ui::text(list, fonts.semibold, fonts.semibold.font->fit(row.label, 26, room), x, label_y, 26, ink.with_alpha(dim));
|
||||
// A chevron for a row that opens: right when closed, down when open.
|
||||
const float indent = row.expander ? 30.0f : 0.0f;
|
||||
if (row.expander)
|
||||
{
|
||||
const float cx = x + 8, cy = label_y - 9;
|
||||
const Color c = ink.with_alpha(dim);
|
||||
if (row.expander == 1)
|
||||
{
|
||||
list.line(cx - 3, cy - 8, cx + 4, cy, 3.0f, c);
|
||||
list.line(cx + 4, cy, cx - 3, cy + 8, 3.0f, c);
|
||||
}
|
||||
else
|
||||
{
|
||||
list.line(cx - 8, cy - 3, cx, cy + 4, 3.0f, c);
|
||||
list.line(cx, cy + 4, cx + 8, cy - 3, 3.0f, c);
|
||||
}
|
||||
}
|
||||
// The platform's logo, then the label.
|
||||
float lx = x + indent;
|
||||
if (row.logo_slot)
|
||||
{
|
||||
constexpr float kLogo = 34.0f;
|
||||
if (row.logo)
|
||||
{
|
||||
const float w = std::min(kLogo * 1.6f, kLogo * row.logo_aspect), h = w / row.logo_aspect;
|
||||
list.image(row.logo, {lx + (kLogo - std::min(w, kLogo)) * 0.5f, label_y - 9 - h * 0.5f, w, h},
|
||||
hui::gfx::kFullUv, hui::gfx::Color::rgb(0xFFFFFF, dim));
|
||||
}
|
||||
lx += kLogo * 1.6f + 8;
|
||||
}
|
||||
const float room = std::max(200.0f, bounds.w - 64 - used - (lx - x));
|
||||
// Rows carry names, paths and the service's errors as they came
|
||||
ui::text(list, fonts.semibold, fonts.semibold.font->fit(plain_text(row.label.c_str()), 26, room), lx, label_y, 26,
|
||||
ink.with_alpha(dim));
|
||||
if (two_lines)
|
||||
ui::text(list, fonts.regular, fonts.regular.font->fit(row.detail, 19, room), x, label_y + 30, 19,
|
||||
ui::text(list, fonts.regular, fonts.regular.font->fit(plain_text(row.detail.c_str()), 19, room), lx, label_y + 30, 19,
|
||||
soft.with_alpha(dim));
|
||||
}
|
||||
list.pop_clip();
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
#include "core/input.hpp"
|
||||
#include "core/tween.hpp"
|
||||
#include "rommps_look.hpp"
|
||||
@@ -25,6 +27,7 @@ enum class RowKind
|
||||
choice, // left and right step through its options
|
||||
action, // Cross does it
|
||||
info, // only shows something
|
||||
header // a section's name
|
||||
};
|
||||
|
||||
struct Row
|
||||
@@ -36,6 +39,12 @@ struct Row
|
||||
bool on = false; // a toggle's state
|
||||
bool enabled = true;
|
||||
bool danger = false; // an action that can't be undone
|
||||
int expander = 0; // 1: closed, 2: open: a chevron before the label (Cross opens what's under it)
|
||||
// A small icon before the label (a platform's logo): the room is kept with
|
||||
// logo_slot even while its texture (0) is still loading.
|
||||
bool logo_slot = false;
|
||||
std::uint32_t logo = 0;
|
||||
float logo_aspect = 1.0f;
|
||||
};
|
||||
|
||||
struct RowEvent
|
||||
@@ -73,7 +82,7 @@ struct Named
|
||||
};
|
||||
inline constexpr Named kPolicies[] = {
|
||||
{"ask", "Ask me"}, {"newest", "Keep the newest"}, {"local", "Keep this console's"}, {"server", "Keep RomM's"}};
|
||||
inline constexpr Named kStates[] = {{"off", "Off"}, {"upload", "Upload only"}, {"sync", "Upload and download"}};
|
||||
inline constexpr Named kStates[] = {{"off", "Off"}, {"upload", "Back up to RomM"}, {"sync", "Back up and restore"}};
|
||||
inline constexpr int kIntervals[] = {0, 15, 30, 60, 120, 360};
|
||||
|
||||
const char *label_of(std::span<const Named> options, const std::string &id);
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -3,10 +3,12 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#include "settings_page.hpp"
|
||||
|
||||
#include "cJSON.h"
|
||||
#include "core/tween.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstdio>
|
||||
#include <memory>
|
||||
|
||||
namespace rommps
|
||||
{
|
||||
@@ -48,24 +50,27 @@ void SettingsPage::enter(App &app)
|
||||
on_credits_ = false;
|
||||
}
|
||||
|
||||
std::vector<Row> SettingsPage::rows(const App &app) const
|
||||
std::vector<Row> SettingsPage::rows(const App &app, std::vector<int> *ids) const
|
||||
{
|
||||
const Config &c = app.config();
|
||||
const Update &u = app.update();
|
||||
std::vector<Row> r(kCount);
|
||||
const Look &l = look(current_look());
|
||||
r[kLook] = {RowKind::choice, "Look", l.name, l.family};
|
||||
r[kInterval] = {RowKind::choice, "Sync every", interval_label(c.sync_interval_min), "Besides the syncs below"};
|
||||
r[kOnExit] = {RowKind::toggle, "Sync when a game closes", "", "Uploads what the game saved", c.sync_on_game_exit};
|
||||
r[kOnStart] = {RowKind::toggle, "Sync when a game starts", "", "Fetches newer saves before you play",
|
||||
r[kSaveSync] = {RowKind::toggle, "Save sync", "", c.save_sync ? "" : "RomM is only for games",
|
||||
c.save_sync};
|
||||
r[kLook] = {RowKind::choice, "Theme", l.name, l.family};
|
||||
r[kSounds] = {RowKind::toggle, "Interface sounds", "", "", ui_sounds()};
|
||||
r[kInterval] = {RowKind::choice, "Sync every", interval_label(c.sync_interval_min), ""};
|
||||
r[kOnExit] = {RowKind::toggle, "Sync when a game closes", "", "", c.sync_on_game_exit};
|
||||
r[kOnStart] = {RowKind::toggle, "Sync when a game starts", "", "",
|
||||
c.sync_on_game_start};
|
||||
r[kOnChange] = {RowKind::toggle, "Upload saves as they change", "", "While you play", c.sync_on_change};
|
||||
r[kStates] = {RowKind::choice, "Save states", label_of(rommps::kStates, c.states), "Besides the saves themselves"};
|
||||
r[kPolicy] = {RowKind::choice, "A save changed in both places", label_of(kPolicies, c.conflict_policy),
|
||||
"On this console and on RomM since the last sync"};
|
||||
r[kConcurrency] = {RowKind::choice, "Downloads at once", std::to_string(c.download_concurrency), ""};
|
||||
r[kNotify] = {RowKind::toggle, "Notifications", "", "On the console when a sync ends or fails", c.notify};
|
||||
r[kUpdateCheck] = {RowKind::toggle, "Look for updates every day", "", "", c.update_check};
|
||||
r[kStates] = {RowKind::choice, "Save states", label_of(rommps::kStates, c.states), ""};
|
||||
r[kPolicy] = {RowKind::choice, "Resolving save conflicts", label_of(kPolicies, c.conflict_policy),
|
||||
"What RommPS will do by default whenever a save conflict exists between this PS5 and your RomM Server"};
|
||||
r[kConcurrency] = {RowKind::choice, "Concurrent Game Downloads", std::to_string(c.download_concurrency), ""};
|
||||
r[kNotify] = {RowKind::toggle, "Notifications", "", "", c.notify};
|
||||
r[kUpdateCheck] = {RowKind::toggle, "Check for updates daily", "", "", c.update_check};
|
||||
{
|
||||
const Status &s = app.status();
|
||||
char detail[96];
|
||||
@@ -82,14 +87,25 @@ std::vector<Row> SettingsPage::rows(const App &app) const
|
||||
else if (s.covers_total > 0)
|
||||
std::snprintf(detail, sizeof detail, "All %d covers are on the console", s.covers_total);
|
||||
else
|
||||
std::snprintf(detail, sizeof detail, "Faster browsing: every game's cover, kept up to date");
|
||||
std::snprintf(detail, sizeof detail, "%s", "");
|
||||
r[kCovers] = {RowKind::toggle, "Keep all covers on the console", "", detail, c.cover_cache};
|
||||
}
|
||||
|
||||
Row &update = r[kUpdate];
|
||||
update.kind = RowKind::action;
|
||||
update.label = "RomM Sync " + (u.current.empty() ? app.status().version : u.current);
|
||||
if (u.busy())
|
||||
update.label = "RommPS " + (u.current.empty() ? app.status().version : u.current);
|
||||
if (!u.self_update)
|
||||
{
|
||||
// The app brings its background service with it: the store updates both.
|
||||
update.label = std::string("RommPS ") + App::kMinPayload;
|
||||
update.value = u.state == "checking" ? "Checking" : "Check for updates";
|
||||
update.enabled = u.state != "checking";
|
||||
update.detail = u.available ? "RommPS " + u.latest + " is out: update it in ProsperoStore"
|
||||
: u.state == "error" ? (u.error.empty() ? u.message : u.error)
|
||||
: u.latest.empty() ? "Updates come from ProsperoStore"
|
||||
: "Up to date";
|
||||
}
|
||||
else if (u.busy())
|
||||
{
|
||||
update.value = u.message.empty() ? "Working" : u.message;
|
||||
if (u.total > 0)
|
||||
@@ -103,7 +119,7 @@ std::vector<Row> SettingsPage::rows(const App &app) const
|
||||
else if (u.available)
|
||||
{
|
||||
update.value = "Install " + u.latest;
|
||||
update.detail = "RomM Sync restarts with the new version";
|
||||
update.detail = "RommPS restarts with the new version";
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -114,16 +130,56 @@ std::vector<Row> SettingsPage::rows(const App &app) const
|
||||
: "Up to date";
|
||||
}
|
||||
|
||||
r[kEmulators] = {RowKind::action, "Emulators", "", "Which emulators RomM Sync uses, and for which systems"};
|
||||
r[kSetup] = {RowKind::action, "Set up again", "", "Pick your emulators again. Background syncs wait until it's done"};
|
||||
r[kEmulators] = {RowKind::action, "Configure Emulators", "", ""};
|
||||
r[kSetup] = {RowKind::action, "Re-run setup wizard", "", ""};
|
||||
r[kForget] = {RowKind::action, "Unpair from RomM", "", "Files on this console are kept"};
|
||||
r[kForget].danger = true;
|
||||
r[kDiagnostics] = {RowKind::action, "Diagnostic bundle", diag_ == 1 ? "Making" : "",
|
||||
diag_ == 2 ? "Saved to " + diag_where_
|
||||
: diag_ == 3 ? "Couldn't make it"
|
||||
: "Gather logs to report an issue. The bundle does not collect any personally identifiable information."};
|
||||
r[kDiagnostics].enabled = diag_ != 1;
|
||||
if (armed_ == kSetup || armed_ == kForget)
|
||||
r[static_cast<std::size_t>(armed_)].value = "Press again to confirm";
|
||||
if (!c.known)
|
||||
for (int i = kInterval; i <= kCovers; ++i)
|
||||
r[static_cast<std::size_t>(i)].enabled = false;
|
||||
return r;
|
||||
|
||||
// In sections.
|
||||
static const struct
|
||||
{
|
||||
const char *name;
|
||||
int ids[7];
|
||||
} kSections[] = {
|
||||
{"Sync", {kSaveSync, kInterval, kOnExit, kOnStart, kOnChange, kPolicy, kStates}},
|
||||
{"Downloads", {kConcurrency, kCovers, -1}},
|
||||
{"Theme, sounds and notifications", {kLook, kSounds, kNotify, -1}},
|
||||
{"Emulators", {kEmulators, -1}},
|
||||
{"Updates", {kUpdate, kUpdateCheck, -1}},
|
||||
{"RomM", {kSetup, kForget, -1}},
|
||||
{"Help", {kDiagnostics, -1}},
|
||||
};
|
||||
std::vector<Row> out;
|
||||
if (ids)
|
||||
ids->clear();
|
||||
for (const auto §ion : kSections)
|
||||
{
|
||||
out.push_back({RowKind::header, section.name});
|
||||
if (ids)
|
||||
ids->push_back(kHeader);
|
||||
for (int id : section.ids)
|
||||
{
|
||||
if (id < 0)
|
||||
break;
|
||||
// Save sync off: just the switch to turn it back on.
|
||||
if (!c.save_sync && id >= kInterval && id <= kPolicy)
|
||||
continue;
|
||||
out.push_back(r[static_cast<std::size_t>(id)]);
|
||||
if (ids)
|
||||
ids->push_back(id);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const char *SettingsPage::cross_hint(const App &app) const
|
||||
@@ -131,7 +187,7 @@ const char *SettingsPage::cross_hint(const App &app) const
|
||||
if (on_credits_)
|
||||
return "Answer";
|
||||
const std::vector<Row> r = rows(app);
|
||||
const Row &row = r[static_cast<std::size_t>(std::clamp(list_.focus(), 0, kCount - 1))];
|
||||
const Row &row = r[static_cast<std::size_t>(std::clamp(list_.focus(), 0, static_cast<int>(r.size()) - 1))];
|
||||
switch (row.kind)
|
||||
{
|
||||
case RowKind::toggle:
|
||||
@@ -146,12 +202,14 @@ const char *SettingsPage::cross_hint(const App &app) const
|
||||
void SettingsPage::update(App &app, const hui::InputFrame &input, float dt, hui::ui::Feedback &feedback)
|
||||
{
|
||||
avatar_texture(static_cast<hui::gfx::Renderer *>(rommps_renderer()));
|
||||
const std::vector<Row> r = rows(app);
|
||||
std::vector<int> ids;
|
||||
const std::vector<Row> r = rows(app, &ids);
|
||||
const int count = static_cast<int>(r.size());
|
||||
armed_age_ += dt;
|
||||
// Below the last row: the author's portrait, and the call behind it.
|
||||
if (on_credits_)
|
||||
{
|
||||
list_.update(dt, kCount, kRows.h);
|
||||
list_.update(dt, count, kRows.h);
|
||||
if (input.nav == hui::Direction::up || input.is_pressed(hui::Action::back))
|
||||
{
|
||||
on_credits_ = false;
|
||||
@@ -166,24 +224,32 @@ void SettingsPage::update(App &app, const hui::InputFrame &input, float dt, hui:
|
||||
list_.refuse(feedback);
|
||||
return;
|
||||
}
|
||||
if (input.nav == hui::Direction::down && !input.nav_repeat && list_.focus() == kCount - 1)
|
||||
if (input.nav == hui::Direction::down && !input.nav_repeat && list_.focus() == count - 1)
|
||||
{
|
||||
on_credits_ = true;
|
||||
feedback.play(hui::audio::Cue::focus);
|
||||
return;
|
||||
}
|
||||
if (armed_ >= 0 && (armed_age_ > kArmedFor || list_.focus() != armed_))
|
||||
const int focused_id = ids[static_cast<std::size_t>(std::clamp(list_.focus(), 0, count - 1))];
|
||||
if (armed_ >= 0 && (armed_age_ > kArmedFor || focused_id != armed_))
|
||||
armed_ = -1;
|
||||
const RowEvent e = list_.handle(r, input, feedback);
|
||||
list_.update(dt, kCount, kRows.h);
|
||||
list_.update(dt, count, kRows.h);
|
||||
if (e.row < 0)
|
||||
return;
|
||||
const int id = ids[static_cast<std::size_t>(e.row)];
|
||||
const Config &c = app.config();
|
||||
switch (e.row)
|
||||
switch (id)
|
||||
{
|
||||
case kSaveSync:
|
||||
app.set_config(flag_json("save_sync", !c.save_sync), [](Config &k) { k.save_sync = !k.save_sync; });
|
||||
break;
|
||||
case kLook:
|
||||
set_look((current_look() + e.step + look_count()) % look_count());
|
||||
break;
|
||||
case kSounds:
|
||||
set_ui_sounds(!ui_sounds());
|
||||
break;
|
||||
case kInterval:
|
||||
{
|
||||
const int minutes = step_interval(c.sync_interval_min, e.step);
|
||||
@@ -230,7 +296,7 @@ void SettingsPage::update(App &app, const hui::InputFrame &input, float dt, hui:
|
||||
app.set_config(flag_json("cover_cache", !c.cover_cache), [](Config &k) { k.cover_cache = !k.cover_cache; });
|
||||
break;
|
||||
case kUpdate:
|
||||
if (app.update().available)
|
||||
if (app.update().available && app.update().self_update)
|
||||
app.install_update();
|
||||
else
|
||||
app.check_update();
|
||||
@@ -238,16 +304,27 @@ void SettingsPage::update(App &app, const hui::InputFrame &input, float dt, hui:
|
||||
case kEmulators:
|
||||
emulators_asked_ = true;
|
||||
break;
|
||||
case kDiagnostics:
|
||||
diag_ = 1;
|
||||
app.post("/api/diagnostics", "{}", [this](const Response &r) {
|
||||
std::unique_ptr<cJSON, void (*)(cJSON *)> j(r.ok() ? r.json() : nullptr, cJSON_Delete);
|
||||
const cJSON *usb = cJSON_GetObjectItemCaseSensitive(j.get(), "usb"),
|
||||
*path = cJSON_GetObjectItemCaseSensitive(j.get(), "path");
|
||||
const cJSON *at = cJSON_IsString(usb) ? usb : path;
|
||||
diag_ = cJSON_IsString(at) ? 2 : 3;
|
||||
diag_where_ = cJSON_IsString(at) ? at->valuestring : "";
|
||||
});
|
||||
break;
|
||||
case kSetup:
|
||||
case kForget:
|
||||
if (armed_ != e.row)
|
||||
if (armed_ != id)
|
||||
{
|
||||
armed_ = e.row;
|
||||
armed_ = id;
|
||||
armed_age_ = 0.0f;
|
||||
break;
|
||||
}
|
||||
armed_ = -1;
|
||||
if (e.row == kSetup)
|
||||
if (id == kSetup)
|
||||
app.setup_restart();
|
||||
else
|
||||
app.pair_forget();
|
||||
@@ -275,8 +352,9 @@ void SettingsPage::draw(hui::gfx::DrawList &list, const hui::ui::Fonts &fonts, c
|
||||
float y = panel.y + 64;
|
||||
auto item = [&](const char *label, const std::string &value) {
|
||||
ui::text(list, fonts.semibold, label, x, y, 16, look.panel_muted, hui::gfx::Align::left, 3.0f);
|
||||
ui::text(list, fonts.regular, fonts.regular.font->fit(value.empty() ? "-" : value, 24, panel.w - 72), x, y + 36, 24,
|
||||
look.panel_text);
|
||||
// The server's address is as it was typed
|
||||
ui::text(list, fonts.regular, fonts.regular.font->fit(value.empty() ? "-" : plain_text(value.c_str()), 24, panel.w - 72), x,
|
||||
y + 36, 24, look.panel_text);
|
||||
y += 80;
|
||||
};
|
||||
item("ROMM SERVER", s.server);
|
||||
@@ -286,7 +364,7 @@ void SettingsPage::draw(hui::gfx::DrawList &list, const hui::ui::Fonts &fonts, c
|
||||
std::snprintf(web, sizeof web, "http://%s:%d", s.ip.c_str(), s.port);
|
||||
item("ALSO ON YOUR PHONE OR COMPUTER", s.ip.empty() ? "" : web);
|
||||
const Update &u = app.update();
|
||||
item("STARTS WITH THE CONSOLE", !u.known ? "" : u.autostart ? "Yes" : "No, load romm-sync.elf after each boot");
|
||||
item("STARTS WITH THE CONSOLE", !u.known ? "" : u.autostart ? "Yes" : "No: reopen RommPS to set it up");
|
||||
list.line(panel.x + 36, panel.y + panel.h - 196, panel.x + panel.w - 36, panel.y + panel.h - 196, 1.5f, look.outline);
|
||||
draw_credits(list, fonts, look, {x, panel.y + panel.h - 168, panel.w - 72, 136}, on_credits_, clock);
|
||||
list.pop_opacity();
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
// RommPS: the Settings tab. The look, when and what RomM Sync syncs, its
|
||||
// updates, the setup again and unpairing; an About panel beside them.
|
||||
// RommPS: the Settings tab, in sections: when and what it syncs, downloads,
|
||||
// the look and notifications, emulators, updates and the RomM connection; an
|
||||
// About panel beside them.
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
|
||||
#include "credits.hpp"
|
||||
#include "rommps_app.hpp"
|
||||
#include "rows.hpp"
|
||||
@@ -34,7 +37,9 @@ class SettingsPage
|
||||
private:
|
||||
enum Id
|
||||
{
|
||||
kSaveSync,
|
||||
kLook,
|
||||
kSounds,
|
||||
kInterval,
|
||||
kOnExit,
|
||||
kOnStart,
|
||||
@@ -49,14 +54,20 @@ class SettingsPage
|
||||
kEmulators,
|
||||
kSetup,
|
||||
kForget,
|
||||
kCount
|
||||
kDiagnostics,
|
||||
kCount,
|
||||
kHeader = -1 // a section's name
|
||||
};
|
||||
std::vector<Row> rows(const App &app) const;
|
||||
// The rows as shown, sections included; ids[i] is row i's Id.
|
||||
std::vector<Row> rows(const App &app, std::vector<int> *ids = nullptr) const;
|
||||
|
||||
RowList list_;
|
||||
bool on_credits_ = false;
|
||||
bool emulators_asked_ = false; // the focus is on the author's portrait, below the rows
|
||||
CodecCall call_;
|
||||
// The diagnostic bundle: 0 not asked, 1 being made, 2 saved (where_), 3 failed.
|
||||
int diag_ = 0;
|
||||
std::string diag_where_;
|
||||
int armed_ = -1; // a row that does something lasting, waiting for its second press
|
||||
float armed_age_ = 0.0f;
|
||||
};
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -2,8 +2,9 @@
|
||||
//
|
||||
// 1. the RomM server's address, typed on the kit's on-screen keyboard;
|
||||
// 2. the code to approve in RomM (its device pairing), until it's approved;
|
||||
// 3. the emulators found on the console, and which one plays a system
|
||||
// several of them can;
|
||||
// 3. the emulators found on the console, then each system they play: which
|
||||
// one plays it when several can, and its folders (the ones found, or
|
||||
// folders the user picks);
|
||||
// 4. what the first sync would do, and a word about backups;
|
||||
// 5. when to sync, then the first sync.
|
||||
//
|
||||
@@ -17,6 +18,7 @@
|
||||
#include "rows.hpp"
|
||||
#include "ui/components/keyboard.hpp"
|
||||
|
||||
#include <array>
|
||||
#include <map>
|
||||
#include <set>
|
||||
#include <string>
|
||||
@@ -72,9 +74,14 @@ class SetupPage
|
||||
std::vector<std::string> cores;
|
||||
std::vector<System> systems;
|
||||
bool on = true;
|
||||
std::string sync = "all"; // its save sync: "all", "saves" (no states) or "off"
|
||||
bool ready = true; // false: found, no preset yet (set up by its folders, like a system with no emulator)
|
||||
bool fresh = false; // found since the last setup
|
||||
std::string note;
|
||||
// Its folders for each system when left as they are (detection's
|
||||
// "dirs"): games, saves, states; and which it doesn't let you change.
|
||||
std::map<std::string, std::array<std::string, 3>> dirs;
|
||||
std::map<std::string, int> games; // games already in its folder, by system
|
||||
std::set<std::string> fixed; // "rom_dir", "save_dir", "state_dir"
|
||||
};
|
||||
// What a row of the emulator step does.
|
||||
struct RowRef
|
||||
@@ -83,11 +90,17 @@ class SetupPage
|
||||
{
|
||||
kNone,
|
||||
kEmulator,
|
||||
kEmuSync, // an emulator's save sync (unused since save sync is per platform)
|
||||
kSysSync, // a platform's save sync
|
||||
kMain,
|
||||
kAlso,
|
||||
kPs2,
|
||||
kOwn,
|
||||
kOwnFolder,
|
||||
kFolders, // a system's folders in an emulator: as found, or picked
|
||||
kSysFolder, // one of them (emulator: 0 games, 1 saves, 2 states)
|
||||
kOwnMove, // an extra's games of its own: to the main emulator
|
||||
kOwnDelete, // or deleted
|
||||
kContinue
|
||||
} kind = kNone;
|
||||
int emulator = -1; // or, for kOwnFolder, which folder (0 games, 1 saves, 2 states)
|
||||
@@ -117,21 +130,35 @@ class SetupPage
|
||||
void update_preview(App &app, const hui::InputFrame &input, float dt, hui::ui::Feedback &feedback);
|
||||
void update_finish(App &app, const hui::InputFrame &input, float dt, hui::ui::Feedback &feedback);
|
||||
void load_emulators(App &app);
|
||||
// Whether each extra plays the main emulator's games through a mount, or
|
||||
// why not (/api/paths), by "system|root".
|
||||
void load_mounts(App &app);
|
||||
void load_preview(App &app);
|
||||
void load_autostart(App &app);
|
||||
std::vector<Conflict> conflicts() const;
|
||||
// The root of the emulator that keeps a system: the one picked, else RetroArch, else the first.
|
||||
std::vector<Conflict> conflicts() const; // systems several emulators on play
|
||||
std::vector<Conflict> systems() const; // every system an emulator on plays, by name
|
||||
// The root of the emulator that keeps a system: the one picked ("" when
|
||||
// it's off), else RetroArch, else the first.
|
||||
std::string chosen(const Conflict &conflict) const;
|
||||
// Rows above the emulators' (a message instead of them).
|
||||
int lead() const { return error_.empty() && !emulators_.empty() ? 0 : 1; }
|
||||
// Whether an emulator plays a system, as the main one or an extra.
|
||||
bool used(const Emulator &e) const;
|
||||
// Rows above the emulators': their heading, or a message instead of them.
|
||||
int lead() const { return 1; }
|
||||
std::vector<Row> emulator_rows(const App &app, std::vector<RowRef> *refs = nullptr) const;
|
||||
std::vector<Row> preview_rows() const;
|
||||
std::vector<Row> finish_rows() const;
|
||||
// Settings' Emulators: what Save sends, whether it differs from what was
|
||||
// saved, and saving it.
|
||||
std::string save_body() const;
|
||||
bool dirty() const;
|
||||
void save_emulators(App &app, hui::ui::Feedback &feedback);
|
||||
// Leaving with changes not saved: Save, Discard or Keep editing.
|
||||
std::vector<Row> leave_rows() const;
|
||||
// ids: what each row is (the steps' rows depend on save_sync_).
|
||||
std::vector<Row> preview_rows(std::vector<int> *ids = nullptr) const;
|
||||
std::vector<Row> finish_rows(std::vector<int> *ids = nullptr) const;
|
||||
bool has_ps2() const;
|
||||
// The server's systems no ticked emulator plays, in the server's order.
|
||||
std::vector<std::string> uncovered() const;
|
||||
// The folder picker (/api/fs), for one of a system's folders.
|
||||
void open_picker(App &app, const std::string &key, int which);
|
||||
void open_picker(App &app, const std::string &key, int which, const std::string &root = "");
|
||||
void browse(App &app, const std::string &path);
|
||||
void update_picker(App &app, const hui::InputFrame &input, float dt, hui::ui::Feedback &feedback);
|
||||
void draw_picker(hui::gfx::DrawList &list, const hui::ui::Fonts &fonts, const Look &look, float clock) const;
|
||||
@@ -144,6 +171,23 @@ class SetupPage
|
||||
void draw_heading(hui::gfx::DrawList &list, const hui::ui::Fonts &fonts, const Look &look, const char *title,
|
||||
const std::string &text) const;
|
||||
|
||||
struct MountStatus
|
||||
{
|
||||
bool mounted = false;
|
||||
std::string note;
|
||||
int own = 0; // games of its own in the way
|
||||
};
|
||||
std::map<std::string, MountStatus> mounts_;
|
||||
// Each system's memory card method (/api/paths): "shared" or "per_game",
|
||||
// and why an emulator's cards don't sync (it keeps the other).
|
||||
struct CardMethod
|
||||
{
|
||||
std::string method;
|
||||
std::string note;
|
||||
};
|
||||
std::map<std::string, CardMethod> cards_;
|
||||
std::string confirm_delete_; // "system|root" after the first Cross on Delete
|
||||
|
||||
Step step_ = kServer;
|
||||
float step_age_ = 0.0f;
|
||||
RowList rows_;
|
||||
@@ -159,15 +203,29 @@ class SetupPage
|
||||
// 3
|
||||
std::vector<Emulator> emulators_;
|
||||
bool emulators_loaded_ = false;
|
||||
std::map<std::string, std::string> choices_; // system key -> the root of its main emulator
|
||||
float retry_in_ = 0.0f; // after an error: looking again (the service may have been restarting)
|
||||
std::map<std::string, std::string> choices_; // system key -> the root of its main emulator ("": off)
|
||||
std::map<std::string, std::string> sync_; // system key -> its save sync: "all", "saves" or "off"
|
||||
std::string expanded_; // the system whose details show
|
||||
int focus_expander_ = 0; // the focused row's chevron (for the hint): 0, 1 closed, 2 open
|
||||
std::map<std::string, std::set<std::string>> also_; // system key -> roots of its extra emulators
|
||||
// Folders picked for a system in an emulator, instead of the ones found:
|
||||
// system key -> emulator root -> games, saves, states ("" keeps that one).
|
||||
std::map<std::string, std::map<std::string, std::array<std::string, 3>>> custom_dirs_;
|
||||
bool editing_ = false;
|
||||
std::string saved_; // save_body() + ps2_cards_ as last saved (Settings' Emulators)
|
||||
bool leaving_ = false; // asking whether to save before going back
|
||||
RowList leave_list_;
|
||||
std::string ps2_cards_ = "per_game";
|
||||
std::map<std::string, Own> own_; // by system key (first slug)
|
||||
// Each platform's logo (texture, width over height) for the emulator rows,
|
||||
// asked for in update, where the app can fetch them.
|
||||
std::map<std::string, std::pair<std::uint32_t, float>> logos_;
|
||||
std::vector<std::string> own_order_; // the server's order
|
||||
std::vector<std::string> manual_; // own_ keys of emulators with no preset, in the order found
|
||||
bool picking_ = false;
|
||||
std::string pick_key_;
|
||||
std::string pick_root_; // set: the folder is for custom_dirs_[pick_key_][pick_root_], not own_
|
||||
int pick_which_ = 0;
|
||||
std::string fs_path_, fs_parent_;
|
||||
std::vector<std::string> fs_folders_;
|
||||
@@ -178,11 +236,11 @@ class SetupPage
|
||||
// 4
|
||||
Preview preview_;
|
||||
bool backed_up_ = false;
|
||||
bool save_sync_ = true; // false: RomM only as a game source, the sync steps skipped
|
||||
|
||||
// 5
|
||||
Config finish_;
|
||||
bool finish_known_ = false;
|
||||
bool autostart_supported_ = false, autostart_installed_ = false, autostart_on_ = false;
|
||||
};
|
||||
|
||||
} // namespace rommps
|
||||
@@ -0,0 +1,495 @@
|
||||
// RommPS: lists about the sync. See sync_views.hpp.
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#include "sync_views.hpp"
|
||||
|
||||
#include "cJSON.h"
|
||||
#include "core/tween.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstdio>
|
||||
#include <ctime>
|
||||
#include <memory>
|
||||
|
||||
namespace rommps
|
||||
{
|
||||
|
||||
namespace
|
||||
{
|
||||
|
||||
namespace ui = hui::ui;
|
||||
using hui::Action;
|
||||
using hui::audio::Cue;
|
||||
using hui::gfx::Rect;
|
||||
using Json = std::unique_ptr<cJSON, void (*)(cJSON *)>;
|
||||
|
||||
constexpr float kMargin = 96.0f;
|
||||
constexpr float kArmedFor = 4.0f;
|
||||
const Rect kRows{kMargin - 32, 300, 1792, 650};
|
||||
|
||||
const char *str(const cJSON *o, const char *key, const char *fallback = "")
|
||||
{
|
||||
const cJSON *v = cJSON_GetObjectItemCaseSensitive(o, key);
|
||||
return cJSON_IsString(v) ? v->valuestring : fallback;
|
||||
}
|
||||
|
||||
double num(const cJSON *o, const char *key, double fallback = 0)
|
||||
{
|
||||
const cJSON *v = cJSON_GetObjectItemCaseSensitive(o, key);
|
||||
return cJSON_IsNumber(v) ? v->valuedouble : fallback;
|
||||
}
|
||||
|
||||
std::string json_str(const std::string &s)
|
||||
{
|
||||
std::string out = "\"";
|
||||
for (char c : s)
|
||||
{
|
||||
if (c == '"' || c == '\\')
|
||||
out += '\\';
|
||||
out += c;
|
||||
}
|
||||
return out + "\"";
|
||||
}
|
||||
|
||||
std::string ago(double t)
|
||||
{
|
||||
if (t <= 0)
|
||||
return "never";
|
||||
const double s = std::difftime(std::time(nullptr), static_cast<std::time_t>(t));
|
||||
char text[32];
|
||||
if (s < 60)
|
||||
return "just now";
|
||||
if (s < 3600)
|
||||
std::snprintf(text, sizeof text, "%d min ago", static_cast<int>(s / 60));
|
||||
else if (s < 86400)
|
||||
std::snprintf(text, sizeof text, "%d h ago", static_cast<int>(s / 3600));
|
||||
else
|
||||
std::snprintf(text, sizeof text, s < 2 * 86400 ? "%d day ago" : "%d days ago", static_cast<int>(s / 86400));
|
||||
return text;
|
||||
}
|
||||
|
||||
// "2026-10-14T06:53:37+00:00" as "2026-10-14 06:53 UTC".
|
||||
std::string when(const char *iso)
|
||||
{
|
||||
const std::string s = iso ? iso : "";
|
||||
if (s.size() < 16)
|
||||
return s;
|
||||
return s.substr(0, 10) + " " + s.substr(11, 5) + " UTC";
|
||||
}
|
||||
|
||||
std::string local_time(double t)
|
||||
{
|
||||
if (t <= 0)
|
||||
return "never";
|
||||
const std::time_t tt = static_cast<std::time_t>(t);
|
||||
char text[32];
|
||||
std::strftime(text, sizeof text, "%Y-%m-%d %H:%M", std::localtime(&tt));
|
||||
return text;
|
||||
}
|
||||
|
||||
std::string base_name(const std::string &path)
|
||||
{
|
||||
const std::string p = path.substr(0, path.find('#')); // a save folder synced as a zip
|
||||
const std::size_t slash = p.find_last_of('/');
|
||||
return slash == std::string::npos ? p : p.substr(slash + 1);
|
||||
}
|
||||
|
||||
std::string size_text(double bytes)
|
||||
{
|
||||
char text[32];
|
||||
if (bytes >= 1024 * 1024)
|
||||
std::snprintf(text, sizeof text, "%.1f MB", bytes / (1024 * 1024));
|
||||
else
|
||||
std::snprintf(text, sizeof text, "%.0f KB", bytes / 1024);
|
||||
return text;
|
||||
}
|
||||
|
||||
// "uploaded" -> "Uploaded"
|
||||
std::string capital(std::string s)
|
||||
{
|
||||
if (!s.empty() && s[0] >= 'a' && s[0] <= 'z')
|
||||
s[0] = static_cast<char>(s[0] - 'a' + 'A');
|
||||
return s;
|
||||
}
|
||||
|
||||
const char *status_label(const std::string &status)
|
||||
{
|
||||
return status == "synced" ? "In sync"
|
||||
: status == "changed" ? "Changed here"
|
||||
: status == "conflict" ? "Conflict"
|
||||
: status == "missing" ? "Missing"
|
||||
: status == "no_game" ? "Game not here"
|
||||
: status.c_str();
|
||||
}
|
||||
|
||||
// A platform's name: RomM's, else the folder's own.
|
||||
std::string platform_name(const App &app, const std::string &dir)
|
||||
{
|
||||
for (const Platform &p : app.platforms())
|
||||
if (p.slug == dir)
|
||||
return p.name;
|
||||
std::string up = dir;
|
||||
for (char &c : up)
|
||||
if (c >= 'a' && c <= 'z')
|
||||
c = static_cast<char>(c - 'a' + 'A');
|
||||
return up.empty() ? std::string("Other") : up;
|
||||
}
|
||||
|
||||
// One log entry as a row: the game, its result ("Downloaded") on the right,
|
||||
// then "Save · RetroArch (mGBA) · 3 min ago".
|
||||
Row event_row(const cJSON *e, bool with_game)
|
||||
{
|
||||
const std::string kind = str(e, "kind"), action = str(e, "action");
|
||||
const std::string what = kind == "card" ? std::string("Memory card") : kind == "bios" ? std::string("BIOS") : capital(kind);
|
||||
// BIOS files and emulators belong to no game: they're named by their kind.
|
||||
const bool no_game = kind == "card" || num(e, "rom_id") <= 0;
|
||||
std::string label = what;
|
||||
if (with_game && !no_game)
|
||||
{
|
||||
const std::string game = plain_text(str(e, "game"));
|
||||
label = game.empty() ? "Game " + std::to_string(static_cast<int>(num(e, "rom_id"))) : game;
|
||||
}
|
||||
std::string detail = with_game && !no_game ? what : std::string();
|
||||
auto more = [&detail](const std::string &part) {
|
||||
if (!part.empty())
|
||||
detail += (detail.empty() ? "" : " \xC2\xB7 ") + part;
|
||||
};
|
||||
more(plain_text(str(e, "detail"))); // file names, the service's errors
|
||||
if (action != "skipped")
|
||||
more(plain_text(str(e, "emulator")));
|
||||
more(ago(num(e, "t")));
|
||||
Row r{RowKind::info, label, capital(action), detail};
|
||||
r.danger = action == "failed" || action == "conflict";
|
||||
return r;
|
||||
}
|
||||
|
||||
// One tracked file as a row: the game (or the kind, inside a game's view),
|
||||
// its status on the right, then "Save · synced 3 min ago".
|
||||
Row file_row(const cJSON *f, const std::string &game)
|
||||
{
|
||||
const std::string kind = str(f, "kind");
|
||||
std::string detail = (game.empty() ? std::string() : capital(kind) + " \xC2\xB7 ") + "synced " + ago(num(f, "synced_at"));
|
||||
if (kind == "state" && !cJSON_IsTrue(cJSON_GetObjectItemCaseSensitive(f, "from_here")) && num(f, "server_id") > 0)
|
||||
detail += " \xC2\xB7 from another device";
|
||||
Row r{RowKind::info, game.empty() ? capital(kind) : game, status_label(str(f, "status")), detail};
|
||||
r.danger = std::string(str(f, "status")) == "missing";
|
||||
return r;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
void SyncView::open(App &app, Kind kind, int rom_id, const std::string &name)
|
||||
{
|
||||
active_ = true;
|
||||
kind_ = kind;
|
||||
rom_id_ = rom_id;
|
||||
name_ = name;
|
||||
choosing_ = -1;
|
||||
armed_ = -1;
|
||||
age_ = 0.0f;
|
||||
busy_ = false;
|
||||
list_.set_focus(0);
|
||||
load(app);
|
||||
}
|
||||
|
||||
void SyncView::load(App &app)
|
||||
{
|
||||
const int gen = ++generation_;
|
||||
items_.clear();
|
||||
conflicts_.clear();
|
||||
switch (kind_)
|
||||
{
|
||||
case kConflicts:
|
||||
title_ = "Conflicts";
|
||||
subtitle_ = "The copy you don't keep is backed up.";
|
||||
break;
|
||||
case kActivity:
|
||||
title_ = "Activity";
|
||||
subtitle_ = "Newest first";
|
||||
break;
|
||||
case kTracked:
|
||||
title_ = "Tracked saves";
|
||||
subtitle_ = "";
|
||||
break;
|
||||
case kGame:
|
||||
title_ = plain_text(name_.c_str());
|
||||
subtitle_ = "Cross twice restores a version from RomM";
|
||||
break;
|
||||
}
|
||||
add({RowKind::info, "Loading", "", ""});
|
||||
waiting_ = 1;
|
||||
|
||||
if (kind_ == kConflicts)
|
||||
{
|
||||
app.get("/api/status", [this, gen](const Response &r) {
|
||||
if (gen != generation_)
|
||||
return;
|
||||
Json j(r.json(), cJSON_Delete);
|
||||
const cJSON *c;
|
||||
cJSON_ArrayForEach(c, cJSON_GetObjectItemCaseSensitive(cJSON_GetObjectItemCaseSensitive(j.get(), "sync"), "conflicts"))
|
||||
{
|
||||
Conflict k;
|
||||
k.path = str(c, "path");
|
||||
k.game = plain_text(*str(c, "rom_name") ? str(c, "rom_name") : base_name(k.path).c_str());
|
||||
k.local = local_time(num(c, "local_mtime"));
|
||||
k.server = when(str(c, "server_updated_at"));
|
||||
conflicts_.push_back(std::move(k));
|
||||
}
|
||||
if (!r.ok())
|
||||
conflicts_.clear();
|
||||
show_conflicts();
|
||||
if (!r.ok())
|
||||
{
|
||||
items_.clear();
|
||||
add({RowKind::info, "Couldn't load them", "", r.message()});
|
||||
}
|
||||
});
|
||||
}
|
||||
else if (kind_ == kActivity)
|
||||
{
|
||||
app.get("/api/events?limit=200", [this, gen](const Response &r) {
|
||||
if (gen != generation_)
|
||||
return;
|
||||
items_.clear();
|
||||
Json j(r.json(), cJSON_Delete);
|
||||
const bool ok = r.ok() && cJSON_IsArray(j.get());
|
||||
const cJSON *e;
|
||||
if (ok)
|
||||
cJSON_ArrayForEach(e, j.get()) add(event_row(e, true));
|
||||
if (items_.empty())
|
||||
add({RowKind::info, ok ? "Nothing yet" : "Couldn't load the log", "",
|
||||
ok ? "" : r.ok() ? "RommPS's background service is out of date" : r.message()});
|
||||
});
|
||||
}
|
||||
else if (kind_ == kTracked)
|
||||
{
|
||||
app.get("/api/tracked", [this, gen, &app](const Response &r) {
|
||||
if (gen != generation_)
|
||||
return;
|
||||
items_.clear();
|
||||
Json j(r.json(), cJSON_Delete);
|
||||
const bool ok = r.ok() && cJSON_IsArray(cJSON_GetObjectItemCaseSensitive(j.get(), "platforms"));
|
||||
struct Plat
|
||||
{
|
||||
std::string name;
|
||||
const cJSON *games;
|
||||
};
|
||||
std::vector<Plat> plats;
|
||||
const cJSON *p;
|
||||
cJSON_ArrayForEach(p, cJSON_GetObjectItemCaseSensitive(j.get(), "platforms"))
|
||||
plats.push_back({platform_name(app, str(p, "platform")), cJSON_GetObjectItemCaseSensitive(p, "games")});
|
||||
std::sort(plats.begin(), plats.end(), [](const Plat &a, const Plat &b) { return a.name < b.name; });
|
||||
for (const Plat &pl : plats)
|
||||
{
|
||||
add({RowKind::header, pl.name});
|
||||
const cJSON *g, *f;
|
||||
cJSON_ArrayForEach(g, pl.games)
|
||||
{
|
||||
const std::string game = plain_text(str(g, "name"));
|
||||
cJSON_ArrayForEach(f, cJSON_GetObjectItemCaseSensitive(g, "files")) add(file_row(f, game));
|
||||
}
|
||||
}
|
||||
if (items_.empty())
|
||||
add({RowKind::info, ok ? "Nothing tracked yet" : "Couldn't load them", "",
|
||||
ok ? "" : r.ok() ? "RommPS's background service is out of date" : r.message()});
|
||||
char sub[96];
|
||||
std::snprintf(sub, sizeof sub, "%d saves, %d states", static_cast<int>(num(j.get(), "saves")),
|
||||
static_cast<int>(num(j.get(), "states")));
|
||||
if (ok)
|
||||
subtitle_ = sub;
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
// A game: what's here, RomM's versions and the game's log, shown together.
|
||||
waiting_ = 3;
|
||||
game_here_.clear();
|
||||
game_history_.clear();
|
||||
game_log_.clear();
|
||||
const std::string id = std::to_string(rom_id_);
|
||||
auto done = [this, gen](std::string *slot, const Response &r) {
|
||||
if (gen != generation_)
|
||||
return;
|
||||
*slot = r.ok() ? r.body : std::string("!") + r.message();
|
||||
if (--waiting_ > 0)
|
||||
return;
|
||||
items_.clear();
|
||||
Json here(cJSON_Parse(game_here_.c_str()), cJSON_Delete), history(cJSON_Parse(game_history_.c_str()), cJSON_Delete),
|
||||
log(cJSON_Parse(game_log_.c_str()), cJSON_Delete);
|
||||
add({RowKind::header, "On this console"});
|
||||
const std::size_t before = items_.size();
|
||||
const cJSON *p, *g, *f;
|
||||
cJSON_ArrayForEach(p, cJSON_GetObjectItemCaseSensitive(here.get(), "platforms"))
|
||||
cJSON_ArrayForEach(g, cJSON_GetObjectItemCaseSensitive(p, "games"))
|
||||
cJSON_ArrayForEach(f, cJSON_GetObjectItemCaseSensitive(g, "files")) add(file_row(f, ""));
|
||||
if (items_.size() == before)
|
||||
add({RowKind::info, "Nothing here yet", "", ""});
|
||||
static const char *const kinds[2][2] = {{"saves", "Saves on RomM"}, {"states", "Save states on RomM"}};
|
||||
for (int k = 0; k < 2; ++k)
|
||||
{
|
||||
add({RowKind::header, kinds[k][1]});
|
||||
const cJSON *list = cJSON_GetObjectItemCaseSensitive(history.get(), kinds[k][0]);
|
||||
std::vector<const cJSON *> versions;
|
||||
const cJSON *v;
|
||||
cJSON_ArrayForEach(v, list) versions.push_back(v);
|
||||
std::sort(versions.begin(), versions.end(), [](const cJSON *a, const cJSON *b) {
|
||||
return std::string(str(a, "updated_at")) > std::string(str(b, "updated_at"));
|
||||
});
|
||||
for (const cJSON *x : versions)
|
||||
{
|
||||
std::string from = *str(x, "device") ? str(x, "device") : str(x, "emulator");
|
||||
std::string detail = plain_text(str(x, "file_name")) + " \xC2\xB7 " + size_text(num(x, "size"));
|
||||
if (!from.empty())
|
||||
detail += " \xC2\xB7 from " + plain_text(from.c_str());
|
||||
add({RowKind::action, when(str(x, "updated_at")), "Restore", detail}, k ? kRestoreState : kRestoreSave,
|
||||
static_cast<int>(num(x, "id")));
|
||||
}
|
||||
if (versions.empty())
|
||||
add({RowKind::info, game_history_[0] == '!' ? "Couldn't ask RomM" : "Nothing on RomM yet", "",
|
||||
game_history_[0] == '!' ? game_history_.substr(1) : ""});
|
||||
}
|
||||
add({RowKind::header, "Recent"});
|
||||
const std::size_t log_at = items_.size();
|
||||
const cJSON *e;
|
||||
int n = 0;
|
||||
cJSON_ArrayForEach(e, log.get()) if (n++ < 30) add(event_row(e, false));
|
||||
if (items_.size() == log_at)
|
||||
add({RowKind::info, "Nothing synced yet", "", ""});
|
||||
};
|
||||
app.get("/api/tracked?rom_id=" + id, [done, this](const Response &r) { done(&game_here_, r); });
|
||||
app.get("/api/history?rom_id=" + id, [done, this](const Response &r) { done(&game_history_, r); });
|
||||
app.get("/api/events?limit=30&rom_id=" + id, [done, this](const Response &r) { done(&game_log_, r); });
|
||||
}
|
||||
}
|
||||
|
||||
void SyncView::show_conflicts()
|
||||
{
|
||||
items_.clear();
|
||||
choosing_ = -1;
|
||||
for (std::size_t i = 0; i < conflicts_.size(); ++i)
|
||||
{
|
||||
const Conflict &c = conflicts_[i];
|
||||
add({RowKind::action, c.game, "Choose", "This console: " + c.local + " \xC2\xB7 RomM: " + c.server}, kPickConflict,
|
||||
static_cast<int>(i));
|
||||
}
|
||||
if (items_.empty())
|
||||
add({RowKind::info, "No conflicts", "", ""});
|
||||
}
|
||||
|
||||
std::vector<Row> SyncView::rows() const
|
||||
{
|
||||
std::vector<Row> out;
|
||||
out.reserve(items_.size());
|
||||
for (const Item &it : items_)
|
||||
out.push_back(it.row);
|
||||
if (armed_ >= 0 && armed_ < static_cast<int>(out.size()))
|
||||
out[static_cast<std::size_t>(armed_)].value = "Press again to restore";
|
||||
return out;
|
||||
}
|
||||
|
||||
void SyncView::update(App &app, const hui::InputFrame &input, float dt, hui::ui::Feedback &feedback)
|
||||
{
|
||||
age_ += dt;
|
||||
armed_age_ += dt;
|
||||
if (input.is_pressed(Action::back))
|
||||
{
|
||||
feedback.play(Cue::back);
|
||||
if (choosing_ >= 0)
|
||||
show_conflicts();
|
||||
else
|
||||
active_ = false;
|
||||
return;
|
||||
}
|
||||
const std::vector<Row> r = rows();
|
||||
if (armed_ >= 0 && (armed_age_ > kArmedFor || list_.focus() != armed_))
|
||||
armed_ = -1;
|
||||
const RowEvent e = list_.handle(r, input, feedback);
|
||||
list_.update(dt, static_cast<int>(r.size()), kRows.h);
|
||||
if (e.row < 0 || !e.activated || busy_)
|
||||
return;
|
||||
const Item it = items_[static_cast<std::size_t>(e.row)];
|
||||
switch (it.action)
|
||||
{
|
||||
case kPickConflict:
|
||||
{
|
||||
const Conflict &c = conflicts_[static_cast<std::size_t>(it.arg)];
|
||||
items_.clear();
|
||||
choosing_ = it.arg;
|
||||
add({RowKind::info, c.game, "", "Which copy should stay?"});
|
||||
add({RowKind::action, "Keep this console's", "", "Changed here " + c.local + ". RomM's copy is replaced."}, kKeepLocal);
|
||||
add({RowKind::action, "Keep RomM's", "", "Changed on RomM " + c.server + ". This console's copy is backed up."},
|
||||
kKeepServer);
|
||||
add({RowKind::action, "Not now"}, kCancel);
|
||||
list_.set_focus(1);
|
||||
break;
|
||||
}
|
||||
case kKeepLocal:
|
||||
case kKeepServer:
|
||||
{
|
||||
const Conflict c = conflicts_[static_cast<std::size_t>(choosing_)];
|
||||
const bool local = it.action == kKeepLocal;
|
||||
busy_ = true;
|
||||
app.post("/api/conflicts/resolve",
|
||||
"{\"path\":" + json_str(c.path) + ",\"keep\":\"" + (local ? "local" : "server") + "\"}",
|
||||
[this, &app, local](const Response &r) {
|
||||
busy_ = false;
|
||||
app.say(r.ok() ? (local ? "Kept this console's save" : "Kept RomM's save") : "Couldn't settle it: " + r.message());
|
||||
app.refresh_now();
|
||||
load(app);
|
||||
});
|
||||
break;
|
||||
}
|
||||
case kCancel:
|
||||
show_conflicts();
|
||||
list_.set_focus(0);
|
||||
break;
|
||||
case kRestoreSave:
|
||||
case kRestoreState:
|
||||
if (armed_ != e.row)
|
||||
{
|
||||
armed_ = e.row;
|
||||
armed_age_ = 0.0f;
|
||||
break;
|
||||
}
|
||||
armed_ = -1;
|
||||
busy_ = true;
|
||||
app.post("/api/restore",
|
||||
std::string("{\"kind\":\"") + (it.action == kRestoreSave ? "save" : "state") + "\",\"id\":" +
|
||||
std::to_string(it.arg) + "}",
|
||||
[this, &app](const Response &r) {
|
||||
busy_ = false;
|
||||
app.say(r.ok() ? "Restored. The current file was backed up" : "Couldn't restore it: " + r.message());
|
||||
if (r.ok())
|
||||
load(app);
|
||||
});
|
||||
break;
|
||||
default:
|
||||
list_.refuse(feedback);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
void SyncView::draw(hui::gfx::DrawList &list, const hui::ui::Fonts &fonts, const Look &look, const App &app,
|
||||
float clock) const
|
||||
{
|
||||
(void)app;
|
||||
const float in = hui::tween::stagger(age_, 1, 0.08f, 0.6f);
|
||||
list.push_opacity(in);
|
||||
ui::text(list, fonts.display, fonts.display.font->fit(title_, 64, 1700), kMargin, 220, 64, look.text);
|
||||
ui::text(list, fonts.regular, fonts.regular.font->fit(subtitle_, 24, 1700), kMargin, 268, 24, look.muted);
|
||||
list_.draw(list, fonts, look, rows(), {kRows.x, kRows.y + 24 * (1.0f - in), kRows.w, kRows.h}, clock);
|
||||
list.pop_opacity();
|
||||
}
|
||||
|
||||
std::vector<hui::ui::Hint> SyncView::hints() const
|
||||
{
|
||||
std::vector<hui::ui::Hint> h;
|
||||
const int f = list_.focus();
|
||||
const bool can = f >= 0 && f < static_cast<int>(items_.size()) && items_[static_cast<std::size_t>(f)].action != kNothing;
|
||||
if (can)
|
||||
h.push_back({hui::ui::Button::cross, choosing_ >= 0 ? "Choose" : kind_ == kGame ? "Restore" : "Choose"});
|
||||
h.push_back({hui::ui::Button::circle, "Back"});
|
||||
return h;
|
||||
}
|
||||
|
||||
} // namespace rommps
|
||||
@@ -0,0 +1,93 @@
|
||||
// RommPS: lists about the sync, opened from Home and from a game's sheet.
|
||||
//
|
||||
// - Conflicts: saves changed here and on RomM since the last sync. Cross on
|
||||
// one asks which copy to keep; the other is backed up.
|
||||
// - Activity: the sync log (the payload's /api/events), newest first.
|
||||
// - Tracked saves: the saves and states kept in sync, by platform, each
|
||||
// with its status (/api/tracked).
|
||||
// - A game's saves and states: what's on this console, the versions RomM
|
||||
// keeps (Cross twice restores one) and the game's log.
|
||||
//
|
||||
// Each is a list of rows (rows.hpp) filled from the payload when it opens;
|
||||
// Circle closes it.
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#pragma once
|
||||
|
||||
#include "rommps_app.hpp"
|
||||
#include "rows.hpp"
|
||||
#include "ui/glyphs.hpp"
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace rommps
|
||||
{
|
||||
|
||||
class SyncView
|
||||
{
|
||||
public:
|
||||
enum Kind
|
||||
{
|
||||
kConflicts,
|
||||
kActivity,
|
||||
kTracked,
|
||||
kGame
|
||||
};
|
||||
|
||||
void open(App &app, Kind kind, int rom_id = 0, const std::string &name = "");
|
||||
void close() { active_ = false; }
|
||||
bool active() const { return active_; }
|
||||
Kind kind() const { return kind_; }
|
||||
|
||||
void update(App &app, const hui::InputFrame &input, float dt, hui::ui::Feedback &feedback);
|
||||
void draw(hui::gfx::DrawList &list, const hui::ui::Fonts &fonts, const Look &look, const App &app, float clock) const;
|
||||
std::vector<hui::ui::Hint> hints() const;
|
||||
|
||||
private:
|
||||
// What Cross does on a row.
|
||||
enum Do
|
||||
{
|
||||
kNothing,
|
||||
kPickConflict, // opens the choice for conflicts_[arg]
|
||||
kKeepLocal,
|
||||
kKeepServer,
|
||||
kCancel,
|
||||
kRestoreSave, // arg: RomM's save id
|
||||
kRestoreState // arg: RomM's state id
|
||||
};
|
||||
struct Item
|
||||
{
|
||||
Row row;
|
||||
Do action = kNothing;
|
||||
int arg = 0;
|
||||
};
|
||||
struct Conflict
|
||||
{
|
||||
std::string path, game, local, server;
|
||||
};
|
||||
|
||||
void load(App &app);
|
||||
void show_conflicts();
|
||||
void add(Row row, Do action = kNothing, int arg = 0) { items_.push_back({std::move(row), action, arg}); }
|
||||
std::vector<Row> rows() const;
|
||||
|
||||
bool active_ = false;
|
||||
Kind kind_ = kActivity;
|
||||
int rom_id_ = 0;
|
||||
std::string name_;
|
||||
std::string title_, subtitle_;
|
||||
std::vector<Item> items_;
|
||||
std::vector<Conflict> conflicts_;
|
||||
int choosing_ = -1; // the conflict whose choice is showing, or -1
|
||||
int armed_ = -1; // a restore row waiting for its second press
|
||||
float armed_age_ = 0.0f, age_ = 0.0f;
|
||||
int generation_ = 0; // answers for an older open() are dropped
|
||||
int waiting_ = 0; // requests still out
|
||||
bool busy_ = false; // a resolve or restore in flight
|
||||
// A game's view gathers three answers before it shows.
|
||||
std::string game_here_, game_history_, game_log_;
|
||||
RowList list_;
|
||||
};
|
||||
|
||||
} // namespace rommps
|
||||
@@ -19,9 +19,9 @@
|
||||
extern "C" void rommps_set_renderer(void *renderer);
|
||||
|
||||
// RommPS's own interface sounds: assets/rommps/sounds/<cue>_NN.wav in the
|
||||
// title (platform/ps5/app/assets/sounds in the repository; focus_01.wav,
|
||||
// select_01.wav, back_01.wav, ...: the kit's cue names, audio/cues.cpp), 48 kHz
|
||||
// 16- or 24-bit PCM. Each cue there replaces the kit's recordings of that cue;
|
||||
// title (platform/ps5/app/assets/primary-menu-sounds in the repository;
|
||||
// focus_01.wav, select_01.wav, back_01.wav, ...: the kit's cue names,
|
||||
// audio/cues.cpp), 48 kHz 16- or 24-bit PCM. Each cue there replaces the kit's recordings of that cue;
|
||||
// the cues it leaves out keep them.
|
||||
std::string soundPackDir(const ps5ui::Kit &kit)
|
||||
{
|
||||
|
||||
@@ -8,12 +8,12 @@ submodule) stay out, and the samples that use them get the replacements here.
|
||||
| Path under `/app0/assets/` | Asset | Author | Licence | Samples |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `Roboto-Medium.ttf` | [Roboto Medium](https://fonts.google.com/specimen/Roboto) | Christian Robertson (Google Fonts) | Apache-2.0 | all |
|
||||
| `hui/fonts/inter-regular.huifont` | [Inter Regular](https://github.com/rsms/inter) (a signed-distance-field atlas, made by ps5-homebrew-ui's tools/bake-fonts.sh) | The Inter Project Authors | OFL-1.1 | the UI module |
|
||||
| `hui/fonts/inter-semibold.huifont` | [Inter SemiBold](https://github.com/rsms/inter) (a signed-distance-field atlas, made by ps5-homebrew-ui's tools/bake-fonts.sh) | The Inter Project Authors | OFL-1.1 | the UI module |
|
||||
| `hui/fonts/montserrat-medium.huifont` | [Montserrat Medium](https://github.com/JulietaUla/Montserrat) (a signed-distance-field atlas, made by ps5-homebrew-ui's tools/bake-fonts.sh) | The Montserrat Project Authors | OFL-1.1 | the UI module |
|
||||
| `hui/fonts/dejavu-sans-mono.huifont` | [DejaVu Sans Mono](https://dejavu-fonts.github.io/) (a signed-distance-field atlas, made by ps5-homebrew-ui's tools/bake-fonts.sh) | Bitstream, Inc.; the DejaVu changes are in the public domain | Bitstream-Vera | the UI module |
|
||||
| `hui/fonts/press-start-2p.huifont` | [Press Start 2P](https://fonts.google.com/specimen/Press+Start+2P) (a signed-distance-field atlas, made by ps5-homebrew-ui's tools/bake-fonts.sh) | The Press Start 2P Project Authors | OFL-1.1 | the UI module |
|
||||
| `hui/fonts/patrick-hand.huifont` | [Patrick Hand](https://fonts.google.com/specimen/Patrick+Hand) (a signed-distance-field atlas, made by ps5-homebrew-ui's tools/bake-fonts.sh) | Patrick Wagesreiter | OFL-1.1 | the UI module |
|
||||
| `hui/fonts/inter-regular.huifont` | [Inter Regular](https://github.com/rsms/inter) (a signed-distance-field atlas with accented Latin and Cyrillic, baked by ps5-homebrew-ui's font baker (ps5/ui/setup-kit.sh)) | The Inter Project Authors | OFL-1.1 | the UI module |
|
||||
| `hui/fonts/inter-semibold.huifont` | [Inter SemiBold](https://github.com/rsms/inter) (a signed-distance-field atlas with accented Latin and Cyrillic, baked by ps5-homebrew-ui's font baker (ps5/ui/setup-kit.sh)) | The Inter Project Authors | OFL-1.1 | the UI module |
|
||||
| `hui/fonts/montserrat-medium.huifont` | [Montserrat Medium](https://github.com/JulietaUla/Montserrat) (a signed-distance-field atlas with accented Latin and Cyrillic, baked by ps5-homebrew-ui's font baker (ps5/ui/setup-kit.sh)) | The Montserrat Project Authors | OFL-1.1 | the UI module |
|
||||
| `hui/fonts/dejavu-sans-mono.huifont` | [DejaVu Sans Mono](https://dejavu-fonts.github.io/) (a signed-distance-field atlas with accented Latin and Cyrillic, baked by ps5-homebrew-ui's font baker (ps5/ui/setup-kit.sh)) | Bitstream, Inc.; the DejaVu changes are in the public domain | Bitstream-Vera | the UI module |
|
||||
| `hui/fonts/press-start-2p.huifont` | [Press Start 2P](https://fonts.google.com/specimen/Press+Start+2P) (a signed-distance-field atlas with accented Latin and Cyrillic, baked by ps5-homebrew-ui's font baker (ps5/ui/setup-kit.sh)) | The Press Start 2P Project Authors | OFL-1.1 | the UI module |
|
||||
| `hui/fonts/patrick-hand.huifont` | [Patrick Hand](https://fonts.google.com/specimen/Patrick+Hand) (a signed-distance-field atlas with accented Latin and Cyrillic, baked by ps5-homebrew-ui's font baker (ps5/ui/setup-kit.sh)) | Patrick Wagesreiter | OFL-1.1 | the UI module |
|
||||
| `hui/audio/sfx/glass` | [The glass sound set (ProsperoEden's sound effects)](https://github.com/blackbearreloaded/ps5-homebrew-ui) (generated with ElevenLabs Sound Effects v2 for ProsperoEden, trimmed, faded and levelled by ps5-homebrew-ui's tools/process-sfx.py (its THIRD_PARTY_NOTICES.md)) | BlackBearReloaded | GPL-3.0-or-later | the UI module |
|
||||
| `hui/audio/sfx/paper` | [The paper sound set (ProsperoPuzzles' sound effects)](https://github.com/blackbearreloaded/ps5-homebrew-ui) (generated with ElevenLabs Sound Effects v2 for ProsperoPuzzles, trimmed, faded and levelled by ps5-homebrew-ui's tools/process-sfx.py (its THIRD_PARTY_NOTICES.md)) | BlackBearReloaded | GPL-3.0-or-later | the UI module |
|
||||
| `hui/audio/music` | [First Light, Open Strings and Quiet Hours](https://github.com/blackbearreloaded/ps5-homebrew-ui) (48 kHz OGG Vorbis at -18 LUFS, as ps5-homebrew-ui ships them) | BlackBearReloaded | GPL-3.0-or-later | the UI module |
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
"author": "The Inter Project Authors",
|
||||
"licence": "OFL-1.1",
|
||||
"url": "https://github.com/rsms/inter",
|
||||
"changes": "a signed-distance-field atlas, made by ps5-homebrew-ui's tools/bake-fonts.sh",
|
||||
"changes": "a signed-distance-field atlas with accented Latin and Cyrillic, baked by ps5-homebrew-ui's font baker (ps5/ui/setup-kit.sh)",
|
||||
"licence_file": "kit:assets/fonts/Inter-LICENSE.txt"
|
||||
},
|
||||
{
|
||||
@@ -50,7 +50,7 @@
|
||||
"author": "The Inter Project Authors",
|
||||
"licence": "OFL-1.1",
|
||||
"url": "https://github.com/rsms/inter",
|
||||
"changes": "a signed-distance-field atlas, made by ps5-homebrew-ui's tools/bake-fonts.sh",
|
||||
"changes": "a signed-distance-field atlas with accented Latin and Cyrillic, baked by ps5-homebrew-ui's font baker (ps5/ui/setup-kit.sh)",
|
||||
"licence_file": "kit:assets/fonts/Inter-LICENSE.txt"
|
||||
},
|
||||
{
|
||||
@@ -64,7 +64,7 @@
|
||||
"author": "The Montserrat Project Authors",
|
||||
"licence": "OFL-1.1",
|
||||
"url": "https://github.com/JulietaUla/Montserrat",
|
||||
"changes": "a signed-distance-field atlas, made by ps5-homebrew-ui's tools/bake-fonts.sh",
|
||||
"changes": "a signed-distance-field atlas with accented Latin and Cyrillic, baked by ps5-homebrew-ui's font baker (ps5/ui/setup-kit.sh)",
|
||||
"licence_file": "kit:assets/fonts/Montserrat-LICENSE.txt"
|
||||
},
|
||||
{
|
||||
@@ -78,7 +78,7 @@
|
||||
"author": "Bitstream, Inc.; the DejaVu changes are in the public domain",
|
||||
"licence": "Bitstream-Vera",
|
||||
"url": "https://dejavu-fonts.github.io/",
|
||||
"changes": "a signed-distance-field atlas, made by ps5-homebrew-ui's tools/bake-fonts.sh",
|
||||
"changes": "a signed-distance-field atlas with accented Latin and Cyrillic, baked by ps5-homebrew-ui's font baker (ps5/ui/setup-kit.sh)",
|
||||
"licence_file": "kit:assets/fonts/DejaVu-LICENSE.txt"
|
||||
},
|
||||
{
|
||||
@@ -92,7 +92,7 @@
|
||||
"author": "The Press Start 2P Project Authors",
|
||||
"licence": "OFL-1.1",
|
||||
"url": "https://fonts.google.com/specimen/Press+Start+2P",
|
||||
"changes": "a signed-distance-field atlas, made by ps5-homebrew-ui's tools/bake-fonts.sh",
|
||||
"changes": "a signed-distance-field atlas with accented Latin and Cyrillic, baked by ps5-homebrew-ui's font baker (ps5/ui/setup-kit.sh)",
|
||||
"licence_file": "kit:assets/fonts/PressStart2P-LICENSE.txt"
|
||||
},
|
||||
{
|
||||
@@ -106,7 +106,7 @@
|
||||
"author": "Patrick Wagesreiter",
|
||||
"licence": "OFL-1.1",
|
||||
"url": "https://fonts.google.com/specimen/Patrick+Hand",
|
||||
"changes": "a signed-distance-field atlas, made by ps5-homebrew-ui's tools/bake-fonts.sh",
|
||||
"changes": "a signed-distance-field atlas with accented Latin and Cyrillic, baked by ps5-homebrew-ui's font baker (ps5/ui/setup-kit.sh)",
|
||||
"licence_file": "kit:assets/fonts/PatrickHand-LICENSE.txt"
|
||||
},
|
||||
{
|
||||
@@ -224,7 +224,7 @@
|
||||
{
|
||||
"path": "rommps/sounds",
|
||||
"from": "file",
|
||||
"source": "../assets/sounds",
|
||||
"source": "../assets/primary-menu-sounds",
|
||||
"used_by": [
|
||||
"*"
|
||||
],
|
||||
@@ -233,9 +233,7 @@
|
||||
"licence": "used with Konami's permission (supplied by s0liton)",
|
||||
"url": "https://www.konami.com",
|
||||
"changes": "MP3 to 48 kHz 16-bit WAV, one file per interface cue",
|
||||
"licence_file": "SOUNDS-LICENCE.txt",
|
||||
"optional": true,
|
||||
"comment": "Kept out of the public repository: present only in builds made where the files are (release builds). Without them RommPS uses the UI kit's sounds."
|
||||
"licence_file": "SOUNDS-LICENCE.txt"
|
||||
},
|
||||
{
|
||||
"path": "rommps/romm-sync.elf",
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
"conceptId": "76677",
|
||||
"contentBadgeType": 1,
|
||||
"contentId": "UP9000-PPSA76677_00-ROMMPS0000000000",
|
||||
"contentVersion": "01.001.000",
|
||||
"contentVersion": "01.003.000",
|
||||
"downloadDataSize": 256,
|
||||
"gameIntent": {
|
||||
"permittedIntents": [
|
||||
|
||||
@@ -136,8 +136,7 @@ def install(folder):
|
||||
if "*" not in used and not samples.intersection(used) and not ("@ui" in used and ui_linked()):
|
||||
continue
|
||||
source = source_path(asset, manifest)
|
||||
# An optional asset kept out of the repository (RommPS's licensed sounds)
|
||||
# is packaged only where it is present.
|
||||
# An optional asset is packaged only where it is present.
|
||||
if asset.get("optional") and not source.exists():
|
||||
print(f"==> assets: {asset['path']} not here, left out (optional)")
|
||||
continue
|
||||
|
||||
@@ -132,6 +132,12 @@ hui::InputFrame Kit::scripted(const char *program, uint32_t frame) const
|
||||
input.pressed |= hui::action_bit(hui::Action::page_next);
|
||||
} else if (press.action == "l1") {
|
||||
input.pressed |= hui::action_bit(hui::Action::page_prev);
|
||||
} else if (press.action == "circle") {
|
||||
input.pressed |= hui::action_bit(hui::Action::back);
|
||||
} else if (press.action == "triangle") {
|
||||
input.pressed |= hui::action_bit(hui::Action::north);
|
||||
} else if (press.action == "square") {
|
||||
input.pressed |= hui::action_bit(hui::Action::west);
|
||||
}
|
||||
input.held |= input.pressed;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,13 @@
|
||||
#
|
||||
# ps5/ui/setup-kit.sh export it into .deps/hui (once a revision); prints the path
|
||||
#
|
||||
# The kit's own fonts are baked with printable ASCII and a handful of symbols;
|
||||
# RommPS shows names it didn't write (Pokémon, Luigi’s Mansion), so the export
|
||||
# bakes them again from the same TTFs with the kit's "european" glyph set
|
||||
# (accented Latin, Cyrillic, curly quotes, euro, trade mark). Same faces, same
|
||||
# sizes and SDF ranges as the kit's tools/bake-fonts.sh; only the set and the
|
||||
# atlas (2048, which the bigger set needs) differ.
|
||||
#
|
||||
# The kit is PS5_VKHomebrewUI, my fork of BlackBearReloaded's ps5-homebrew-ui
|
||||
# with a Vulkan backend. The pinned revision is exported with git archive from
|
||||
# the sibling checkout (../PS5_VKHomebrewUI, or PS5_VKHOMEBREWUI), or from
|
||||
@@ -19,7 +26,7 @@ fork="${PS5_VKHOMEBREWUI:-$root/../PS5_VKHomebrewUI}"
|
||||
revision=2188642afbcc8d84d36149124f3d9e39dc1b1a05
|
||||
|
||||
# What is exported, beside the revision: a change to the list exports again
|
||||
stamp="$revision src assets/fonts assets/audio third_party/fonts"
|
||||
stamp="$revision src assets/fonts assets/audio third_party/fonts fonts:european-2048"
|
||||
if [[ ! -f $kit/.revision || $(<"$kit/.revision") != "$stamp" ]]; then
|
||||
if ! git -C "$fork" cat-file -e "$revision^{commit}" 2>/dev/null; then
|
||||
fork="$root/.deps/PS5_VKHomebrewUI.git"
|
||||
@@ -33,9 +40,27 @@ if [[ ! -f $kit/.revision || $(<"$kit/.revision") != "$stamp" ]]; then
|
||||
rm -rf -- "$kit"
|
||||
mkdir -p "$kit"
|
||||
# The code, the assets a title ships (the TTFs are for the samples' ImGui
|
||||
# windows, ps5/ui/overlay_theme.cpp), and the licence texts
|
||||
git -C "$fork" archive "$revision" src assets/fonts assets/audio third_party/fonts LICENSE \
|
||||
THIRD_PARTY_NOTICES.md | tar -x -C "$kit"
|
||||
# windows, ps5/ui/overlay_theme.cpp, and the font bake), the font baker,
|
||||
# and the licence texts
|
||||
git -C "$fork" archive "$revision" src assets/fonts assets/audio third_party/fonts \
|
||||
third_party/stb/stb_truetype.h tools/font-baker LICENSE THIRD_PARTY_NOTICES.md | tar -x -C "$kit"
|
||||
# A host tool: the build machine's compiler, not the console's. The glyphs
|
||||
# a face lacks are skipped (Press Start 2P has few beyond ASCII); the app
|
||||
# asks the font before drawing one (has_glyph).
|
||||
cxx=$(command -v "${HOST_CXX:-clang++}" || command -v c++) || { echo "no host C++ compiler to bake the fonts" >&2; exit 2; }
|
||||
mkdir -p "$kit/build"
|
||||
"$cxx" -std=c++20 -O2 -w "$kit/tools/font-baker/bake_font.cpp" -o "$kit/build/bake_font" >&2
|
||||
while read -r source output size range; do
|
||||
"$kit/build/bake_font" "$kit/third_party/fonts/$source" "$kit/assets/fonts/$output.huifont" \
|
||||
"$size" "$range" 2048 european 2>&1 | grep -v '^skipping U+' >&2
|
||||
done <<'FONTS'
|
||||
Inter-Regular.ttf inter-regular 56 8
|
||||
Inter-SemiBold.ttf inter-semibold 56 8
|
||||
Montserrat-Medium.ttf montserrat-medium 56 8
|
||||
DejaVuSansMono.ttf dejavu-sans-mono 52 8
|
||||
PressStart2P-Regular.ttf press-start-2p 32 4
|
||||
PatrickHand-Regular.ttf patrick-hand 56 8
|
||||
FONTS
|
||||
echo "$stamp" > "$kit/.revision"
|
||||
fi
|
||||
echo "$kit"
|
||||
@@ -9,8 +9,10 @@
|
||||
#include <sys/time.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#include <atomic>
|
||||
#include <cerrno>
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
|
||||
#include "cJSON.h"
|
||||
@@ -20,7 +22,7 @@ namespace rommps {
|
||||
cJSON *Response::json() const { return body.empty() ? nullptr : cJSON_ParseWithLength(body.data(), body.size()); }
|
||||
|
||||
std::string Response::message() const {
|
||||
if (status == 0) return error.empty() ? "RomM Sync isn't answering" : error;
|
||||
if (status == 0) return error.empty() ? "RommPS's background service isn't answering" : error;
|
||||
std::string out;
|
||||
if (cJSON *j = json()) {
|
||||
const cJSON *e = cJSON_GetObjectItemCaseSensitive(j, "error");
|
||||
@@ -41,6 +43,21 @@ static bool send_all(int fd, const char *p, size_t n) {
|
||||
return true;
|
||||
}
|
||||
|
||||
namespace {
|
||||
// The request on the wire, so closing the app needn't wait for its answer.
|
||||
std::atomic<int> g_open_fd{-1};
|
||||
|
||||
struct OpenSocket {
|
||||
int fd;
|
||||
explicit OpenSocket(int f) : fd(f) { g_open_fd = f; }
|
||||
~OpenSocket() {
|
||||
int expected = fd;
|
||||
g_open_fd.compare_exchange_strong(expected, -1);
|
||||
::close(fd);
|
||||
}
|
||||
};
|
||||
} // namespace
|
||||
|
||||
Response http_request(const std::string &host, int port, const std::string &method, const std::string &path,
|
||||
const std::string &body, int timeout_ms) {
|
||||
Response r;
|
||||
@@ -49,6 +66,7 @@ Response http_request(const std::string &host, int port, const std::string &meth
|
||||
r.error = std::string("socket: ") + std::strerror(errno);
|
||||
return r;
|
||||
}
|
||||
const OpenSocket open(fd);
|
||||
timeval tv{timeout_ms / 1000, (timeout_ms % 1000) * 1000};
|
||||
::setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof tv);
|
||||
::setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, &tv, sizeof tv);
|
||||
@@ -56,8 +74,7 @@ Response http_request(const std::string &host, int port, const std::string &meth
|
||||
sa.sin_family = AF_INET;
|
||||
sa.sin_port = htons((uint16_t)port);
|
||||
if (::inet_pton(AF_INET, host.c_str(), &sa.sin_addr) != 1 || ::connect(fd, (sockaddr *)&sa, sizeof sa) != 0) {
|
||||
r.error = "RomM Sync isn't running (" + std::string(std::strerror(errno)) + ")";
|
||||
::close(fd);
|
||||
r.error = "RommPS's background service isn't running (" + std::string(std::strerror(errno)) + ")";
|
||||
return r;
|
||||
}
|
||||
char head[1024];
|
||||
@@ -67,7 +84,6 @@ Response http_request(const std::string &host, int port, const std::string &meth
|
||||
method.c_str(), path.c_str(), host.c_str(), body.size());
|
||||
if (hn <= 0 || hn >= (int)sizeof head || !send_all(fd, head, (size_t)hn) || !send_all(fd, body.data(), body.size())) {
|
||||
r.error = "couldn't send the request";
|
||||
::close(fd);
|
||||
return r;
|
||||
}
|
||||
std::string in;
|
||||
@@ -76,18 +92,16 @@ Response http_request(const std::string &host, int port, const std::string &meth
|
||||
ssize_t n = ::recv(fd, buf, sizeof buf, 0);
|
||||
if (n < 0 && errno == EINTR) continue;
|
||||
if (n < 0) {
|
||||
r.error = errno == EAGAIN || errno == EWOULDBLOCK ? "RomM Sync took too long to answer" : std::strerror(errno);
|
||||
::close(fd);
|
||||
r.error = errno == EAGAIN || errno == EWOULDBLOCK ? "RommPS's background service took too long to answer" : std::strerror(errno);
|
||||
return r;
|
||||
}
|
||||
if (n == 0) break;
|
||||
in.append(buf, (size_t)n);
|
||||
}
|
||||
::close(fd);
|
||||
size_t split = in.find("\r\n\r\n");
|
||||
int status = 0;
|
||||
if (split == std::string::npos || std::sscanf(in.c_str(), "HTTP/%*d.%*d %d", &status) != 1) {
|
||||
r.error = "RomM Sync sent a reply the app can't read";
|
||||
r.error = "RommPS's background service sent a reply the app can't read";
|
||||
return r;
|
||||
}
|
||||
r.status = status;
|
||||
@@ -95,6 +109,29 @@ Response http_request(const std::string &host, int port, const std::string &meth
|
||||
return r;
|
||||
}
|
||||
|
||||
int payload_port()
|
||||
{
|
||||
static const int port = [] {
|
||||
int p = 8780;
|
||||
if (FILE *f = std::fopen("/data/romm-sync/config.json", "rb"))
|
||||
{
|
||||
char buf[8192];
|
||||
const size_t n = std::fread(buf, 1, sizeof buf - 1, f);
|
||||
std::fclose(f);
|
||||
buf[n] = 0;
|
||||
if (const char *k = std::strstr(buf, "\"web_port\""))
|
||||
if (const char *colon = std::strchr(k, ':'))
|
||||
{
|
||||
const long v = std::strtol(colon + 1, nullptr, 10);
|
||||
if (v > 0 && v < 65536)
|
||||
p = static_cast<int>(v);
|
||||
}
|
||||
}
|
||||
return p;
|
||||
}();
|
||||
return port;
|
||||
}
|
||||
|
||||
Api::Api(std::string host, int port) : host_(std::move(host)), port_(port), thread_(&Api::worker, this) {}
|
||||
|
||||
Api::~Api() {
|
||||
@@ -103,13 +140,16 @@ Api::~Api() {
|
||||
stopping_ = true;
|
||||
}
|
||||
wake_.notify_all();
|
||||
// A request still waiting for its answer ends now.
|
||||
const int fd = g_open_fd.exchange(-1);
|
||||
if (fd >= 0) ::shutdown(fd, SHUT_RDWR);
|
||||
thread_.join();
|
||||
}
|
||||
|
||||
void Api::get(const std::string &path, Callback done) {
|
||||
void Api::get(const std::string &path, Callback done, int timeout_ms) {
|
||||
{
|
||||
std::lock_guard<std::mutex> g(lock_);
|
||||
queue_.push_back(Job{"GET", path, "", std::move(done), {}});
|
||||
queue_.push_back(Job{"GET", path, "", std::move(done), {}, timeout_ms});
|
||||
}
|
||||
wake_.notify_one();
|
||||
}
|
||||
@@ -148,7 +188,7 @@ void Api::worker() {
|
||||
queue_.pop_front();
|
||||
in_flight_ = 1;
|
||||
}
|
||||
job.result = http_request(host_, port_, job.method, job.path, job.body);
|
||||
job.result = http_request(host_, port_, job.method, job.path, job.body, job.timeout_ms);
|
||||
std::lock_guard<std::mutex> g(lock_);
|
||||
in_flight_ = 0;
|
||||
finished_.push_back(std::move(job));
|
||||
|
||||
@@ -37,16 +37,20 @@ struct Response {
|
||||
Response http_request(const std::string &host, int port, const std::string &method, const std::string &path,
|
||||
const std::string &body = "", int timeout_ms = 15000);
|
||||
|
||||
// The port RomM Sync's API is on: web_port in its config.json, 8780 by default.
|
||||
int payload_port();
|
||||
|
||||
class Api {
|
||||
public:
|
||||
using Callback = std::function<void(const Response &)>;
|
||||
|
||||
explicit Api(std::string host = "127.0.0.1", int port = 8780);
|
||||
explicit Api(std::string host = "127.0.0.1", int port = payload_port());
|
||||
~Api();
|
||||
Api(const Api &) = delete;
|
||||
Api &operator=(const Api &) = delete;
|
||||
|
||||
void get(const std::string &path, Callback done);
|
||||
// timeout_ms: how long to wait for the answer (most come in well under a second).
|
||||
void get(const std::string &path, Callback done, int timeout_ms = 15000);
|
||||
void post(const std::string &path, const std::string &json_body, Callback done);
|
||||
// Runs the callbacks of finished requests. Call once per frame.
|
||||
void poll();
|
||||
@@ -58,6 +62,7 @@ class Api {
|
||||
std::string method, path, body;
|
||||
Callback done;
|
||||
Response result;
|
||||
int timeout_ms = 15000;
|
||||
};
|
||||
void worker();
|
||||
|
||||
|
||||
@@ -4,14 +4,14 @@
|
||||
make-title-art.py OUT.png
|
||||
|
||||
A 3840x2160 picture for sce_sys/pic0.dds and pic1.dds (tools/title-art.sh
|
||||
turns it into BC7): RommPS's Acrylic aurora, a shelf of abstract cover cards
|
||||
easing into the distance, and the RommPS wordmark in Montserrat (OFL,
|
||||
third_party/Montserrat-Medium.ttf). No game's art: every shape is drawn here.
|
||||
turns it into BC7): deep space with soft nebulae, a spiral galaxy swirling on
|
||||
the right, a field of stars, and the RommPS wordmark in Montserrat (OFL,
|
||||
third_party/Montserrat-Medium.ttf), its "PS" lit in the app's cyan and violet.
|
||||
Every shape is drawn here.
|
||||
|
||||
SPDX-License-Identifier: GPL-3.0-or-later
|
||||
"""
|
||||
import math
|
||||
import random
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
@@ -21,111 +21,177 @@ from PIL import Image, ImageDraw, ImageFilter, ImageFont
|
||||
W, H = 3840, 2160
|
||||
REPO = Path(__file__).resolve().parents[4]
|
||||
FONT = REPO / "third_party/Montserrat-Medium.ttf"
|
||||
CYAN, VIOLET, BLUE = 0x76d6ff, 0xc04bd6, 0x3a5bd9
|
||||
|
||||
|
||||
def hex_rgb(value):
|
||||
return np.array([(value >> 16) & 255, (value >> 8) & 255, value & 255], dtype=np.float32)
|
||||
|
||||
|
||||
def aurora():
|
||||
"""The app's Acrylic backdrop: deep navy, with blue and violet light."""
|
||||
def blur(field, radius):
|
||||
"""A float field blurred, through Pillow (values kept in 0..1)."""
|
||||
img = Image.fromarray(np.clip(field * 255, 0, 255).astype(np.uint8), "L")
|
||||
return np.asarray(img.filter(ImageFilter.GaussianBlur(radius)), dtype=np.float32) / 255
|
||||
|
||||
|
||||
def noise(rng, cells, radius):
|
||||
"""Smooth clouds: random cells scaled up and softened."""
|
||||
small = Image.fromarray((rng.random((cells[1], cells[0])) * 255).astype(np.uint8), "L")
|
||||
big = small.resize((W, H), Image.BICUBIC).filter(ImageFilter.GaussianBlur(radius))
|
||||
return np.asarray(big, dtype=np.float32) / 255
|
||||
|
||||
|
||||
def space(rng):
|
||||
"""Deep navy, with nebulae in the app's blue, violet and cyan."""
|
||||
y, x = np.mgrid[0:H, 0:W].astype(np.float32)
|
||||
u, v = x / W, y / H
|
||||
base = hex_rgb(0x070a1c) * (1 - v[..., None]) + hex_rgb(0x0b1026) * v[..., None]
|
||||
img = base.copy()
|
||||
# Soft lights: centre, radius, colour, strength.
|
||||
for cx, cy, r, colour, k in [
|
||||
(0.78, 0.18, 0.55, 0x3a5bd9, 0.85),
|
||||
(0.95, 0.70, 0.45, 0xc04bd6, 0.55),
|
||||
(0.30, 0.95, 0.60, 0x1b1f4a, 0.90),
|
||||
(0.55, 0.45, 0.35, 0x76d6ff, 0.18),
|
||||
]:
|
||||
d = np.sqrt(((u - cx) * (W / H)) ** 2 + (v - cy) ** 2) / r
|
||||
fall = np.exp(-d * d * 2.2)[..., None] * k
|
||||
img = img * (1 - fall) + hex_rgb(colour) * fall
|
||||
# Aurora ribbons: sine bands across the upper half.
|
||||
for phase, colour, k in [(0.0, 0x76d6ff, 0.10), (1.7, 0xc04bd6, 0.08)]:
|
||||
band = 0.30 + 0.06 * np.sin(u * 5.0 + phase) + 0.03 * np.sin(u * 13.0 + phase * 2)
|
||||
d = (v - band) / 0.05
|
||||
fall = (np.exp(-d * d) * (0.4 + 0.6 * u))[..., None] * k
|
||||
img = img + hex_rgb(colour) * fall
|
||||
# A whisper of grain, so the gradients never band.
|
||||
rng = np.random.default_rng(7)
|
||||
img += rng.normal(0, 1.2, img.shape).astype(np.float32)
|
||||
return Image.fromarray(np.clip(img, 0, 255).astype(np.uint8), "RGB").convert("RGBA")
|
||||
img = hex_rgb(0x03050d) * (1 - v[..., None]) + hex_rgb(0x090c20) * v[..., None]
|
||||
clouds = 0.55 * noise(rng, (8, 5), 260) + 0.30 * noise(rng, (16, 9), 140) + 0.15 * noise(rng, (32, 18), 70)
|
||||
clouds = np.clip((clouds - 0.40) * 2.6, 0, 1) ** 1.8
|
||||
# Brighter towards the galaxy, quiet behind the wordmark.
|
||||
near = np.exp(-(((u - 0.70) * W / H) ** 2 + (v - 0.42) ** 2) / 0.35)
|
||||
for colour, k, shift in [(BLUE, 0.55, 0.0), (VIOLET, 0.40, 0.25), (CYAN, 0.18, 0.5)]:
|
||||
tint = np.roll(clouds, int(shift * W / 3), axis=1) * (0.25 + 0.75 * near)
|
||||
img = img + hex_rgb(colour) * (tint * k)[..., None]
|
||||
return img
|
||||
|
||||
|
||||
def cards(canvas):
|
||||
"""A shelf of cover-shaped cards on the right, receding and fading."""
|
||||
rnd = random.Random(42)
|
||||
hues = [0x3a5bd9, 0xc04bd6, 0x76d6ff, 0x4ade80, 0xfbbf24, 0xf87171, 0x8b5cf6, 0x22d3ee]
|
||||
glow = Image.new("RGBA", (W, H), (0, 0, 0, 0))
|
||||
def galaxy(rng, img):
|
||||
"""A two-armed spiral seen at a slant, its arms trailing into the swirl."""
|
||||
cx, cy, tilt, squash = 0.68 * W, 0.43 * H, math.radians(-22), 0.52
|
||||
stars = np.zeros((H, W), dtype=np.float32)
|
||||
colour = np.zeros((H, W, 3), dtype=np.float32)
|
||||
|
||||
def place(r, theta, bright, rgb):
|
||||
# The disc: a circle squashed into an ellipse, then tilted.
|
||||
dx, dy = r * np.cos(theta), r * np.sin(theta) * squash
|
||||
x = cx + dx * math.cos(tilt) - dy * math.sin(tilt)
|
||||
y = cy + dx * math.sin(tilt) + dy * math.cos(tilt)
|
||||
keep = (x >= 0) & (x < W) & (y >= 0) & (y < H)
|
||||
xi, yi = x[keep].astype(int), y[keep].astype(int)
|
||||
np.add.at(stars, (yi, xi), bright[keep])
|
||||
np.add.at(colour, (yi, xi), rgb[None, :] * bright[keep, None])
|
||||
|
||||
n = 160000
|
||||
for arm in range(2):
|
||||
t = rng.random(n) ** 0.8 * 4.2 * math.pi # how far round the arm
|
||||
r = 95 * np.exp(0.185 * t) # a logarithmic spiral
|
||||
spread = 0.07 + 0.05 * t / (4.2 * math.pi)
|
||||
theta = t + arm * math.pi + rng.normal(0, spread, n) * (1 + t / 8)
|
||||
r = r * (1 + rng.normal(0, 0.10, n))
|
||||
bright = (0.15 + rng.random(n) ** 3) * (1.3 - 0.8 * t / (4.2 * math.pi))
|
||||
place(r, theta, bright, hex_rgb(0xcfdcff))
|
||||
# The outer arms take the app's colours.
|
||||
far = t > 2.2 * math.pi
|
||||
place(r[far] * 1.01, theta[far] + 0.02, bright[far] * 0.6, hex_rgb(CYAN if arm else VIOLET))
|
||||
# The bulge: a dense, warm core.
|
||||
m = 40000
|
||||
r = np.abs(rng.normal(0, 150, m))
|
||||
place(r, rng.random(m) * 2 * math.pi, rng.random(m) ** 2 * 0.5, hex_rgb(0xffe6c4))
|
||||
|
||||
norm = np.maximum(stars, 1e-6)[..., None]
|
||||
tint = colour / norm
|
||||
sharp = np.clip(stars, 0, 1)
|
||||
haze = blur(np.clip(stars * 0.08, 0, 1), 40) * 6 + blur(np.clip(stars * 0.2, 0, 1), 10) * 2.5
|
||||
glow_tint = np.stack([blur(np.clip(tint[..., c] / 255 * np.clip(stars, 0, 1), 0, 1), 30) for c in range(3)], -1)
|
||||
glow_tint = glow_tint / np.maximum(blur(sharp, 30), 1e-3)[..., None] * 255
|
||||
img = img + glow_tint * np.clip(haze, 0, 1.5)[..., None] * 0.55
|
||||
img = img + tint * sharp[..., None] * 0.9
|
||||
# The core's light, falling off over the whole disc.
|
||||
y, x = np.mgrid[0:H, 0:W].astype(np.float32)
|
||||
dx, dy = x - cx, y - cy
|
||||
rx = dx * math.cos(-tilt) - dy * math.sin(-tilt)
|
||||
ry = (dx * math.sin(-tilt) + dy * math.cos(-tilt)) / squash
|
||||
d = np.sqrt(rx * rx + ry * ry)
|
||||
img = img + hex_rgb(0xfff1dc) * (np.exp(-d / 45) * 0.75 + np.exp(-d / 220) * 0.28)[..., None]
|
||||
img = img + hex_rgb(BLUE) * (np.exp(-d / 1100) * 0.22)[..., None]
|
||||
return img
|
||||
|
||||
|
||||
def starfield(rng, img):
|
||||
"""Stars across the sky; a few bright ones with a soft cross."""
|
||||
n = 14000
|
||||
x, y = rng.integers(0, W, n), rng.integers(0, H, n)
|
||||
bright = 0.12 + 0.88 * rng.random(n) ** 5
|
||||
field = np.zeros((H, W), dtype=np.float32)
|
||||
np.add.at(field, (y, x), bright * 1.4)
|
||||
img = img + 255 * np.clip(field, 0, 1)[..., None] + 255 * blur(np.clip(field * 0.5, 0, 1), 2)[..., None] * 2
|
||||
layer = Image.new("RGBA", (W, H), (0, 0, 0, 0))
|
||||
gd, ld = ImageDraw.Draw(glow), ImageDraw.Draw(layer)
|
||||
base_y = 1380
|
||||
for i in range(9):
|
||||
t = i / 8
|
||||
scale = 1.0 - 0.55 * t # nearer cards first, from the left
|
||||
w, h = 430 * scale, 573 * scale
|
||||
x = 1880 + 520 * i * (1.0 - 0.32 * t)
|
||||
y = base_y - h + 40 * t
|
||||
alpha = int(255 * (0.92 - 0.6 * t))
|
||||
top, bottom = hex_rgb(hues[i % len(hues)]), hex_rgb(hues[(i + 3) % len(hues)]) * 0.45
|
||||
# The card: a vertical gradient, rounded, with a light edge.
|
||||
card = Image.new("RGBA", (int(w), int(h)))
|
||||
grad = np.linspace(0, 1, int(h), dtype=np.float32)[:, None, None]
|
||||
rgb = top * (1 - grad) + bottom * grad
|
||||
rgb = np.broadcast_to(rgb, (int(h), int(w), 3))
|
||||
a = np.full((int(h), int(w), 1), alpha, dtype=np.float32)
|
||||
card = Image.fromarray(np.concatenate([rgb, a], axis=2).astype(np.uint8), "RGBA")
|
||||
mask = Image.new("L", card.size, 0)
|
||||
ImageDraw.Draw(mask).rounded_rectangle([0, 0, card.size[0] - 1, card.size[1] - 1], radius=int(34 * scale),
|
||||
fill=alpha)
|
||||
layer.paste(card, (int(x), int(y)), mask)
|
||||
ld.rounded_rectangle([x, y, x + w, y + h], radius=int(34 * scale), outline=(255, 255, 255, int(alpha * 0.35)),
|
||||
width=max(2, int(4 * scale)))
|
||||
gd.rounded_rectangle([x - 30, y - 30, x + w + 30, y + h + 30], radius=int(60 * scale),
|
||||
fill=tuple(int(c) for c in top) + (int(alpha * 0.45),))
|
||||
# The focus ring on the first card, as the app draws it.
|
||||
if i == 0:
|
||||
ld.rounded_rectangle([x - 16, y - 16, x + w + 16, y + h + 16], radius=int(46 * scale),
|
||||
outline=(255, 255, 255, 240), width=10)
|
||||
# A reflection fading into the floor.
|
||||
refl = card.transpose(Image.FLIP_TOP_BOTTOM).crop((0, 0, card.size[0], int(h * 0.35)))
|
||||
fade = np.linspace(0.22, 0, refl.size[1], dtype=np.float32)[:, None] * alpha
|
||||
rmask = Image.fromarray(np.broadcast_to(fade, (refl.size[1], refl.size[0])).astype(np.uint8), "L")
|
||||
layer.paste(refl, (int(x), int(y + h + 18)), rmask)
|
||||
glow = glow.filter(ImageFilter.GaussianBlur(70))
|
||||
canvas.alpha_composite(glow)
|
||||
canvas.alpha_composite(layer)
|
||||
# The shelf's far end dissolves into the backdrop.
|
||||
fade = Image.new("RGBA", (W, H), (0, 0, 0, 0))
|
||||
ramp = np.clip((np.arange(W, dtype=np.float32) - 3000) / 840, 0, 1) ** 1.5
|
||||
alpha = np.broadcast_to((ramp * 200)[None, :], (H, W)).astype(np.uint8)
|
||||
fade.putalpha(Image.fromarray(alpha, "L"))
|
||||
tint = Image.new("RGBA", (W, H), (11, 16, 38, 255))
|
||||
tint.putalpha(Image.fromarray(alpha, "L"))
|
||||
canvas.alpha_composite(tint)
|
||||
draw = ImageDraw.Draw(layer)
|
||||
for _ in range(26):
|
||||
sx, sy, s = rng.integers(0, W), rng.integers(0, H), rng.uniform(10, 34)
|
||||
rgb = tuple(int(c) for c in [hex_rgb(0xffffff), hex_rgb(CYAN), hex_rgb(0xffe6c4)][rng.integers(0, 3)])
|
||||
draw.line([(sx - s, sy), (sx + s, sy)], fill=rgb + (150,), width=2)
|
||||
draw.line([(sx, sy - s), (sx, sy + s)], fill=rgb + (150,), width=2)
|
||||
draw.ellipse([sx - 4, sy - 4, sx + 4, sy + 4], fill=rgb + (255,))
|
||||
glow = layer.filter(ImageFilter.GaussianBlur(5))
|
||||
out = Image.fromarray(np.clip(img, 0, 255).astype(np.uint8), "RGB").convert("RGBA")
|
||||
out.alpha_composite(glow)
|
||||
out.alpha_composite(layer)
|
||||
return out
|
||||
|
||||
|
||||
def swirl(rng, canvas):
|
||||
"""Faint star trails arcing round the galaxy, as on a long exposure."""
|
||||
cx, cy = 0.68 * W, 0.43 * H
|
||||
layer = Image.new("RGBA", (W, H), (0, 0, 0, 0))
|
||||
draw = ImageDraw.Draw(layer)
|
||||
for _ in range(420):
|
||||
r = rng.uniform(600, 2600)
|
||||
start = rng.uniform(0, 360)
|
||||
length = rng.uniform(4, 14) * (1100 / r) # degrees: inner trails sweep further
|
||||
rgb = [hex_rgb(0xffffff), hex_rgb(CYAN), hex_rgb(VIOLET), hex_rgb(0xcfdcff)][rng.integers(0, 4)]
|
||||
alpha = int(rng.uniform(25, 90) * min(1, 900 / r + 0.3))
|
||||
box = [cx - r, cy - r * 0.62, cx + r, cy + r * 0.62]
|
||||
draw.arc(box, start, start + length, fill=tuple(int(c) for c in rgb) + (alpha,), width=2)
|
||||
canvas.alpha_composite(layer.filter(ImageFilter.GaussianBlur(1.2)))
|
||||
|
||||
|
||||
def shade(canvas):
|
||||
"""Darker towards the lower left, so the wordmark reads."""
|
||||
y, x = np.mgrid[0:H, 0:W].astype(np.float32)
|
||||
d = np.sqrt(((x / W - 0.0) * 1.2) ** 2 + ((y / H - 1.0) * 1.0) ** 2)
|
||||
a = (np.clip(1 - d / 0.75, 0, 1) ** 1.5 * 150).astype(np.uint8)
|
||||
dark = Image.new("RGBA", (W, H), (2, 3, 10, 255))
|
||||
dark.putalpha(Image.fromarray(a, "L"))
|
||||
canvas.alpha_composite(dark)
|
||||
|
||||
|
||||
def wordmark(canvas):
|
||||
draw = ImageDraw.Draw(canvas)
|
||||
big = ImageFont.truetype(str(FONT), 300)
|
||||
small = ImageFont.truetype(str(FONT), 76)
|
||||
x, y = 250, 1560
|
||||
# A soft shadow, then the name.
|
||||
""""Romm" in white, "PS" filled cyan to violet, with a glow of its own."""
|
||||
font = ImageFont.truetype(str(FONT), 300)
|
||||
x, y = 250, 1700
|
||||
romm_w = font.getlength("Romm")
|
||||
shadow = Image.new("RGBA", (W, H), (0, 0, 0, 0))
|
||||
ImageDraw.Draw(shadow).text((x, y + 18), "RommPS", font=big, fill=(0, 0, 0, 170), anchor="ls")
|
||||
ImageDraw.Draw(shadow).text((x, y + 18), "RommPS", font=font, fill=(0, 0, 0, 180), anchor="ls")
|
||||
canvas.alpha_composite(shadow.filter(ImageFilter.GaussianBlur(26)))
|
||||
draw.text((x, y), "RommPS", font=big, fill=(255, 255, 255, 255), anchor="ls")
|
||||
draw.rounded_rectangle([x + 8, y + 54, x + 8 + 280, y + 66], radius=6, fill=(118, 214, 255, 255))
|
||||
draw.text((x + 8, y + 190), "Your RomM library and save sync", font=small, fill=(255, 255, 255, 190), anchor="ls")
|
||||
ImageDraw.Draw(canvas).text((x, y), "Romm", font=font, fill=(255, 255, 255, 255), anchor="ls")
|
||||
|
||||
# The letters' shape, filled with a diagonal gradient.
|
||||
mask = Image.new("L", (W, H), 0)
|
||||
ImageDraw.Draw(mask).text((x + romm_w, y), "PS", font=font, fill=255, anchor="ls")
|
||||
yy, xx = np.mgrid[0:H, 0:W].astype(np.float32)
|
||||
left, width = x + romm_w, font.getlength("PS")
|
||||
t = np.clip(((xx - left) / width) * 0.75 + ((y - yy) / 220) * 0.25, 0, 1)[..., None]
|
||||
fill = hex_rgb(CYAN) * (1 - t) + hex_rgb(VIOLET) * t
|
||||
gradient = Image.fromarray(fill.astype(np.uint8), "RGB").convert("RGBA")
|
||||
glow = Image.new("RGBA", (W, H), (0, 0, 0, 0))
|
||||
glow.paste(gradient, (0, 0), mask.filter(ImageFilter.MaxFilter(9)))
|
||||
canvas.alpha_composite(glow.filter(ImageFilter.GaussianBlur(34)))
|
||||
letters = Image.new("RGBA", (W, H), (0, 0, 0, 0))
|
||||
letters.paste(gradient, (0, 0), mask)
|
||||
canvas.alpha_composite(letters)
|
||||
|
||||
|
||||
def main():
|
||||
out = Path(sys.argv[1])
|
||||
canvas = aurora()
|
||||
cards(canvas)
|
||||
rng = np.random.default_rng(11)
|
||||
img = space(rng)
|
||||
img = galaxy(rng, img)
|
||||
img += rng.normal(0, 1.2, img.shape).astype(np.float32) # grain, so gradients never band
|
||||
canvas = starfield(rng, img)
|
||||
swirl(rng, canvas)
|
||||
shade(canvas)
|
||||
wordmark(canvas)
|
||||
canvas.convert("RGB").convert("RGBA").save(out)
|
||||
print(f"title art: {out} ({W}x{H})")
|
||||
|
||||
+150
-16
@@ -1,15 +1,65 @@
|
||||
[
|
||||
{
|
||||
"id": "psxs5", "name": "PSXS5 / SwanStationPS5",
|
||||
"title_ids": ["PPSA97510", "PPSA98510"],
|
||||
"detect": ["/data/PSXS5", "/data/homebrew/PPSA97510", "/data/homebrew/PPSA98510"],
|
||||
"note": "Both apps share /data/PSXS5. One memory card per game, named after the disc.",
|
||||
"id": "psxs5", "name": "PSXS5",
|
||||
"title_ids": ["PPSA97510"],
|
||||
"detect": ["/data/PSXS5", "/data/homebrew/PPSA97510"],
|
||||
"note": "One memory card per game, named after the game file (after the disc with PCSX-ReARMed).",
|
||||
"profile": {
|
||||
"root": "/data/PSXS5", "rom_dir": "{root}/games", "save_dir": "{root}/saves", "state_dir": "{root}/states",
|
||||
"root": "/data/PSXS5", "mount_games": "data", "rom_dir": "{root}/games", "save_dir": "{root}/saves", "state_dir": "{root}/states",
|
||||
"bios_dir": "{root}/bios",
|
||||
"save_exts": ".mcd", "save_name": "{serial}_1{ext}", "save_stems": "{serial}_1",
|
||||
"state_exts": ".state*", "state_stems": "{rom_stem_safe}", "state_name": "{rom_stem_safe}{ext}",
|
||||
"platforms": [{"romm": ["psx", "ps1", "playstation"], "dir": "psx", "save_format": "psx-card"}]
|
||||
"save_name_by_core": {"mednafen_psx_hw": "{rom_stem}_1{ext}"},
|
||||
"state_format": "raw", "state_slot0": ".state0", "state_cores": "pcsx_rearmed,mednafen_psx_hw",
|
||||
"state_core_setting": {"file": "{root}/psxs5.ini", "key": "emulator",
|
||||
"values": {"0": "mednafen_psx_hw", "1": "pcsx_rearmed", "2": "mednafen_psx_hw"},
|
||||
"auto": "0", "log": "{root}/logs/psxs5.log", "log_line": "host: emulator ",
|
||||
"log_names": {"rearmed": "pcsx_rearmed", "beetle": "mednafen_psx_hw", "mednafen": "mednafen_psx_hw"}},
|
||||
"firmware": {"names": {"8dd7d5296a650fac7319bce665a6a53c": "scph5500.bin", "490f666e1afb15b7362b406ed1cea246": "scph5501.bin",
|
||||
"32736f17079d0b2b7024407c39bd3050": "scph5502.bin"}},
|
||||
"platforms": [{"romm": ["psx", "ps1", "playstation"], "dir": "psx", "save_format": "psx-card", "cards": {"default": "per_game", "modes": "per_game"}}]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "swanstationps5", "name": "SwanStationPS5",
|
||||
"title_ids": ["PPSA98510"],
|
||||
"detect": ["/data/SwanStationPS5", "/data/homebrew/PPSA98510"],
|
||||
"note": "Plays the games in PSXS5's folder. One memory card per game, named after the game file. Its save states load only in SwanStationPS5.",
|
||||
"profile": {
|
||||
"root": "/data/SwanStationPS5", "mount_games": "data", "rom_dir": "/data/PSXS5/games", "save_dir": "{root}/saves", "state_dir": "{root}/states",
|
||||
"bios_dir": "{root}/bios",
|
||||
"save_exts": ".mcd", "save_name": "{rom_stem}_1{ext}", "save_stems": "{rom_stem}_1",
|
||||
"state_exts": ".state*", "state_stems": "{rom_stem_safe}", "state_name": "{rom_stem_safe}{ext}",
|
||||
"state_format": "raw", "state_slot0": ".state0", "state_cores": "swanstation",
|
||||
"platforms": [{"romm": ["psx", "ps1", "playstation"], "dir": "psx", "save_format": "psx-card", "cards": {"default": "per_game", "modes": "per_game"}}]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "psswanstation", "name": "PSSwanStation",
|
||||
"title_ids": ["PPSA99248"],
|
||||
"detect": ["/data/homebrew/PPSA99248"],
|
||||
"note": "One memory card per game, named after the game file. Its save states load only in PSSwanStation.",
|
||||
"profile": {
|
||||
"root": "/data/homebrew/PPSA99248", "rom_dir": "{root}/games", "save_dir": "{root}/data/saves",
|
||||
"state_dir": "{root}/data/states", "bios_dir": "{root}/bios",
|
||||
"mount_games": true,
|
||||
"save_exts": ".mcd", "save_name": "{rom_stem}_1{ext}", "save_stems": "{rom_stem}_1",
|
||||
"state_exts": ".state", "state_name": "{rom_stem}{ext}", "state_slot0": ".0.state",
|
||||
"state_format": "raw", "state_cores": "psswanstation",
|
||||
"platforms": [{"romm": ["psx", "ps1", "playstation"], "dir": "psx", "save_format": "psx-card", "cards": {"default": "per_game", "modes": "per_game"}}]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "ps64", "name": "PS64",
|
||||
"title_ids": ["PPSA06464"],
|
||||
"detect": ["/data/ps64", "/data/homebrew/PPSA06464"],
|
||||
"note": "Games save into their own disk images. Save Disks sync; there are no save states.",
|
||||
"profile": {
|
||||
"root": "/data/ps64", "rom_dir": "{root}/games", "save_dir": "{root}/emulator/saves",
|
||||
"state_dir": "{root}/emulator/saves", "bios_dir": "{root}/emulator/system",
|
||||
"save_exts": ".d64", "save_name": "{rom_stem}.save0{ext}", "state_exts": ".vsf", "state_cores": "ps64",
|
||||
"firmware": {"skip": true},
|
||||
"platforms": [{"romm": ["c64"], "dir": "c64", "emulator": "vice"}]
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -20,7 +70,9 @@
|
||||
"profile": {
|
||||
"root": "/data/PCSX2", "rom_dir": "{root}/games", "bios_dir": "{root}",
|
||||
"sync_saves": false, "memcards": "{root}/Mcd001.ps2,{root}/Mcd002.ps2",
|
||||
"platforms": [{"romm": ["ps2"], "dir": "ps2"}]
|
||||
"firmware": {"pick_one": ["21038400dc633070a78ad53090c53017", "dc69f0643a3030aaa4797501b483d6c4", "80ac46fa7e77b8ab4366e86948e54f83",
|
||||
"93ea3bcee4252627919175ff1b16a1d9", "d3e81e95db25f5a86a7b7474550a2155"]},
|
||||
"platforms": [{"romm": ["ps2"], "dir": "ps2", "cards": {"default": "shared", "modes": "shared"}}]
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -30,20 +82,22 @@
|
||||
"note": "Saves and states are named after a hash of the game file.",
|
||||
"profile": {
|
||||
"root": "/data/PS5N64", "rom_dir": "{root}/games", "save_dir": "{root}/saves", "state_dir": "{root}/saves",
|
||||
"fixed": ["rom_dir", "save_dir", "state_dir"],
|
||||
"save_exts": ".sav", "save_name": "{rom_fnv64}{ext}", "save_stems": "{rom_fnv64}",
|
||||
"state_exts": ".state", "state_stems": "{rom_fnv64}", "state_name": "{rom_fnv64}{ext}",
|
||||
"platforms": [{"romm": ["n64"], "dir": "n64", "save_format": "n64-srm"}]
|
||||
"state_exts": ".state", "state_stems": "{rom_fnv64}", "state_name": "{rom_fnv64}{ext}", "state_format": "raw",
|
||||
"firmware": {"skip": true},
|
||||
"platforms": [{"romm": ["n64"], "dir": "n64", "save_format": "n64-srm", "cards": {"default": "per_game", "modes": "per_game"}}]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "ps5nes", "name": "PS5NES",
|
||||
"title_ids": ["PPSA99204"],
|
||||
"detect": ["/data/homebrew/PPSA99204"],
|
||||
"ready": false,
|
||||
"note": "PS5NES v7 looks for its games in PS5SX2's folder, so there's no preset for it yet.",
|
||||
"detect": ["/data/PS5NES", "/data/homebrew/PPSA99204"],
|
||||
"note": "Untested: v7 may look for games in PS5SX2's folder.",
|
||||
"profile": {
|
||||
"root": "/data/PS5NES", "rom_dir": "{root}/games", "save_dir": "{root}/saves", "state_dir": "{root}/saves",
|
||||
"save_exts": ".sav", "save_name": "{rom_fnv64}{ext}", "save_stems": "{rom_fnv64}",
|
||||
"firmware": {"skip": true},
|
||||
"platforms": [{"romm": ["nes", "famicom"], "dir": "nes"}]
|
||||
}
|
||||
},
|
||||
@@ -51,11 +105,91 @@
|
||||
"id": "porpoise", "name": "Porpoise",
|
||||
"title_ids": ["PPSA99764"],
|
||||
"detect": ["/data/porpoise", "/data/homebrew/PPSA99764"],
|
||||
"ready": false,
|
||||
"note": "The current build crashes when it leaves the app sandbox, so there's no preset for it yet.",
|
||||
"note": "Dolphin, with its user folder in {root}/saves/User.",
|
||||
"profile": {
|
||||
"root": "/data/porpoise", "rom_dir": "{root}/games", "save_dir": "{root}/saves",
|
||||
"platforms": [{"romm": ["ngc", "gamecube"], "dir": "gamecube"}, {"romm": ["wii"], "dir": "wii"}]
|
||||
"root": "/data/porpoise", "rom_dir": "{root}/games", "save_dir": "{root}/saves/User",
|
||||
"state_dir": "{root}/states/{game_id}", "state_exts": ".state", "state_format": "raw",
|
||||
"bios_dir": "{root}/bios",
|
||||
"firmware": {"only_named": true, "names": {
|
||||
"b17148254a5799684c7d783206504926": "gc-ntsc-ipl.bin", "fc924a7c879b661abc37cec4f018fdf3": "gc-ntsc-ipl.bin",
|
||||
"019e39822a9ca3029124f74dd4d55ac4": "gc-ntsc-ipl.bin", "db92574caab77a7ec99d4605fd6f2450": "gc-pal-ipl.bin",
|
||||
"339848a0b7c2124cf155276c1e79cbd0": "gc-pal-ipl.bin", "0cdda509e2da83c85bfe423dd87346cc": "gc-pal-ipl.bin"}},
|
||||
"platforms": [
|
||||
{"romm": ["ngc", "gamecube"], "dir": "gamecube", "emulator": "dolphin", "save_layout": "gci", "bundle_dirs": "{root}/saves/User/GC", "cards": {"default": "per_game", "modes": "per_game"}},
|
||||
{"romm": ["wii"], "dir": "wii", "emulator": "dolphin", "save_layout": "wii", "bundle_dirs": "{root}/saves/User/Wii"}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "psflycast", "name": "PSFlyCast",
|
||||
"title_ids": ["PPSA99247"],
|
||||
"detect": ["/data/homebrew/PPSA99247"],
|
||||
"note": "Each game's own memory card syncs as its save; the shared ones (Own memory card for each game off) are backed up as RomM memory cards. Save states don't sync yet.",
|
||||
"profile": {
|
||||
"root": "/data/homebrew/PPSA99247", "rom_dir": "{root}/games", "save_dir": "{root}/data",
|
||||
"state_dir": "{root}/data/savestates", "bios_dir": "{root}/bios",
|
||||
"save_exts": ".bin", "state_exts": "", "game_ids": "{root}/data/game-ids.txt", "disc_id": "dreamcast",
|
||||
"save_stems": "{game_id}_vmu_save_A1", "save_name": "{game_id}_vmu_save_A1{ext}",
|
||||
"memcards": "{root}/data/vmu_save_A1.bin,{root}/data/vmu_save_B1.bin,{root}/data/vmu_save_C1.bin,{root}/data/vmu_save_D1.bin",
|
||||
"firmware": {"names": {"e10c53c2f8b90bab96ead2d368858623": "dc_boot.bin", "0a93f7940c455905bea6e392dfde92a4": "dc_flash.bin"},
|
||||
"from": {"arcade": ["naomi.zip", "naomi2.zip", "awbios.zip"]}},
|
||||
"platforms": [{"romm": ["dc", "dreamcast"], "dir": "dc", "emulator": "flycast", "save_format": "dc-vmu", "cards": {"default": "per_game", "modes": "per_game,shared", "change": "Turn on Own memory card for each game in its settings (players 2 to 4 always share theirs)", "setting": {"file": "{root}/emu.cfg", "key": "PerGameVmu", "per_game": "yes,true", "shared": "no,false"}}}]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "seedee5", "name": "Seedee5",
|
||||
"title_ids": ["PPSA99142"],
|
||||
"detect": ["/data/homebrew/PPSA99142"],
|
||||
"note": "Games and BIOS files only: saved games are kept as folders, and only after a clean exit.",
|
||||
"profile": {
|
||||
"root": "/data/homebrew/PPSA99142/data", "rom_dir": "{root}/roms", "save_dir": "{root}/saves",
|
||||
"bios_dir": "{root}/system/same_cdi/bios", "sync_saves": false,
|
||||
"platforms": [{"romm": ["philips-cd-i"], "dir": "cdi", "emulator": "same_cdi"}]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "xpsemu", "name": "XPSemu",
|
||||
"title_ids": ["PPSA97358"],
|
||||
"detect": ["/data/xemu", "/data/homebrew/PPSA97358"],
|
||||
"note": "Games and BIOS files only: saves live inside its hard disk image.",
|
||||
"profile": {
|
||||
"root": "/data/xemu", "rom_dir": "{root}/games", "bios_dir": "{root}", "sync_saves": false,
|
||||
"firmware": {"only_files": ["mcpx_1.0.bin", "Complex_4627.bin"]},
|
||||
"platforms": [{"romm": ["xbox"], "dir": "xbox", "emulator": "xemu"}]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "ps5cemu", "name": "PS5CEMU-HAR",
|
||||
"title_ids": ["PPSA99360"],
|
||||
"detect": ["/data/ps5cemu", "/data/homebrew/PPSA99360"],
|
||||
"note": "Wii U (Cemu), 3DS (Azahar) and DS (melonDS). Wii U and 3DS saves sync; 3DS save states load only in PS5CEMU-HAR. DS: games and BIOS files only for now.",
|
||||
"profile": {
|
||||
"root": "/data/ps5cemu",
|
||||
"platforms": [
|
||||
{"romm": ["wiiu"], "dir": "wiiu", "emulator": "cemu", "rom_dir": "{root}/games", "bios_dir": "{root}",
|
||||
"save_layout": "wiiu", "bundle_dirs": "{root}/mlc01/usr/save",
|
||||
"firmware": {"only_files": ["keys.txt"]}},
|
||||
{"romm": ["3ds"], "dir": "3ds", "emulator": "azahar", "rom_dir": "{root}/azahar/games", "bios_dir": "{root}/azahar/sysdata",
|
||||
"save_layout": "3ds", "bundle_dirs": "{root}/azahar/sdmc/Nintendo 3DS/00000000000000000000000000000000/00000000000000000000000000000000/title",
|
||||
"state_dir": "{root}/azahar/states", "state_exts": ".cst", "state_stems": "{game_id}", "state_name": "{game_id}.01{ext}",
|
||||
"state_format": "raw", "state_cores": "ps5cemu-azahar",
|
||||
"firmware": {"only_files": ["aes_keys.txt", "seeddb.bin"]}},
|
||||
{"romm": ["nds"], "dir": "nds", "emulator": "melonds", "rom_dir": "{root}/melonds/games",
|
||||
"save_dir": "{root}/melonds/saves", "bios_dir": "{root}/melonds/bios", "sync_saves": false,
|
||||
"firmware": {"only_files": ["bios7.bin", "bios9.bin", "firmware.bin"]}}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "prosperoeden", "name": "ProsperoEden",
|
||||
"title_ids": ["PPSA99008", "PPSA99186"],
|
||||
"detect": ["/data/prosperoeden", "/data/homebrew/PPSA99008", "/data/homebrew/PPSA99186"],
|
||||
"note": "Games (with their updates and DLC) and keys only: saves are kept per Switch user profile. Its Switch UI app shares the same folders.",
|
||||
"profile": {
|
||||
"root": "/data/prosperoeden", "rom_dir": "{root}/roms", "update_dir": "{root}/updates", "dlc_dir": "{root}/updates",
|
||||
"bios_dir": "{root}", "sync_saves": false,
|
||||
"firmware": {"by_ext": {".keys": "keys", ".nca": "firmware"}},
|
||||
"platforms": [{"romm": ["switch"], "dir": "switch", "emulator": "prosperoeden"}]
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,495 @@
|
||||
/* plat_mounts_set on the PS5: nullfs mounts for an emulator to play another
|
||||
* one's games without a copy (bfs, under /user and /data, has no hard links
|
||||
* or symlinks).
|
||||
*
|
||||
* An app reads its folders through its sandbox, /mnt/sandbox/<title>_<n>/
|
||||
* (n changes with every launch), and a mount on the real folder isn't seen
|
||||
* there, so ours go on the sandbox's view: "app0/games" is the app's
|
||||
* /app0/games. An app that leaves its sandbox to read its files (PSXS5
|
||||
* "unlocks storage") reads the real folder: it gets the mount there
|
||||
* ("/data/PSXS5/games"), for as long as it runs. They go on as soon as the
|
||||
* sandbox and the app's process appear, before its first look at its
|
||||
* folders, and come off the moment one of its processes exits: the system
|
||||
* takes app0 down 40-240 ms after the exit, and a mount of ours still on it
|
||||
* then crashes the console. Rest mode leaves them alone (unmounting there and
|
||||
* mounting again at wake lined up with the HEN's toolbox failing). Tested
|
||||
* with PSSwanStation and PSXS5, on OnionHEN and etaHEN with ShadowMountPlus.
|
||||
*
|
||||
* Mounts on a real folder are written down (mounts-held.txt in the data
|
||||
* folder): one a copy of the payload that stopped left on, for an app that
|
||||
* isn't running or no longer gets it, comes off, and its folder goes back to
|
||||
* its owner. */
|
||||
#include "platform.h"
|
||||
|
||||
#include <errno.h>
|
||||
#include <pthread.h>
|
||||
#include <signal.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <time.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#include <sys/event.h>
|
||||
#include <sys/mount.h>
|
||||
#include <sys/stat.h>
|
||||
#include <sys/uio.h>
|
||||
|
||||
#include "console.h"
|
||||
#include "events.h"
|
||||
#include "ps5.h"
|
||||
#include "util.h"
|
||||
|
||||
#define MAX_WANT 32
|
||||
#define MAX_LAUNCH 4
|
||||
#define MAX_PIDS 16
|
||||
#define MAX_HELD 8
|
||||
#define MAX_REAL 16
|
||||
#define TICK_MS 100
|
||||
|
||||
typedef struct {
|
||||
char name[64]; /* the emulator's */
|
||||
char from[1024];
|
||||
char target[1300];
|
||||
uid_t owner; /* the folder's owner before we took it, or -1 */
|
||||
int on;
|
||||
} held_t;
|
||||
|
||||
/* One run of an app with mounts. Over once one of its processes exits; the
|
||||
* slot is free again when the system has taken its sandbox down. */
|
||||
typedef struct {
|
||||
int used, over;
|
||||
int polled; /* kqueue couldn't watch a process: checked every 2 ms */
|
||||
char title[16];
|
||||
char sandbox[256];
|
||||
pid_t pids[MAX_PIDS];
|
||||
int np;
|
||||
held_t held[MAX_HELD];
|
||||
int nh;
|
||||
} launch_t;
|
||||
|
||||
static pthread_mutex_t g_lock = PTHREAD_MUTEX_INITIALIZER;
|
||||
static plat_mount g_want[MAX_WANT];
|
||||
static int g_nwant, g_started;
|
||||
|
||||
static launch_t g_runs[MAX_LAUNCH];
|
||||
static int g_kq = -1;
|
||||
|
||||
/* Our mounts on real folders, as written down: only the guard thread uses them. */
|
||||
typedef struct {
|
||||
char title[16];
|
||||
char target[1300];
|
||||
uid_t owner;
|
||||
} real_t;
|
||||
|
||||
static real_t g_real[MAX_REAL];
|
||||
static int g_nreal;
|
||||
|
||||
static double now_ms(void) {
|
||||
struct timespec ts;
|
||||
clock_gettime(CLOCK_MONOTONIC, &ts);
|
||||
return ts.tv_sec * 1000.0 + ts.tv_nsec / 1e6;
|
||||
}
|
||||
|
||||
static int is_mount_point(const char *path) {
|
||||
struct statfs fs;
|
||||
return statfs(path, &fs) == 0 && !strcmp(fs.f_mntonname, path);
|
||||
}
|
||||
|
||||
int plat_is_mount(const char *path) { return is_mount_point(path); }
|
||||
|
||||
static void real_path(char *out, size_t n) { path_join(out, n, plat_data_dir(), "mounts-held.txt"); }
|
||||
|
||||
static void real_save(void) {
|
||||
char path[PATH_MAX_LEN], *buf = malloc((size_t)MAX_REAL * 1400 + 1);
|
||||
size_t o = 0;
|
||||
if (!buf) return;
|
||||
buf[0] = 0;
|
||||
for (int i = 0; i < g_nreal; i++)
|
||||
o += (size_t)snprintf(buf + o, 1400, "%s\t%d\t%s\n", g_real[i].title, (int)g_real[i].owner, g_real[i].target);
|
||||
real_path(path, sizeof path);
|
||||
if (g_nreal) write_file_atomic(path, buf, o);
|
||||
else unlink(path);
|
||||
free(buf);
|
||||
}
|
||||
|
||||
static void real_load(void) {
|
||||
char path[PATH_MAX_LEN];
|
||||
real_path(path, sizeof path);
|
||||
char *txt = read_file(path, NULL), *save = NULL;
|
||||
for (char *l = txt ? strtok_r(txt, "\n", &save) : NULL; l && g_nreal < MAX_REAL; l = strtok_r(NULL, "\n", &save)) {
|
||||
char *t1 = strchr(l, '\t'), *t2 = t1 ? strchr(t1 + 1, '\t') : NULL;
|
||||
if (!t2 || t2[1] != '/') continue;
|
||||
*t1 = *t2 = 0;
|
||||
real_t *r = &g_real[g_nreal++];
|
||||
snprintf(r->title, sizeof r->title, "%s", l);
|
||||
r->owner = (uid_t)atoi(t1 + 1);
|
||||
snprintf(r->target, sizeof r->target, "%s", t2 + 1);
|
||||
}
|
||||
free(txt);
|
||||
}
|
||||
|
||||
static real_t *real_find(const char *target) {
|
||||
for (int i = 0; i < g_nreal; i++)
|
||||
if (!strcmp(g_real[i].target, target)) return &g_real[i];
|
||||
return NULL;
|
||||
}
|
||||
|
||||
static void real_add(const char *title, const char *target, uid_t owner) {
|
||||
real_t *r = real_find(target);
|
||||
if (!r && g_nreal == MAX_REAL) return;
|
||||
if (!r) r = &g_real[g_nreal++];
|
||||
snprintf(r->title, sizeof r->title, "%s", title);
|
||||
snprintf(r->target, sizeof r->target, "%s", target);
|
||||
r->owner = owner;
|
||||
real_save();
|
||||
}
|
||||
|
||||
static void real_drop(const char *target) {
|
||||
real_t *r = real_find(target);
|
||||
if (!r) return;
|
||||
*r = g_real[--g_nreal];
|
||||
real_save();
|
||||
}
|
||||
|
||||
static int null_mount(const char *from, const char *on) {
|
||||
struct iovec iov[8];
|
||||
char errmsg[256] = "";
|
||||
const char *kv[] = {"fstype", "nullfs", "fspath", on, "target", from};
|
||||
int n = 0;
|
||||
for (int i = 0; i < 6; i++) {
|
||||
iov[n].iov_base = (void *)kv[i];
|
||||
iov[n++].iov_len = strlen(kv[i]) + 1;
|
||||
}
|
||||
iov[n].iov_base = "errmsg";
|
||||
iov[n++].iov_len = sizeof "errmsg";
|
||||
iov[n].iov_base = errmsg;
|
||||
iov[n++].iov_len = sizeof errmsg;
|
||||
if (nmount(iov, (unsigned)n, 0) == 0) return 0;
|
||||
LOGW("mounts: can't mount %s on %s: %s %s", from, on, strerror(errno), errmsg);
|
||||
return -1;
|
||||
}
|
||||
|
||||
struct pids_ctx {
|
||||
const char *title;
|
||||
pid_t *pids;
|
||||
int n, max;
|
||||
};
|
||||
|
||||
static int pids_cb(pid_t pid, const char *tdname, void *vctx) {
|
||||
struct pids_ctx *c = vctx;
|
||||
char title[16];
|
||||
(void)tdname;
|
||||
if (c->n < c->max && !ps5_proc_app(pid, title, NULL) && !strncmp(title, c->title, 9)) c->pids[c->n++] = pid;
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int find_pids(const char *title, pid_t *pids, int max) {
|
||||
struct pids_ctx c = {title, pids, 0, max};
|
||||
console_for_each_proc(pids_cb, &c);
|
||||
return c.n;
|
||||
}
|
||||
|
||||
/* The kernel only lets us mount over a folder we own: even root can't over
|
||||
* one the app made (uid 1). Taken for the mount, given back after. */
|
||||
static void give_back(held_t *h) {
|
||||
if (h->owner == (uid_t)-1) return;
|
||||
if (chown(h->target, h->owner, (gid_t)-1) != 0)
|
||||
LOGW("mounts: can't give %s back to uid %d: %s", h->target, (int)h->owner, strerror(errno));
|
||||
h->owner = (uid_t)-1;
|
||||
}
|
||||
|
||||
static int put_on(held_t *h) {
|
||||
struct stat st;
|
||||
h->owner = (uid_t)-1;
|
||||
if (stat(h->target, &st) != 0 && mkdir(h->target, 0777) != 0) {
|
||||
LOGW("mounts: no %s: %s", h->target, strerror(errno));
|
||||
return -1;
|
||||
}
|
||||
if (stat(h->target, &st) == 0 && st.st_uid != 0) {
|
||||
if (chown(h->target, 0, (gid_t)-1) == 0) h->owner = st.st_uid;
|
||||
else LOGW("mounts: can't take %s from uid %d: %s", h->target, (int)st.st_uid, strerror(errno));
|
||||
}
|
||||
if (null_mount(h->from, h->target) != 0) {
|
||||
give_back(h);
|
||||
return -1;
|
||||
}
|
||||
h->on = 1;
|
||||
LOGI("mounts: %s on %s", h->from, h->target);
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* Without forcing: a mount the app has a file open on stays. 0 when off. */
|
||||
static int take_off(held_t *h, int tries_max) {
|
||||
int tries = 0;
|
||||
while (unmount(h->target, 0) != 0 && errno != EINVAL && errno != ENOENT) {
|
||||
if (++tries >= tries_max) return -1;
|
||||
usleep(1000);
|
||||
}
|
||||
h->on = 0;
|
||||
give_back(h);
|
||||
real_drop(h->target);
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* At the exit there's no app left to pull files from, so a mount still busy
|
||||
* after 200 ms is forced off: the system is about to take app0 down. */
|
||||
static void take_off_all(launch_t *r) {
|
||||
const double t = now_ms();
|
||||
int n = 0;
|
||||
for (int k = r->nh - 1; k >= 0; k--) {
|
||||
held_t *h = &r->held[k];
|
||||
if (!h->on) continue;
|
||||
n++;
|
||||
if (take_off(h, 200) != 0) {
|
||||
LOGW("mounts: %s still busy after 200 ms (%s), forcing", h->target, strerror(errno));
|
||||
if (unmount(h->target, MNT_FORCE) != 0) {
|
||||
LOGE("mounts: forced unmount of %s failed: %s", h->target, strerror(errno));
|
||||
events_add("emulator", "failed", 0, h->target, h->name, "Its games stayed mounted after it closed");
|
||||
events_save();
|
||||
}
|
||||
h->on = 0;
|
||||
give_back(h);
|
||||
real_drop(h->target);
|
||||
}
|
||||
}
|
||||
if (n) LOGI("mounts: %s closed, %d mount%s off in %.1f ms", r->title, n, n == 1 ? "" : "s", now_ms() - t);
|
||||
}
|
||||
|
||||
static void exited(launch_t *r, pid_t pid) {
|
||||
int ours = 0;
|
||||
for (int i = 0; i < r->np; i++) ours |= r->pids[i] == pid;
|
||||
if (!r->used || r->over || !ours) return; /* a late one from the run this slot had before */
|
||||
r->over = 1;
|
||||
LOGI("mounts: %s's process %d exited", r->title, (int)pid);
|
||||
take_off_all(r);
|
||||
}
|
||||
|
||||
static void watch_pids(launch_t *r) {
|
||||
for (int i = 0; i < r->np && !r->over; i++) {
|
||||
struct kevent ev;
|
||||
EV_SET(&ev, r->pids[i], EVFILT_PROC, EV_ADD | EV_ONESHOT, NOTE_EXIT, 0, r);
|
||||
if (g_kq >= 0 && kevent(g_kq, &ev, 1, NULL, 0, NULL) == 0) continue;
|
||||
if (errno == ESRCH) exited(r, r->pids[i]); /* gone already */
|
||||
else r->polled = 1;
|
||||
}
|
||||
}
|
||||
|
||||
static void poll_pids(void) {
|
||||
for (int i = 0; i < MAX_LAUNCH; i++)
|
||||
for (int k = 0; g_runs[i].used && g_runs[i].polled && !g_runs[i].over && k < g_runs[i].np; k++)
|
||||
if (kill(g_runs[i].pids[k], 0) != 0 && errno == ESRCH) exited(&g_runs[i], g_runs[i].pids[k]);
|
||||
}
|
||||
|
||||
static launch_t *run_of_sandbox(const char *sandbox) {
|
||||
for (int i = 0; i < MAX_LAUNCH; i++)
|
||||
if (g_runs[i].used && !strcmp(g_runs[i].sandbox, sandbox)) return &g_runs[i];
|
||||
return NULL;
|
||||
}
|
||||
|
||||
/* A nullfs mount below the sandbox's app0: only we put those there (the
|
||||
* HEN's /data and fonts go on the sandbox itself, the system's app0 isn't
|
||||
* below itself). */
|
||||
static int ours_left(const struct statfs *m, const char *sandbox) {
|
||||
char app0[300];
|
||||
snprintf(app0, sizeof app0, "%s/app0/", sandbox);
|
||||
return !strncmp(m->f_mntonname, app0, strlen(app0)) && !strcmp(m->f_fstypename, "nullfs");
|
||||
}
|
||||
|
||||
/* A new sandbox of an app with mounts, with its process running. */
|
||||
static void start_run(const char *title, const char *sandbox, const pid_t *pids, int np, const struct statfs *mnt, int nmnt) {
|
||||
launch_t *r = NULL;
|
||||
for (int i = 0; i < MAX_LAUNCH && !r; i++)
|
||||
if (!g_runs[i].used) r = &g_runs[i];
|
||||
if (!r) {
|
||||
LOGW("mounts: too many apps at once, %s gets none", title);
|
||||
return;
|
||||
}
|
||||
memset(r, 0, sizeof *r);
|
||||
r->used = 1;
|
||||
snprintf(r->title, sizeof r->title, "%s", title);
|
||||
snprintf(r->sandbox, sizeof r->sandbox, "%s", sandbox);
|
||||
memcpy(r->pids, pids, sizeof *pids * (size_t)np);
|
||||
r->np = np;
|
||||
/* Ours left on by a copy of the payload that stopped (an update). Taken
|
||||
* over, so they come off at the exit. */
|
||||
for (int i = 0; i < nmnt && r->nh < MAX_HELD; i++) {
|
||||
if (!ours_left(&mnt[i], sandbox)) continue;
|
||||
held_t *h = &r->held[r->nh++];
|
||||
snprintf(h->target, sizeof h->target, "%s", mnt[i].f_mntonname);
|
||||
snprintf(h->from, sizeof h->from, "%s", mnt[i].f_mntfromname);
|
||||
h->owner = (uid_t)-1;
|
||||
h->on = 1;
|
||||
LOGI("mounts: took over %s", mnt[i].f_mntonname);
|
||||
}
|
||||
for (int i = 0; i < g_nreal && r->nh < MAX_HELD; i++) {
|
||||
if (strcmp(g_real[i].title, title) != 0 || !is_mount_point(g_real[i].target)) continue;
|
||||
held_t *h = &r->held[r->nh++];
|
||||
memset(h, 0, sizeof *h);
|
||||
snprintf(h->target, sizeof h->target, "%s", g_real[i].target);
|
||||
h->owner = g_real[i].owner;
|
||||
h->on = 1;
|
||||
LOGI("mounts: took over %s", h->target);
|
||||
}
|
||||
const double t = now_ms();
|
||||
int on = 0, failed = 0;
|
||||
pthread_mutex_lock(&g_lock);
|
||||
for (int k = 0; k < g_nwant && r->nh < MAX_HELD; k++) {
|
||||
const plat_mount *m = &g_want[k];
|
||||
if (strcmp(m->title, title) != 0) continue;
|
||||
char target[1300];
|
||||
if (m->into[0] == '/') snprintf(target, sizeof target, "%s", m->into); /* the real folder */
|
||||
else snprintf(target, sizeof target, "%s/%s", sandbox, m->into);
|
||||
int have = 0;
|
||||
for (int h = 0; h < r->nh; h++) have |= !strcmp(r->held[h].target, target);
|
||||
if (have) continue;
|
||||
held_t *h = &r->held[r->nh++];
|
||||
memset(h, 0, sizeof *h);
|
||||
snprintf(h->target, sizeof h->target, "%s", target);
|
||||
snprintf(h->from, sizeof h->from, "%s", m->from);
|
||||
snprintf(h->name, sizeof h->name, "%s", m->name);
|
||||
h->owner = (uid_t)-1;
|
||||
if (m->into[0] == '/' && is_mount_point(target)) {
|
||||
/* Left on by a copy of the payload that stopped, and not written
|
||||
* down: its owner is unknown. */
|
||||
h->on = 1;
|
||||
LOGI("mounts: took over %s", target);
|
||||
} else if (put_on(h) == 0) {
|
||||
if (m->into[0] == '/') real_add(title, target, h->owner);
|
||||
on++;
|
||||
} else {
|
||||
failed++;
|
||||
/* The app runs without the games: say so, as RommPS. */
|
||||
events_add("emulator", "failed", 0, h->target, m->name, "Its games couldn't be shown to it this time");
|
||||
events_save();
|
||||
plat_notify("RommPS: %s can't see its games this time. Close it and open it again.", m->name);
|
||||
}
|
||||
}
|
||||
pthread_mutex_unlock(&g_lock);
|
||||
LOGI("mounts: %s started (%s, %d process%s): %d on in %.1f ms%s", title, sandbox, np, np == 1 ? "" : "es", on,
|
||||
now_ms() - t, failed ? ", some failed" : "");
|
||||
watch_pids(r);
|
||||
}
|
||||
|
||||
/* A real folder's mount written down, for an app that isn't running: left by
|
||||
* a copy of the payload that stopped. Nothing reads it, so it comes off. */
|
||||
static void clear_left_real(void) {
|
||||
for (int i = g_nreal - 1; i >= 0; i--) {
|
||||
real_t *x = &g_real[i];
|
||||
int held = 0;
|
||||
for (int k = 0; k < MAX_LAUNCH && !held; k++)
|
||||
for (int j = 0; g_runs[k].used && j < g_runs[k].nh && !held; j++)
|
||||
held = g_runs[k].held[j].on && !strcmp(g_runs[k].held[j].target, x->target);
|
||||
if (held) continue;
|
||||
pid_t pids[MAX_PIDS];
|
||||
if (is_mount_point(x->target)) {
|
||||
if (find_pids(x->title, pids, MAX_PIDS) > 0) continue; /* its sandbox's run takes it over */
|
||||
if (unmount(x->target, 0) != 0) continue; /* busy: next time */
|
||||
LOGI("mounts: took %s off, left on by an earlier copy", x->target);
|
||||
}
|
||||
/* Off now, or gone with a reboot: the folder goes back to its owner. */
|
||||
if (x->owner != (uid_t)-1 && chown(x->target, x->owner, (gid_t)-1) != 0)
|
||||
LOGW("mounts: can't give %s back to uid %d: %s", x->target, (int)x->owner, strerror(errno));
|
||||
real_drop(x->target);
|
||||
}
|
||||
}
|
||||
|
||||
/* New sandboxes of the apps with mounts, and old ones the system took down. */
|
||||
static void scan_sandboxes(void) {
|
||||
struct statfs *mnt;
|
||||
int nmnt = getmntinfo(&mnt, MNT_NOWAIT);
|
||||
if (nmnt <= 0) return;
|
||||
for (int i = 0; i < MAX_LAUNCH; i++) {
|
||||
launch_t *r = &g_runs[i];
|
||||
if (!r->used) continue;
|
||||
char app0[300];
|
||||
snprintf(app0, sizeof app0, "%s/app0", r->sandbox);
|
||||
int there = 0;
|
||||
for (int k = 0; k < nmnt && !there; k++) there = !strcmp(mnt[k].f_mntonname, app0);
|
||||
if (!there) {
|
||||
if (!r->over) { /* the exit went unseen: shouldn't happen, but don't leave ours behind */
|
||||
LOGW("mounts: %s's sandbox went before its exit was seen", r->title);
|
||||
take_off_all(r);
|
||||
}
|
||||
r->used = 0;
|
||||
}
|
||||
}
|
||||
for (int k = 0; k < nmnt; k++) {
|
||||
const char *on = mnt[k].f_mntonname;
|
||||
size_t len = strlen(on);
|
||||
if (strncmp(on, "/mnt/sandbox/", 13) != 0 || len < 5 + 13 || strcmp(on + len - 5, "/app0") != 0) continue;
|
||||
char sandbox[256], title[16];
|
||||
if (len - 5 >= sizeof sandbox) continue;
|
||||
memcpy(sandbox, on, len - 5);
|
||||
sandbox[len - 5] = 0;
|
||||
const char *base = sandbox + 13, *us = strrchr(base, '_');
|
||||
if (!us || us - base != 9) continue;
|
||||
memcpy(title, base, 9);
|
||||
title[9] = 0;
|
||||
int wanted = 0;
|
||||
pthread_mutex_lock(&g_lock);
|
||||
for (int w = 0; w < g_nwant && !wanted; w++) wanted = !strcmp(g_want[w].title, title);
|
||||
pthread_mutex_unlock(&g_lock);
|
||||
if (run_of_sandbox(sandbox)) continue;
|
||||
if (!wanted) {
|
||||
/* Not ours to mount, but ours may be on it from a copy that
|
||||
* stopped, under a list that has changed since. */
|
||||
int left = 0;
|
||||
for (int i = 0; i < nmnt && !left; i++) left = ours_left(&mnt[i], sandbox);
|
||||
for (int i = 0; i < g_nreal && !left; i++) left = !strcmp(g_real[i].title, title) && is_mount_point(g_real[i].target);
|
||||
if (!left) continue;
|
||||
}
|
||||
pid_t pids[MAX_PIDS];
|
||||
int np = find_pids(title, pids, MAX_PIDS);
|
||||
if (np > 0) start_run(title, sandbox, pids, np, mnt, nmnt);
|
||||
}
|
||||
clear_left_real();
|
||||
}
|
||||
|
||||
static void *guard_thread(void *arg) {
|
||||
(void)arg;
|
||||
real_load();
|
||||
g_kq = kqueue();
|
||||
if (g_kq < 0) LOGW("mounts: no kqueue (%s), checking processes every 2 ms", strerror(errno));
|
||||
double next_scan = 0;
|
||||
for (;;) {
|
||||
int polled = 0;
|
||||
for (int i = 0; i < MAX_LAUNCH; i++) polled |= g_runs[i].used && g_runs[i].polled && !g_runs[i].over;
|
||||
const long wait_ms = polled ? 2 : TICK_MS;
|
||||
if (g_kq >= 0) {
|
||||
struct kevent ev[8];
|
||||
struct timespec ts = {0, wait_ms * 1000 * 1000};
|
||||
int got = kevent(g_kq, NULL, 0, ev, 8, &ts);
|
||||
for (int i = 0; i < got; i++)
|
||||
if (ev[i].filter == EVFILT_PROC) exited(ev[i].udata, (pid_t)ev[i].ident);
|
||||
} else {
|
||||
usleep((useconds_t)wait_ms * 1000);
|
||||
}
|
||||
if (polled) poll_pids();
|
||||
if (now_ms() >= next_scan) {
|
||||
scan_sandboxes();
|
||||
next_scan = now_ms() + TICK_MS;
|
||||
}
|
||||
}
|
||||
return NULL;
|
||||
}
|
||||
|
||||
void plat_mounts_set(const plat_mount *mounts, int n) {
|
||||
if (n > MAX_WANT) n = MAX_WANT;
|
||||
pthread_mutex_lock(&g_lock);
|
||||
int changed = n != g_nwant || (n && memcmp(g_want, mounts, sizeof *mounts * (size_t)n) != 0);
|
||||
if (n) memcpy(g_want, mounts, sizeof *mounts * (size_t)n);
|
||||
g_nwant = n;
|
||||
/* Always running once started, and started at the first call even with
|
||||
* none: mounts an earlier copy left must come off at the app's exit. */
|
||||
int start = !g_started;
|
||||
g_started = 1;
|
||||
pthread_mutex_unlock(&g_lock);
|
||||
for (int i = 0; changed && i < n; i++) LOGI("mounts: %s gets %s on %s", mounts[i].title, mounts[i].from, mounts[i].into);
|
||||
if (start && thread_start(guard_thread, NULL) != 0) {
|
||||
LOGE("mounts: can't start the guard");
|
||||
pthread_mutex_lock(&g_lock);
|
||||
g_started = 0;
|
||||
pthread_mutex_unlock(&g_lock);
|
||||
}
|
||||
}
|
||||
+74
-111
@@ -1,9 +1,9 @@
|
||||
/* platform.h for the PS5 (etaHEN, ps5-payload-dev SDK). The parts shared with
|
||||
* the PS4 are in platform/console/console.c.
|
||||
*
|
||||
* Adapted from John Törnblom's GPLv3 ftpsrv and websrv: the home screen tile
|
||||
* (ftpsrv install-ps5.c) and the foreground app (websrv). Links against
|
||||
* SceSystemService and SceAppInstUtil. */
|
||||
* Adapted from John Törnblom's GPLv3 ftpsrv and websrv: removing the old home
|
||||
* screen tile (ftpsrv install-ps5.c) and the foreground app (websrv). Links
|
||||
* against SceSystemService and SceAppInstUtil. */
|
||||
#include "platform.h"
|
||||
#include "emulators_json.h" /* generated from platform/ps5/emulators.json */
|
||||
|
||||
@@ -20,18 +20,21 @@
|
||||
#include <ps5/kernel.h>
|
||||
|
||||
#include "console.h"
|
||||
#include "ps5.h"
|
||||
#include "util.h" /* APP_VERSION */
|
||||
|
||||
#define ROMM_TITLE_ID "ROMM00001"
|
||||
#define ROMM_TITLE_NAME "RomM Sync"
|
||||
#define ROMM_APP_DIR "/user/app/" ROMM_TITLE_ID
|
||||
#define ROMM_APPMETA "/user/appmeta/" ROMM_TITLE_ID "/param.json"
|
||||
/* The "RomM Sync" tile versions before 1.1.1 installed, opening the web UI,
|
||||
* and RommPS, which took its place. */
|
||||
#define OLD_TILE_ID "ROMM00001"
|
||||
#define ROMMPS_ID "PPSA76677"
|
||||
|
||||
/* AuthID used by ftpsrv/websrv (ShellCore-like privileges). */
|
||||
#define ROMM_AUTHID 0x4801000000000013L
|
||||
|
||||
|
||||
/* libkernel: layout from sdk/samples/ps/main.c */
|
||||
typedef struct app_info {
|
||||
/* libkernel: layout from sdk/samples/ps/main.c. Packed: the title starts at
|
||||
* byte 16 (unpadded, it would land at 20 and read 4 bytes late). */
|
||||
typedef struct __attribute__((packed)) app_info {
|
||||
uint32_t app_id;
|
||||
uint64_t unknown1;
|
||||
uint32_t app_type;
|
||||
@@ -46,7 +49,6 @@ int sceSystemServiceGetAppTitleId(int app_id, char *title_id);
|
||||
|
||||
/* libSceAppInstUtil: ftpsrv install-ps5.c */
|
||||
int sceAppInstUtilInitialize(void);
|
||||
int sceAppInstUtilAppInstallAll(void *);
|
||||
int sceAppInstUtilAppUnInstall(const char *);
|
||||
|
||||
|
||||
@@ -60,10 +62,12 @@ static const char *const EMULATOR_DIRS[] = {NULL};
|
||||
* next to it. onionHEN (github.com/aydencharles/onionHEN) keeps its runtime
|
||||
* state in /system_tmp/onionhen, which is gone after a reboot, so that tells
|
||||
* the one running now; they refuse to run together. Both leave the ELF loader
|
||||
* on 9021 to us. An update replaces RomM Sync in the other HEN's folder too,
|
||||
* so switching HENs never brings back an old copy. */
|
||||
* on 9021 to us. Autostart is set up in both HENs' folders, so RomM Sync
|
||||
* starts with either, and switching HENs never brings back an old copy. */
|
||||
static const char *const ETAHEN_ALSO[] = {"/data/OnionHEN/payloads", NULL};
|
||||
static const char *const ONIONHEN_ALSO[] = {"/data/etaHEN/payloads", NULL};
|
||||
/* pldmgr's autoload starts the copies it keeps here. */
|
||||
static const char *const PAYLOAD_COPIES[] = {"/data/pldmgr/payloads/romm-sync/romm-sync.elf", NULL};
|
||||
|
||||
static plat_info_t INFO = {
|
||||
.name = "ps5",
|
||||
@@ -76,14 +80,16 @@ static plat_info_t INFO = {
|
||||
.autostart_dir = "/data/etaHEN/payloads",
|
||||
.autostart_flag = "romm-sync.elf.auto_start",
|
||||
.autostart_also = ETAHEN_ALSO,
|
||||
.payload_copies = PAYLOAD_COPIES,
|
||||
.loader_dir = "/data/etaHEN",
|
||||
.homebrew_dirs = HOMEBREW_DIRS,
|
||||
.emulator_dirs = EMULATOR_DIRS,
|
||||
.emulator_catalog = EMULATORS_JSON,
|
||||
.retroarch_root = "/data/homebrew/PPSA99169",
|
||||
.mednafen_root = "/data/homebrew/Mednafen",
|
||||
.has_tile = 1,
|
||||
.can_relaunch = 1,
|
||||
.can_mount = 1,
|
||||
.self_update = 0,
|
||||
};
|
||||
|
||||
static int is_dir(const char *path) {
|
||||
@@ -110,27 +116,33 @@ const plat_info_t *plat_info(void) {
|
||||
|
||||
int plat_init(void) {
|
||||
signal(SIGPIPE, SIG_IGN);
|
||||
console_single_instance();
|
||||
|
||||
/* Elevated AuthID as ftpsrv/websrv do; needed for /user/app writes and
|
||||
* AppInstUtil. Non-fatal: kernel_* returns -1 without kernel R/W. */
|
||||
/* Elevated AuthID as ftpsrv/websrv do; needed for AppInstUtil.
|
||||
* Non-fatal: kernel_* returns -1 without kernel R/W. */
|
||||
kernel_set_ucred_authid(getpid(), ROMM_AUTHID);
|
||||
|
||||
if (console_single_instance(APP_VERSION)) return 1;
|
||||
if (console_mkdir_p(CONSOLE_DATA_DIR)) return -1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
int ps5_proc_app(pid_t pid, char title[16], uint32_t *app_id) {
|
||||
app_info_t info;
|
||||
memset(&info, 0, sizeof info);
|
||||
if (sceKernelGetAppInfo(pid, &info)) return -1;
|
||||
memcpy(title, info.title_id, sizeof info.title_id);
|
||||
title[sizeof info.title_id] = 0;
|
||||
if (app_id) *app_id = info.app_id;
|
||||
return 0;
|
||||
}
|
||||
|
||||
struct appid_ctx { int app_id; char title_id[16]; };
|
||||
|
||||
static int appid_cb(pid_t pid, const char *tdname, void *vctx) {
|
||||
struct appid_ctx *c = vctx;
|
||||
app_info_t info;
|
||||
uint32_t app_id;
|
||||
(void)tdname;
|
||||
memset(&info, 0, sizeof info);
|
||||
if (sceKernelGetAppInfo(pid, &info)) return 0;
|
||||
if ((int)info.app_id != c->app_id) return 0;
|
||||
memcpy(c->title_id, info.title_id, sizeof info.title_id);
|
||||
c->title_id[sizeof info.title_id] = 0;
|
||||
if (ps5_proc_app(pid, c->title_id, &app_id) || (int)app_id != c->app_id) return 0;
|
||||
return 1;
|
||||
}
|
||||
|
||||
@@ -159,56 +171,6 @@ int plat_running_title(char *title_id, size_t n) {
|
||||
}
|
||||
|
||||
|
||||
static int write_file(const char *path, const void *data, size_t size) {
|
||||
FILE *f = fopen(path, "wb");
|
||||
if (!f) return -1;
|
||||
if (size && fwrite(data, size, 1, f) != 1) {
|
||||
fclose(f);
|
||||
return -1;
|
||||
}
|
||||
return fclose(f) ? -1 : 0;
|
||||
}
|
||||
|
||||
/* Returns malloc'd contents (NUL-terminated) or NULL. */
|
||||
static char *read_file(const char *path, size_t *size) {
|
||||
FILE *f = fopen(path, "rb");
|
||||
char *buf = NULL;
|
||||
long len;
|
||||
if (!f) return NULL;
|
||||
if (fseek(f, 0, SEEK_END) || (len = ftell(f)) < 0 || len > 16 * 1024 * 1024 ||
|
||||
fseek(f, 0, SEEK_SET) || !(buf = malloc((size_t)len + 1)) ||
|
||||
(len && fread(buf, (size_t)len, 1, f) != 1)) {
|
||||
free(buf);
|
||||
fclose(f);
|
||||
return NULL;
|
||||
}
|
||||
fclose(f);
|
||||
buf[len] = 0;
|
||||
if (size) *size = (size_t)len;
|
||||
return buf;
|
||||
}
|
||||
|
||||
static int copy_file(const char *src, const char *dst) {
|
||||
size_t size;
|
||||
char *data = read_file(src, &size);
|
||||
int r;
|
||||
if (!data) return -1;
|
||||
r = write_file(dst, data, size);
|
||||
free(data);
|
||||
return r;
|
||||
}
|
||||
|
||||
static int install_title_dir(const char *title_id, const char *dir) {
|
||||
int (*install_dir)(const char *, const char *, void *) = NULL;
|
||||
const char *nid = "Wudg3Xe3heE"; /* sceAppInstUtilAppInstallTitleDir */
|
||||
uint32_t handle;
|
||||
|
||||
if (!kernel_dynlib_handle(-1, "libSceAppInstUtil.sprx", &handle))
|
||||
install_dir = (void *)kernel_dynlib_resolve(-1, handle, nid);
|
||||
if (install_dir) return install_dir(title_id, dir, NULL);
|
||||
return sceAppInstUtilAppInstallAll(NULL);
|
||||
}
|
||||
|
||||
void plat_describe(char *buf, size_t n) {
|
||||
app_info_t info;
|
||||
char host[16] = "";
|
||||
@@ -217,49 +179,50 @@ void plat_describe(char *buf, size_t n) {
|
||||
console_describe(buf, n, host);
|
||||
}
|
||||
|
||||
int plat_install_tile(int port) {
|
||||
char param[512];
|
||||
struct stat st;
|
||||
char *old;
|
||||
int installed, have_icon, err;
|
||||
static int on_home_screen(const char *title_id) {
|
||||
char app[64], meta[64];
|
||||
snprintf(app, sizeof app, "/user/app/%s", title_id);
|
||||
snprintf(meta, sizeof meta, "/user/appmeta/%s", title_id);
|
||||
return dir_exists(app) || dir_exists(meta);
|
||||
}
|
||||
|
||||
if (port <= 0 || port > 65535) return -1;
|
||||
int plat_app_installed(void) { return on_home_screen(ROMMPS_ID); }
|
||||
|
||||
snprintf(param, sizeof param,
|
||||
"{\n"
|
||||
" \"titleId\": \"" ROMM_TITLE_ID "\",\n"
|
||||
" \"deeplinkUri\": \"http://127.0.0.1:%d/\",\n"
|
||||
" \"localizedParameters\": {\n"
|
||||
" \"defaultLanguage\": \"en-US\",\n"
|
||||
" \"en-US\": {\n"
|
||||
" \"titleName\": \"" ROMM_TITLE_NAME "\"\n"
|
||||
" }\n"
|
||||
" }\n"
|
||||
"}\n",
|
||||
port);
|
||||
|
||||
installed = !stat(ROMM_APPMETA, &st);
|
||||
have_icon = !stat(CONSOLE_ICON, &st);
|
||||
|
||||
/* Already installed with identical param.json (same port) -> nothing to do. */
|
||||
if (installed && (old = read_file(ROMM_APP_DIR "/sce_sys/param.json", NULL))) {
|
||||
int same = !strcmp(old, param);
|
||||
free(old);
|
||||
if (same && (!have_icon || !stat(ROMM_APP_DIR "/sce_sys/icon0.png", &st)))
|
||||
return 0;
|
||||
/* From its param.json: contentVersion "01.001.000" is 1.1.0. The app's own
|
||||
* folder first (mount.lnk names it when ShadowMount mounts it from
|
||||
* /data/homebrew): copying a new version over it leaves the home screen's
|
||||
* copies in /user/app and /user/appmeta at the old one. */
|
||||
void plat_app_version(char *out, size_t n) {
|
||||
char params[3][PATH_MAX_LEN] = {"", "/user/app/" ROMMPS_ID "/sce_sys/param.json", "/user/appmeta/" ROMMPS_ID "/param.json"};
|
||||
char *link = read_file("/user/app/" ROMMPS_ID "/mount.lnk", NULL);
|
||||
if (link) {
|
||||
link[strcspn(link, "\r\n")] = 0;
|
||||
if (link[0] == '/') snprintf(params[0], sizeof params[0], "%s/sce_sys/param.json", link);
|
||||
free(link);
|
||||
}
|
||||
out[0] = 0;
|
||||
for (size_t i = 0; i < 3 && !out[0]; i++) {
|
||||
if (!params[i][0]) continue;
|
||||
char *json = read_file(params[i], NULL);
|
||||
const char *k = json ? strstr(json, "\"contentVersion\"") : NULL;
|
||||
const char *q = k ? strchr(k + 16, '"') : NULL;
|
||||
int v[3];
|
||||
if (q && sscanf(q + 1, "%d.%d.%d", &v[0], &v[1], &v[2]) == 3) snprintf(out, n, "%d.%d.%d", v[0], v[1], v[2]);
|
||||
free(json);
|
||||
}
|
||||
}
|
||||
|
||||
if ((err = sceAppInstUtilInitialize())) return -1;
|
||||
|
||||
/* Re-install (e.g. port changed): uninstall may remove /user/app/<id>. */
|
||||
if (installed) sceAppInstUtilAppUnInstall(ROMM_TITLE_ID);
|
||||
|
||||
if (console_mkdir_p(ROMM_APP_DIR "/sce_sys")) return -1;
|
||||
if (write_file(ROMM_APP_DIR "/sce_sys/param.json", param, strlen(param))) return -1;
|
||||
if (have_icon) copy_file(CONSOLE_ICON, ROMM_APP_DIR "/sce_sys/icon0.png");
|
||||
|
||||
if ((err = install_title_dir(ROMM_TITLE_ID, "/user/app/"))) return -1;
|
||||
return 0;
|
||||
void plat_remove_old_tile(void) {
|
||||
if (!on_home_screen(OLD_TILE_ID)) return;
|
||||
/* Without RommPS the tile is the only way in from the home screen. */
|
||||
if (!on_home_screen(ROMMPS_ID)) {
|
||||
LOGI("kept the old RomM Sync tile: RommPS isn't installed");
|
||||
return;
|
||||
}
|
||||
int err = sceAppInstUtilInitialize();
|
||||
if (!err) err = sceAppInstUtilAppUnInstall(OLD_TILE_ID);
|
||||
if (err) LOGW("could not remove the old RomM Sync tile (0x%x)", (unsigned)err);
|
||||
else LOGI("removed the old RomM Sync tile: RommPS is the app now");
|
||||
}
|
||||
|
||||
/* etaHEN's loader runs whatever arrives on port 9021, loopback included
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
/* Shared by the PS5's platform.c and mounts.c. */
|
||||
#ifndef ROMM_PS5_H
|
||||
#define ROMM_PS5_H
|
||||
|
||||
#include <stdint.h>
|
||||
#include <sys/types.h>
|
||||
|
||||
/* The title id ("PPSA99248", into 16 bytes) and app id of the app a process
|
||||
* belongs to. 0 on success, -1 for a process that isn't an app's. */
|
||||
int ps5_proc_app(pid_t pid, char title[16], uint32_t *app_id);
|
||||
|
||||
#endif
|
||||
+2
-2
@@ -23,9 +23,9 @@ build/ps5/gen/emulators_json.h: platform/ps5/emulators.json tools/embed.py
|
||||
python3 -c "import json,sys; json.load(open(sys.argv[1]))" $<
|
||||
python3 tools/embed.py $< $@ EMULATORS_JSON
|
||||
|
||||
PS5_PLAT := platform/ps5/platform.c platform/console/console.c
|
||||
PS5_PLAT := platform/ps5/platform.c platform/ps5/mounts.c platform/console/console.c
|
||||
|
||||
$(PS5_ELF): $(SRCS) $(PS5_PLAT) platform/console/console.h $(HDRS) $(UI_HEADER) build/ps5/gen/cacert_pem.h build/ps5/gen/emulators_json.h
|
||||
$(PS5_ELF): $(SRCS) $(PS5_PLAT) platform/console/console.h platform/ps5/ps5.h $(HDRS) $(GEN_HEADERS) build/ps5/gen/cacert_pem.h build/ps5/gen/emulators_json.h
|
||||
@mkdir -p $(dir $@)
|
||||
$(PS5_CC) $(COMMON_CFLAGS) -Ibuild/ps5/gen -Iplatform/console -DROMM_EMBED_CA $$($(PS5_PKGCONF) --cflags libcurl) -DCURL_STATICLIB \
|
||||
-o $@ $(SRCS) $(PS5_PLAT) \
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
{
|
||||
"about": [
|
||||
"What a RetroArch core needs that the defaults don't give (src/detect.c, add_core_platforms), on both consoles.",
|
||||
"Each rule matches a core by its name (\"names\", exact, any case, or \"contains\") and, when given, the system it plays",
|
||||
"(\"systems\", the first slug of detect.c's DB_MAP). The first rule that matches adds its \"entry\" keys to the core's",
|
||||
"platform entry (src/profiles.h: save_exts, save_name, save_stems, save_format, save_layout, disc_id, memcards, cards).",
|
||||
"Defaults: saves <save folder>/<game>.srm (.sav, .rtc), states <state folder>/<game>.state*. \"note\" is for docs/emulators.md."
|
||||
],
|
||||
"rules": [
|
||||
{"names": ["LRPS2", "PCSX2"], "systems": ["ps2"],
|
||||
"note": "Per game: <save folder>/<game>.ps2 (slot 1 only). Shared (the default): <system>/pcsx2/memcards/Mcd001.ps2 and Mcd002.ps2.",
|
||||
"entry": {"save_exts": ".ps2",
|
||||
"memcards": "{bios}/pcsx2/memcards/Mcd001.ps2,{bios}/pcsx2/memcards/Mcd002.ps2",
|
||||
"cards": {"default": "shared", "modes": "per_game,shared", "change": "Turn off Shared Memory Cards in LRPS2's core options",
|
||||
"setting": {"core_option": "pcsx2_shared_memory_cards", "per_game": "disabled", "shared": "enabled", "default": "enabled"}}}},
|
||||
{"names": ["PS5SX2"], "systems": ["ps2"],
|
||||
"note": "Shared only: <system>/pcsx2/memcards/Mcd001.ps2 and Mcd002.ps2.",
|
||||
"entry": {"memcards": "{bios}/pcsx2/memcards/Mcd001.ps2,{bios}/pcsx2/memcards/Mcd002.ps2",
|
||||
"cards": {"default": "shared", "modes": "shared"}}},
|
||||
{"names": ["SwanStation"], "systems": ["psx"],
|
||||
"note": "Card 1: <game>.srm (Libretro, the default), <serial>_1.mcd (per game by code) or <game>_1.mcd (by title). Shared: duckstation_shared_card_1.mcd.",
|
||||
"entry": {"save_format": "psx-card", "save_exts": ".srm,.mcd", "save_stems": "{rom_stem}_1,{serial}_1",
|
||||
"cards": {"default": "per_game", "modes": "per_game,shared", "change": "Set Memory Card 1 Type to Libretro in SwanStation's core options",
|
||||
"setting": {"core_option": "swanstation_MemoryCards_Card1Type", "per_game": "Libretro,PerGame,PerGameTitle", "shared": "Shared", "default": "Libretro"}}}},
|
||||
{"contains": ["Beetle PSX"], "systems": ["psx"],
|
||||
"note": "Card 1: <game>.srm. Shared cards (mednafen_psx_libretro_shared.N.mcr) need the Mednafen card method.",
|
||||
"entry": {"save_format": "psx-card",
|
||||
"cards": {"default": "per_game", "modes": "per_game,shared", "change": "Turn off Shared Memory Cards in the core options"}}},
|
||||
{"names": ["PCSX-ReARMed"], "systems": ["psx"],
|
||||
"note": "Card 1: <game>.srm.",
|
||||
"entry": {"save_format": "psx-card", "cards": {"default": "per_game", "modes": "per_game"}}},
|
||||
{"names": ["Flycast"], "systems": ["dc"],
|
||||
"note": "Shared (the default): <system>/dc/vmu_save_<port>.bin. Per-Game VMUs on VMU A1 or All VMUs: <save folder>/<disc id>.A1.bin (<game>.A1.bin before late 2024), the same raw 128 KB card as PSFlyCast's.",
|
||||
"entry": {"save_exts": ".bin", "disc_id": "dreamcast", "save_stems": "{game_id}.A1,{rom_stem}.A1", "save_name": "{game_id}.A1{ext}",
|
||||
"save_format": "dc-vmu",
|
||||
"memcards": "{bios}/dc/vmu_save_A1.bin,{bios}/dc/vmu_save_B1.bin,{bios}/dc/vmu_save_C1.bin,{bios}/dc/vmu_save_D1.bin",
|
||||
"cards": {"default": "shared", "modes": "per_game,shared", "change": "Set Per-Game VMUs to VMU A1 in Flycast's core options",
|
||||
"setting": {"core_option": "reicast_per_content_vmus", "per_game": "VMU A1,All VMUs", "shared": "disabled", "default": "disabled"}}}},
|
||||
{"systems": ["saturn"],
|
||||
"note": "Beetle Saturn: internal backup memory as <game>.srm (its Libretro save method, the default) or <game>.bkr; the cartridge's as <game>.bcr.",
|
||||
"entry": {"save_exts": ".srm,.bcr,.bkr",
|
||||
"cards": {"default": "per_game", "modes": "per_game,shared", "change": "Turn off Shared Internal and Backup Memory in Beetle Saturn's core options"}}},
|
||||
{"contains": ["ParaLLEl", "Mupen64Plus"], "systems": ["n64"],
|
||||
"note": "Controller paks live in each game's .srm (EEPROM, four paks, SRAM, flash): there's no shared one.",
|
||||
"entry": {"save_format": "n64-srm", "cards": {"default": "per_game", "modes": "per_game"}}},
|
||||
{"names": ["PPSSPP"], "entry": {"save_layout": "psp"}},
|
||||
{"names": ["Dolphin"], "systems": ["wii"], "entry": {"save_layout": "wii"}},
|
||||
{"names": ["Dolphin"],
|
||||
"note": "A GCI folder: one file per save, so each game's own; synced as a zip of the game's .gci files.",
|
||||
"entry": {"save_layout": "gci", "cards": {"default": "per_game", "modes": "per_game"}}},
|
||||
{"names": ["Azahar", "Citra"], "entry": {"save_layout": "3ds"}}
|
||||
]
|
||||
}
|
||||
+259
-94
@@ -9,123 +9,288 @@
|
||||
#include "platform.h"
|
||||
#include "util.h"
|
||||
|
||||
/* The folder the loader starts payloads from (plat_info), or NULL if the
|
||||
* loader can't start payloads at boot. */
|
||||
static const char *autostart_dir(void) {
|
||||
const char *e = getenv("ROMM_SYNC_AUTOSTART");
|
||||
return e && *e ? e : plat_info()->autostart_dir;
|
||||
#define MAX_PLACES 8
|
||||
|
||||
/* A loader's payload folder: the payload in it and the file that turns it on
|
||||
* ("" when the payload's presence is enough). */
|
||||
typedef struct {
|
||||
char elf[PATH_MAX_LEN];
|
||||
char flag[PATH_MAX_LEN];
|
||||
int present; /* the loader is installed */
|
||||
} place;
|
||||
|
||||
/* "a,b,c" into list, at most max entries. */
|
||||
static int split(const char *s, char list[][PATH_MAX_LEN], int max) {
|
||||
int n = 0;
|
||||
while (s && *s && n < max) {
|
||||
const char *comma = strchr(s, ',');
|
||||
size_t len = comma ? (size_t)(comma - s) : strlen(s);
|
||||
if (len && len < PATH_MAX_LEN) {
|
||||
memcpy(list[n], s, len);
|
||||
list[n++][len] = 0;
|
||||
}
|
||||
s = comma ? comma + 1 : NULL;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
/* The loader counts as installed when plat_info's loader_dir exists, or else the
|
||||
* folder above its payload folder: /data/etaHEN for /data/etaHEN/payloads. */
|
||||
static int loader_found(void) {
|
||||
static int parent_exists(const char *path) {
|
||||
char parent[PATH_MAX_LEN];
|
||||
if (!autostart_dir()) return 0;
|
||||
if (plat_info()->loader_dir && !getenv("ROMM_SYNC_AUTOSTART")) return dir_exists(plat_info()->loader_dir);
|
||||
str_copy(parent, sizeof parent, autostart_dir());
|
||||
str_copy(parent, sizeof parent, path);
|
||||
char *slash = strrchr(parent, '/');
|
||||
if (slash && slash != parent) *slash = 0;
|
||||
return dir_exists(parent);
|
||||
}
|
||||
|
||||
/* Same mode "make install" leaves over FTP. */
|
||||
static int write_payload(const char *elf, const void *data, size_t len) {
|
||||
if (write_file_atomic(elf, data, len) != 0) return -1;
|
||||
chmod(elf, 0777);
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* flag is "" when the payload's presence is enough. Both are "" without autostart. */
|
||||
static void paths(char *elf, char *flag, size_t n) {
|
||||
/* Every loader payload folder, the running loader's first. ROMM_SYNC_AUTOSTART
|
||||
* (comma separated, running loader first) replaces them in tests. 0 if the
|
||||
* loader can't start payloads at boot. */
|
||||
static int places(place *p, int max) {
|
||||
const plat_info_t *pi = plat_info();
|
||||
elf[0] = flag[0] = 0;
|
||||
if (!autostart_dir()) return;
|
||||
snprintf(elf, n, "%s/%s", autostart_dir(), pi->payload);
|
||||
if (pi->autostart_flag) snprintf(flag, n, "%s/%s", autostart_dir(), pi->autostart_flag);
|
||||
char dirs[MAX_PLACES][PATH_MAX_LEN];
|
||||
int n = 0;
|
||||
const char *env = getenv("ROMM_SYNC_AUTOSTART");
|
||||
if (env && *env) {
|
||||
n = split(env, dirs, MAX_PLACES);
|
||||
} else if (pi->autostart_dir) {
|
||||
str_copy(dirs[n++], PATH_MAX_LEN, pi->autostart_dir);
|
||||
for (int i = 0; pi->autostart_also && pi->autostart_also[i] && n < MAX_PLACES; i++)
|
||||
str_copy(dirs[n++], PATH_MAX_LEN, pi->autostart_also[i]);
|
||||
}
|
||||
if (n > max) n = max;
|
||||
for (int i = 0; i < n; i++) {
|
||||
snprintf(p[i].elf, sizeof p[i].elf, "%s/%s", dirs[i], pi->payload);
|
||||
p[i].flag[0] = 0;
|
||||
if (pi->autostart_flag) snprintf(p[i].flag, sizeof p[i].flag, "%s/%s", dirs[i], pi->autostart_flag);
|
||||
/* A loader counts as installed when its folder (/data/etaHEN for
|
||||
* /data/etaHEN/payloads) exists; plat_info's loader_dir for the
|
||||
* running one. */
|
||||
p[i].present = (i == 0 && pi->loader_dir && !(env && *env)) ? dir_exists(pi->loader_dir) : parent_exists(dirs[i]);
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
cJSON *autostart_status(void) {
|
||||
char elf[PATH_MAX_LEN], flag[PATH_MAX_LEN];
|
||||
paths(elf, flag, sizeof elf);
|
||||
cJSON *j = cJSON_CreateObject();
|
||||
cJSON_AddBoolToObject(j, "supported", autostart_dir() != NULL);
|
||||
cJSON_AddStringToObject(j, "loader", plat_info()->loader);
|
||||
cJSON_AddStringToObject(j, "payload", plat_info()->payload);
|
||||
if (plat_info()->autostart_hint) cJSON_AddStringToObject(j, "hint", plat_info()->autostart_hint);
|
||||
cJSON_AddBoolToObject(j, "loader_found", loader_found());
|
||||
cJSON_AddBoolToObject(j, "installed", elf[0] && file_exists(elf));
|
||||
cJSON_AddBoolToObject(j, "enabled", elf[0] && file_exists(elf) && (!flag[0] || file_exists(flag)));
|
||||
cJSON_AddStringToObject(j, "path", elf);
|
||||
return j;
|
||||
/* Copies other tools keep, that exist. */
|
||||
static int copies(char list[][PATH_MAX_LEN], int max) {
|
||||
const char *env = getenv("ROMM_SYNC_PAYLOAD_COPIES");
|
||||
int n = 0;
|
||||
if (env) {
|
||||
n = split(env, list, max);
|
||||
} else {
|
||||
const char *const *c = plat_info()->payload_copies;
|
||||
for (int i = 0; c && c[i] && n < max; i++) str_copy(list[n++], PATH_MAX_LEN, c[i]);
|
||||
}
|
||||
int kept = 0;
|
||||
for (int i = 0; i < n; i++)
|
||||
if (file_exists(list[i])) memmove(list[kept++], list[i], PATH_MAX_LEN);
|
||||
return kept;
|
||||
}
|
||||
|
||||
int autostart_replace(const void *data, size_t len, char *err, int en) {
|
||||
char elf[PATH_MAX_LEN], flag[PATH_MAX_LEN], bak[PATH_MAX_LEN + 8];
|
||||
paths(elf, flag, sizeof elf);
|
||||
if (!elf[0] || !file_exists(elf)) return 0;
|
||||
snprintf(bak, sizeof bak, "%s.bak", elf);
|
||||
if (copy_file(elf, bak) != 0) {
|
||||
snprintf(err, (size_t)en, "cannot back up %s", elf);
|
||||
static int enabled_at(const place *p) { return file_exists(p->elf) && (!p->flag[0] || file_exists(p->flag)); }
|
||||
|
||||
/* The version of the payload at path, "" if none or too old to say. Read
|
||||
* every time: two builds can have the same size and date to the second. */
|
||||
static void file_version(const char *path, char *out, size_t n) {
|
||||
size_t len;
|
||||
char *data = read_file(path, &len);
|
||||
out[0] = 0;
|
||||
if (data) payload_version(data, len, out, n);
|
||||
free(data);
|
||||
}
|
||||
|
||||
/* Writes the payload to path unless a newer one is already there. Same mode
|
||||
* "make install" leaves over FTP. 1 if written, 0 if kept, -1 on error. */
|
||||
static int put(const char *path, const void *data, size_t len, const char *version) {
|
||||
char have[32];
|
||||
if (file_exists(path)) {
|
||||
file_version(path, have, sizeof have);
|
||||
if (version_cmp(have, version) > 0) {
|
||||
LOGI("kept %s: it's %s, newer than %s", path, have, version[0] ? version : "this payload");
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
if (write_file_atomic(path, data, len) != 0) {
|
||||
LOGW("could not write %s", path);
|
||||
return -1;
|
||||
}
|
||||
if (write_payload(elf, data, len) != 0) {
|
||||
snprintf(err, (size_t)en, "cannot write %s", elf);
|
||||
return -1;
|
||||
}
|
||||
LOGI("replaced %s (%zu bytes), previous version kept as %s", elf, len, bak);
|
||||
/* A copy left in another loader's folder (from before switching loaders)
|
||||
* would start the old version if that loader came back. */
|
||||
const char *const *also = plat_info()->autostart_also;
|
||||
for (int i = 0; also && also[i] && !getenv("ROMM_SYNC_AUTOSTART"); i++) {
|
||||
char other[PATH_MAX_LEN];
|
||||
snprintf(other, sizeof other, "%s/%s", also[i], plat_info()->payload);
|
||||
if (!file_exists(other)) continue;
|
||||
if (write_payload(other, data, len) == 0) LOGI("also replaced %s", other);
|
||||
else LOGW("could not replace %s", other);
|
||||
}
|
||||
chmod(path, 0777);
|
||||
return 1;
|
||||
}
|
||||
|
||||
int autostart_set(int enable, const void *data, size_t len, char *err, int en) {
|
||||
char elf[PATH_MAX_LEN], flag[PATH_MAX_LEN];
|
||||
paths(elf, flag, sizeof elf);
|
||||
if (!autostart_dir()) {
|
||||
snprintf(err, (size_t)en, "%s can't start payloads with the console", plat_info()->loader);
|
||||
return -1;
|
||||
static void remove_bak(const place *p) {
|
||||
char bak[PATH_MAX_LEN + 8];
|
||||
snprintf(bak, sizeof bak, "%s.bak", p->elf);
|
||||
if (file_exists(bak) && unlink(bak) == 0) LOGI("removed %s", bak);
|
||||
}
|
||||
|
||||
cJSON *autostart_status(void) {
|
||||
place p[MAX_PLACES];
|
||||
int n = places(p, MAX_PLACES);
|
||||
char version[32] = "";
|
||||
if (n && file_exists(p[0].elf)) file_version(p[0].elf, version, sizeof version);
|
||||
cJSON *j = cJSON_CreateObject();
|
||||
cJSON_AddBoolToObject(j, "supported", n > 0);
|
||||
cJSON_AddStringToObject(j, "loader", plat_info()->loader);
|
||||
cJSON_AddStringToObject(j, "payload", plat_info()->payload);
|
||||
if (plat_info()->autostart_hint) cJSON_AddStringToObject(j, "hint", plat_info()->autostart_hint);
|
||||
cJSON_AddBoolToObject(j, "loader_found", n && p[0].present);
|
||||
cJSON_AddBoolToObject(j, "installed", n && file_exists(p[0].elf));
|
||||
cJSON_AddBoolToObject(j, "enabled", n && enabled_at(&p[0]));
|
||||
cJSON_AddStringToObject(j, "path", n ? p[0].elf : "");
|
||||
cJSON_AddStringToObject(j, "version", version);
|
||||
cJSON *list = cJSON_AddArrayToObject(j, "copies");
|
||||
char extra[MAX_PLACES][PATH_MAX_LEN];
|
||||
int ne = copies(extra, MAX_PLACES);
|
||||
for (int i = 0; i < n + ne; i++) {
|
||||
const char *path = i < n ? p[i].elf : extra[i - n];
|
||||
if (!file_exists(path)) continue;
|
||||
char v[32];
|
||||
file_version(path, v, sizeof v);
|
||||
cJSON *o = cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(o, "path", path);
|
||||
cJSON_AddStringToObject(o, "version", v);
|
||||
cJSON_AddBoolToObject(o, "enabled", i < n ? enabled_at(&p[i]) : 1);
|
||||
cJSON_AddItemToArray(list, o);
|
||||
}
|
||||
if (!loader_found()) {
|
||||
snprintf(err, (size_t)en, "%s folder not found (%s)", plat_info()->loader, autostart_dir());
|
||||
return -1;
|
||||
return j;
|
||||
}
|
||||
|
||||
/* Writes the payload to every installed loader's folder (or, with existing_only,
|
||||
* only over copies already there) and over other tools' copies. The number
|
||||
* written or kept, -1 if one couldn't be written. */
|
||||
static int put_everywhere(const void *data, size_t len, int existing_only) {
|
||||
place p[MAX_PLACES];
|
||||
int n = places(p, MAX_PLACES), done = 0, failed = 0;
|
||||
char version[32];
|
||||
payload_version(data, len, version, sizeof version);
|
||||
for (int i = 0; i < n; i++) {
|
||||
if (!p[i].present || (existing_only && !file_exists(p[i].elf))) continue;
|
||||
int r = put(p[i].elf, data, len, version);
|
||||
if (r < 0) failed = 1;
|
||||
else done++;
|
||||
if (r > 0) LOGI("installed %s %s (%zu bytes)", version[0] ? version : "payload", p[i].elf, len);
|
||||
remove_bak(&p[i]);
|
||||
}
|
||||
if (data && len) {
|
||||
/* Only accept ELF files. */
|
||||
if (len < 4 || memcmp(data, "\x7f" "ELF", 4) != 0) {
|
||||
snprintf(err, (size_t)en, "that file is not an ELF payload");
|
||||
return -1;
|
||||
}
|
||||
if (write_payload(elf, data, len) != 0) {
|
||||
snprintf(err, (size_t)en, "cannot write %s", elf);
|
||||
return -1;
|
||||
}
|
||||
LOGI("installed payload to %s (%zu bytes)", elf, len);
|
||||
char extra[MAX_PLACES][PATH_MAX_LEN];
|
||||
int ne = copies(extra, MAX_PLACES);
|
||||
for (int i = 0; i < ne; i++) {
|
||||
int r = put(extra[i], data, len, version);
|
||||
if (r < 0) failed = 1;
|
||||
else done++;
|
||||
if (r > 0) LOGI("updated %s to %s", extra[i], version[0] ? version : "this payload");
|
||||
}
|
||||
if (enable) {
|
||||
if (!file_exists(elf)) {
|
||||
snprintf(err, (size_t)en, "upload %s first", plat_info()->payload);
|
||||
return failed ? -1 : done;
|
||||
}
|
||||
|
||||
/* Turns autostart on in every installed loader's folder that has the payload.
|
||||
* Some file managers "turn off" a payload by renaming its flag to .off; that
|
||||
* one goes too, so nothing on the console says it's off. */
|
||||
static int enable_all(char *err, int en) {
|
||||
place p[MAX_PLACES];
|
||||
int n = places(p, MAX_PLACES);
|
||||
for (int i = 0; i < n; i++) {
|
||||
if (!p[i].present || !p[i].flag[0] || !file_exists(p[i].elf)) continue;
|
||||
char off[PATH_MAX_LEN + 8];
|
||||
snprintf(off, sizeof off, "%s.off", p[i].flag);
|
||||
if (file_exists(off) && unlink(off) == 0) LOGI("removed %s", off);
|
||||
if (file_exists(p[i].flag)) continue;
|
||||
if (write_file_atomic(p[i].flag, "", 0) != 0) {
|
||||
snprintf(err, (size_t)en, "cannot write %s", p[i].flag);
|
||||
return -1;
|
||||
}
|
||||
if (flag[0] && write_file_atomic(flag, "", 0) != 0) {
|
||||
snprintf(err, (size_t)en, "cannot write %s", flag);
|
||||
return -1;
|
||||
}
|
||||
LOGI("autostart enabled");
|
||||
} else if (flag[0]) {
|
||||
unlink(flag);
|
||||
LOGI("autostart disabled");
|
||||
} else {
|
||||
/* Nothing but the payload itself turns autostart on, so remove it. */
|
||||
unlink(elf);
|
||||
LOGI("autostart disabled, removed %s", elf);
|
||||
LOGI("turned on autostart in %s", p[i].flag);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int check_elf(const void *data, size_t len, char *err, int en) {
|
||||
if (len < 4 || memcmp(data, "\x7f" "ELF", 4) != 0) {
|
||||
snprintf(err, (size_t)en, "that file is not an ELF payload");
|
||||
return -1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
int autostart_enable(const void *data, size_t len, char *err, int en) {
|
||||
place p[MAX_PLACES];
|
||||
int n = places(p, MAX_PLACES);
|
||||
if (!n) {
|
||||
snprintf(err, (size_t)en, "%s can't start payloads with the console", plat_info()->loader);
|
||||
return -1;
|
||||
}
|
||||
if (!p[0].present) {
|
||||
snprintf(err, (size_t)en, "%s folder not found (%s)", plat_info()->loader, p[0].elf);
|
||||
return -1;
|
||||
}
|
||||
if (data && len) {
|
||||
if (check_elf(data, len, err, en)) return -1;
|
||||
if (put_everywhere(data, len, 0) < 0) {
|
||||
snprintf(err, (size_t)en, "cannot write %s", p[0].elf);
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
if (!file_exists(p[0].elf)) {
|
||||
snprintf(err, (size_t)en, "upload %s first", plat_info()->payload);
|
||||
return -1;
|
||||
}
|
||||
/* A loader that's installed but has no copy yet gets the running
|
||||
* loader's, so the payload starts whichever one runs. */
|
||||
for (int i = 1; i < n; i++)
|
||||
if (p[i].present && !file_exists(p[i].elf) && copy_file(p[0].elf, p[i].elf) == 0) chmod(p[i].elf, 0777);
|
||||
return enable_all(err, en);
|
||||
}
|
||||
|
||||
int autostart_replace(const void *data, size_t len, char *err, int en) {
|
||||
int r = put_everywhere(data, len, 1);
|
||||
if (r < 0) {
|
||||
snprintf(err, (size_t)en, "cannot write the payload to %s's folder", plat_info()->loader);
|
||||
return -1;
|
||||
}
|
||||
return r > 0;
|
||||
}
|
||||
|
||||
void autostart_repair(void) {
|
||||
place p[MAX_PLACES];
|
||||
int n = places(p, MAX_PLACES);
|
||||
char extra[MAX_PLACES][PATH_MAX_LEN];
|
||||
int ne = copies(extra, MAX_PLACES);
|
||||
|
||||
/* The newest copy on the console. */
|
||||
char best[PATH_MAX_LEN] = "", best_v[32] = "";
|
||||
for (int i = 0; i < n + ne; i++) {
|
||||
const char *path = i < n ? p[i].elf : extra[i - n];
|
||||
if (i < n) {
|
||||
remove_bak(&p[i]);
|
||||
if (!p[i].present) continue;
|
||||
}
|
||||
if (!file_exists(path)) continue;
|
||||
char v[32];
|
||||
file_version(path, v, sizeof v);
|
||||
if (!best[0] || version_cmp(v, best_v) > 0) {
|
||||
str_copy(best, sizeof best, path);
|
||||
str_copy(best_v, sizeof best_v, v);
|
||||
}
|
||||
}
|
||||
if (!best[0]) return;
|
||||
|
||||
for (int i = 0; i < n + ne; i++) {
|
||||
const char *path = i < n ? p[i].elf : extra[i - n];
|
||||
if (i < n && !p[i].present) continue;
|
||||
int have = file_exists(path);
|
||||
/* Every installed loader gets one; other tools' copies are only kept current. */
|
||||
if (!have && i >= n) continue;
|
||||
if (have) {
|
||||
char v[32];
|
||||
file_version(path, v, sizeof v);
|
||||
if (version_cmp(v, best_v) >= 0) continue;
|
||||
}
|
||||
if (copy_file(best, path) == 0) {
|
||||
chmod(path, 0777);
|
||||
LOGI("%s %s with %s from %s", have ? "updated" : "set up", path, best_v[0] ? best_v : "the payload", best);
|
||||
} else {
|
||||
LOGW("could not copy %s to %s", best, path);
|
||||
}
|
||||
}
|
||||
char err[256];
|
||||
if (enable_all(err, sizeof err)) LOGW("%s", err);
|
||||
}
|
||||
+20
-7
@@ -1,5 +1,10 @@
|
||||
/* Starting with the console through the loader (etaHEN on the PS5). Consoles
|
||||
* whose loader can't do that report supported: false. */
|
||||
/* Starting with the console through the loader (etaHEN or onionHEN on the
|
||||
* PS5). Consoles whose loader can't do that report supported: false.
|
||||
*
|
||||
* It's always on. The payload goes into every installed loader's payload
|
||||
* folder, so it starts whichever loader the console runs, and copies other
|
||||
* tools keep (a payload manager's) are kept up to date. A copy is never
|
||||
* replaced by an older one. */
|
||||
#ifndef ROMM_AUTOSTART_H
|
||||
#define ROMM_AUTOSTART_H
|
||||
|
||||
@@ -7,11 +12,19 @@
|
||||
|
||||
#include "cJSON.h"
|
||||
|
||||
cJSON *autostart_status(void); /* {supported, loader, payload, loader_found, installed, enabled, path} */
|
||||
/* Turns autostart on or off, installing the payload first if one is given. */
|
||||
int autostart_set(int enable, const void *elf, size_t elf_len, char *err, int en);
|
||||
/* Replaces an installed payload, keeping the old one as <payload>.bak.
|
||||
* 1 if replaced, 0 if none is installed, -1 on error. */
|
||||
/* {supported, loader, payload, hint, loader_found, installed, enabled, path,
|
||||
* version, copies: [{path, version, enabled}]}. installed, enabled, path and
|
||||
* version are about the running loader's folder. */
|
||||
cJSON *autostart_status(void);
|
||||
/* Turns autostart on in every installed loader's folder, installing the
|
||||
* payload there first if one is given. There's no off: RommPS needs it. */
|
||||
int autostart_enable(const void *elf, size_t elf_len, char *err, int en);
|
||||
/* Replaces every copy of the payload with this one (an update).
|
||||
* 1 if one was replaced, 0 if none is installed, -1 on error. */
|
||||
int autostart_replace(const void *elf, size_t elf_len, char *err, int en);
|
||||
/* At startup: brings every copy up to the newest one on the console, turns
|
||||
* autostart on in every installed loader that's missing it, and removes the
|
||||
* .bak files older versions left. */
|
||||
void autostart_repair(void);
|
||||
|
||||
#endif
|
||||
+179
-39
@@ -12,7 +12,8 @@
|
||||
#include "zip.h"
|
||||
|
||||
int bundle_supported(const char *layout) {
|
||||
return layout && (!strcmp(layout, "psp") || !strcmp(layout, "gci") || !strcmp(layout, "wii"));
|
||||
return layout && (!strcmp(layout, "psp") || !strcmp(layout, "gci") || !strcmp(layout, "wii") ||
|
||||
!strcmp(layout, "3ds") || !strcmp(layout, "wiiu"));
|
||||
}
|
||||
|
||||
static void add(bundle *b, const char *path, const char *name) {
|
||||
@@ -44,7 +45,7 @@ static void add_tree(bundle *b, const char *dir, const char *prefix, int depth)
|
||||
else
|
||||
str_copy(name, sizeof name, de->d_name);
|
||||
if (dir_exists(path)) {
|
||||
if (depth < 4) add_tree(b, path, name, depth + 1);
|
||||
if (depth < 8) add_tree(b, path, name, depth + 1);
|
||||
} else {
|
||||
add(b, path, name);
|
||||
}
|
||||
@@ -73,6 +74,37 @@ static void wii_title_hex(const char *id, char *out) {
|
||||
for (int i = 0; i < 4; i++) sprintf(out + i * 2, "%02x", (unsigned char)id[i]);
|
||||
}
|
||||
|
||||
/* A 3DS or Wii U title id, "0004000000066C00": its halves in lowercase. */
|
||||
static int title_halves(const char *id, char hi[9], char lo[9]) {
|
||||
if (strlen(id) != 16) return -1;
|
||||
for (int i = 0; i < 16; i++) {
|
||||
char c = id[i];
|
||||
if (!((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F'))) return -1;
|
||||
(i < 8 ? hi : lo)[i % 8] = (char)(c >= 'A' && c <= 'F' ? c - 'A' + 'a' : c);
|
||||
}
|
||||
hi[8] = lo[8] = 0;
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* Cemu's account on this console, from settings.xml beside mlc01 ("<base>"
|
||||
* is mlc01/usr/save): <PersistentId>2147483649</PersistentId> is 80000001. */
|
||||
static void wiiu_account(const char *base, char out[16]) {
|
||||
char cfg[PATH_MAX_LEN];
|
||||
str_copy(cfg, sizeof cfg, base);
|
||||
for (int k = 0; k < 3; k++) {
|
||||
char *p = strrchr(cfg, '/');
|
||||
if (p) *p = 0;
|
||||
}
|
||||
str_copy(out, 16, "80000001");
|
||||
char path[PATH_MAX_LEN];
|
||||
path_join(path, sizeof path, cfg, "settings.xml");
|
||||
char *xml = read_file(path, NULL);
|
||||
const char *at = xml ? strstr(xml, "<PersistentId>") : NULL;
|
||||
unsigned long v = at ? strtoul(at + 14, NULL, 10) : 0;
|
||||
if (v >= 0x80000000UL && v <= 0xFFFFFFFFUL) snprintf(out, 16, "%08lx", v);
|
||||
free(xml);
|
||||
}
|
||||
|
||||
int bundle_open(bundle *b, const char *layout, const char *id, char bases[][PATH_MAX_LEN], int nbases) {
|
||||
memset(b, 0, sizeof *b);
|
||||
if (!bundle_supported(layout) || !id[0] || nbases < 1) return -1;
|
||||
@@ -86,6 +118,7 @@ int bundle_open(bundle *b, const char *layout, const char *id, char bases[][PATH
|
||||
break;
|
||||
}
|
||||
const char *base = bases[pick];
|
||||
str_copy(b->base, sizeof b->base, base);
|
||||
|
||||
if (!strcmp(layout, "psp")) {
|
||||
str_copy(b->dir, sizeof b->dir, base);
|
||||
@@ -115,12 +148,37 @@ int bundle_open(bundle *b, const char *layout, const char *id, char bases[][PATH
|
||||
if (ok) add(b, path, de->d_name);
|
||||
}
|
||||
if (d) closedir(d);
|
||||
} else if (!strcmp(layout, "3ds")) {
|
||||
char hi[9], lo[9], root[64], meta[PATH_MAX_LEN];
|
||||
if (title_halves(id, hi, lo) != 0) return -1;
|
||||
snprintf(b->dir, sizeof b->dir, "%s/%s/%s/data/00000001", base, hi, lo);
|
||||
snprintf(root, sizeof root, "%s/%s/data/00000001", hi, lo);
|
||||
add_tree(b, b->dir, root, 0);
|
||||
snprintf(meta, sizeof meta, "%s.metadata", b->dir);
|
||||
snprintf(root, sizeof root, "%s/%s/data/00000001.metadata", hi, lo);
|
||||
if (b->n && file_exists(meta)) add(b, meta, root);
|
||||
} else if (!strcmp(layout, "wiiu")) {
|
||||
char hi[9], lo[9], dir[PATH_MAX_LEN], name[64];
|
||||
if (title_halves(id, hi, lo) != 0) return -1;
|
||||
wiiu_account(base, b->account);
|
||||
snprintf(b->dir, sizeof b->dir, "%s/%s/%s", base, hi, lo);
|
||||
snprintf(dir, sizeof dir, "%s/user/%s", b->dir, b->account);
|
||||
snprintf(name, sizeof name, "%s/user/account", lo);
|
||||
add_tree(b, dir, name, 0);
|
||||
snprintf(dir, sizeof dir, "%s/user/common", b->dir);
|
||||
snprintf(name, sizeof name, "%s/user/common", lo);
|
||||
add_tree(b, dir, name, 0);
|
||||
if (b->n) { /* the title's meta only goes with a save */
|
||||
snprintf(dir, sizeof dir, "%s/meta", b->dir);
|
||||
snprintf(name, sizeof name, "%s/meta", lo);
|
||||
add_tree(b, dir, name, 0);
|
||||
}
|
||||
} else {
|
||||
char hex[9];
|
||||
if (strlen(id) < 4) return -1;
|
||||
wii_title_hex(id, hex);
|
||||
snprintf(b->dir, sizeof b->dir, "%s/title/00010000/%s", base, hex);
|
||||
add_tree(b, b->dir, "", 0);
|
||||
add_tree(b, b->dir, hex, 0);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
@@ -166,6 +224,101 @@ int bundle_zip(const bundle *b, const char *zip_path) {
|
||||
return rc;
|
||||
}
|
||||
|
||||
/* The folder named name somewhere below dir (at most depth levels down),
|
||||
* whose parent is called parent when that's given. */
|
||||
static int find_dir(const char *dir, const char *name, const char *parent, int depth, char *out, size_t n) {
|
||||
DIR *d = opendir(dir);
|
||||
struct dirent *de;
|
||||
int found = 0;
|
||||
while (d && !found && (de = readdir(d))) {
|
||||
if (de->d_name[0] == '.') continue;
|
||||
char path[PATH_MAX_LEN];
|
||||
path_join(path, sizeof path, dir, de->d_name);
|
||||
if (!dir_exists(path)) continue;
|
||||
if (!strcasecmp(de->d_name, name) && (!parent || !strcasecmp(path_basename(dir), parent))) {
|
||||
str_copy(out, n, path);
|
||||
found = 1;
|
||||
} else if (depth > 0) {
|
||||
found = find_dir(path, name, parent, depth - 1, out, n) == 0;
|
||||
}
|
||||
}
|
||||
if (d) closedir(d);
|
||||
return found ? 0 : -1;
|
||||
}
|
||||
|
||||
/* Puts the folder from where to (replacing to), across drives too. */
|
||||
static int put_folder(const char *from, const char *to) {
|
||||
remove_tree(to);
|
||||
if (mkdir_parent(to) != 0) return -1;
|
||||
if (rename(from, to) == 0) return 0;
|
||||
return link_or_copy(from, to);
|
||||
}
|
||||
|
||||
/* The save inside an unpacked zip, in whatever form it came. */
|
||||
static int place_unpacked(bundle *b, const char *tmp) {
|
||||
char src[PATH_MAX_LEN], hex[9];
|
||||
if (!strcmp(b->layout, "wii")) {
|
||||
/* "<id>/..." (RomM's save units), or the folder's contents as 1.2 sent them. */
|
||||
wii_title_hex(b->game_id, hex);
|
||||
path_join(src, sizeof src, tmp, hex);
|
||||
return put_folder(dir_exists(src) ? src : tmp, b->dir);
|
||||
}
|
||||
if (!strcmp(b->layout, "3ds")) {
|
||||
/* data/00000001 wherever it is: "<hi>/<lo>/data/00000001", "data/00000001"
|
||||
* or the whole path from the SD card. */
|
||||
if (find_dir(tmp, "00000001", "data", 8, src, sizeof src) != 0) return -1;
|
||||
char meta[PATH_MAX_LEN + 16], local_meta[PATH_MAX_LEN + 16];
|
||||
snprintf(meta, sizeof meta, "%s.metadata", src);
|
||||
snprintf(local_meta, sizeof local_meta, "%s.metadata", b->dir);
|
||||
if (put_folder(src, b->dir) != 0) return -1;
|
||||
if (file_exists(meta)) {
|
||||
unlink(local_meta);
|
||||
if (rename(meta, local_meta) != 0) link_or_copy(meta, local_meta);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
/* wiiu: "<lo>/...", or the save folder's contents, or Cemu's export (an
|
||||
* account's files and a cemu_meta file). The account's files go to this
|
||||
* console's account; meta stays as it is unless the zip has it. */
|
||||
char root[PATH_MAX_LEN], hi[9], lo[9], cemu_meta[PATH_MAX_LEN];
|
||||
if (title_halves(b->game_id, hi, lo) != 0) return -1;
|
||||
path_join(root, sizeof root, tmp, lo);
|
||||
if (!dir_exists(root)) str_copy(root, sizeof root, tmp);
|
||||
char user[PATH_MAX_LEN], account[PATH_MAX_LEN], to[PATH_MAX_LEN];
|
||||
path_join(user, sizeof user, root, "user");
|
||||
path_join(cemu_meta, sizeof cemu_meta, root, "cemu_meta");
|
||||
snprintf(to, sizeof to, "%s/user/%s", b->dir, b->account);
|
||||
if (!dir_exists(user) && file_exists(cemu_meta)) {
|
||||
unlink(cemu_meta);
|
||||
return put_folder(root, to);
|
||||
}
|
||||
path_join(account, sizeof account, user, "account");
|
||||
if (!dir_exists(account)) { /* another console's account folder: 8xxxxxxx */
|
||||
DIR *d = opendir(user);
|
||||
struct dirent *de;
|
||||
account[0] = 0;
|
||||
while (d && !account[0] && (de = readdir(d)))
|
||||
if (de->d_name[0] == '8' && strlen(de->d_name) == 8) path_join(account, sizeof account, user, de->d_name);
|
||||
if (d) closedir(d);
|
||||
}
|
||||
int rc = 0, any = 0;
|
||||
if (account[0] && dir_exists(account)) {
|
||||
rc |= put_folder(account, to);
|
||||
any = 1;
|
||||
}
|
||||
char common[PATH_MAX_LEN], meta[PATH_MAX_LEN];
|
||||
path_join(common, sizeof common, user, "common");
|
||||
snprintf(to, sizeof to, "%s/user/common", b->dir);
|
||||
if (dir_exists(common)) {
|
||||
rc |= put_folder(common, to);
|
||||
any = 1;
|
||||
}
|
||||
path_join(meta, sizeof meta, root, "meta");
|
||||
snprintf(to, sizeof to, "%s/meta", b->dir);
|
||||
if (any && dir_exists(meta)) rc |= put_folder(meta, to);
|
||||
return any ? rc : -1;
|
||||
}
|
||||
|
||||
int bundle_replace(bundle *b, const char *zip_path, const char *tmp_dir) {
|
||||
/* Unpack somewhere else first. A bad zip should cost us a download, not
|
||||
* someone's 40 hour save. */
|
||||
@@ -174,50 +327,37 @@ int bundle_replace(bundle *b, const char *zip_path, const char *tmp_dir) {
|
||||
remove_tree(tmp_dir);
|
||||
return -1;
|
||||
}
|
||||
remove_tree(tmp_dir);
|
||||
|
||||
if (!strcmp(b->layout, "psp")) {
|
||||
/* Remove whole save folders; the zip holds them as top-level folders. */
|
||||
for (int i = 0; i < b->n; i++) {
|
||||
char folder[PATH_MAX_LEN], *slash;
|
||||
str_copy(folder, sizeof folder, b->files[i].name);
|
||||
slash = strchr(folder, '/');
|
||||
if (!slash) continue;
|
||||
*slash = 0;
|
||||
char path[PATH_MAX_LEN];
|
||||
path_join(path, sizeof path, b->dir, folder);
|
||||
remove_tree(path);
|
||||
int rc;
|
||||
if (!strcmp(b->layout, "psp") || !strcmp(b->layout, "gci")) {
|
||||
remove_tree(tmp_dir);
|
||||
if (!strcmp(b->layout, "psp")) {
|
||||
/* Remove whole save folders; the zip holds them as top-level folders. */
|
||||
for (int i = 0; i < b->n; i++) {
|
||||
char folder[PATH_MAX_LEN], *slash;
|
||||
str_copy(folder, sizeof folder, b->files[i].name);
|
||||
slash = strchr(folder, '/');
|
||||
if (!slash) continue;
|
||||
*slash = 0;
|
||||
char path[PATH_MAX_LEN];
|
||||
path_join(path, sizeof path, b->dir, folder);
|
||||
remove_tree(path);
|
||||
}
|
||||
} else {
|
||||
for (int i = 0; i < b->n; i++) unlink(b->files[i].path);
|
||||
}
|
||||
} else if (!strcmp(b->layout, "gci")) {
|
||||
for (int i = 0; i < b->n; i++) unlink(b->files[i].path);
|
||||
mkdir_p(b->dir);
|
||||
rc = zip_extract_all(zip_path, b->dir) < 0 ? -1 : 0;
|
||||
} else {
|
||||
remove_tree(b->dir);
|
||||
rc = place_unpacked(b, tmp_dir);
|
||||
remove_tree(tmp_dir);
|
||||
}
|
||||
mkdir_p(b->dir);
|
||||
int rc = zip_extract_all(zip_path, b->dir) < 0 ? -1 : 0;
|
||||
|
||||
char id[16], dir[PATH_MAX_LEN], layout[8];
|
||||
char id[24], base[1][PATH_MAX_LEN], layout[8];
|
||||
str_copy(id, sizeof id, b->game_id);
|
||||
str_copy(layout, sizeof layout, b->layout);
|
||||
str_copy(dir, sizeof dir, b->dir);
|
||||
str_copy(base[0], sizeof base[0], b->base);
|
||||
bundle_free(b);
|
||||
/* List the files again from the same folder. */
|
||||
char base[1][PATH_MAX_LEN];
|
||||
if (!strcmp(layout, "psp")) {
|
||||
str_copy(base[0], sizeof base[0], dir);
|
||||
} else if (!strcmp(layout, "gci")) {
|
||||
str_copy(base[0], sizeof base[0], dir);
|
||||
char *p = strrchr(base[0], '/'); /* strip "/Card A" */
|
||||
if (p) *p = 0;
|
||||
p = strrchr(base[0], '/'); /* and the region */
|
||||
if (p) *p = 0;
|
||||
} else {
|
||||
str_copy(base[0], sizeof base[0], dir);
|
||||
for (int k = 0; k < 3; k++) {
|
||||
char *p = strrchr(base[0], '/');
|
||||
if (p) *p = 0;
|
||||
}
|
||||
}
|
||||
bundle_open(b, layout, id, base, 1);
|
||||
return rc;
|
||||
}
|
||||
+19
-7
@@ -1,7 +1,16 @@
|
||||
/* Saves made of several files, synced as one zip per game:
|
||||
* psp PSP/SAVEDATA/<game id>* folders (PPSSPP)
|
||||
* gci GC/<region>/Card A/ .gci files with the game id (Dolphin)
|
||||
* wii Wii/title/00010000/<id>/ folder (Dolphin) */
|
||||
/* Saves made of several files, synced as one zip per game, in the layout of
|
||||
* RomM's own save units (rommapp/argosy-sigil), so other clients take them:
|
||||
* psp PSP/SAVEDATA/<game id>* folders (PPSSPP); each folder by its name
|
||||
* gci GC/<region>/Card A/ .gci files with the game id (Dolphin)
|
||||
* wii Wii/title/00010000/<id>/ folder (Dolphin), as "<id>/..."
|
||||
* 3ds title/<high>/<low>/data/00000001/ (Azahar), as
|
||||
* "<high>/<low>/data/00000001/..." and its 00000001.metadata
|
||||
* wiiu usr/save/<high>/<low>/ (Cemu), as "<low>/meta/...",
|
||||
* "<low>/user/common/..." and this console's account folder as
|
||||
* "<low>/user/account/..."
|
||||
* Ids: the disc's "GALE01", or a 3DS or Wii U title "0004000000066C00". A
|
||||
* zip from elsewhere is taken in other forms too (no root folder, Cemu's own
|
||||
* export, the whole path from the SD card). */
|
||||
#ifndef ROMM_BUNDLE_H
|
||||
#define ROMM_BUNDLE_H
|
||||
|
||||
@@ -17,8 +26,10 @@ typedef struct {
|
||||
|
||||
typedef struct {
|
||||
char layout[8];
|
||||
char game_id[16];
|
||||
char dir[PATH_MAX_LEN]; /* where the files live and the zip is unpacked */
|
||||
char game_id[24];
|
||||
char base[PATH_MAX_LEN]; /* the candidate folder picked */
|
||||
char dir[PATH_MAX_LEN]; /* where the files live and the zip is unpacked */
|
||||
char account[16]; /* wiiu: this console's account folder, "80000001" */
|
||||
bundle_file *files;
|
||||
int n, cap;
|
||||
time_t mtime;
|
||||
@@ -28,7 +39,8 @@ typedef struct {
|
||||
int bundle_supported(const char *layout);
|
||||
|
||||
/* Picks the game's folder from candidate base folders (.../PSP/SAVEDATA,
|
||||
* .../User/GC or .../User/Wii), preferring one that exists, and lists its files. */
|
||||
* .../User/GC, .../User/Wii, the 3DS SD card's .../title or Cemu's
|
||||
* mlc01/usr/save), preferring one that exists, and lists its files. */
|
||||
int bundle_open(bundle *b, const char *layout, const char *game_id, char bases[][PATH_MAX_LEN], int nbases);
|
||||
void bundle_free(bundle *b);
|
||||
|
||||
|
||||
+39
-6
@@ -47,13 +47,13 @@ static void get_int(const cJSON *j, const char *key, int *dst) {
|
||||
|
||||
static void set_defaults(void) {
|
||||
memset(&g_cfg, 0, sizeof g_cfg);
|
||||
str_copy(g_cfg.device_name, sizeof g_cfg.device_name, "PlayStation 5");
|
||||
g_cfg.tls_verify = 1;
|
||||
g_cfg.web_port = 8780;
|
||||
g_cfg.sync_interval_min = 15;
|
||||
g_cfg.sync_on_game_exit = 1;
|
||||
g_cfg.sync_on_game_start = 1;
|
||||
g_cfg.sync_on_change = 1;
|
||||
g_cfg.save_sync = 1;
|
||||
g_cfg.exit_delay_sec = 5;
|
||||
g_cfg.sync_states = 1;
|
||||
g_cfg.notify = 1;
|
||||
@@ -64,8 +64,30 @@ static void set_defaults(void) {
|
||||
g_cfg.download_concurrency = 3;
|
||||
g_cfg.conflicts = CONFLICT_ASK;
|
||||
str_copy(g_cfg.slot, sizeof g_cfg.slot, "autosave");
|
||||
str_copy(g_cfg.ps2_cards, sizeof g_cfg.ps2_cards, "per_game");
|
||||
str_copy(g_cfg.ui_theme, sizeof g_cfg.ui_theme, "classic");
|
||||
str_copy(g_cfg.ui_theme, sizeof g_cfg.ui_theme, "acrylic");
|
||||
}
|
||||
|
||||
/* The console's own name, or else "PlayStation 4" or "PlayStation 5". */
|
||||
static void auto_device_name(char *out, size_t n) {
|
||||
char name[128];
|
||||
plat_console_name(name, sizeof name);
|
||||
str_copy_utf8(out, n, name[0] ? name : plat_info()->console);
|
||||
}
|
||||
|
||||
/* A device name given in a config, or "" to follow the console's. The
|
||||
* defaults older versions saved ("PlayStation 5" even on a PS4, where it
|
||||
* collided with a real PS5's states) count as never chosen. */
|
||||
static void set_device_name(const char *name) {
|
||||
char autoname[64];
|
||||
auto_device_name(autoname, sizeof autoname);
|
||||
if (!name[0] || !strcmp(name, "PlayStation 5") || !strcmp(name, "PlayStation 4") ||
|
||||
(g_cfg.device_name_auto && !strcmp(name, autoname))) {
|
||||
g_cfg.device_name_auto = 1;
|
||||
str_copy(g_cfg.device_name, sizeof g_cfg.device_name, autoname);
|
||||
} else {
|
||||
g_cfg.device_name_auto = 0;
|
||||
str_copy_utf8(g_cfg.device_name, sizeof g_cfg.device_name, name); /* half a character: invalid JSON to RomM */
|
||||
}
|
||||
}
|
||||
|
||||
void config_apply_json(const cJSON *j) {
|
||||
@@ -73,13 +95,15 @@ void config_apply_json(const cJSON *j) {
|
||||
get_str(j, "server_url", g_cfg.server_url, sizeof g_cfg.server_url);
|
||||
size_t l = strlen(g_cfg.server_url);
|
||||
while (l && g_cfg.server_url[l - 1] == '/') g_cfg.server_url[--l] = 0;
|
||||
get_str(j, "device_name", g_cfg.device_name, sizeof g_cfg.device_name);
|
||||
const cJSON *dn = cJSON_GetObjectItemCaseSensitive(j, "device_name");
|
||||
if (cJSON_IsString(dn)) set_device_name(dn->valuestring);
|
||||
get_int(j, "tls_verify", &g_cfg.tls_verify);
|
||||
get_int(j, "web_port", &g_cfg.web_port);
|
||||
get_int(j, "sync_interval_min", &g_cfg.sync_interval_min);
|
||||
get_int(j, "sync_on_game_exit", &g_cfg.sync_on_game_exit);
|
||||
get_int(j, "sync_on_game_start", &g_cfg.sync_on_game_start);
|
||||
get_int(j, "sync_on_change", &g_cfg.sync_on_change);
|
||||
get_int(j, "save_sync", &g_cfg.save_sync);
|
||||
get_int(j, "exit_delay_sec", &g_cfg.exit_delay_sec);
|
||||
get_int(j, "sync_states", &g_cfg.sync_states); /* older configs: true/false */
|
||||
char states[16] = "";
|
||||
@@ -99,7 +123,9 @@ void config_apply_json(const cJSON *j) {
|
||||
if (g_cfg.download_concurrency > 8) g_cfg.download_concurrency = 8;
|
||||
get_str(j, "slot", g_cfg.slot, sizeof g_cfg.slot);
|
||||
get_str(j, "ps2_cards", g_cfg.ps2_cards, sizeof g_cfg.ps2_cards);
|
||||
if (strcmp(g_cfg.ps2_cards, "backup") != 0) str_copy(g_cfg.ps2_cards, sizeof g_cfg.ps2_cards, "per_game");
|
||||
/* Before 1.3: "backup" (the shared cards, backed up) or "per_game". */
|
||||
if (!strcmp(g_cfg.ps2_cards, "backup")) str_copy(g_cfg.ps2_cards, sizeof g_cfg.ps2_cards, "shared");
|
||||
else if (strcmp(g_cfg.ps2_cards, "shared") != 0 && strcmp(g_cfg.ps2_cards, "per_game") != 0) g_cfg.ps2_cards[0] = 0;
|
||||
/* Written into the page as an attribute, so only [a-z0-9-] gets through. */
|
||||
char theme[sizeof g_cfg.ui_theme] = "";
|
||||
get_str(j, "ui_theme", theme, sizeof theme);
|
||||
@@ -136,6 +162,7 @@ int config_load(void) {
|
||||
get_str(j, "device_id", g_cfg.device_id, sizeof g_cfg.device_id);
|
||||
get_str(j, "username", g_cfg.username, sizeof g_cfg.username);
|
||||
get_str(j, "server_version", g_cfg.server_version, sizeof g_cfg.server_version);
|
||||
get_str(j, "romm_device_name", g_cfg.romm_device_name, sizeof g_cfg.romm_device_name);
|
||||
get_str(j, "client_device_identifier", g_cfg.client_device_identifier,
|
||||
sizeof g_cfg.client_device_identifier);
|
||||
/* Installs paired before the wizard existed are already set up. */
|
||||
@@ -153,6 +180,7 @@ int config_load(void) {
|
||||
LOGE("config.json is not valid JSON; using defaults");
|
||||
}
|
||||
}
|
||||
if (!g_cfg.device_name[0]) set_device_name("");
|
||||
if (!g_cfg.profiles) g_cfg.profiles = profiles_default();
|
||||
if (!g_cfg.client_device_identifier[0]) random_uuid(g_cfg.client_device_identifier);
|
||||
return config_save();
|
||||
@@ -161,7 +189,9 @@ int config_load(void) {
|
||||
cJSON *config_to_json(int include_secrets) {
|
||||
cJSON *j = cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(j, "server_url", g_cfg.server_url);
|
||||
cJSON_AddStringToObject(j, "device_name", g_cfg.device_name);
|
||||
/* Saved as "" while it follows the console, so a new Console Name is picked up. */
|
||||
cJSON_AddStringToObject(j, "device_name", include_secrets && g_cfg.device_name_auto ? "" : g_cfg.device_name);
|
||||
if (include_secrets) cJSON_AddStringToObject(j, "romm_device_name", g_cfg.romm_device_name);
|
||||
cJSON_AddStringToObject(j, "device_id", g_cfg.device_id);
|
||||
cJSON_AddStringToObject(j, "username", g_cfg.username);
|
||||
cJSON_AddStringToObject(j, "client_device_identifier", g_cfg.client_device_identifier);
|
||||
@@ -172,6 +202,7 @@ cJSON *config_to_json(int include_secrets) {
|
||||
cJSON_AddBoolToObject(j, "sync_on_game_exit", g_cfg.sync_on_game_exit);
|
||||
cJSON_AddBoolToObject(j, "sync_on_game_start", g_cfg.sync_on_game_start);
|
||||
cJSON_AddBoolToObject(j, "sync_on_change", g_cfg.sync_on_change);
|
||||
cJSON_AddBoolToObject(j, "save_sync", g_cfg.save_sync);
|
||||
cJSON_AddNumberToObject(j, "exit_delay_sec", g_cfg.exit_delay_sec);
|
||||
cJSON_AddStringToObject(j, "states", g_cfg.sync_states == 2 ? "sync" : g_cfg.sync_states ? "upload" : "off");
|
||||
cJSON_AddBoolToObject(j, "notify", g_cfg.notify);
|
||||
@@ -204,6 +235,8 @@ int config_save(void) {
|
||||
return rc;
|
||||
}
|
||||
|
||||
const char *config_card_mode(const char *system) { return !strcmp(system, "ps2") ? g_cfg.ps2_cards : ""; }
|
||||
|
||||
int config_is_paired(void) { return g_cfg.server_url[0] && g_cfg.token[0] && g_cfg.device_id[0]; }
|
||||
|
||||
int config_server_at_least(int major, int minor, int patch) {
|
||||
|
||||
+8
-2
@@ -10,7 +10,9 @@ typedef struct {
|
||||
char server_url[512]; /* https://romm.example.com (no trailing slash) */
|
||||
char token[256]; /* rmm_... client API token */
|
||||
char device_id[64]; /* RomM device UUID */
|
||||
char device_name[64];
|
||||
char device_name[64]; /* what this console is called on RomM and in names it uploads */
|
||||
int device_name_auto; /* device_name follows the console's own name (saved as "") */
|
||||
char romm_device_name[64]; /* the name RomM has for the device, last we told it */
|
||||
char client_device_identifier[40]; /* sent when pairing */
|
||||
char username[64];
|
||||
int tls_verify;
|
||||
@@ -19,6 +21,7 @@ typedef struct {
|
||||
int sync_interval_min; /* periodic sync, 0 = off */
|
||||
int sync_on_game_exit;
|
||||
int sync_on_game_start;
|
||||
int save_sync; /* 0: RomM only as a game source, no save or state sync */
|
||||
int sync_on_change; /* upload saves as soon as they change */
|
||||
int exit_delay_sec;
|
||||
int sync_states; /* 0 off, 1 upload, 2 upload and download */
|
||||
@@ -33,7 +36,7 @@ typedef struct {
|
||||
cJSON *profiles;
|
||||
int profiles_custom; /* 0: built-in profiles, not written to config.json */
|
||||
int setup_complete; /* background syncs wait for the setup wizard */
|
||||
char ps2_cards[16]; /* "per_game" or "backup" */
|
||||
char ps2_cards[16]; /* PS2's memory card method: "per_game", "shared" or "" (as its emulator keeps them) */
|
||||
char ui_theme[24]; /* web UI theme id (ui/themes/<id>.css) */
|
||||
int cover_cache; /* keep every game's cover on the console (covers.h) */
|
||||
char server_version[32];
|
||||
@@ -45,6 +48,9 @@ void config_lock(void);
|
||||
void config_unlock(void);
|
||||
|
||||
int config_load(void); /* loads or creates defaults */
|
||||
/* The memory card method chosen for a system ("per_game", "shared"), or ""
|
||||
* for the one its main emulator keeps (profiles.c). Caller holds the lock. */
|
||||
const char *config_card_mode(const char *system);
|
||||
int config_save(void);
|
||||
|
||||
/* The config as JSON, with or without the token. */
|
||||
|
||||
+343
-82
@@ -22,8 +22,8 @@
|
||||
|
||||
/* An asset RomM doesn't have is remembered for a day (a .missing file), so it
|
||||
* isn't asked for again on every page. The folder is pruned, oldest first,
|
||||
* past its limit: room for a whole library's covers on the PS5 (66 KB each on
|
||||
* average), less where storage is tight. The full pass stops short of it. */
|
||||
* past its limit: on the consoles, room for some 30,000 covers (66 KB each on
|
||||
* average). The full pass stops short of it. */
|
||||
#define MISSING_SEC (24 * 60 * 60)
|
||||
#define FULL_EVERY_SEC (24 * 60 * 60)
|
||||
#define WARM_SLOTS 256
|
||||
@@ -37,17 +37,26 @@ static long long g_cached_bytes = -1; /* -1 until counted */
|
||||
static char g_warm[WARM_SLOTS][512];
|
||||
static int g_nwarm;
|
||||
|
||||
/* The full pass: every game's cover path, worked through by the workers. */
|
||||
static char **g_all;
|
||||
static int g_nall, g_next, g_done;
|
||||
/* The full pass: every game's cover path, worked through by the workers. It
|
||||
* stays on disk, a line each in cache/cover-pass (twenty thousand paths are
|
||||
* more than the PS4 can spare), with its size and counts in cover-pass.json
|
||||
* and how far it got in cover-pass.pos, so a restart carries on from there. */
|
||||
static FILE *g_pass;
|
||||
static int g_nall, g_next, g_done, g_total; /* g_total: the pass's size, after it's gone */
|
||||
/* The library was being looked at: the pass waits, its pages' covers don't. */
|
||||
static time_t g_browse_at;
|
||||
#define BROWSE_PAUSE_SEC 15
|
||||
static int g_refresh; /* a full pass was asked for */
|
||||
static char g_state[16] = "idle";
|
||||
|
||||
static int is_ps5(void) { return strcmp(plat_info()->name, "ps5") == 0; }
|
||||
|
||||
static long long cache_limit(void) { return is_ps5() ? 1024LL * 1024 * 1024 : 300LL * 1024 * 1024; }
|
||||
static long long cache_limit(void) { return strcmp(plat_info()->name, "host") ? 2048LL * 1024 * 1024 : 300LL * 1024 * 1024; }
|
||||
|
||||
static int workers(void) { return is_ps5() ? 4 : 2; }
|
||||
static int is_ps4(void) { return strcmp(plat_info()->name, "ps4") == 0; }
|
||||
|
||||
/* The PS4 payload shares a small memory pool with GoldHEN: one cover at a time. */
|
||||
static int workers(void) { return is_ps5() ? 4 : is_ps4() ? 1 : 2; }
|
||||
|
||||
static void cache_path(const char *p, const char *suffix, char *out, size_t n) {
|
||||
uint64_t h = 0xcbf29ce484222325ULL; /* FNV-1a of the asset path and query */
|
||||
@@ -92,58 +101,74 @@ static char *svg_with_size(const char *svg, size_t len, size_t *out_len) {
|
||||
return outp;
|
||||
}
|
||||
|
||||
typedef struct {
|
||||
time_t mtime;
|
||||
long long size;
|
||||
char name[200];
|
||||
} cached_file;
|
||||
/* Pruning goes by age without a list of the files, so it takes the same
|
||||
* little memory for a hundred covers as for twenty thousand: one pass sorts
|
||||
* their sizes into AGE_BINS slices of the folder's age span, the next deletes
|
||||
* the oldest slices, whole, until the rest fit. */
|
||||
#define AGE_BINS 256
|
||||
|
||||
static int by_age(const void *a, const void *b) {
|
||||
time_t x = ((const cached_file *)a)->mtime, y = ((const cached_file *)b)->mtime;
|
||||
return x < y ? -1 : x > y;
|
||||
}
|
||||
|
||||
/* The folder's size; and, past the limit, the oldest files deleted until it's
|
||||
* back under 95% of it. One pass over the folder, on the planner's thread. */
|
||||
static long long cache_scan(int prune) {
|
||||
char dir[PATH_MAX_LEN], path[PATH_MAX_LEN];
|
||||
snprintf(dir, sizeof dir, "%s/cache/assets", plat_data_dir());
|
||||
/* Calls fn on each cached file (the folder's own, not dotfiles). */
|
||||
static void each_cached(const char *dir, void (*fn)(const char *path, const struct stat *st, void *ud), void *ud) {
|
||||
char path[PATH_MAX_LEN];
|
||||
DIR *d = opendir(dir);
|
||||
if (!d) return 0;
|
||||
cached_file *files = NULL;
|
||||
int n = 0, cap = 0;
|
||||
long long total = 0;
|
||||
if (!d) return;
|
||||
struct dirent *de;
|
||||
while ((de = readdir(d))) {
|
||||
struct stat st;
|
||||
if (de->d_name[0] == '.' || strlen(de->d_name) >= sizeof files->name) continue;
|
||||
if (de->d_name[0] == '.') continue;
|
||||
path_join(path, sizeof path, dir, de->d_name);
|
||||
if (stat(path, &st) != 0) continue;
|
||||
total += st.st_size;
|
||||
if (!prune) continue;
|
||||
if (n == cap) {
|
||||
cached_file *grown = realloc(files, (size_t)(cap = cap ? cap * 2 : 1024) * sizeof *files);
|
||||
if (!grown) break;
|
||||
files = grown;
|
||||
}
|
||||
files[n].mtime = st.st_mtime;
|
||||
files[n].size = st.st_size;
|
||||
str_copy(files[n].name, sizeof files[n].name, de->d_name);
|
||||
n++;
|
||||
if (stat(path, &st) == 0 && S_ISREG(st.st_mode)) fn(path, &st, ud);
|
||||
}
|
||||
closedir(d);
|
||||
if (prune && total > cache_limit()) {
|
||||
qsort(files, (size_t)n, sizeof *files, by_age);
|
||||
for (int i = 0; i < n && total > cache_limit() / 20 * 19; i++) {
|
||||
path_join(path, sizeof path, dir, files[i].name);
|
||||
if (unlink(path) == 0) total -= files[i].size;
|
||||
}
|
||||
}
|
||||
|
||||
struct age_scan {
|
||||
long long total, bins[AGE_BINS];
|
||||
time_t oldest, newest;
|
||||
int pass, cutoff; /* cutoff: the newest slice deleted */
|
||||
};
|
||||
|
||||
static int age_bin(const struct age_scan *a, time_t t) {
|
||||
const long long span = (long long)(a->newest - a->oldest) + 1;
|
||||
const long long b = (long long)(t - a->oldest) * AGE_BINS / span;
|
||||
return b < 0 ? 0 : b >= AGE_BINS ? AGE_BINS - 1 : (int)b;
|
||||
}
|
||||
|
||||
static void age_visit(const char *path, const struct stat *st, void *ud) {
|
||||
struct age_scan *a = ud;
|
||||
if (a->pass == 0) {
|
||||
a->total += st->st_size;
|
||||
if (!a->oldest || st->st_mtime < a->oldest) a->oldest = st->st_mtime;
|
||||
if (st->st_mtime > a->newest) a->newest = st->st_mtime;
|
||||
} else if (a->pass == 1) {
|
||||
a->bins[age_bin(a, st->st_mtime)] += st->st_size;
|
||||
} else if (age_bin(a, st->st_mtime) <= a->cutoff && unlink(path) == 0) {
|
||||
a->total -= st->st_size;
|
||||
}
|
||||
}
|
||||
|
||||
/* The folder's size; and, past the limit, the oldest files deleted until it's
|
||||
* back under 95% of it. On the planner's thread. */
|
||||
static long long cache_scan(int prune) {
|
||||
char dir[PATH_MAX_LEN];
|
||||
snprintf(dir, sizeof dir, "%s/cache/assets", plat_data_dir());
|
||||
struct age_scan a;
|
||||
memset(&a, 0, sizeof a);
|
||||
each_cached(dir, age_visit, &a);
|
||||
if (prune && a.total > cache_limit()) {
|
||||
a.pass = 1;
|
||||
each_cached(dir, age_visit, &a);
|
||||
long long over = a.total - cache_limit() / 20 * 19, freed = 0;
|
||||
int last = 0;
|
||||
while (last < AGE_BINS - 1 && (freed += a.bins[last]) < over) last++;
|
||||
a.cutoff = last;
|
||||
a.pass = 2;
|
||||
each_cached(dir, age_visit, &a);
|
||||
}
|
||||
free(files);
|
||||
pthread_mutex_lock(&g_lock);
|
||||
g_cached_bytes = total;
|
||||
g_cached_bytes = a.total;
|
||||
pthread_mutex_unlock(&g_lock);
|
||||
return total;
|
||||
return a.total;
|
||||
}
|
||||
|
||||
static void cache_store(const char *path, const void *body, size_t len) {
|
||||
@@ -177,7 +202,7 @@ static int have(const char *p) {
|
||||
|
||||
int covers_fetch(const char *p, char **out, size_t *out_len, const char **type) {
|
||||
char base[512], auth[300], url[3200], cached[PATH_MAX_LEN], missing[PATH_MAX_LEN];
|
||||
if (!valid_path(p)) return -1;
|
||||
if (!valid_path(p)) return -2;
|
||||
*type = image_type(p);
|
||||
cache_path(p, "", cached, sizeof cached);
|
||||
cache_path(p, ".missing", missing, sizeof missing);
|
||||
@@ -229,6 +254,9 @@ int covers_fetch(const char *p, char **out, size_t *out_len, const char **type)
|
||||
}
|
||||
|
||||
void covers_warm(const char *p) {
|
||||
pthread_mutex_lock(&g_lock);
|
||||
g_browse_at = time(NULL);
|
||||
pthread_mutex_unlock(&g_lock);
|
||||
if (!valid_path(p) || have(p)) return;
|
||||
pthread_mutex_lock(&g_lock);
|
||||
for (int i = 0; i < g_nwarm; i++)
|
||||
@@ -262,49 +290,240 @@ static int enabled(void) {
|
||||
/* The console is busy with something that matters more. */
|
||||
static int busy(void) { return sync_game_running() || sync_is_running() || library_busy(); }
|
||||
|
||||
static void pass_path(char *out, size_t n, const char *suffix) {
|
||||
snprintf(out, n, "%s/cache/cover-pass%s", plat_data_dir(), suffix);
|
||||
}
|
||||
|
||||
/* Where the pass got to: every so many covers, not each one. Call with g_lock held. */
|
||||
static void save_pos(void) {
|
||||
char path[PATH_MAX_LEN], pos[64];
|
||||
long at = g_pass ? ftell(g_pass) : -1;
|
||||
if (at < 0) return;
|
||||
pass_path(path, sizeof path, ".pos");
|
||||
int len = snprintf(pos, sizeof pos, "%d %ld\n", g_next, at);
|
||||
write_file_atomic(path, pos, (size_t)len);
|
||||
}
|
||||
|
||||
/* Each platform's game count when a pass last listed it: the daily pass
|
||||
* lists only the platforms whose count changed since (a cover replaced on
|
||||
* RomM comes down when its game is next looked at). The counts of a pass are
|
||||
* kept once its covers are done, so an unfinished one is listed again. */
|
||||
static cJSON *g_pass_counts;
|
||||
|
||||
static void close_pass(void) {
|
||||
if (g_pass) fclose(g_pass);
|
||||
g_pass = NULL;
|
||||
g_nall = g_next = 0;
|
||||
}
|
||||
|
||||
/* A pass that's over (done, or stopped by a full cache) goes, files and all.
|
||||
* Call with g_lock held. */
|
||||
static void drop_pass(void) {
|
||||
char path[PATH_MAX_LEN];
|
||||
close_pass();
|
||||
static const char *const files[] = {"", ".json", ".pos"};
|
||||
for (size_t i = 0; i < sizeof files / sizeof files[0]; i++) {
|
||||
pass_path(path, sizeof path, files[i]);
|
||||
unlink(path);
|
||||
}
|
||||
}
|
||||
|
||||
/* Opens the pass on disk where it was left (the top, for a new one). 0 if
|
||||
* there's one to carry on with. Call with g_lock held. */
|
||||
static int open_pass(void) {
|
||||
char path[PATH_MAX_LEN];
|
||||
close_pass();
|
||||
pass_path(path, sizeof path, ".json");
|
||||
char *js = read_file(path, NULL);
|
||||
cJSON *head = js ? cJSON_Parse(js) : NULL;
|
||||
free(js);
|
||||
pass_path(path, sizeof path, "");
|
||||
g_pass = head ? fopen(path, "r") : NULL;
|
||||
if (!g_pass) {
|
||||
cJSON_Delete(head);
|
||||
return -1;
|
||||
}
|
||||
setvbuf(g_pass, NULL, _IOFBF, 4096);
|
||||
int next = 0;
|
||||
long at = 0;
|
||||
pass_path(path, sizeof path, ".pos");
|
||||
char *pos = read_file(path, NULL);
|
||||
if (!pos || sscanf(pos, "%d %ld", &next, &at) != 2 || next < 0 || at < 0 || fseek(g_pass, at, SEEK_SET) != 0) {
|
||||
next = 0;
|
||||
fseek(g_pass, 0, SEEK_SET);
|
||||
}
|
||||
free(pos);
|
||||
g_nall = g_total = (int)jget_num(head, "total", 0);
|
||||
g_next = g_done = next;
|
||||
cJSON_Delete(g_pass_counts);
|
||||
g_pass_counts = cJSON_DetachItemFromObjectCaseSensitive(head, "counts");
|
||||
cJSON_Delete(head);
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* While RomM lists the games: each platform's first covers (its tile) in one
|
||||
* file, the rest in another. */
|
||||
struct pass_build { FILE *heads, *rest; int n, in_plat; };
|
||||
|
||||
static int collect_cover(const cJSON *it, const cJSON *page, int first, void *ctx) {
|
||||
(void)page, (void)first;
|
||||
if (!it) return 0;
|
||||
const char *p = jget_str(it, "path_cover_small", "");
|
||||
if (!valid_path(p)) return 0;
|
||||
struct { char **list; int n, cap; } *c = ctx;
|
||||
if (c->n == c->cap) {
|
||||
c->cap = c->cap ? c->cap * 2 : 512;
|
||||
char **grown = realloc(c->list, sizeof *grown * (size_t)c->cap);
|
||||
if (!grown) return -1;
|
||||
c->list = grown;
|
||||
}
|
||||
c->list[c->n++] = strdup(p);
|
||||
if (!valid_path(p) || strlen(p) >= 500 || strchr(p, '\n')) return 0;
|
||||
struct pass_build *b = ctx;
|
||||
if (fprintf(b->in_plat++ < 3 ? b->heads : b->rest, "%s\n", p) < 0) return -1;
|
||||
b->n++;
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* Every game's cover path, platform by platform. 0 when RomM gave the whole
|
||||
* list; -1 if it couldn't be reached part way (what came is still used). */
|
||||
static int list_all(void) {
|
||||
struct { char **list; int n, cap; } c = {0};
|
||||
static int by_str(const void *a, const void *b) { return strcmp(*(char *const *)a, *(char *const *)b); }
|
||||
|
||||
/* Goes through one file's lines, leaving out those in mine (sorted): with
|
||||
* out, copies the rest onto it, and says how many; without, marks in seen
|
||||
* the ones of mine it came across. */
|
||||
static int merge_lines(FILE *out, const char *path, char **mine, int nmine, char *seen) {
|
||||
char line[512];
|
||||
FILE *in = fopen(path, "r");
|
||||
int n = 0;
|
||||
while (in && fgets(line, sizeof line, in)) {
|
||||
line[strcspn(line, "\r\n")] = 0;
|
||||
const char *key = line;
|
||||
char **hit = nmine ? bsearch(&key, mine, (size_t)nmine, sizeof *mine, by_str) : NULL;
|
||||
if (hit && seen) seen[hit - mine] = 1;
|
||||
if (hit || !out) continue;
|
||||
fprintf(out, "%s\n", line);
|
||||
n++;
|
||||
}
|
||||
if (in) fclose(in);
|
||||
return n;
|
||||
}
|
||||
|
||||
/* The covers of the games on this console, sorted, without repeats. */
|
||||
static char **my_covers(int *n) {
|
||||
char **mine = NULL;
|
||||
int cap = 0;
|
||||
*n = 0;
|
||||
state_lock();
|
||||
const cJSON *e;
|
||||
cJSON_ArrayForEach(e, state_section("roms")) {
|
||||
const char *cover = jget_str(e, "cover", "");
|
||||
if (!valid_path(cover) || !(file_exists(e->string) || dir_exists(e->string))) continue;
|
||||
if (*n == cap) {
|
||||
char **grown = realloc(mine, sizeof *mine * (size_t)(cap = cap ? cap * 2 : 64));
|
||||
if (!grown) break;
|
||||
mine = grown;
|
||||
}
|
||||
if ((mine[*n] = strdup(cover))) (*n)++;
|
||||
}
|
||||
state_unlock();
|
||||
if (*n) qsort(mine, (size_t)*n, sizeof *mine, by_str);
|
||||
int kept = 0;
|
||||
for (int i = 0; i < *n; i++) {
|
||||
if (kept && !strcmp(mine[kept - 1], mine[i])) free(mine[i]);
|
||||
else mine[kept++] = mine[i];
|
||||
}
|
||||
*n = kept;
|
||||
return mine;
|
||||
}
|
||||
|
||||
/* Every game's cover path, platform by platform, into a new pass on disk;
|
||||
* all: even the platforms whose game count didn't change. The covers seen
|
||||
* first go first: the games on this console, then each platform's tile, then
|
||||
* the rest in RomM's order. 0 when RomM gave the whole list; -1 if it couldn't
|
||||
* be reached part way (what came is still used). */
|
||||
static int list_all(int all) {
|
||||
char heads[PATH_MAX_LEN], rest[PATH_MAX_LEN], fresh[PATH_MAX_LEN], path[PATH_MAX_LEN];
|
||||
pass_path(heads, sizeof heads, ".heads");
|
||||
pass_path(rest, sizeof rest, ".rest");
|
||||
pass_path(fresh, sizeof fresh, ".new");
|
||||
mkdir_parent(heads);
|
||||
struct pass_build b = {fopen(heads, "w"), fopen(rest, "w"), 0, 0};
|
||||
if (!b.heads || !b.rest) {
|
||||
if (b.heads) fclose(b.heads);
|
||||
if (b.rest) fclose(b.rest);
|
||||
LOGW("covers: can't write %s", heads);
|
||||
return -1;
|
||||
}
|
||||
long st = 0;
|
||||
cJSON *plats = romm_call("GET", "/api/platforms", NULL, &st), *p;
|
||||
int ok = st == 200 && cJSON_IsArray(plats);
|
||||
int ok = st == 200 && cJSON_IsArray(plats), same = 0;
|
||||
state_lock();
|
||||
cJSON *had = cJSON_Duplicate(cJSON_GetObjectItemCaseSensitive(state_section("covers"), "counts"), 1);
|
||||
state_unlock();
|
||||
cJSON *counts = cJSON_CreateObject();
|
||||
cJSON_ArrayForEach(p, plats) {
|
||||
if (jget_num(p, "rom_count", 0) <= 0) continue;
|
||||
char path[160];
|
||||
snprintf(path, sizeof path, "/api/roms?platform_ids=%d&order_by=name&order_dir=asc&with_char_index=false" ROMS_LEAN, (int)jget_num(p, "id", 0));
|
||||
if (romm_list(path, 0, 100000, collect_cover, &c, &st) < 0) ok = 0;
|
||||
const double n = jget_num(p, "rom_count", 0);
|
||||
if (n <= 0) continue;
|
||||
char id[24], list[160];
|
||||
snprintf(id, sizeof id, "%d", (int)jget_num(p, "id", 0));
|
||||
if (!all && jget_num(had, id, -1) == n) {
|
||||
cJSON_AddNumberToObject(counts, id, n);
|
||||
same++;
|
||||
continue;
|
||||
}
|
||||
snprintf(list, sizeof list, "/api/roms?platform_ids=%s&order_by=name&order_dir=asc&with_char_index=false" ROMS_LEAN, id);
|
||||
b.in_plat = 0;
|
||||
if (romm_list(list, 0, 100000, collect_cover, &b, &st) < 0) ok = 0;
|
||||
else cJSON_AddNumberToObject(counts, id, n);
|
||||
}
|
||||
cJSON_Delete(had);
|
||||
cJSON_Delete(plats);
|
||||
const int written = fclose(b.heads) == 0 && fclose(b.rest) == 0;
|
||||
|
||||
FILE *out = written ? fopen(fresh, "w") : NULL;
|
||||
int total = 0, nmine = 0;
|
||||
if (out) {
|
||||
/* This console's games first, those RomM listed (a cover there since
|
||||
* replaced has a new path), then the tiles and the rest without them. */
|
||||
char **mine = my_covers(&nmine);
|
||||
char *seen = calloc((size_t)nmine + 1, 1);
|
||||
merge_lines(NULL, heads, mine, seen ? nmine : 0, seen);
|
||||
merge_lines(NULL, rest, mine, seen ? nmine : 0, seen);
|
||||
for (int i = 0; seen && i < nmine; i++)
|
||||
if (seen[i]) {
|
||||
fprintf(out, "%s\n", mine[i]);
|
||||
total++;
|
||||
}
|
||||
total += merge_lines(out, heads, mine, seen ? nmine : 0, NULL);
|
||||
total += merge_lines(out, rest, mine, seen ? nmine : 0, NULL);
|
||||
for (int i = 0; i < nmine; i++) free(mine[i]);
|
||||
free(mine);
|
||||
free(seen);
|
||||
if (fclose(out) != 0) total = -1;
|
||||
}
|
||||
unlink(heads);
|
||||
unlink(rest);
|
||||
cJSON *head = cJSON_CreateObject();
|
||||
cJSON_AddNumberToObject(head, "total", total);
|
||||
cJSON_AddItemToObject(head, "counts", counts);
|
||||
char *js = cJSON_PrintUnformatted(head);
|
||||
cJSON_Delete(head);
|
||||
|
||||
pthread_mutex_lock(&g_lock);
|
||||
for (int i = 0; i < g_nall; i++) free(g_all[i]);
|
||||
free(g_all);
|
||||
g_all = c.list;
|
||||
g_nall = c.n;
|
||||
g_next = g_done = 0;
|
||||
drop_pass();
|
||||
int opened = -1;
|
||||
pass_path(path, sizeof path, "");
|
||||
if (total >= 0 && js && rename(fresh, path) == 0) {
|
||||
char meta[PATH_MAX_LEN];
|
||||
pass_path(meta, sizeof meta, ".json");
|
||||
if (write_file_atomic(meta, js, strlen(js)) == 0) opened = open_pass();
|
||||
}
|
||||
if (opened != 0) drop_pass();
|
||||
pthread_mutex_unlock(&g_lock);
|
||||
LOGI("covers: %d games to check%s", c.n, ok ? "" : " (RomM couldn't be reached for all of them)");
|
||||
unlink(fresh);
|
||||
free(js);
|
||||
if (opened != 0) {
|
||||
LOGW("covers: couldn't keep the list of %d games to check", b.n);
|
||||
return -1;
|
||||
}
|
||||
LOGI("covers: %d games to check%s%s", total, ok ? "" : " (RomM couldn't be reached for all of them)",
|
||||
same ? ", the platforms with as many games as last time skipped" : "");
|
||||
return ok ? 0 : -1;
|
||||
}
|
||||
|
||||
/* The next cover to fetch: the pages being looked at first, then the full
|
||||
* pass. "" when there's nothing to do. */
|
||||
* pass, which waits while the library is being looked at. "" when there's
|
||||
* nothing to do. */
|
||||
static void next_job(char *out, size_t n, int *from_pass) {
|
||||
out[0] = 0;
|
||||
*from_pass = 0;
|
||||
@@ -313,9 +532,16 @@ static void next_job(char *out, size_t n, int *from_pass) {
|
||||
str_copy(out, n, g_warm[0]);
|
||||
g_nwarm--;
|
||||
memmove(g_warm[0], g_warm[1], sizeof g_warm[0] * (size_t)g_nwarm);
|
||||
} else if (g_next < g_nall) {
|
||||
str_copy(out, n, g_all[g_next++]);
|
||||
*from_pass = 1;
|
||||
} else if (g_pass && g_next < g_nall && time(NULL) - g_browse_at >= BROWSE_PAUSE_SEC) {
|
||||
char line[512];
|
||||
if (fgets(line, sizeof line, g_pass)) {
|
||||
line[strcspn(line, "\r\n")] = 0;
|
||||
str_copy(out, n, line);
|
||||
*from_pass = 1;
|
||||
if (++g_next % 25 == 0) save_pos();
|
||||
} else {
|
||||
g_next = g_nall; /* shorter than it said: it's done anyway */
|
||||
}
|
||||
}
|
||||
pthread_mutex_unlock(&g_lock);
|
||||
}
|
||||
@@ -335,7 +561,10 @@ static void *worker(void *arg) {
|
||||
next_job(p, sizeof p, &from_pass);
|
||||
if (!p[0]) {
|
||||
pthread_mutex_lock(&g_lock);
|
||||
if (g_next >= g_nall && g_nall) str_copy(g_state, sizeof g_state, "done");
|
||||
if (g_next >= g_nall && g_nall) {
|
||||
str_copy(g_state, sizeof g_state, "done");
|
||||
drop_pass();
|
||||
}
|
||||
struct timespec until;
|
||||
clock_gettime(CLOCK_REALTIME, &until);
|
||||
until.tv_sec += 30;
|
||||
@@ -351,7 +580,7 @@ static void *worker(void *arg) {
|
||||
/* Stop short of the limit, so pruning never undoes the pass. */
|
||||
if (used >= 0 && used > cache_limit() * 9 / 10) {
|
||||
pthread_mutex_lock(&g_lock);
|
||||
g_next = g_nall;
|
||||
drop_pass();
|
||||
str_copy(g_state, sizeof g_state, "full");
|
||||
pthread_mutex_unlock(&g_lock);
|
||||
LOGW("covers: the cache is nearly full, the rest wait");
|
||||
@@ -368,7 +597,26 @@ static void *worker(void *arg) {
|
||||
char *body = NULL;
|
||||
size_t len = 0;
|
||||
const char *type;
|
||||
if (covers_fetch(p, &body, &len, &type) == 0) free(body);
|
||||
char why[160];
|
||||
if (covers_fetch(p, &body, &len, &type) == 0) {
|
||||
free(body);
|
||||
} else if (http_out_of_memory()) {
|
||||
/* No room for it now: it goes first again once some is free,
|
||||
* rather than every cover after it failing the same way. */
|
||||
covers_warm(p);
|
||||
pthread_mutex_lock(&g_lock);
|
||||
str_copy(g_state, sizeof g_state, "waiting");
|
||||
pthread_mutex_unlock(&g_lock);
|
||||
sleep(10);
|
||||
} else if (romm_no_answer(why, sizeof why)) {
|
||||
/* Not skipped: it goes first again, once RomM answers (this
|
||||
* worker's next try finds out). */
|
||||
covers_warm(p);
|
||||
pthread_mutex_lock(&g_lock);
|
||||
str_copy(g_state, sizeof g_state, "waiting");
|
||||
pthread_mutex_unlock(&g_lock);
|
||||
sleep(5);
|
||||
}
|
||||
}
|
||||
if (from_pass) {
|
||||
pthread_mutex_lock(&g_lock);
|
||||
@@ -383,6 +631,9 @@ static void *worker(void *arg) {
|
||||
static void *planner(void *arg) {
|
||||
(void)arg;
|
||||
cache_scan(0);
|
||||
pthread_mutex_lock(&g_lock);
|
||||
if (open_pass() == 0 && g_next < g_nall) LOGI("covers: carrying on, %d of %d games left to check", g_nall - g_next, g_nall);
|
||||
pthread_mutex_unlock(&g_lock);
|
||||
/* How often it looks for work; ROMM_SYNC_COVERS_TICK shortens it in tests. */
|
||||
const char *env = getenv("ROMM_SYNC_COVERS_TICK");
|
||||
unsigned tick = env && atoi(env) > 0 ? (unsigned)atoi(env) : 20;
|
||||
@@ -398,7 +649,17 @@ static void *planner(void *arg) {
|
||||
pthread_mutex_lock(&g_lock);
|
||||
int running = g_next < g_nall, asked = g_refresh;
|
||||
g_refresh = 0;
|
||||
cJSON *counts = running ? NULL : g_pass_counts;
|
||||
if (counts) g_pass_counts = NULL;
|
||||
pthread_mutex_unlock(&g_lock);
|
||||
if (counts) { /* a pass's covers are done */
|
||||
state_lock();
|
||||
cJSON *cs = state_section("covers");
|
||||
cJSON_DeleteItemFromObjectCaseSensitive(cs, "counts");
|
||||
cJSON_AddItemToObject(cs, "counts", counts);
|
||||
state_save();
|
||||
state_unlock();
|
||||
}
|
||||
if (running) continue;
|
||||
state_lock();
|
||||
double last = jget_num(state_section("covers"), "last_full", 0);
|
||||
@@ -407,7 +668,7 @@ static void *planner(void *arg) {
|
||||
if (!asked && time(NULL) < retry_at) continue;
|
||||
/* A pass RomM couldn't list in full isn't counted: it's tried again in
|
||||
* a few minutes, not a day. */
|
||||
if (list_all() == 0) {
|
||||
if (list_all(asked) == 0) {
|
||||
state_lock();
|
||||
jset_num(state_section("covers"), "last_full", (double)time(NULL));
|
||||
state_save();
|
||||
@@ -435,7 +696,7 @@ cJSON *covers_status(void) {
|
||||
pthread_mutex_lock(&g_lock);
|
||||
cJSON_AddStringToObject(j, "state", g_state);
|
||||
cJSON_AddNumberToObject(j, "done", g_done);
|
||||
cJSON_AddNumberToObject(j, "total", g_nall);
|
||||
cJSON_AddNumberToObject(j, "total", g_total);
|
||||
cJSON_AddNumberToObject(j, "cached_mb", g_cached_bytes > 0 ? (double)(g_cached_bytes / (1024 * 1024)) : 0);
|
||||
pthread_mutex_unlock(&g_lock);
|
||||
cJSON_AddNumberToObject(j, "limit_mb", (double)(cache_limit() / (1024 * 1024)));
|
||||
|
||||
+2
-1
@@ -15,7 +15,8 @@
|
||||
void covers_init(void);
|
||||
|
||||
/* path: a RomM asset path ("/assets/..."). 0 with the body (malloc'd, the
|
||||
* caller frees it) and its MIME type, 1 if RomM has no such asset, -1 on error. */
|
||||
* caller frees it) and its MIME type, 1 if RomM has no such asset, -1 if RomM
|
||||
* couldn't be asked this time (worth trying again), -2 for a path that isn't one. */
|
||||
int covers_fetch(const char *path, char **body, size_t *len, const char **type);
|
||||
|
||||
/* Fetches a cover ahead of time, in the background, if it isn't cached. */
|
||||
|
||||
+250
-66
@@ -13,63 +13,105 @@
|
||||
#include "detect.h"
|
||||
|
||||
#include <dirent.h>
|
||||
#include <pthread.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
#include "platform.h"
|
||||
#include "profiles.h"
|
||||
#include "ra2.h"
|
||||
#include "state.h"
|
||||
#include "util.h"
|
||||
|
||||
/* Bump this when generated profiles gain new keys, or existing installs keep
|
||||
* their old profile forever (ask me how I know). */
|
||||
#define PROFILE_GENERATION 2
|
||||
#define PROFILE_GENERATION 3
|
||||
|
||||
/* libretro database names to RomM slugs. The first slug is the folder name. */
|
||||
/* libretro database names to RomM slugs (the first is the folder name), and
|
||||
* the system's ES-DE name: PS5 RetroArch 3.0 and newer's folder for it. */
|
||||
static const struct {
|
||||
const char *db;
|
||||
const char *slugs;
|
||||
const char *esde;
|
||||
} DB_MAP[] = {
|
||||
{"Nintendo - Nintendo Entertainment System", "nes,famicom"},
|
||||
{"Nintendo - Family Computer Disk System", "fds"},
|
||||
{"Nintendo - Super Nintendo Entertainment System", "snes,sfam"},
|
||||
{"Nintendo - Satellaview", "satellaview"},
|
||||
{"Nintendo - Game Boy", "gb"},
|
||||
{"Nintendo - Game Boy Color", "gbc"},
|
||||
{"Nintendo - Game Boy Advance", "gba"},
|
||||
{"Nintendo - Nintendo 64", "n64"},
|
||||
{"Nintendo - Nintendo DS", "nds"},
|
||||
{"Nintendo - Virtual Boy", "virtualboy"},
|
||||
{"Nintendo - GameCube", "ngc,gamecube"},
|
||||
{"Nintendo - Wii", "wii"},
|
||||
{"Sega - Mega Drive - Genesis", "genesis,genesis-slash-megadrive,megadrive"},
|
||||
{"Sega - Master System - Mark III", "sms,mastersystem"},
|
||||
{"Sega - Game Gear", "gamegear,gg"},
|
||||
{"Sega - Mega-CD - Sega CD", "segacd,sega-cd"},
|
||||
{"Sega - SG-1000", "sg1000"},
|
||||
{"Sega - 32X", "sega32,sega-32x"},
|
||||
{"Sega - Saturn", "saturn"},
|
||||
{"Sega - Dreamcast", "dc,dreamcast"},
|
||||
{"Sony - PlayStation", "psx,ps1"},
|
||||
{"Sony - PlayStation 2", "ps2"},
|
||||
{"Sony - PlayStation Portable", "psp"},
|
||||
{"FBNeo - Arcade Games", "arcade,fbneo,neogeoaes,neogeomvs,cps1,cps2,cps3"},
|
||||
{"MAME", "arcade,mame"},
|
||||
{"NEC - PC Engine - TurboGrafx 16", "tg16,turbografx16--1,pce,pcengine"},
|
||||
{"NEC - PC Engine CD - TurboGrafx-CD", "turbografx-cd,pcecd"},
|
||||
{"SNK - Neo Geo Pocket", "neo-geo-pocket,ngp"},
|
||||
{"SNK - Neo Geo Pocket Color", "neo-geo-pocket-color,ngpc"},
|
||||
{"Bandai - WonderSwan", "wonderswan,ws"},
|
||||
{"Bandai - WonderSwan Color", "wonderswan-color,wsc"},
|
||||
{"Atari - 2600", "atari2600"},
|
||||
{"Atari - 7800", "atari7800"},
|
||||
{"Atari - Lynx", "lynx"},
|
||||
{"Commodore - 64", "c64"},
|
||||
{"Commodore - Amiga", "amiga"},
|
||||
{"DOS", "dos"},
|
||||
{"Nintendo - Nintendo Entertainment System", "nes,famicom", "nes"},
|
||||
{"Nintendo - Family Computer Disk System", "fds", "fds"},
|
||||
{"Nintendo - Super Nintendo Entertainment System", "snes,sfam", "snes"},
|
||||
{"Nintendo - Satellaview", "satellaview", "satellaview"},
|
||||
{"Nintendo - Game Boy", "gb", "gb"},
|
||||
{"Nintendo - Game Boy Color", "gbc", "gbc"},
|
||||
{"Nintendo - Game Boy Advance", "gba", "gba"},
|
||||
{"Nintendo - Nintendo 64", "n64", "n64"},
|
||||
{"Nintendo - Nintendo DS", "nds", "nds"},
|
||||
{"Nintendo - Virtual Boy", "virtualboy", "virtualboy"},
|
||||
{"Nintendo - GameCube", "ngc,gamecube", "gc"},
|
||||
{"Nintendo - Wii", "wii", "wii"},
|
||||
{"Sega - Mega Drive - Genesis", "genesis,genesis-slash-megadrive,megadrive", "genesis"},
|
||||
{"Sega - Master System - Mark III", "sms,mastersystem", "mastersystem"},
|
||||
{"Sega - Game Gear", "gamegear,gg", "gamegear"},
|
||||
{"Sega - Mega-CD - Sega CD", "segacd,sega-cd", "segacd"},
|
||||
{"Sega - SG-1000", "sg1000", "sg-1000"},
|
||||
{"Sega - 32X", "sega32,sega-32x", "sega32x"},
|
||||
{"Sega - Saturn", "saturn", "saturn"},
|
||||
{"Sega - Dreamcast", "dc,dreamcast", "dreamcast"},
|
||||
{"Sony - PlayStation", "psx,ps1", "psx"},
|
||||
{"Sony - PlayStation 2", "ps2", "ps2"},
|
||||
{"Sony - PlayStation Portable", "psp", "psp"},
|
||||
{"FBNeo - Arcade Games", "arcade,fbneo,neogeoaes,neogeomvs,cps1,cps2,cps3", "fbneo"},
|
||||
{"MAME", "arcade,mame", "mame"},
|
||||
{"NEC - PC Engine - TurboGrafx 16", "tg16,turbografx16--1,pce,pcengine", "pcengine"},
|
||||
{"NEC - PC Engine CD - TurboGrafx-CD", "turbografx-cd,pcecd", "pcenginecd"},
|
||||
{"SNK - Neo Geo Pocket", "neo-geo-pocket,ngp", "ngp"},
|
||||
{"SNK - Neo Geo Pocket Color", "neo-geo-pocket-color,ngpc", "ngpc"},
|
||||
{"Bandai - WonderSwan", "wonderswan,ws", "wonderswan"},
|
||||
{"Bandai - WonderSwan Color", "wonderswan-color,wsc", "wonderswancolor"},
|
||||
{"Atari - 2600", "atari2600", "atari2600"},
|
||||
{"Atari - 7800", "atari7800", "atari7800"},
|
||||
{"Atari - Lynx", "lynx", "atarilynx"},
|
||||
{"Commodore - 64", "c64", "c64"},
|
||||
{"Commodore - Amiga", "amiga", "amiga"},
|
||||
{"DOS", "dos", "dos"},
|
||||
{"Nintendo - Nintendo 3DS", "3ds", "n3ds"},
|
||||
{"Nintendo - Pokemon Mini", "pokemon-mini", "pokemini"},
|
||||
{"Nintendo - Sufami Turbo", "sufami-turbo", "sufami"},
|
||||
{"Nintendo - Wii (Digital)", "wii", "wii"},
|
||||
{"NEC - PC Engine SuperGrafx", "supergrafx", "supergrafx"},
|
||||
{"NEC - PC-FX", "pc-fx", "pcfx"},
|
||||
{"SNK - Neo Geo CD", "neo-geo-cd", "neogeocd"},
|
||||
{"The 3DO Company - 3DO", "3do", "3do"},
|
||||
{"Atari - 5200", "atari5200", "atari5200"},
|
||||
{"Atari - Jaguar", "jaguar", "atarijaguar"},
|
||||
{"Sega - PICO", "sega-pico", "pico"},
|
||||
{"Commodore - CD32", "amiga-cd32", "amigacd32"},
|
||||
{"Commodore - CDTV", "commodore-cdtv", "cdtv"},
|
||||
{"ScummVM", "scummvm", "scummvm"},
|
||||
};
|
||||
|
||||
const char *detect_esde_name(const cJSON *entry) {
|
||||
const char *db = detect_db_name(entry);
|
||||
for (size_t i = 0; db && i < sizeof DB_MAP / sizeof DB_MAP[0]; i++)
|
||||
if (!strcmp(DB_MAP[i].db, db)) return DB_MAP[i].esde;
|
||||
return NULL;
|
||||
}
|
||||
|
||||
const char *detect_db_name(const cJSON *entry) {
|
||||
const char *db = jget_str(entry, "db", NULL);
|
||||
if (db) return db;
|
||||
if (str_ieq(jget_str(entry, "core_name", ""), "MAME")) return "MAME"; /* arcade, like FBNeo */
|
||||
const cJSON *slug;
|
||||
cJSON_ArrayForEach(slug, cJSON_GetObjectItemCaseSensitive(entry, "romm")) {
|
||||
if (!cJSON_IsString(slug)) continue;
|
||||
for (size_t i = 0; i < sizeof DB_MAP / sizeof DB_MAP[0]; i++) {
|
||||
char slugs[256], *save = NULL;
|
||||
str_copy(slugs, sizeof slugs, DB_MAP[i].slugs);
|
||||
for (char *t = strtok_r(slugs, ",", &save); t; t = strtok_r(NULL, ",", &save))
|
||||
if (str_ieq(t, slug->valuestring)) return DB_MAP[i].db;
|
||||
}
|
||||
}
|
||||
return NULL;
|
||||
}
|
||||
|
||||
/* Value of key = "value" in a libretro .info file. */
|
||||
static int info_get(const char *txt, const char *key, char *out, size_t n) {
|
||||
size_t kl = strlen(key);
|
||||
@@ -104,16 +146,43 @@ static cJSON *slug_array(const char *csv) {
|
||||
return a;
|
||||
}
|
||||
|
||||
/* The save format a RetroArch core keeps, where another emulator keeps the
|
||||
* same: a raw 128 KB PS1 memory card, or N64's combined .srm (EEPROM, four
|
||||
* controller paks, SRAM and flash). */
|
||||
static const char *save_format_of(const char *system, const char *corename) {
|
||||
if (!strcmp(system, "psx") && (strstr(corename, "Beetle PSX") || str_ieq(corename, "PCSX-ReARMed") ||
|
||||
str_ieq(corename, "SwanStation")))
|
||||
return "psx-card";
|
||||
if (!strcmp(system, "n64") && (strstr(corename, "ParaLLEl") || strstr(corename, "Mupen64Plus")))
|
||||
return "n64-srm";
|
||||
return NULL;
|
||||
/* What a core needs beyond the defaults, from platform/retroarch_cores.json:
|
||||
* the first rule whose names (or "contains") and systems match adds its
|
||||
* "entry" keys to the core's platform entry. */
|
||||
#include "retroarch_cores_json.h" /* generated from platform/retroarch_cores.json */
|
||||
|
||||
static int name_in(const cJSON *list, const char *name, int contains) {
|
||||
const cJSON *n;
|
||||
cJSON_ArrayForEach(n, list)
|
||||
if (cJSON_IsString(n) && (contains ? strstr(name, n->valuestring) != NULL : str_ieq(name, n->valuestring))) return 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* Adds the matching rule's keys to e: all of them, or (missing_only) those e
|
||||
* doesn't have, for a profile saved before the rule had them. How many. */
|
||||
static int apply_core_rules(cJSON *e, const char *system, const char *corename, int missing_only) {
|
||||
int added = 0;
|
||||
static cJSON *rules;
|
||||
static pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;
|
||||
pthread_mutex_lock(&lock);
|
||||
if (!rules) rules = cJSON_Parse(RETROARCH_CORES_JSON);
|
||||
const cJSON *r;
|
||||
cJSON_ArrayForEach(r, cJSON_GetObjectItemCaseSensitive(rules, "rules")) {
|
||||
const cJSON *names = cJSON_GetObjectItemCaseSensitive(r, "names"), *has = cJSON_GetObjectItemCaseSensitive(r, "contains"),
|
||||
*systems = cJSON_GetObjectItemCaseSensitive(r, "systems");
|
||||
if ((names || has) && !name_in(names, corename, 0) && !name_in(has, corename, 1)) continue;
|
||||
if (systems && !name_in(systems, system, 0)) continue;
|
||||
const cJSON *k;
|
||||
cJSON_ArrayForEach(k, cJSON_GetObjectItemCaseSensitive(r, "entry")) {
|
||||
if (missing_only && cJSON_GetObjectItemCaseSensitive(e, k->string)) continue;
|
||||
cJSON_DeleteItemFromObjectCaseSensitive(e, k->string);
|
||||
cJSON_AddItemToObject(e, k->string, cJSON_Duplicate(k, 1));
|
||||
added++;
|
||||
}
|
||||
break;
|
||||
}
|
||||
pthread_mutex_unlock(&lock);
|
||||
return added;
|
||||
}
|
||||
|
||||
/* One platform entry per system; the first core found for a system wins. */
|
||||
@@ -141,25 +210,12 @@ static void add_core_platforms(cJSON *plats, cJSON *cores, const char *core_id,
|
||||
cJSON *e = cJSON_CreateObject();
|
||||
cJSON_AddItemToObject(e, "romm", slug_array(slugs));
|
||||
cJSON_AddStringToObject(e, "dir", first);
|
||||
cJSON_AddStringToObject(e, "db", db);
|
||||
cJSON_AddStringToObject(e, "emulator", core_id);
|
||||
cJSON_AddStringToObject(e, "core_name", corename);
|
||||
/* Saves other emulators keep in the same format share one save on RomM. */
|
||||
const char *format = save_format_of(first, corename);
|
||||
if (format) cJSON_AddStringToObject(e, "save_format", format);
|
||||
if (str_ieq(corename, "LRPS2") || str_ieq(corename, "PCSX2")) {
|
||||
/* Per-game cards (<game>.ps2) sync like any other save. */
|
||||
cJSON_AddStringToObject(e, "save_exts", ".ps2");
|
||||
cJSON *req = cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(req, "key", "pcsx2_shared_memory_cards");
|
||||
cJSON_AddStringToObject(req, "value", "disabled");
|
||||
cJSON_AddStringToObject(req, "default", "enabled");
|
||||
cJSON_AddStringToObject(req, "hint", "LRPS2 uses one shared memory card for all games. Turn off Shared Memory Cards in the core options to give each game its own card, which then syncs.");
|
||||
cJSON_AddItemToObject(e, "sync_requires_option", req);
|
||||
} else if (str_ieq(corename, "PPSSPP")) {
|
||||
cJSON_AddStringToObject(e, "save_layout", "psp");
|
||||
} else if (str_ieq(corename, "Dolphin")) {
|
||||
cJSON_AddStringToObject(e, "save_layout", !strcmp(first, "wii") ? "wii" : "gci");
|
||||
}
|
||||
/* Saves other emulators keep in the same format (save_format) share
|
||||
* one save on RomM; save files, cards and layouts per core. */
|
||||
apply_core_rules(e, first, corename, 0);
|
||||
cJSON_AddItemToArray(plats, e);
|
||||
}
|
||||
}
|
||||
@@ -207,6 +263,9 @@ static cJSON *retroarch_candidate(const char *root) {
|
||||
|
||||
char name[128] = "RetroArch", title_id[16] = "";
|
||||
app_title(root, name, sizeof name, title_id, sizeof title_id);
|
||||
/* No app folder to name it (the PS4's builds): told apart by their folder. */
|
||||
if (!title_id[0] && strcmp(path_basename(root), "retroarch") != 0)
|
||||
snprintf(name, sizeof name, "RetroArch (%s)", path_basename(root));
|
||||
if (!strstr(name, "RetroArch") && !strstr(name, "retroarch") && !has_cfg) return NULL;
|
||||
|
||||
cJSON *plats = cJSON_CreateArray(), *cores = cJSON_CreateArray();
|
||||
@@ -226,6 +285,17 @@ static cJSON *retroarch_candidate(const char *root) {
|
||||
if (!dup) str_copy(names[n++], sizeof names[0], id);
|
||||
}
|
||||
if (d) closedir(d);
|
||||
/* RetroArch 2.0's PS5SX2 replaces LRPS2, whose files an upgrade can leave behind. */
|
||||
int has_ps5sx2 = 0;
|
||||
for (int i = 0; i < n; i++) has_ps5sx2 |= !strcmp(names[i], "ps5sx2");
|
||||
if (has_ps5sx2 && ra2_is_v2(root))
|
||||
for (int i = 0; i < n; i++)
|
||||
if (!strcmp(names[i], "pcsx2")) {
|
||||
LOGI("ignoring LRPS2 at %s: RetroArch 2.0 plays PS2 with PS5SX2", root);
|
||||
memmove(names[i], names[n - 1], sizeof names[0]);
|
||||
n--;
|
||||
break;
|
||||
}
|
||||
/* Sorted so the first core per system doesn't depend on readdir order. */
|
||||
for (int i = 0; i < n; i++)
|
||||
for (int k = i + 1; k < n; k++)
|
||||
@@ -267,6 +337,7 @@ static cJSON *retroarch_candidate(const char *root) {
|
||||
} else {
|
||||
cJSON_Delete(plats); /* no .info files, keep the default platforms */
|
||||
}
|
||||
ra2_update_profile(profile, NULL);
|
||||
|
||||
cJSON *c = cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(c, "kind", "retroarch");
|
||||
@@ -323,6 +394,18 @@ static cJSON *emulator_catalog(void) {
|
||||
return plat_info()->emulator_catalog ? cJSON_Parse(plat_info()->emulator_catalog) : NULL;
|
||||
}
|
||||
|
||||
static cJSON *g_catalog;
|
||||
static void load_catalog(void) { g_catalog = emulator_catalog(); }
|
||||
|
||||
const cJSON *detect_catalog_entry(const char *id) {
|
||||
static pthread_once_t once = PTHREAD_ONCE_INIT;
|
||||
pthread_once(&once, load_catalog);
|
||||
const cJSON *e;
|
||||
cJSON_ArrayForEach(e, g_catalog)
|
||||
if (!strcmp(jget_str(e, "id", ""), id)) return e;
|
||||
return NULL;
|
||||
}
|
||||
|
||||
static void add_standalone(cJSON *out) {
|
||||
cJSON *catalog = emulator_catalog();
|
||||
const cJSON *e, *x;
|
||||
@@ -398,12 +481,100 @@ cJSON *detect_emulators(void) {
|
||||
closedir(d);
|
||||
}
|
||||
add_standalone(out);
|
||||
/* The folders each emulator uses by default, for a given platform. */
|
||||
cJSON *c;
|
||||
cJSON_ArrayForEach(c, out) {
|
||||
if (cJSON_IsFalse(cJSON_GetObjectItemCaseSensitive(c, "ready"))) continue; /* No preset, custom */
|
||||
cJSON *profile = cJSON_GetObjectItemCaseSensitive(c, "profile"), *one = cJSON_CreateArray();
|
||||
cJSON_AddItemReferenceToArray(one, profile);
|
||||
profile_map *maps = NULL;
|
||||
int n = profiles_expand(one, &maps);
|
||||
cJSON *dirs = cJSON_AddObjectToObject(c, "dirs");
|
||||
for (int i = 0; i < n; i++) {
|
||||
char key[64];
|
||||
str_copy(key, sizeof key, maps[i].romm_slugs);
|
||||
char *comma = strchr(key, ',');
|
||||
if (comma) *comma = 0;
|
||||
cJSON *d = cJSON_AddObjectToObject(dirs, key);
|
||||
cJSON_AddStringToObject(d, "rom_dir", maps[i].rom_dir);
|
||||
cJSON_AddStringToObject(d, "save_dir", maps[i].save_dir);
|
||||
cJSON_AddStringToObject(d, "state_dir", maps[i].state_dir);
|
||||
/* Games already there, so setup can pick the emulator they're in. */
|
||||
int games = 0;
|
||||
DIR *g = opendir(maps[i].rom_dir);
|
||||
struct dirent *ge;
|
||||
while (g && (ge = readdir(g)))
|
||||
if (ge->d_name[0] != '.' && !ext_in_list(ge->d_name, maps[i].save_exts) &&
|
||||
!ext_in_list(ge->d_name, maps[i].state_exts) && !ext_in_list(ge->d_name, ".part,.tmp,.txt,.png,.jpg,.xml"))
|
||||
games++;
|
||||
if (g) closedir(g);
|
||||
cJSON_AddNumberToObject(d, "games", games);
|
||||
}
|
||||
free(maps);
|
||||
cJSON_Delete(one);
|
||||
const cJSON *fixed = cJSON_GetObjectItemCaseSensitive(profile, "fixed");
|
||||
cJSON_AddItemToObject(c, "fixed", fixed ? cJSON_Duplicate(fixed, 1) : cJSON_CreateArray());
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/* Keys a newer preset has that a saved profile lacks (a state folder added
|
||||
* to Porpoise's, say) are added to it. Nothing already there changes, so
|
||||
* folders the user picked stay; but for the keys nobody can set but the
|
||||
* preset, which follow it (PSXS5's cores, as its versions change them). */
|
||||
static const char *const PRESET_ONLY[] = {"state_core_setting"};
|
||||
|
||||
static int fill_from_catalog(cJSON *p) {
|
||||
const cJSON *entry = detect_catalog_entry(jget_str(p, "id", ""));
|
||||
const cJSON *preset = entry ? cJSON_GetObjectItemCaseSensitive(entry, "profile") : NULL, *k;
|
||||
cJSON *pe;
|
||||
int added = 0;
|
||||
/* A RetroArch core's platform entry saved before its rule had a key
|
||||
* (memcards, cards...): what the rule says now. */
|
||||
cJSON_ArrayForEach(pe, cJSON_GetObjectItemCaseSensitive(p, "platforms")) {
|
||||
const cJSON *first = cJSON_GetArrayItem(cJSON_GetObjectItemCaseSensitive(pe, "romm"), 0);
|
||||
const char *core = jget_str(pe, "core_name", "");
|
||||
if (cJSON_IsString(first) && core[0]) added += apply_core_rules(pe, first->valuestring, core, 1);
|
||||
}
|
||||
if (!preset) {
|
||||
if (added) LOGI("the %s profile got %d setting%s from RetroArch's core rules", jget_str(p, "name", ""), added, added == 1 ? "" : "s");
|
||||
return added;
|
||||
}
|
||||
cJSON_ArrayForEach(k, preset) {
|
||||
if (!strcmp(k->string, "platforms")) continue;
|
||||
const cJSON *have = cJSON_GetObjectItemCaseSensitive(p, k->string);
|
||||
int preset_only = 0;
|
||||
for (size_t i = 0; i < sizeof PRESET_ONLY / sizeof PRESET_ONLY[0]; i++) preset_only |= !strcmp(k->string, PRESET_ONLY[i]);
|
||||
if (!have) {
|
||||
cJSON_AddItemToObject(p, k->string, cJSON_Duplicate(k, 1));
|
||||
added++;
|
||||
} else if (preset_only && !cJSON_Compare(have, k, 1)) {
|
||||
cJSON_ReplaceItemInObjectCaseSensitive(p, k->string, cJSON_Duplicate(k, 1));
|
||||
added++;
|
||||
}
|
||||
}
|
||||
cJSON_ArrayForEach(pe, cJSON_GetObjectItemCaseSensitive(p, "platforms")) {
|
||||
const cJSON *first = cJSON_GetArrayItem(cJSON_GetObjectItemCaseSensitive(pe, "romm"), 0), *ce;
|
||||
if (!cJSON_IsString(first)) continue;
|
||||
cJSON_ArrayForEach(ce, cJSON_GetObjectItemCaseSensitive(preset, "platforms")) {
|
||||
const cJSON *cf = cJSON_GetArrayItem(cJSON_GetObjectItemCaseSensitive(ce, "romm"), 0);
|
||||
if (!cJSON_IsString(cf) || strcmp(cf->valuestring, first->valuestring)) continue;
|
||||
cJSON_ArrayForEach(k, ce)
|
||||
if (!cJSON_GetObjectItemCaseSensitive(pe, k->string)) {
|
||||
cJSON_AddItemToObject(pe, k->string, cJSON_Duplicate(k, 1));
|
||||
added++;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (added) LOGI("the %s profile got %d setting%s from its newer preset", jget_str(p, "name", ""), added, added == 1 ? "" : "s");
|
||||
return added;
|
||||
}
|
||||
|
||||
int detect_refresh_profiles(cJSON *profiles) {
|
||||
int changed = 0;
|
||||
cJSON *found = NULL;
|
||||
cJSON *each;
|
||||
cJSON_ArrayForEach(each, profiles) changed += fill_from_catalog(each) > 0;
|
||||
for (int i = 0; i < cJSON_GetArraySize(profiles); i++) {
|
||||
cJSON *p = cJSON_GetArrayItem(profiles, i);
|
||||
const char *id = jget_str(p, "id", "");
|
||||
@@ -420,6 +591,19 @@ int detect_refresh_profiles(cJSON *profiles) {
|
||||
/* The user's choice of emulator per system survives a refresh. */
|
||||
const cJSON *ex = cJSON_GetObjectItemCaseSensitive(p, "exclude");
|
||||
if (ex) cJSON_AddItemToObject(fresh, "exclude", cJSON_Duplicate(ex, 1));
|
||||
const cJSON *extra = cJSON_GetObjectItemCaseSensitive(p, "extra");
|
||||
if (extra) cJSON_AddItemToObject(fresh, "extra", cJSON_Duplicate(extra, 1));
|
||||
const cJSON *mode = cJSON_GetObjectItemCaseSensitive(p, "sync");
|
||||
if (cJSON_IsString(mode)) cJSON_AddStringToObject(fresh, "sync", mode->valuestring);
|
||||
/* ... and each platform's. */
|
||||
cJSON *fe;
|
||||
cJSON_ArrayForEach(fe, cJSON_GetObjectItemCaseSensitive(fresh, "platforms")) {
|
||||
const cJSON *pe, *m;
|
||||
cJSON_ArrayForEach(pe, cJSON_GetObjectItemCaseSensitive(p, "platforms"))
|
||||
if (!strcmp(profiles_system_key(pe), profiles_system_key(fe)) &&
|
||||
cJSON_IsString(m = cJSON_GetObjectItemCaseSensitive(pe, "sync")))
|
||||
cJSON_AddStringToObject(fe, "sync", m->valuestring);
|
||||
}
|
||||
LOGI("updated the %s profile for %s", jget_str(fresh, "name", ""), jget_str(p, "root", ""));
|
||||
cJSON_ReplaceItemInArray(profiles, i, fresh);
|
||||
changed++;
|
||||
|
||||
@@ -11,4 +11,15 @@ cJSON *detect_emulators(void);
|
||||
* features. Profiles written by hand are left alone. Returns how many changed. */
|
||||
int detect_refresh_profiles(cJSON *profiles);
|
||||
|
||||
/* The built-in emulator catalog entry with this id (emulators.json), or NULL.
|
||||
* Parsed once; don't free. */
|
||||
const cJSON *detect_catalog_entry(const char *id);
|
||||
|
||||
/* The libretro database a RetroArch platform entry plays ("Sony - PlayStation
|
||||
* 2"): its own "db", else the first that takes one of its RomM slugs. NULL if
|
||||
* none does. */
|
||||
const char *detect_db_name(const cJSON *entry);
|
||||
/* The system's ES-DE name ("psx", "gc", "dreamcast"): RetroArch 3.0+'s folder. */
|
||||
const char *detect_esde_name(const cJSON *entry);
|
||||
|
||||
#endif
|
||||
+134
@@ -0,0 +1,134 @@
|
||||
#include "events.h"
|
||||
|
||||
#include <pthread.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <time.h>
|
||||
|
||||
#include "platform.h"
|
||||
#include "state.h"
|
||||
#include "util.h"
|
||||
|
||||
static pthread_mutex_t g_lock = PTHREAD_MUTEX_INITIALIZER;
|
||||
static cJSON *g_events; /* oldest first */
|
||||
static char g_sync[64] = "";
|
||||
static int g_dirty;
|
||||
static unsigned g_seq; /* entries ever added */
|
||||
|
||||
static void path_of(char *out, size_t n) { path_join(out, n, plat_data_dir(), "events.json"); }
|
||||
|
||||
void events_load(void) {
|
||||
char path[PATH_MAX_LEN];
|
||||
path_of(path, sizeof path);
|
||||
char *txt = read_file(path, NULL);
|
||||
cJSON *j = txt ? cJSON_Parse(txt) : NULL;
|
||||
free(txt);
|
||||
pthread_mutex_lock(&g_lock);
|
||||
cJSON_Delete(g_events);
|
||||
g_events = cJSON_IsArray(j) ? j : cJSON_CreateArray();
|
||||
if (!cJSON_IsArray(j)) cJSON_Delete(j);
|
||||
pthread_mutex_unlock(&g_lock);
|
||||
}
|
||||
|
||||
void events_save(void) {
|
||||
pthread_mutex_lock(&g_lock);
|
||||
char *txt = g_dirty && g_events ? cJSON_PrintUnformatted(g_events) : NULL;
|
||||
g_dirty = 0;
|
||||
pthread_mutex_unlock(&g_lock);
|
||||
if (!txt) return;
|
||||
char path[PATH_MAX_LEN];
|
||||
path_of(path, sizeof path);
|
||||
if (write_file_atomic(path, txt, strlen(txt)) != 0) LOGW("could not write %s", path);
|
||||
free(txt);
|
||||
}
|
||||
|
||||
void events_set_sync(const char *reason) {
|
||||
pthread_mutex_lock(&g_lock);
|
||||
str_copy(g_sync, sizeof g_sync, reason ? reason : "");
|
||||
pthread_mutex_unlock(&g_lock);
|
||||
}
|
||||
|
||||
void events_add(const char *kind, const char *action, int rom_id, const char *path, const char *emulator,
|
||||
const char *detail) {
|
||||
cJSON *e = cJSON_CreateObject();
|
||||
cJSON_AddNumberToObject(e, "t", (double)time(NULL));
|
||||
cJSON_AddNumberToObject(e, "rom_id", rom_id);
|
||||
cJSON_AddStringToObject(e, "kind", kind);
|
||||
cJSON_AddStringToObject(e, "action", action);
|
||||
cJSON_AddStringToObject(e, "path", path ? path : "");
|
||||
cJSON_AddStringToObject(e, "emulator", emulator ? emulator : "");
|
||||
if (detail && detail[0]) cJSON_AddStringToObject(e, "detail", detail);
|
||||
pthread_mutex_lock(&g_lock);
|
||||
if (!g_events) g_events = cJSON_CreateArray();
|
||||
cJSON_AddStringToObject(e, "sync", g_sync);
|
||||
cJSON_AddItemToArray(g_events, e);
|
||||
g_seq++;
|
||||
while (cJSON_GetArraySize(g_events) > EVENTS_MAX) cJSON_DeleteItemFromArray(g_events, 0);
|
||||
g_dirty = 1;
|
||||
pthread_mutex_unlock(&g_lock);
|
||||
}
|
||||
|
||||
unsigned events_seq(void) {
|
||||
pthread_mutex_lock(&g_lock);
|
||||
unsigned s = g_seq;
|
||||
pthread_mutex_unlock(&g_lock);
|
||||
return s;
|
||||
}
|
||||
|
||||
int events_games(unsigned since, int max, char *out, size_t n) {
|
||||
int ids[64], nids = 0;
|
||||
pthread_mutex_lock(&g_lock);
|
||||
int size = g_events ? cJSON_GetArraySize(g_events) : 0;
|
||||
unsigned added = g_seq - since;
|
||||
for (int i = added < (unsigned)size ? size - (int)added : 0; i < size && nids < 64; i++) {
|
||||
const cJSON *e = cJSON_GetArrayItem(g_events, i);
|
||||
const char *a = jget_str(e, "action", "");
|
||||
int id = (int)jget_num(e, "rom_id", 0), seen = 0;
|
||||
if (!id || !(!strcmp(a, "uploaded") || !strcmp(a, "downloaded") || !strcmp(a, "conflict") || !strcmp(a, "failed")))
|
||||
continue;
|
||||
for (int k = 0; k < nids; k++) seen |= ids[k] == id;
|
||||
if (!seen) ids[nids++] = id;
|
||||
}
|
||||
pthread_mutex_unlock(&g_lock);
|
||||
size_t o = 0;
|
||||
int named = 0, more = 0;
|
||||
out[0] = 0;
|
||||
state_lock();
|
||||
const cJSON *roms = state_section("roms"), *r;
|
||||
for (int k = 0; k < nids; k++) {
|
||||
const char *name = "";
|
||||
cJSON_ArrayForEach(r, roms) if ((int)jget_num(r, "rom_id", 0) == ids[k] && jget_str(r, "name", "")[0])
|
||||
name = jget_str(r, "name", "");
|
||||
if (!name[0]) continue;
|
||||
if (named == max) { more++; continue; }
|
||||
if (o < n) o += (size_t)snprintf(out + o, n - o, "%s%s", named ? ", " : "", name);
|
||||
named++;
|
||||
}
|
||||
state_unlock();
|
||||
if (more && o < n) snprintf(out + o, n - o, " and %d more", more);
|
||||
return named > 0;
|
||||
}
|
||||
|
||||
cJSON *events_json(int rom_id, int limit) {
|
||||
cJSON *out = cJSON_CreateArray();
|
||||
pthread_mutex_lock(&g_lock);
|
||||
cJSON *copy = g_events ? cJSON_Duplicate(g_events, 1) : cJSON_CreateArray();
|
||||
pthread_mutex_unlock(&g_lock);
|
||||
state_lock();
|
||||
const cJSON *roms = state_section("roms");
|
||||
for (int i = cJSON_GetArraySize(copy) - 1; i >= 0 && (limit <= 0 || cJSON_GetArraySize(out) < limit); i--) {
|
||||
cJSON *e = cJSON_GetArrayItem(copy, i);
|
||||
int id = (int)jget_num(e, "rom_id", 0);
|
||||
if (rom_id && id != rom_id) continue;
|
||||
const cJSON *r, *game = NULL;
|
||||
cJSON_ArrayForEach(r, roms) if ((int)jget_num(r, "rom_id", -1) == id && id) game = r;
|
||||
cJSON *o = cJSON_Duplicate(e, 1);
|
||||
cJSON_AddStringToObject(o, "game", game ? jget_str(game, "name", "") : "");
|
||||
cJSON_AddStringToObject(o, "platform", game ? jget_str(game, "platform_dir", "") : "");
|
||||
cJSON_AddItemToArray(out, o);
|
||||
}
|
||||
state_unlock();
|
||||
cJSON_Delete(copy);
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
/* The sync log: one entry per file a sync moved or left alone on purpose,
|
||||
* newest last, the last EVENTS_MAX kept in events.json.
|
||||
*
|
||||
* {"t": 1791936000, "sync": "game exited", "rom_id": 878,
|
||||
* "kind": "save" | "state" | "card" | "bios" | "game" | "emulator",
|
||||
* "action": "uploaded" | "downloaded" | "conflict" | "resolved" |
|
||||
* "skipped" | "restored" | "backed up" | "failed" |
|
||||
* "moved" | "removed",
|
||||
* "path": "/data/.../Game.srm", "emulator": "mgba", "detail": "..."}
|
||||
*
|
||||
* "bios" (rom_id 0): a BIOS file put in or removed, "detail" its name. "game":
|
||||
* a game moved to the main emulator, or an extra's copy removed. "emulator"
|
||||
* (rom_id 0): a mount that couldn't go on. The game's name and platform are
|
||||
* filled in when the log is read. */
|
||||
#ifndef ROMM_EVENTS_H
|
||||
#define ROMM_EVENTS_H
|
||||
|
||||
#include "cJSON.h"
|
||||
|
||||
#define EVENTS_MAX 500
|
||||
|
||||
void events_load(void);
|
||||
void events_save(void); /* after each sync; cheap when nothing changed */
|
||||
|
||||
/* Names the sync the next entries come from ("startup", "game exited"...). */
|
||||
void events_set_sync(const char *reason);
|
||||
|
||||
void events_add(const char *kind, const char *action, int rom_id, const char *path, const char *emulator,
|
||||
const char *detail);
|
||||
|
||||
/* Counts the entries added so far; with events_games, the games a sync touched. */
|
||||
unsigned events_seq(void);
|
||||
/* The games of the saves and states moved since events_seq() was "since":
|
||||
* "Wind Waker, Mario Kart and 2 more" (at most "max" names). 0 if none. */
|
||||
int events_games(unsigned since, int max, char *out, size_t n);
|
||||
|
||||
/* Newest first; rom_id 0 for every game. Caller frees. */
|
||||
cJSON *events_json(int rom_id, int limit);
|
||||
|
||||
#endif
|
||||
+513
@@ -0,0 +1,513 @@
|
||||
#include "firmware.h"
|
||||
|
||||
#include <dirent.h>
|
||||
#include <pthread.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <sys/stat.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#include "config.h"
|
||||
#include "events.h"
|
||||
#include "platform.h"
|
||||
#include "romm.h"
|
||||
#include "state.h"
|
||||
#include "util.h"
|
||||
|
||||
/* PS2 BIOS, the USA v2.30 first: PCSX2 takes the first one it finds, so only
|
||||
* one goes in. */
|
||||
#define PS2_PICK \
|
||||
"[\"21038400dc633070a78ad53090c53017\",\"dc69f0643a3030aaa4797501b483d6c4\",\"80ac46fa7e77b8ab4366e86948e54f83\"," \
|
||||
"\"93ea3bcee4252627919175ff1b16a1d9\",\"d3e81e95db25f5a86a7b7474550a2155\"]"
|
||||
|
||||
/* RetroArch cores, by the core name in their .info file: profiles detected
|
||||
* before the rules existed don't carry them. From the cores' .info files. */
|
||||
static const struct {
|
||||
const char *core;
|
||||
const char *rules;
|
||||
} CORE_RULES[] = {
|
||||
{"mGBA", "{\"names\":{\"32fbbd84168d3482956eb3c5051637f5\":\"gb_bios.bin\",\"dbfce9db9deaa2567f6a84fde55f9680\":\"gbc_bios.bin\","
|
||||
"\"d574d4f9c12f305074798f54c091a8b4\":\"sgb_bios.bin\"},\"from\":{\"super-gb\":[\"sgb_bios.bin\",\"sgb1.boot.rom\"]}}"},
|
||||
{"Snes9x", "{\"from\":{\"satellaview\":[\"BS-X.bin\"],\"sufami-turbo\":[\"STBIOS.bin\"]}}"},
|
||||
{"Mupen64Plus-Next", "{\"only_named\":true,\"names\":{\"8d3d9f294b6e174bc7b1d2fd1c727530\":\"Mupen64plus/IPL.n64\"},"
|
||||
"\"from\":{\"64dd\":[\"*\"]}}"},
|
||||
{"FinalBurn Neo", "{\"dir\":\"{bios}/fbneo\"}"},
|
||||
{"LRPS2", "{\"pick_one\":" PS2_PICK "}"},
|
||||
{"PCSX2", "{\"pick_one\":" PS2_PICK "}"},
|
||||
{"PS5SX2", "{\"pick_one\":" PS2_PICK "}"},
|
||||
{"PPSSPP", "{\"skip\":true}"},
|
||||
{"Dolphin", "{\"dir\":\"{bios}/dolphin-emu/Sys/GC\",\"only_named\":true,\"names\":{"
|
||||
"\"b17148254a5799684c7d783206504926\":\"USA/IPL.bin\",\"fc924a7c879b661abc37cec4f018fdf3\":\"USA/IPL.bin\","
|
||||
"\"019e39822a9ca3029124f74dd4d55ac4\":\"USA/IPL.bin\",\"db92574caab77a7ec99d4605fd6f2450\":\"EUR/IPL.bin\","
|
||||
"\"339848a0b7c2124cf155276c1e79cbd0\":\"EUR/IPL.bin\",\"0cdda509e2da83c85bfe423dd87346cc\":\"EUR/IPL.bin\"}}"},
|
||||
{"Flycast", "{\"dir\":\"{bios}/dc\",\"names\":{\"e10c53c2f8b90bab96ead2d368858623\":\"dc_boot.bin\","
|
||||
"\"0a93f7940c455905bea6e392dfde92a4\":\"dc_flash.bin\"},"
|
||||
"\"from\":{\"arcade\":[\"naomi.zip\",\"naomi2.zip\",\"awbios.zip\"]}}"},
|
||||
{"Azahar", "{\"dir\":\"{save_dir}/Azahar/sysdata\",\"only_files\":[\"aes_keys.txt\",\"seeddb.bin\"]}"},
|
||||
};
|
||||
|
||||
static pthread_mutex_t g_lock = PTHREAD_MUTEX_INITIALIZER;
|
||||
|
||||
static cJSON *rules_for(const profile_map *m) {
|
||||
config_lock();
|
||||
cJSON *r = profile_firmware_rules(g_cfg.profiles, m);
|
||||
config_unlock();
|
||||
if (r || !m->core_name[0]) return r;
|
||||
for (size_t i = 0; i < sizeof CORE_RULES / sizeof CORE_RULES[0]; i++)
|
||||
if (str_ieq(CORE_RULES[i].core, m->core_name)) return cJSON_Parse(CORE_RULES[i].rules);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
static void target_dir(const profile_map *m, const cJSON *rules, char *out, size_t n) {
|
||||
const char *tpl = jget_str(rules, "dir", NULL);
|
||||
if (!tpl) {
|
||||
if (m->firmware_subdir[0]) path_join(out, n, m->bios_dir, m->firmware_subdir);
|
||||
else str_copy(out, n, m->bios_dir);
|
||||
return;
|
||||
}
|
||||
const char *vars[] = {"bios", m->bios_dir, "root", m->root, "rom_dir", m->rom_dir, "save_dir", m->save_dir, NULL};
|
||||
str_template(out, n, tpl, vars);
|
||||
}
|
||||
|
||||
/* A file RomM really has, with a name that can't leave the folder. */
|
||||
static int usable(const cJSON *f) {
|
||||
const char *name = jget_str(f, "file_name", "");
|
||||
return name[0] && name[0] != '.' && !strchr(name, '/') && !strchr(name, '\\') &&
|
||||
!cJSON_IsTrue(cJSON_GetObjectItemCaseSensitive(f, "missing_from_fs")) && jget_num(f, "id", 0) > 0;
|
||||
}
|
||||
|
||||
/* The platform's BIOS files: in the platform itself on RomM 5, else asked for. */
|
||||
static cJSON *platform_firmware(const cJSON *plat) {
|
||||
const cJSON *fw = cJSON_GetObjectItemCaseSensitive(plat, "firmware");
|
||||
cJSON *list = NULL;
|
||||
if (cJSON_IsArray(fw)) {
|
||||
list = cJSON_Duplicate(fw, 1);
|
||||
} else if (jget_num(plat, "firmware_count", 0) <= 0) {
|
||||
return cJSON_CreateArray();
|
||||
} else {
|
||||
char path[64];
|
||||
long st = 0;
|
||||
snprintf(path, sizeof path, "/api/firmware?platform_id=%d", (int)jget_num(plat, "id", 0));
|
||||
list = romm_call("GET", path, NULL, &st);
|
||||
if (st != 200 || !cJSON_IsArray(list)) {
|
||||
LOGW("BIOS files for %s: RomM said %ld", jget_str(plat, "slug", "?"), st);
|
||||
cJSON_Delete(list);
|
||||
return NULL;
|
||||
}
|
||||
}
|
||||
cJSON *f;
|
||||
cJSON_ArrayForEach(f, list) if (!cJSON_GetObjectItemCaseSensitive(f, "platform_id"))
|
||||
cJSON_AddNumberToObject(f, "platform_id", jget_num(plat, "id", 0));
|
||||
return list;
|
||||
}
|
||||
|
||||
/* The platform's files plus the ones the rules take from other platforms
|
||||
* ("from": {"arcade": ["naomi.zip"]}, "*" for all): RomM keeps some BIOS
|
||||
* under a platform of their own (64dd, satellaview) or another (Naomi's under
|
||||
* arcade, played by Dreamcast emulators). */
|
||||
static cJSON *with_borrowed(const cJSON *files, const cJSON *rules, const cJSON *platforms, int *fails) {
|
||||
cJSON *out = cJSON_Duplicate(files, 1);
|
||||
const cJSON *from = cJSON_GetObjectItemCaseSensitive(rules, "from"), *src, *plat;
|
||||
cJSON_ArrayForEach(src, from) {
|
||||
cJSON_ArrayForEach(plat, platforms) {
|
||||
if (!str_ieq(jget_str(plat, "slug", ""), src->string) && !str_ieq(jget_str(plat, "fs_slug", ""), src->string)) continue;
|
||||
cJSON *theirs = platform_firmware(plat);
|
||||
if (!theirs) (*fails)++;
|
||||
const cJSON *f;
|
||||
cJSON_ArrayForEach(f, theirs) {
|
||||
const char *name = jget_str(f, "file_name", "");
|
||||
const cJSON *x;
|
||||
int take = 0;
|
||||
cJSON_ArrayForEach(x, src) take |= cJSON_IsString(x) && (!strcmp(x->valuestring, "*") || str_ieq(x->valuestring, name));
|
||||
if (take) cJSON_AddItemToArray(out, cJSON_Duplicate(f, 1));
|
||||
}
|
||||
cJSON_Delete(theirs);
|
||||
break;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
static const cJSON *by_md5(const cJSON *files, const char *md5) {
|
||||
const cJSON *f;
|
||||
cJSON_ArrayForEach(f, files) if (usable(f) && str_ieq(jget_str(f, "md5_hash", ""), md5)) return f;
|
||||
return NULL;
|
||||
}
|
||||
|
||||
typedef struct {
|
||||
const cJSON *f;
|
||||
char name[256];
|
||||
int named; /* the name came from the rules' "names" */
|
||||
} plan_t;
|
||||
|
||||
/* A Switch firmware set alone is 200-odd .nca files. */
|
||||
#define MAX_PLAN 1024
|
||||
|
||||
static int planned_name(const plan_t *p, int n, const char *name) {
|
||||
for (int i = 0; i < n; i++)
|
||||
if (!strcmp(p[i].name, name)) return 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int planned_file(const plan_t *p, int n, const cJSON *f) {
|
||||
for (int i = 0; i < n; i++)
|
||||
if (p[i].f == f) return 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int list_has_name(const cJSON *arr, const char *name) {
|
||||
const cJSON *x;
|
||||
cJSON_ArrayForEach(x, arr) if (cJSON_IsString(x) && str_ieq(x->valuestring, name)) return 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* A whole BIOS for an emulator that takes the first one it finds (PS2: 4 MB).
|
||||
* The dumps' companions (.nvm, .mec, rom1, erom) are smaller, and are never
|
||||
* the one to keep or the proof there's one already. */
|
||||
#define BIOS_IMAGE_MIN (4 << 20)
|
||||
|
||||
static int bios_image(const cJSON *files, int64_t size) {
|
||||
const cJSON *f;
|
||||
if (size < BIOS_IMAGE_MIN) return 0;
|
||||
cJSON_ArrayForEach(f, files) if (usable(f) && size == (int64_t)jget_num(f, "file_size_bytes", -1)) return 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* An emulator that takes the first BIOS it finds already has one: a file of
|
||||
* the size of one of RomM's. */
|
||||
static int has_one_already(const char *dir, const cJSON *files) {
|
||||
DIR *d = opendir(dir);
|
||||
struct dirent *de;
|
||||
int found = 0;
|
||||
while (d && !found && (de = readdir(d))) {
|
||||
if (de->d_name[0] == '.') continue;
|
||||
char p[PATH_MAX_LEN];
|
||||
path_join(p, sizeof p, dir, de->d_name);
|
||||
found = bios_image(files, file_size(p));
|
||||
}
|
||||
if (d) closedir(d);
|
||||
return found;
|
||||
}
|
||||
|
||||
static int same_stem_file(const char *dir, const char *name, const char *ext, char *out, size_t n) {
|
||||
char stem[256];
|
||||
path_stem(stem, sizeof stem, name);
|
||||
char file[300];
|
||||
snprintf(file, sizeof file, "%s%s", stem, ext);
|
||||
path_join(out, n, dir, file);
|
||||
return file_exists(out);
|
||||
}
|
||||
|
||||
/* Once per folder, for an emulator that takes the first BIOS it finds: of
|
||||
* the files there that are RomM's (by md5), the one used last stays (PCSX2
|
||||
* keeps a BIOS's settings in <bios>.nvm beside it), else the preferred one;
|
||||
* the others go. Their settings files stay: they're the user's, and they do
|
||||
* nothing without the BIOS. Files RomM doesn't have are the user's too. */
|
||||
static void tidy_pick_one(const char *dir, const cJSON *files, const cJSON *pick) {
|
||||
state_lock();
|
||||
const int done = state_entry("firmware_tidy", dir) != NULL;
|
||||
state_unlock();
|
||||
if (done || !dir_exists(dir)) return;
|
||||
DIR *d = opendir(dir);
|
||||
struct dirent *de;
|
||||
char names[256][256];
|
||||
int nn = 0, keep = -1;
|
||||
long long keep_rank = 1LL << 62;
|
||||
while (d && nn < 256 && (de = readdir(d))) {
|
||||
if (de->d_name[0] == '.') continue;
|
||||
char p[PATH_MAX_LEN], md5[33];
|
||||
path_join(p, sizeof p, dir, de->d_name);
|
||||
if (!bios_image(files, file_size(p)) || md5_file_hex(p, md5) != 0 || !by_md5(files, md5)) continue;
|
||||
char nvm[PATH_MAX_LEN];
|
||||
long long r = 1000 + nn;
|
||||
const cJSON *x;
|
||||
int k = 0;
|
||||
cJSON_ArrayForEach(x, pick) {
|
||||
if (cJSON_IsString(x) && str_ieq(x->valuestring, md5)) r = 100 + k;
|
||||
k++;
|
||||
}
|
||||
/* Used: PCSX2 left its settings beside it; the most recently used wins. */
|
||||
struct stat st;
|
||||
if (same_stem_file(dir, de->d_name, ".nvm", nvm, sizeof nvm) && stat(nvm, &st) == 0) r = -(long long)st.st_mtime;
|
||||
str_copy(names[nn], sizeof names[nn], de->d_name);
|
||||
if (r < keep_rank) {
|
||||
keep_rank = r;
|
||||
keep = nn;
|
||||
}
|
||||
nn++;
|
||||
}
|
||||
if (d) closedir(d);
|
||||
int removed = 0;
|
||||
for (int i = 0; i < nn; i++) {
|
||||
if (i == keep) continue;
|
||||
char p[PATH_MAX_LEN];
|
||||
path_join(p, sizeof p, dir, names[i]);
|
||||
if (unlink(p) != 0) continue;
|
||||
state_lock();
|
||||
cJSON_DeleteItemFromObjectCaseSensitive(state_section("firmware"), p);
|
||||
state_unlock();
|
||||
events_add("bios", "removed", 0, p, NULL, names[i]);
|
||||
removed++;
|
||||
}
|
||||
state_lock();
|
||||
jset_num(state_entry_ensure("firmware_tidy", dir), "removed", removed);
|
||||
state_save();
|
||||
state_unlock();
|
||||
if (removed) {
|
||||
LOGI("BIOS: kept %s in %s, removed %d more of RomM's", names[keep], dir, removed);
|
||||
plat_notify("RommPS: %d extra BIOS files removed, %s is kept", removed, names[keep]);
|
||||
}
|
||||
}
|
||||
|
||||
/* Which of the platform's files go to this emulator, under which names. */
|
||||
static int make_plan(const cJSON *rules, const cJSON *files, const char *dir, plan_t *plan) {
|
||||
int n = 0;
|
||||
const cJSON *pick = cJSON_GetObjectItemCaseSensitive(rules, "pick_one");
|
||||
if (cJSON_IsArray(pick)) {
|
||||
tidy_pick_one(dir, files, pick);
|
||||
if (has_one_already(dir, files)) return 0;
|
||||
const cJSON *best = NULL, *x, *f;
|
||||
cJSON_ArrayForEach(x, pick) if (!best && cJSON_IsString(x)) best = by_md5(files, x->valuestring);
|
||||
cJSON_ArrayForEach(f, files) if (!best && cJSON_IsTrue(cJSON_GetObjectItemCaseSensitive(f, "is_verified")) &&
|
||||
bios_image(files, (int64_t)jget_num(f, "file_size_bytes", -1))) best = f;
|
||||
if (best) {
|
||||
plan[0].f = best;
|
||||
plan[0].named = 0;
|
||||
str_copy(plan[0].name, sizeof plan[0].name, jget_str(best, "file_name", ""));
|
||||
n = 1;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
/* Named ones first, in the order listed: the first one RomM has takes the name. */
|
||||
const cJSON *names = cJSON_GetObjectItemCaseSensitive(rules, "names"), *nm;
|
||||
cJSON_ArrayForEach(nm, names) {
|
||||
const cJSON *f = cJSON_IsString(nm) ? by_md5(files, nm->string) : NULL;
|
||||
if (!f || n == MAX_PLAN || planned_name(plan, n, nm->valuestring) || planned_file(plan, n, f) ||
|
||||
strstr(nm->valuestring, "..") || nm->valuestring[0] == '/')
|
||||
continue;
|
||||
plan[n].f = f;
|
||||
str_copy(plan[n].name, sizeof plan[n].name, nm->valuestring);
|
||||
plan[n].named = 1;
|
||||
n++;
|
||||
}
|
||||
if (cJSON_IsTrue(cJSON_GetObjectItemCaseSensitive(rules, "only_named"))) return n;
|
||||
int left_out = 0;
|
||||
const cJSON *only = cJSON_GetObjectItemCaseSensitive(rules, "only_files");
|
||||
const cJSON *by_ext = cJSON_GetObjectItemCaseSensitive(rules, "by_ext");
|
||||
const int named = n;
|
||||
const cJSON *f;
|
||||
cJSON_ArrayForEach(f, files) {
|
||||
if (!usable(f) || planned_file(plan, n, f)) continue;
|
||||
/* RomM keeps some files twice (dc_flash.bin and flash.bin): one that
|
||||
* already goes in under the name the emulator wants is enough. */
|
||||
int dup = 0;
|
||||
for (int k = 0; k < named && !dup; k++) dup = str_ieq(jget_str(plan[k].f, "md5_hash", "-"), jget_str(f, "md5_hash", ""));
|
||||
if (dup) continue;
|
||||
const char *name = jget_str(f, "file_name", "");
|
||||
if (cJSON_IsArray(only) && !list_has_name(only, name)) continue;
|
||||
char full[256];
|
||||
str_copy(full, sizeof full, name);
|
||||
if (cJSON_IsObject(by_ext)) {
|
||||
const cJSON *sub = NULL, *e;
|
||||
cJSON_ArrayForEach(e, by_ext) if (cJSON_IsString(e) && str_ieq(path_ext(name), e->string)) sub = e;
|
||||
if (!sub) continue;
|
||||
if (sub->valuestring[0]) snprintf(full, sizeof full, "%s/%s", sub->valuestring, name);
|
||||
}
|
||||
if (planned_name(plan, n, full)) continue;
|
||||
if (n == MAX_PLAN) {
|
||||
left_out++;
|
||||
continue;
|
||||
}
|
||||
plan[n].f = f;
|
||||
str_copy(plan[n].name, sizeof plan[n].name, full);
|
||||
plan[n].named = 0;
|
||||
n++;
|
||||
}
|
||||
if (left_out) LOGW("BIOS for %s: %d more files than the %d a sync takes, left out", dir, left_out, MAX_PLAN);
|
||||
return n;
|
||||
}
|
||||
|
||||
typedef struct {
|
||||
char md5[33];
|
||||
char path[PATH_MAX_LEN];
|
||||
} fetched_t;
|
||||
|
||||
/* Whether a file we put in is still the one we put there. */
|
||||
static int still_ours(const char *path, const char *rec_md5, int64_t rec_size) {
|
||||
char have[33];
|
||||
if (rec_size >= 0 && file_size(path) != rec_size) return 0;
|
||||
return rec_md5[0] && md5_file_hex(path, have) == 0 && str_ieq(have, rec_md5);
|
||||
}
|
||||
|
||||
/* Puts one file in place. 1 when it was added, 0 when nothing was needed, -1
|
||||
* when it failed. Downloads each file once a run; other emulators get a copy. */
|
||||
static int place(const profile_map *m, const cJSON *f, const char *dest, fetched_t *got, int *ngot, int maxgot) {
|
||||
const char *md5 = jget_str(f, "md5_hash", "");
|
||||
const int64_t size = (int64_t)jget_num(f, "file_size_bytes", -1);
|
||||
if (file_exists(dest) || dir_exists(dest)) {
|
||||
char rec_md5[33];
|
||||
state_lock();
|
||||
const cJSON *rec = state_entry("firmware", dest);
|
||||
const int ours = rec != NULL;
|
||||
str_copy(rec_md5, sizeof rec_md5, jget_str(rec, "md5", ""));
|
||||
const int64_t rec_size = rec ? (int64_t)jget_num(rec, "size", -1) : -1;
|
||||
state_unlock();
|
||||
if (!ours) return 0; /* the user's (or the emulator's) own */
|
||||
if (str_ieq(rec_md5, md5) && file_size(dest) == size) return 0; /* up to date */
|
||||
if (!still_ours(dest, rec_md5, rec_size)) {
|
||||
/* Changed since we put it there: the user's now, never touched again. */
|
||||
LOGI("BIOS %s was changed here, left as it is", dest);
|
||||
state_lock();
|
||||
cJSON_DeleteItemFromObjectCaseSensitive(state_section("firmware"), dest);
|
||||
state_save();
|
||||
state_unlock();
|
||||
return 0;
|
||||
}
|
||||
unlink(dest);
|
||||
}
|
||||
if (mkdir_parent(dest) != 0) {
|
||||
LOGW("BIOS %s: can't make its folder", dest);
|
||||
return -1;
|
||||
}
|
||||
int ok = 0;
|
||||
for (int i = 0; i < *ngot && !ok; i++)
|
||||
if (md5[0] && !strcmp(got[i].md5, md5) && file_exists(got[i].path)) ok = link_or_copy(got[i].path, dest) == 0;
|
||||
if (!ok) {
|
||||
char path[512];
|
||||
http_resp r;
|
||||
char *q = http_escape(jget_str(f, "file_name", ""));
|
||||
snprintf(path, sizeof path, "/api/firmware/%d/content/%s", (int)jget_num(f, "id", 0), q);
|
||||
free(q);
|
||||
ok = romm_download(path, dest, NULL, NULL, &r) == 0;
|
||||
if (!ok) LOGW("BIOS %s didn't download: %s", jget_str(f, "file_name", ""), r.error);
|
||||
}
|
||||
char have[33] = "";
|
||||
if (ok && md5[0] && (md5_file_hex(dest, have) != 0 || !str_ieq(have, md5))) {
|
||||
LOGW("BIOS %s came down damaged (md5 %s, RomM has %s)", dest, have, md5);
|
||||
ok = 0;
|
||||
}
|
||||
if (!ok) {
|
||||
unlink(dest);
|
||||
return -1;
|
||||
}
|
||||
if (*ngot < maxgot) {
|
||||
str_copy(got[*ngot].md5, sizeof got[*ngot].md5, md5);
|
||||
str_copy(got[*ngot].path, sizeof got[*ngot].path, dest);
|
||||
(*ngot)++;
|
||||
}
|
||||
state_lock();
|
||||
cJSON *e = state_entry_ensure("firmware", dest);
|
||||
jset_str(e, "md5", md5);
|
||||
jset_num(e, "size", (double)file_size(dest));
|
||||
jset_num(e, "platform_id", jget_num(f, "platform_id", 0));
|
||||
state_save();
|
||||
state_unlock();
|
||||
LOGI("BIOS for %s: %s", m->profile_name, dest);
|
||||
events_add("bios", "downloaded", 0, dest, m->profile_name, path_basename(dest));
|
||||
return 1;
|
||||
}
|
||||
|
||||
/* Copies of ours under RomM's other name for a file that now goes in under
|
||||
* the name the emulator wants (dc/flash.bin beside dc/dc_flash.bin) go,
|
||||
* unless they were changed since. */
|
||||
static void drop_named_twins(const char *dir, const plan_t *plan, int np) {
|
||||
for (int k = 0; k < np; k++) {
|
||||
if (!plan[k].named) continue;
|
||||
const char *md5 = jget_str(plan[k].f, "md5_hash", "");
|
||||
char keep[PATH_MAX_LEN];
|
||||
path_join(keep, sizeof keep, dir, plan[k].name);
|
||||
if (!md5[0]) continue;
|
||||
state_lock();
|
||||
cJSON *recs = state_section("firmware"), *e = recs ? recs->child : NULL;
|
||||
while (e) {
|
||||
cJSON *next = e->next;
|
||||
size_t dl = strlen(dir);
|
||||
const int here = !strncmp(e->string, dir, dl) && e->string[dl] == '/' && !strchr(e->string + dl + 1, '/');
|
||||
if (here && strcmp(e->string, keep) != 0 && str_ieq(jget_str(e, "md5", ""), md5) &&
|
||||
still_ours(e->string, md5, (int64_t)jget_num(e, "size", -1))) {
|
||||
int planned = 0;
|
||||
for (int j = 0; j < np && !planned; j++) {
|
||||
char p[PATH_MAX_LEN];
|
||||
path_join(p, sizeof p, dir, plan[j].name);
|
||||
planned = !strcmp(p, e->string);
|
||||
}
|
||||
if (!planned) {
|
||||
LOGI("BIOS: removed %s, the same file as %s", e->string, keep);
|
||||
events_add("bios", "removed", 0, e->string, NULL, path_basename(e->string));
|
||||
unlink(e->string);
|
||||
cJSON_DeleteItemFromObjectCaseSensitive(recs, e->string);
|
||||
state_save();
|
||||
}
|
||||
}
|
||||
e = next;
|
||||
}
|
||||
state_unlock();
|
||||
}
|
||||
}
|
||||
|
||||
int firmware_sync(const profile_map *maps, int n, const cJSON *platforms, int only_platform, int *failed) {
|
||||
int added = 0, fails = 0;
|
||||
cJSON *own = NULL;
|
||||
if (!platforms) {
|
||||
long st = 0;
|
||||
own = romm_call("GET", "/api/platforms", NULL, &st);
|
||||
if (st != 200 || !cJSON_IsArray(own)) {
|
||||
cJSON_Delete(own);
|
||||
if (failed) *failed = 0;
|
||||
return -1;
|
||||
}
|
||||
platforms = own;
|
||||
}
|
||||
pthread_mutex_lock(&g_lock);
|
||||
fetched_t *got = calloc(64, sizeof *got);
|
||||
int ngot = 0;
|
||||
plan_t *plan = calloc(MAX_PLAN, sizeof *plan);
|
||||
const cJSON *plat;
|
||||
cJSON_ArrayForEach(plat, platforms) {
|
||||
if (!got || !plan) break;
|
||||
const int pid = (int)jget_num(plat, "id", 0);
|
||||
if (only_platform && pid != only_platform) continue;
|
||||
const char *slug = jget_str(plat, "slug", ""), *fs_slug = jget_str(plat, "fs_slug", "");
|
||||
int wanted = 0;
|
||||
for (int i = 0; i < n && !wanted; i++) wanted = profile_map_handles(&maps[i], slug, fs_slug);
|
||||
if (!wanted) continue;
|
||||
cJSON *files = platform_firmware(plat);
|
||||
if (!files) {
|
||||
fails++;
|
||||
continue;
|
||||
}
|
||||
for (int i = 0; i < n; i++) {
|
||||
const profile_map *m = &maps[i];
|
||||
if (!profile_map_handles(m, slug, fs_slug)) continue;
|
||||
cJSON *rules = rules_for(m);
|
||||
if (cJSON_IsTrue(cJSON_GetObjectItemCaseSensitive(rules, "skip"))) {
|
||||
cJSON_Delete(rules);
|
||||
continue;
|
||||
}
|
||||
cJSON *mine = with_borrowed(files, rules, platforms, &fails);
|
||||
char dir[PATH_MAX_LEN];
|
||||
target_dir(m, rules, dir, sizeof dir);
|
||||
const int np = dir[0] && cJSON_GetArraySize(mine) > 0 ? make_plan(rules, mine, dir, plan) : 0;
|
||||
for (int k = 0; k < np; k++) {
|
||||
char dest[PATH_MAX_LEN];
|
||||
path_join(dest, sizeof dest, dir, plan[k].name);
|
||||
const int r = place(m, plan[k].f, dest, got, &ngot, 64);
|
||||
if (r > 0) added++;
|
||||
else if (r < 0) fails++;
|
||||
}
|
||||
drop_named_twins(dir, plan, np);
|
||||
cJSON_Delete(mine);
|
||||
cJSON_Delete(rules);
|
||||
}
|
||||
cJSON_Delete(files);
|
||||
}
|
||||
free(got);
|
||||
free(plan);
|
||||
pthread_mutex_unlock(&g_lock);
|
||||
cJSON_Delete(own);
|
||||
events_save();
|
||||
if (added) plat_notify("RommPS: %d BIOS file%s added", added, added == 1 ? "" : "s");
|
||||
if (failed) *failed = fails;
|
||||
return added;
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
/* BIOS files: what RomM keeps for a platform goes to every emulator that
|
||||
* plays it, the main one and the extras, as part of each sync.
|
||||
*
|
||||
* Where and under which names comes from the emulator's "firmware" rules (a
|
||||
* platform entry's, else its profile's, else the built-in catalog's; RetroArch
|
||||
* cores have built-in ones by core name):
|
||||
*
|
||||
* "firmware": {
|
||||
* "dir": "{bios}/dc", where they go; default bios_dir/firmware_subdir.
|
||||
* {bios}, {root}, {rom_dir}, {save_dir}
|
||||
* "names": {"<md5>": "dc_boot.bin"}, the name the emulator opens, by the file's md5;
|
||||
* the first listed wins when several map to one name
|
||||
* "only_named": true, nothing but the files in "names"
|
||||
* "only_files": ["keys.txt"], nothing but these names (as RomM has them)
|
||||
* "by_ext": {".nca": "firmware"}, subfolders by extension; other extensions are left out
|
||||
* "pick_one": ["<md5>", ...], one file only, for emulators that take the first
|
||||
* they find (PCSX2): the first of these RomM has, else
|
||||
* a verified one; none if the folder already has one
|
||||
* "from": {"arcade": ["naomi.zip"]}, files RomM keeps under another platform ("*": all)
|
||||
* "skip": true the emulator needs none
|
||||
* }
|
||||
*
|
||||
* A file already there that RommPS didn't put there is never replaced
|
||||
* (keys, an emulator's own eeprom). One it put there is replaced when RomM's
|
||||
* changes, unless it was changed here since: then it's the user's. Nothing
|
||||
* else is removed, but for "pick_one": the first time, of the files there
|
||||
* RomM has too (by md5), only the one in use stays. */
|
||||
#ifndef ROMM_FIRMWARE_H
|
||||
#define ROMM_FIRMWARE_H
|
||||
|
||||
#include "cJSON.h"
|
||||
#include "profiles.h"
|
||||
|
||||
/* platforms: RomM's /api/platforms (NULL fetches it); only_platform: one
|
||||
* platform's id, or 0 for all. Returns the files added, -1 if RomM couldn't be
|
||||
* asked. *failed (may be NULL) counts the ones that didn't come down. */
|
||||
int firmware_sync(const profile_map *maps, int n, const cJSON *platforms, int only_platform, int *failed);
|
||||
|
||||
#endif
|
||||
+202
-3
@@ -8,6 +8,8 @@
|
||||
#include <strings.h>
|
||||
#include <zlib.h>
|
||||
|
||||
#include "cJSON.h"
|
||||
#include "state.h" /* jget_str */
|
||||
#include "util.h"
|
||||
|
||||
#define SECTOR 2048
|
||||
@@ -245,8 +247,8 @@ static int psx_open_file(psx_disc *d, const char *path) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* The first data file a .cue names, beside it. */
|
||||
static int cue_first_file(const char *cue, char *out, size_t n) {
|
||||
/* The file a .cue names index-th (0 for its first), beside it. */
|
||||
static int cue_file(const char *cue, int index, char *out, size_t n) {
|
||||
char *txt = read_file(cue, NULL);
|
||||
if (!txt) return -1;
|
||||
int rc = -1;
|
||||
@@ -254,6 +256,10 @@ static int cue_first_file(const char *cue, char *out, size_t n) {
|
||||
while ((p = strstr(p, "FILE"))) {
|
||||
char *q = strchr(p, '"'), *e = q ? strchr(q + 1, '"') : NULL;
|
||||
if (!q || !e) break;
|
||||
if (index--) {
|
||||
p = e + 1;
|
||||
continue;
|
||||
}
|
||||
*e = 0;
|
||||
char dir[PATH_MAX_LEN];
|
||||
str_copy(dir, sizeof dir, cue);
|
||||
@@ -282,7 +288,7 @@ static int psx_open(psx_disc *d, const char *path) {
|
||||
}
|
||||
if (str_ends_with_ci(path, ".cue")) {
|
||||
char bin[PATH_MAX_LEN];
|
||||
if (cue_first_file(path, bin, sizeof bin) != 0) return -1;
|
||||
if (cue_file(path, 0, bin, sizeof bin) != 0) return -1;
|
||||
return psx_open_file(d, bin);
|
||||
}
|
||||
return psx_open_file(d, path);
|
||||
@@ -373,3 +379,196 @@ int gameid_psx(const char *path, char *out, size_t n) {
|
||||
psx_close(&d);
|
||||
return rc;
|
||||
}
|
||||
|
||||
/* ---- Dreamcast ----
|
||||
* A disc's product number ("MK-51035") from its IP.BIN, at the start of the
|
||||
* high-density area (a GD-ROM's track 3): a .gdi's third track, a .cue's
|
||||
* third file, or in a CHD, frame by frame from the start, since its tracks
|
||||
* sit end to end and the third is only a little way in. A one-track image
|
||||
* (a CD-ROM game, or an .iso) has it in its first sector. Flycast names each
|
||||
* game's own memory card by it. .cdi images aren't read. */
|
||||
#define DC_CHD_FRAMES 30000 /* track 3 starts within the first few thousand */
|
||||
|
||||
/* Flycast's game id: the product number, trailing spaces trimmed and the
|
||||
* characters a file name can't hold made "_" ("T13008D 05" -> "T13008D_05"),
|
||||
* as it names the game's own card (oslib.cpp getVmuPath). */
|
||||
static void flycast_id(char *id) {
|
||||
for (char *c = id; *c; c++)
|
||||
if (strchr(" /\\:*?|<>\"", *c)) *c = '_';
|
||||
}
|
||||
|
||||
static int ipbin_product(const unsigned char *s, char *out, size_t n) {
|
||||
if (memcmp(s, "SEGA SEGAKATANA ", 16) != 0) return -1;
|
||||
size_t len = 10;
|
||||
while (len && s[0x40 + len - 1] == ' ') len--;
|
||||
if (!len || len + 1 > n) return -1;
|
||||
for (size_t i = 0; i < len; i++)
|
||||
if (!isprint(s[0x40 + i])) return -1;
|
||||
memcpy(out, s + 0x40, len);
|
||||
out[len] = 0;
|
||||
flycast_id(out);
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* Track 3's file in a .gdi ("3 45000 4 2352 track03.bin 0", the name maybe
|
||||
* quoted), else its first track's. */
|
||||
static int gdi_track_file(const char *gdi, char *out, size_t n) {
|
||||
char *txt = read_file(gdi, NULL);
|
||||
if (!txt) return -1;
|
||||
char dir[PATH_MAX_LEN], first[PATH_MAX_LEN] = "", name[PATH_MAX_LEN];
|
||||
str_copy(dir, sizeof dir, gdi);
|
||||
char *slash = strrchr(dir, '/');
|
||||
if (slash) *slash = 0;
|
||||
else str_copy(dir, sizeof dir, ".");
|
||||
int rc = -1;
|
||||
char *save = NULL;
|
||||
strtok_r(txt, "\r\n", &save); /* the track count */
|
||||
for (char *line = strtok_r(NULL, "\r\n", &save); line; line = strtok_r(NULL, "\r\n", &save)) {
|
||||
int track = 0, used = 0;
|
||||
if (sscanf(line, "%d %*d %*d %*d %n", &track, &used) < 1 || !used) continue;
|
||||
const char *f = line + used;
|
||||
if (*f == '"') {
|
||||
const char *e = strchr(f + 1, '"');
|
||||
if (!e) continue;
|
||||
snprintf(name, sizeof name, "%.*s", (int)(e - f - 1), f + 1);
|
||||
} else if (sscanf(f, "%1023s", name) != 1) {
|
||||
continue;
|
||||
}
|
||||
if (track == 1) path_join(first, sizeof first, dir, name);
|
||||
if (track == 3) {
|
||||
path_join(out, n, dir, name);
|
||||
rc = 0;
|
||||
break;
|
||||
}
|
||||
}
|
||||
free(txt);
|
||||
if (rc && first[0]) {
|
||||
str_copy(out, n, first);
|
||||
rc = 0;
|
||||
}
|
||||
return rc;
|
||||
}
|
||||
|
||||
static int dc_from_file(const char *path, char *out, size_t n) {
|
||||
psx_disc d;
|
||||
memset(&d, 0, sizeof d);
|
||||
unsigned char s[SECTOR];
|
||||
int rc = psx_open_file(&d, path) == 0 && psx_sector(&d, 0, s) == 0 ? ipbin_product(s, out, n) : -1;
|
||||
psx_close(&d);
|
||||
return rc;
|
||||
}
|
||||
|
||||
int gameid_dreamcast(const char *path, char *out, size_t n) {
|
||||
char track[PATH_MAX_LEN];
|
||||
if (str_ends_with_ci(path, ".gdi"))
|
||||
return gdi_track_file(path, track, sizeof track) == 0 ? dc_from_file(track, out, n) : -1;
|
||||
if (str_ends_with_ci(path, ".cue"))
|
||||
return cue_file(path, 2, track, sizeof track) == 0 || cue_file(path, 0, track, sizeof track) == 0
|
||||
? dc_from_file(track, out, n)
|
||||
: -1;
|
||||
if (!str_ends_with_ci(path, ".chd")) return str_ends_with_ci(path, ".cdi") ? -1 : dc_from_file(path, out, n);
|
||||
psx_disc d;
|
||||
if (psx_open(&d, path) != 0) {
|
||||
psx_close(&d);
|
||||
return -1;
|
||||
}
|
||||
int rc = -1;
|
||||
for (uint32_t f = 0; f < DC_CHD_FRAMES && rc; f++) {
|
||||
long hunk = (long)(f / d.frames_per_hunk);
|
||||
if (hunk != d.cached_hunk) {
|
||||
if (chd_read(d.chd, (uint32_t)hunk, d.hunk) != CHDERR_NONE) break;
|
||||
d.cached_hunk = hunk;
|
||||
}
|
||||
const unsigned char *frame = d.hunk + (size_t)(f % d.frames_per_hunk) * d.frame_bytes;
|
||||
rc = ipbin_product(frame + 16, out, n) == 0 || ipbin_product(frame, out, n) == 0 ? 0 : -1;
|
||||
}
|
||||
psx_close(&d);
|
||||
return rc;
|
||||
}
|
||||
|
||||
int gameid_from_list(const char *list, const char *app_root, const char *path, char *out, size_t n) {
|
||||
char *txt = read_file(list, NULL);
|
||||
if (!txt) return -1;
|
||||
int rc = -1;
|
||||
const size_t plen = strlen(path);
|
||||
char *save = NULL;
|
||||
for (char *line = strtok_r(txt, "\r\n", &save); line && rc; line = strtok_r(NULL, "\r\n", &save)) {
|
||||
char *tab = strchr(line, '\t');
|
||||
if (!tab) continue;
|
||||
*tab = 0;
|
||||
const char *id = tab + 1;
|
||||
char file[PATH_MAX_LEN];
|
||||
if (!strncmp(line, "/app0/", 6)) path_join(file, sizeof file, app_root, line + 6);
|
||||
else str_copy(file, sizeof file, line);
|
||||
/* The game's file, or one inside its folder (a game on several files). */
|
||||
if (strcmp(file, path) != 0 && !(!strncmp(file, path, plen) && file[plen] == '/')) continue;
|
||||
size_t len = strlen(id);
|
||||
if (!len || len + 1 > n) continue;
|
||||
str_copy(out, n, id);
|
||||
flycast_id(out); /* it names files */
|
||||
rc = 0;
|
||||
}
|
||||
free(txt);
|
||||
return rc;
|
||||
}
|
||||
|
||||
static uint64_t le64(const unsigned char *p) {
|
||||
uint64_t v = 0;
|
||||
for (int i = 7; i >= 0; i--) v = v << 8 | p[i];
|
||||
return v;
|
||||
}
|
||||
|
||||
static int read_at(FILE *f, long off, unsigned char *buf, size_t len) {
|
||||
return fseek(f, off, SEEK_SET) == 0 && fread(buf, 1, len, f) == len ? 0 : -1;
|
||||
}
|
||||
|
||||
int gameid_3ds(const char *path, char *out, size_t n) {
|
||||
FILE *f = fopen(path, "rb");
|
||||
if (!f || n < 17) {
|
||||
if (f) fclose(f);
|
||||
return -1;
|
||||
}
|
||||
unsigned char h[0x200];
|
||||
uint64_t id = 0;
|
||||
if (read_at(f, 0, h, sizeof h) == 0) {
|
||||
if (!memcmp(h + 0x100, "NCSD", 4)) {
|
||||
id = le64(h + 0x108); /* the media id: the title's */
|
||||
} else if (!memcmp(h + 0x100, "NCCH", 4)) {
|
||||
id = le64(h + 0x118); /* the program id */
|
||||
} else if (le32(h) == 0x2020) {
|
||||
/* CIA: header, certificates, ticket, then the TMD, each 64-byte
|
||||
* aligned; the TMD (RSA-2048 signed) has the title id at 0x18C,
|
||||
* big-endian. */
|
||||
uint32_t cert = le32(h + 0x08), ticket = le32(h + 0x0C);
|
||||
long tmd = (long)(((0x2020 + 63) & ~63u));
|
||||
tmd = (long)((((uint32_t)tmd + cert + 63) & ~63u));
|
||||
tmd = (long)((((uint32_t)tmd + ticket + 63) & ~63u));
|
||||
unsigned char t[8];
|
||||
if (read_at(f, tmd + 0x18C, t, 8) == 0)
|
||||
for (int i = 0; i < 8; i++) id = id << 8 | t[i];
|
||||
}
|
||||
}
|
||||
fclose(f);
|
||||
if (!id) return -1;
|
||||
snprintf(out, n, "%016llX", (unsigned long long)id);
|
||||
return 0;
|
||||
}
|
||||
|
||||
int gameid_from_library(const char *library_json, const char *system, const char *path, char *out, size_t n) {
|
||||
char *txt = read_file(library_json, NULL);
|
||||
cJSON *lib = txt ? cJSON_Parse(txt) : NULL;
|
||||
free(txt);
|
||||
const cJSON *g;
|
||||
int found = -1;
|
||||
cJSON_ArrayForEach(g, cJSON_GetObjectItemCaseSensitive(lib, system)) {
|
||||
const char *tid = jget_str(g, "titleId", "");
|
||||
if (strcmp(jget_str(g, "path", ""), path) != 0 || strlen(tid) != 16) continue;
|
||||
size_t o = 0;
|
||||
for (; *tid && o + 1 < n; tid++) out[o++] = (char)toupper((unsigned char)*tid);
|
||||
out[o] = 0;
|
||||
found = 0;
|
||||
break;
|
||||
}
|
||||
cJSON_Delete(lib);
|
||||
return found;
|
||||
}
|
||||
@@ -10,6 +10,23 @@ int gameid_psp(const char *path, char *out, size_t n);
|
||||
/* GameCube or Wii game id ("GALE01") from .iso, .gcm, .rvz, .wia, .ciso or .wbfs. */
|
||||
int gameid_dolphin(const char *path, char *out, size_t n);
|
||||
|
||||
/* 3DS title id ("0004000000066C00") from a .cci/.3ds (NCSD), .cxi/.app
|
||||
* (NCCH) or .cia header: plain text even in encrypted dumps. */
|
||||
int gameid_3ds(const char *path, char *out, size_t n);
|
||||
|
||||
/* The title id an emulator front end has for a game file: PS5CEMU-HAR's
|
||||
* library.json ({"wiiu": [{"titleId", "path"}], "3ds": [...]}), the only way to
|
||||
* a .wux's without its disc key. */
|
||||
int gameid_from_library(const char *library_json, const char *system, const char *path, char *out, size_t n);
|
||||
|
||||
/* Dreamcast product number ("MK-51035") from the disc's IP.BIN: .gdi, .cue,
|
||||
* .chd or a one-track image (not .cdi). */
|
||||
int gameid_dreamcast(const char *path, char *out, size_t n);
|
||||
|
||||
/* The id a front end keeps for a game file in a "<path>\t<id>" per line list
|
||||
* (PSFlyCast's data/game-ids.txt), /app0/ standing for its app_root. */
|
||||
int gameid_from_list(const char *list, const char *app_root, const char *path, char *out, size_t n);
|
||||
|
||||
/* PlayStation serial ("SCUS-94194") from SYSTEM.CNF: .iso, .bin, .cue or .chd. */
|
||||
int gameid_psx(const char *path, char *out, size_t n);
|
||||
|
||||
|
||||
+241
@@ -0,0 +1,241 @@
|
||||
#include "gcmem.h"
|
||||
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
#define FIRST_DATA 5
|
||||
#define NENTRIES 127
|
||||
|
||||
static unsigned be16(const unsigned char *p) { return (unsigned)p[0] << 8 | p[1]; }
|
||||
static void put16(unsigned char *p, unsigned v) {
|
||||
p[0] = (unsigned char)(v >> 8);
|
||||
p[1] = (unsigned char)v;
|
||||
}
|
||||
|
||||
/* Dolphin's two checksums over big-endian words; 0xFFFF counts as 0. */
|
||||
static void checksums(const unsigned char *p, size_t n, unsigned *c1, unsigned *c2) {
|
||||
unsigned a = 0, b = 0;
|
||||
for (size_t i = 0; i + 1 < n; i += 2) {
|
||||
unsigned w = be16(p + i);
|
||||
a = (a + w) & 0xFFFF;
|
||||
b = (b + (w ^ 0xFFFF)) & 0xFFFF;
|
||||
}
|
||||
*c1 = a == 0xFFFF ? 0 : a;
|
||||
*c2 = b == 0xFFFF ? 0 : b;
|
||||
}
|
||||
|
||||
static unsigned char *block(const gc_card *c, int n) { return c->data + (size_t)n * GC_BLOCK; }
|
||||
|
||||
/* Directory: checksums over 0..0x1FFB, at 0x1FFC; update counter at 0x1FFA. */
|
||||
static int dir_ok(const unsigned char *d) {
|
||||
unsigned a, b;
|
||||
checksums(d, 0x1FFC, &a, &b);
|
||||
return a == be16(d + 0x1FFC) && b == be16(d + 0x1FFE);
|
||||
}
|
||||
/* Table: checksums over 0x4..0x1FFF, at 0x0; update counter at 0x4. */
|
||||
static int bat_ok(const unsigned char *t) {
|
||||
unsigned a, b;
|
||||
checksums(t + 4, GC_BLOCK - 4, &a, &b);
|
||||
return a == be16(t) && b == be16(t + 2);
|
||||
}
|
||||
|
||||
/* The current copy: the one that checks out, or the newer of two that do. */
|
||||
static int pick(const unsigned char *x, const unsigned char *y, int ok_x, int ok_y, size_t counter_at, int ix, int iy) {
|
||||
if (ok_x && ok_y) return (short)be16(y + counter_at) > (short)be16(x + counter_at) ? iy : ix;
|
||||
return ok_x ? ix : ok_y ? iy : -1;
|
||||
}
|
||||
|
||||
int gc_card_open(gc_card *c, const unsigned char *data, size_t size) {
|
||||
memset(c, 0, sizeof *c);
|
||||
if (size < (size_t)GC_BLOCK * (FIRST_DATA + 1) || size % GC_BLOCK) return -1;
|
||||
c->data = malloc(size);
|
||||
if (!c->data) return -1;
|
||||
memcpy(c->data, data, size);
|
||||
c->size = size;
|
||||
c->nblocks = (int)(size / GC_BLOCK);
|
||||
if (c->nblocks > 0xFFB + FIRST_DATA) c->nblocks = 0xFFB + FIRST_DATA;
|
||||
c->dir = pick(block(c, 1), block(c, 2), dir_ok(block(c, 1)), dir_ok(block(c, 2)), 0x1FFA, 1, 2);
|
||||
c->bat = pick(block(c, 3), block(c, 4), bat_ok(block(c, 3)), bat_ok(block(c, 4)), 4, 3, 4);
|
||||
if (c->dir < 0 || c->bat < 0) {
|
||||
gc_card_free(c);
|
||||
return -1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
void gc_card_free(gc_card *c) {
|
||||
free(c->data);
|
||||
memset(c, 0, sizeof *c);
|
||||
}
|
||||
|
||||
static unsigned char *entry(const gc_card *c, int i) { return block(c, c->dir) + i * GC_DENTRY; }
|
||||
static int empty(const unsigned char *e) { return e[0] == 0xFF && e[1] == 0xFF && e[2] == 0xFF && e[3] == 0xFF; }
|
||||
static unsigned char *map_at(const gc_card *c, int blk) { return block(c, c->bat) + 0xA + (size_t)(blk - FIRST_DATA) * 2; }
|
||||
static int is_data(const gc_card *c, unsigned blk) { return blk >= FIRST_DATA && blk < (unsigned)c->nblocks; }
|
||||
|
||||
int gc_gci_valid(const unsigned char *g, size_t size) {
|
||||
if (size < GC_DENTRY || (size - GC_DENTRY) % GC_BLOCK || empty(g)) return 0;
|
||||
unsigned count = be16(g + 0x38);
|
||||
return count > 0 && (size_t)count * GC_BLOCK == size - GC_DENTRY;
|
||||
}
|
||||
|
||||
static int same_id(const unsigned char *e, const char *id) { return strlen(id) >= 6 && !memcmp(e, id, 6); }
|
||||
|
||||
int gc_gci_same_save(const unsigned char *a, const unsigned char *b) {
|
||||
return !memcmp(a, b, 6) && !memcmp(a + 8, b + 8, 32);
|
||||
}
|
||||
|
||||
void gc_gci_id(const unsigned char *g, char out[7]) {
|
||||
memcpy(out, g, 6);
|
||||
out[6] = 0;
|
||||
}
|
||||
|
||||
/* Copies an entry's blocks after it, following the table. */
|
||||
static unsigned char *to_gci(const gc_card *c, const unsigned char *e, size_t *size) {
|
||||
unsigned count = be16(e + 0x38), blk = be16(e + 0x36);
|
||||
if (!count || count > (unsigned)c->nblocks) return NULL;
|
||||
unsigned char *g = malloc(GC_DENTRY + (size_t)count * GC_BLOCK);
|
||||
if (!g) return NULL;
|
||||
memcpy(g, e, GC_DENTRY);
|
||||
for (unsigned k = 0; k < count; k++) {
|
||||
if (!is_data(c, blk)) {
|
||||
free(g);
|
||||
return NULL;
|
||||
}
|
||||
memcpy(g + GC_DENTRY + (size_t)k * GC_BLOCK, block(c, (int)blk), GC_BLOCK);
|
||||
blk = be16(map_at(c, (int)blk));
|
||||
}
|
||||
*size = GC_DENTRY + (size_t)count * GC_BLOCK;
|
||||
return g;
|
||||
}
|
||||
|
||||
int gc_card_saves(const gc_card *c, const char *id, gc_save_fn fn, void *ud) {
|
||||
int n = 0;
|
||||
for (int i = 0; i < NENTRIES; i++) {
|
||||
const unsigned char *e = entry(c, i);
|
||||
if (empty(e) || !same_id(e, id)) continue;
|
||||
size_t size = 0;
|
||||
unsigned char *g = to_gci(c, e, &size);
|
||||
if (!g) continue;
|
||||
if (fn) fn(g, size, ud);
|
||||
free(g);
|
||||
n++;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
/* Edits go to a copy of the current directory and table, written over the
|
||||
* older copies when done. */
|
||||
typedef struct {
|
||||
unsigned char dir[GC_BLOCK], bat[GC_BLOCK];
|
||||
} draft;
|
||||
|
||||
static void draft_start(const gc_card *c, draft *d) {
|
||||
memcpy(d->dir, block(c, c->dir), GC_BLOCK);
|
||||
memcpy(d->bat, block(c, c->bat), GC_BLOCK);
|
||||
}
|
||||
|
||||
static void draft_commit(gc_card *c, draft *d) {
|
||||
unsigned a, b;
|
||||
put16(d->dir + 0x1FFA, (be16(d->dir + 0x1FFA) + 1) & 0xFFFF);
|
||||
checksums(d->dir, 0x1FFC, &a, &b);
|
||||
put16(d->dir + 0x1FFC, a);
|
||||
put16(d->dir + 0x1FFE, b);
|
||||
put16(d->bat + 4, (be16(d->bat + 4) + 1) & 0xFFFF);
|
||||
checksums(d->bat + 4, GC_BLOCK - 4, &a, &b);
|
||||
put16(d->bat, a);
|
||||
put16(d->bat + 2, b);
|
||||
c->dir = c->dir == 1 ? 2 : 1;
|
||||
c->bat = c->bat == 3 ? 4 : 3;
|
||||
memcpy(block(c, c->dir), d->dir, GC_BLOCK);
|
||||
memcpy(block(c, c->bat), d->bat, GC_BLOCK);
|
||||
}
|
||||
|
||||
static unsigned char *dmap(const gc_card *c, draft *d, unsigned blk) {
|
||||
(void)c;
|
||||
return d->bat + 0xA + (size_t)(blk - FIRST_DATA) * 2;
|
||||
}
|
||||
|
||||
/* Frees entry i's blocks and empties it. */
|
||||
static void draft_drop(const gc_card *c, draft *d, int i) {
|
||||
unsigned char *e = d->dir + i * GC_DENTRY;
|
||||
unsigned count = be16(e + 0x38), blk = be16(e + 0x36), freed = 0;
|
||||
for (unsigned k = 0; k < count && is_data(c, blk); k++) {
|
||||
unsigned char *m = dmap(c, d, blk);
|
||||
blk = be16(m);
|
||||
put16(m, 0);
|
||||
freed++;
|
||||
}
|
||||
put16(d->bat + 6, be16(d->bat + 6) + freed);
|
||||
memset(e, 0xFF, GC_DENTRY);
|
||||
}
|
||||
|
||||
int gc_card_remove(gc_card *c, const char *id) {
|
||||
draft d;
|
||||
draft_start(c, &d);
|
||||
int n = 0;
|
||||
for (int i = 0; i < NENTRIES; i++) {
|
||||
const unsigned char *e = d.dir + i * GC_DENTRY;
|
||||
if (empty(e) || !same_id(e, id)) continue;
|
||||
draft_drop(c, &d, i);
|
||||
n++;
|
||||
}
|
||||
if (n) draft_commit(c, &d);
|
||||
return n;
|
||||
}
|
||||
|
||||
int gc_card_put(gc_card *c, const unsigned char *g, size_t size) {
|
||||
if (!gc_gci_valid(g, size)) return -1;
|
||||
draft d;
|
||||
draft_start(c, &d);
|
||||
int slot = -1;
|
||||
for (int i = 0; i < NENTRIES; i++) {
|
||||
unsigned char *e = d.dir + i * GC_DENTRY;
|
||||
if (!empty(e) && gc_gci_same_save(e, g)) draft_drop(c, &d, i);
|
||||
if (empty(e) && slot < 0) slot = i;
|
||||
}
|
||||
const unsigned count = be16(g + 0x38);
|
||||
if (slot < 0 || be16(d.bat + 6) < count) return -1;
|
||||
/* Free blocks from just after the last one given out, wrapping round,
|
||||
* as Dolphin hands them out. */
|
||||
unsigned *blocks = malloc(sizeof *blocks * count), found = 0;
|
||||
if (!blocks) return -1;
|
||||
unsigned start = be16(d.bat + 8) + 1;
|
||||
if (start < FIRST_DATA || start >= (unsigned)c->nblocks) start = FIRST_DATA;
|
||||
for (unsigned k = 0; k < (unsigned)c->nblocks - FIRST_DATA && found < count; k++) {
|
||||
unsigned blk = FIRST_DATA + (start - FIRST_DATA + k) % ((unsigned)c->nblocks - FIRST_DATA);
|
||||
if (be16(dmap(c, &d, blk)) == 0) blocks[found++] = blk;
|
||||
}
|
||||
if (found < count) {
|
||||
free(blocks);
|
||||
return -1;
|
||||
}
|
||||
for (unsigned k = 0; k < count; k++) {
|
||||
memcpy(block(c, (int)blocks[k]), g + GC_DENTRY + (size_t)k * GC_BLOCK, GC_BLOCK);
|
||||
put16(dmap(c, &d, blocks[k]), k + 1 < count ? blocks[k + 1] : 0xFFFF);
|
||||
}
|
||||
put16(d.bat + 6, be16(d.bat + 6) - count);
|
||||
put16(d.bat + 8, blocks[count - 1]);
|
||||
unsigned char *e = d.dir + slot * GC_DENTRY;
|
||||
memcpy(e, g, GC_DENTRY);
|
||||
put16(e + 0x36, blocks[0]);
|
||||
free(blocks);
|
||||
draft_commit(c, &d);
|
||||
return 0;
|
||||
}
|
||||
|
||||
void gc_gci_name(const unsigned char *g, char *out, size_t n) {
|
||||
char name[33 * 6 + 1];
|
||||
size_t o = 0;
|
||||
for (int i = 0; i < 32 && g[8 + i]; i++) {
|
||||
unsigned char ch = g[8 + i];
|
||||
/* Dolphin's escaping of characters a file name can't have. */
|
||||
if (ch < 0x20 || strchr("\"*/:<>?\\|", ch))
|
||||
o += (size_t)snprintf(name + o, sizeof name - o, "__%02x__", ch);
|
||||
else
|
||||
name[o++] = (char)ch;
|
||||
}
|
||||
name[o] = 0;
|
||||
snprintf(out, n, "%.2s-%.4s-%s.gci", (const char *)g + 4, (const char *)g, name);
|
||||
}
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
/* GameCube memory cards (Dolphin's .raw) and the .gci files of single saves.
|
||||
*
|
||||
* A card is 8 KiB blocks: 0 the header, 1 and 2 the directory and its
|
||||
* previous copy, 3 and 4 the block allocation table and its previous copy,
|
||||
* then the saves. Each directory entry (64 bytes) names a save: game code,
|
||||
* maker, file name, first block and block count; the table chains its
|
||||
* blocks. A .gci is one entry followed by its blocks.
|
||||
*
|
||||
* Changes are made the way Dolphin makes them: the current directory and
|
||||
* table are edited, their update counters go up by one, their checksums are
|
||||
* set, and they're written over the older copies, which become the backups. */
|
||||
#ifndef ROMM_GCMEM_H
|
||||
#define ROMM_GCMEM_H
|
||||
|
||||
#include <stddef.h>
|
||||
|
||||
#define GC_BLOCK 0x2000
|
||||
#define GC_DENTRY 0x40
|
||||
|
||||
typedef struct {
|
||||
unsigned char *data;
|
||||
size_t size;
|
||||
int nblocks;
|
||||
int dir, bat; /* which copy is current: block 1 or 2, 3 or 4 */
|
||||
} gc_card;
|
||||
|
||||
/* 0 when data (size bytes, copied) is a card whose header, directory and
|
||||
* table check out. */
|
||||
int gc_card_open(gc_card *c, const unsigned char *data, size_t size);
|
||||
void gc_card_free(gc_card *c);
|
||||
|
||||
/* A .gci in memory: 0 when it's one entry and as many blocks as it says. */
|
||||
int gc_gci_valid(const unsigned char *gci, size_t size);
|
||||
|
||||
/* The saves of a game: entries whose game code and maker are id's six
|
||||
* characters ("GZLE01"). Calls fn with each as a .gci; returns how many. */
|
||||
typedef void (*gc_save_fn)(const unsigned char *gci, size_t size, void *ud);
|
||||
int gc_card_saves(const gc_card *c, const char *id, gc_save_fn fn, void *ud);
|
||||
|
||||
/* Takes the game's saves off the card. */
|
||||
int gc_card_remove(gc_card *c, const char *id);
|
||||
|
||||
/* Puts a .gci on the card, over any save with the same game code, maker and
|
||||
* file name. 0, or -1 when it doesn't fit or the directory is full. */
|
||||
int gc_card_put(gc_card *c, const unsigned char *gci, size_t size);
|
||||
|
||||
/* Dolphin's name for a .gci in a GCI folder: "01-GZLE-gczelda2.gci". */
|
||||
void gc_gci_name(const unsigned char *gci, char *out, size_t n);
|
||||
|
||||
/* The game id ("GZLE01") a .gci belongs to. */
|
||||
void gc_gci_id(const unsigned char *gci, char out[7]);
|
||||
|
||||
/* Whether two .gci are the same save (game code, maker and file name). */
|
||||
int gc_gci_same_save(const unsigned char *a, const unsigned char *b);
|
||||
|
||||
#endif
|
||||
+326
-29
@@ -1,11 +1,14 @@
|
||||
#include "http.h"
|
||||
#include "jsonlean.h"
|
||||
|
||||
#include <pthread.h>
|
||||
|
||||
#include <curl/curl.h>
|
||||
#include <stddef.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <strings.h>
|
||||
#include <sys/socket.h>
|
||||
#include <time.h>
|
||||
#include <unistd.h>
|
||||
@@ -14,6 +17,9 @@
|
||||
#include "util.h"
|
||||
#ifdef ROMM_EMBED_CA
|
||||
#include "cacert_pem.h" /* generated from the SDK's Mozilla CA bundle */
|
||||
#include <openssl/pem.h>
|
||||
#include <openssl/ssl.h>
|
||||
#include <openssl/x509.h>
|
||||
#endif
|
||||
|
||||
static char g_ca_bundle[PATH_MAX_LEN];
|
||||
@@ -21,6 +27,12 @@ static char g_ca_bundle[PATH_MAX_LEN];
|
||||
* which have no system CA store. */
|
||||
static char *g_ca_blob;
|
||||
static size_t g_ca_blob_len;
|
||||
#ifdef ROMM_EMBED_CA
|
||||
/* The same roots, parsed once and handed to every connection. Given as a
|
||||
* blob, libcurl parses all ~140 of them again for each new connection and
|
||||
* caches nothing, which the PS4's memory pool can't afford. */
|
||||
static X509_STORE *g_ca_store;
|
||||
#endif
|
||||
static int g_tls_verify = 1;
|
||||
static char g_user_agent[64];
|
||||
|
||||
@@ -31,6 +43,146 @@ static char g_user_agent[64];
|
||||
static CURLSH *g_share;
|
||||
static pthread_mutex_t g_share_locks[CURL_LOCK_DATA_LAST];
|
||||
|
||||
/* The connections instead stay with the handles, which are kept between
|
||||
* requests: the next cover, list page or save to the same server goes over a
|
||||
* connection already open (two per handle at most), not a new one. A handle
|
||||
* can move from thread to thread as long as only one uses it at a time, so
|
||||
* each request takes an idle one (the last given back, whose connection is
|
||||
* the freshest) or a new one, and gives it back; POOL_MAX are kept. */
|
||||
#define POOL_MAX 8
|
||||
static CURL *g_pool[POOL_MAX];
|
||||
static int pool_max(void) { return strcmp(plat_info()->name, "ps4") ? POOL_MAX : 3; }
|
||||
static int g_npool;
|
||||
static pthread_mutex_t g_pool_lock = PTHREAD_MUTEX_INITIALIZER;
|
||||
|
||||
static CURL *handle_take(void) {
|
||||
CURL *c = NULL;
|
||||
pthread_mutex_lock(&g_pool_lock);
|
||||
if (g_npool) c = g_pool[--g_npool];
|
||||
pthread_mutex_unlock(&g_pool_lock);
|
||||
if (!c) return curl_easy_init();
|
||||
curl_easy_reset(c); /* the options go; its connections stay */
|
||||
return c;
|
||||
}
|
||||
|
||||
static void handle_give(CURL *c) {
|
||||
pthread_mutex_lock(&g_pool_lock);
|
||||
const int kept = g_npool < pool_max();
|
||||
if (kept) g_pool[g_npool++] = c;
|
||||
pthread_mutex_unlock(&g_pool_lock);
|
||||
if (!kept) curl_easy_cleanup(c);
|
||||
}
|
||||
|
||||
static double mono_sec(void) {
|
||||
struct timespec ts;
|
||||
clock_gettime(CLOCK_MONOTONIC, &ts);
|
||||
return (double)ts.tv_sec + (double)ts.tv_nsec / 1e9;
|
||||
}
|
||||
|
||||
/* When a request last failed for want of memory: a reply we couldn't keep in
|
||||
* memory (a download's write error is the disk's), or curl or OpenSSL coming
|
||||
* up empty. */
|
||||
static double g_oom_at = -1;
|
||||
static pthread_mutex_t g_oom_lock = PTHREAD_MUTEX_INITIALIZER;
|
||||
|
||||
static int out_of_memory(CURLcode rc, int in_memory) {
|
||||
return rc == CURLE_OUT_OF_MEMORY || rc == CURLE_SSL_CACERT_BADFILE || (in_memory && rc == CURLE_WRITE_ERROR);
|
||||
}
|
||||
|
||||
int http_out_of_memory(void) {
|
||||
pthread_mutex_lock(&g_oom_lock);
|
||||
const int recent = g_oom_at >= 0 && mono_sec() - g_oom_at < 30;
|
||||
pthread_mutex_unlock(&g_oom_lock);
|
||||
return recent;
|
||||
}
|
||||
|
||||
/* A server that doesn't answer at all (no connection) isn't asked again by
|
||||
* every thread, each waiting out the connect timeout: requests to it fail at
|
||||
* once, and one at a time checks whether it's back, after 5 s, then twice as
|
||||
* long each time it still isn't, up to 30 s. */
|
||||
#define DOWN_FIRST 5.0
|
||||
#define DOWN_MOST 30.0
|
||||
static pthread_mutex_t g_down_lock = PTHREAD_MUTEX_INITIALIZER;
|
||||
static char g_down[256]; /* its scheme://host:port, "" while all answer */
|
||||
static double g_down_until, g_down_wait;
|
||||
static int g_down_probing;
|
||||
|
||||
static void url_origin(const char *url, char *out, size_t n) {
|
||||
const char *host = strstr(url, "://");
|
||||
host = host ? host + 3 : url;
|
||||
snprintf(out, n, "%.*s", (int)(host - url + (ptrdiff_t)strcspn(host, "/?#")), url);
|
||||
}
|
||||
|
||||
int http_down(const char *url) {
|
||||
char o[256];
|
||||
url_origin(url, o, sizeof o);
|
||||
pthread_mutex_lock(&g_down_lock);
|
||||
int down = g_down[0] && !strcmp(o, g_down);
|
||||
pthread_mutex_unlock(&g_down_lock);
|
||||
return down;
|
||||
}
|
||||
|
||||
/* 0 to go ahead (*probe: this one checks whether it's back), -1 to fail at once. */
|
||||
static int down_gate(const char *url, int *probe, http_resp *out) {
|
||||
char o[200];
|
||||
url_origin(url, o, sizeof o);
|
||||
*probe = 0;
|
||||
pthread_mutex_lock(&g_down_lock);
|
||||
int wait = 0;
|
||||
if (g_down[0] && !strcmp(o, g_down)) {
|
||||
if (g_down_probing || mono_sec() < g_down_until) wait = 1;
|
||||
else g_down_probing = *probe = 1;
|
||||
}
|
||||
pthread_mutex_unlock(&g_down_lock);
|
||||
if (!wait) return 0;
|
||||
memset(out, 0, sizeof *out);
|
||||
snprintf(out->error, sizeof out->error, "%s isn't answering", o);
|
||||
out->no_answer = 1;
|
||||
return -1;
|
||||
}
|
||||
|
||||
static http_back_fn g_on_back;
|
||||
|
||||
void http_on_back(http_back_fn fn) { g_on_back = fn; }
|
||||
|
||||
static void down_note(const char *url, int no_answer, int probe) {
|
||||
char o[256];
|
||||
url_origin(url, o, sizeof o);
|
||||
int back = 0;
|
||||
pthread_mutex_lock(&g_down_lock);
|
||||
const int same = !strcmp(o, g_down);
|
||||
if (no_answer) {
|
||||
if (!same) {
|
||||
str_copy(g_down, sizeof g_down, o);
|
||||
g_down_wait = DOWN_FIRST;
|
||||
LOGW("%s isn't answering: asking again in %.0f s", o, g_down_wait);
|
||||
} else if (probe) {
|
||||
g_down_wait = g_down_wait * 2 > DOWN_MOST ? DOWN_MOST : g_down_wait * 2;
|
||||
}
|
||||
g_down_until = mono_sec() + g_down_wait;
|
||||
} else if (same) {
|
||||
LOGI("%s answers again", o);
|
||||
g_down[0] = 0;
|
||||
back = 1;
|
||||
}
|
||||
if (probe) g_down_probing = 0;
|
||||
pthread_mutex_unlock(&g_down_lock);
|
||||
if (back && g_on_back) g_on_back(o);
|
||||
}
|
||||
|
||||
/* No connection at all: refused, no such host, or the connect timeout. */
|
||||
static int no_answer(CURL *c, CURLcode rc) {
|
||||
if (rc == CURLE_COULDNT_CONNECT || rc == CURLE_COULDNT_RESOLVE_HOST) return 1;
|
||||
if (rc != CURLE_OPERATION_TIMEDOUT) return 0;
|
||||
/* Timed out before a single line of an answer: a server that's hung
|
||||
* (paused, wedged) takes the connection and then says nothing, which is
|
||||
* as good as not being there. One that's slow mid-answer isn't that. */
|
||||
long status = 0, headers = 0;
|
||||
curl_easy_getinfo(c, CURLINFO_RESPONSE_CODE, &status);
|
||||
curl_easy_getinfo(c, CURLINFO_HEADER_SIZE, &headers);
|
||||
return status == 0 && headers == 0;
|
||||
}
|
||||
|
||||
static void share_lock(CURL *c, curl_lock_data data, curl_lock_access access, void *ud) {
|
||||
(void)c, (void)access, (void)ud;
|
||||
if (data < CURL_LOCK_DATA_LAST) pthread_mutex_lock(&g_share_locks[data]);
|
||||
@@ -41,6 +193,37 @@ static void share_unlock(CURL *c, curl_lock_data data, void *ud) {
|
||||
if (data < CURL_LOCK_DATA_LAST) pthread_mutex_unlock(&g_share_locks[data]);
|
||||
}
|
||||
|
||||
#ifdef ROMM_EMBED_CA
|
||||
/* What libcurl does with a CA blob (load_cacert_from_memory and the flags it
|
||||
* sets), done once. NULL if not one certificate in it could be read. */
|
||||
static X509_STORE *ca_store(const char *pem, size_t len) {
|
||||
X509_STORE *st = X509_STORE_new();
|
||||
BIO *bio = st && len <= 0x7fffffff ? BIO_new_mem_buf(pem, (int)len) : NULL;
|
||||
STACK_OF(X509_INFO) *infos = bio ? PEM_X509_INFO_read_bio(bio, NULL, NULL, NULL) : NULL;
|
||||
int n = 0;
|
||||
for (int i = 0; infos && i < sk_X509_INFO_num(infos); i++) {
|
||||
X509_INFO *inf = sk_X509_INFO_value(infos, i);
|
||||
if (inf->x509 && X509_STORE_add_cert(st, inf->x509)) n++;
|
||||
}
|
||||
sk_X509_INFO_pop_free(infos, X509_INFO_free);
|
||||
BIO_free(bio);
|
||||
if (!n) {
|
||||
X509_STORE_free(st);
|
||||
return NULL;
|
||||
}
|
||||
X509_STORE_set_flags(st, X509_V_FLAG_TRUSTED_FIRST | X509_V_FLAG_PARTIAL_CHAIN);
|
||||
return st;
|
||||
}
|
||||
|
||||
/* Every connection's SSL_CTX gets the one store; libcurl's own, empty, goes. */
|
||||
static CURLcode use_ca_store(CURL *c, void *ssl_ctx, void *ud) {
|
||||
(void)c;
|
||||
if (!X509_STORE_up_ref(ud)) return CURLE_OUT_OF_MEMORY;
|
||||
SSL_CTX_set_cert_store(ssl_ctx, ud);
|
||||
return CURLE_OK;
|
||||
}
|
||||
#endif
|
||||
|
||||
void http_global_init(const char *ca_bundle, int tls_verify) {
|
||||
curl_global_init(CURL_GLOBAL_ALL);
|
||||
for (int i = 0; i < CURL_LOCK_DATA_LAST; i++) pthread_mutex_init(&g_share_locks[i], NULL);
|
||||
@@ -68,6 +251,11 @@ void http_global_init(const char *ca_bundle, int tls_verify) {
|
||||
}
|
||||
}
|
||||
free(user);
|
||||
const char *tls = curl_version_info(CURLVERSION_NOW)->ssl_version;
|
||||
if (g_ca_blob && tls && !strncmp(tls, "OpenSSL", 7) && (g_ca_store = ca_store(g_ca_blob, g_ca_blob_len))) {
|
||||
free(g_ca_blob);
|
||||
g_ca_blob = NULL;
|
||||
}
|
||||
LOGI("libcurl %s, CA bundle: built-in%s%s", curl_version_info(CURLVERSION_NOW)->version,
|
||||
g_ca_bundle[0] ? " + " : "", g_ca_bundle);
|
||||
#else
|
||||
@@ -106,19 +294,67 @@ static size_t mem_write(char *ptr, size_t size, size_t nmemb, void *ud) {
|
||||
return n;
|
||||
}
|
||||
|
||||
static CURL *easy_new(const char *url, struct curl_slist **hdrs, const char *auth, int verify) {
|
||||
CURL *c = curl_easy_init();
|
||||
#ifdef ROMM_NETLOG
|
||||
/* NETLOG=1 builds: curl's running commentary (DNS, connect, TLS, headers) goes
|
||||
* to the network log only, so a timeout says which step it died in. Headers
|
||||
* that carry credentials or session cookies are left out. */
|
||||
static int curl_trace(CURL *c, curl_infotype type, char *data, size_t size, void *ud) {
|
||||
static const char *const tags[] = {[CURLINFO_TEXT] = "*", [CURLINFO_HEADER_IN] = "<", [CURLINFO_HEADER_OUT] = ">"};
|
||||
char line[300];
|
||||
(void)c;
|
||||
(void)ud;
|
||||
if (type != CURLINFO_TEXT && type != CURLINFO_HEADER_IN && type != CURLINFO_HEADER_OUT) return 0;
|
||||
while (size > 0) {
|
||||
size_t n = 0;
|
||||
while (n < size && data[n] != '\n') n++;
|
||||
size_t take = n;
|
||||
while (take > 0 && data[take - 1] == '\r') take--;
|
||||
int secret = !strncasecmp(data, "Authorization:", 14) || !strncasecmp(data, "Cookie:", 7) ||
|
||||
!strncasecmp(data, "Set-Cookie:", 11);
|
||||
if (take && !(type != CURLINFO_TEXT && secret)) {
|
||||
snprintf(line, sizeof line, "curl %s %.*s", tags[type], (int)(take > 280 ? 280 : take), data);
|
||||
netlog_send(line);
|
||||
}
|
||||
if (n < size) n++;
|
||||
data += n;
|
||||
size -= n;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
#endif
|
||||
|
||||
/* errbuf, CURL_ERROR_SIZE bytes or NULL, gets curl's own account of a
|
||||
* failure: which host, which phase, how long it waited. */
|
||||
static CURL *easy_new(const char *url, struct curl_slist **hdrs, const char *auth, int verify, char *errbuf) {
|
||||
CURL *c = handle_take();
|
||||
if (!c) return NULL;
|
||||
if (errbuf) {
|
||||
errbuf[0] = 0;
|
||||
curl_easy_setopt(c, CURLOPT_ERRORBUFFER, errbuf);
|
||||
}
|
||||
if (g_share) curl_easy_setopt(c, CURLOPT_SHARE, g_share);
|
||||
curl_easy_setopt(c, CURLOPT_URL, url);
|
||||
curl_easy_setopt(c, CURLOPT_USERAGENT, g_user_agent);
|
||||
curl_easy_setopt(c, CURLOPT_FOLLOWLOCATION, 1L);
|
||||
curl_easy_setopt(c, CURLOPT_MAXREDIRS, 5L);
|
||||
curl_easy_setopt(c, CURLOPT_CONNECTTIMEOUT, 15L);
|
||||
curl_easy_setopt(c, CURLOPT_MAXCONNECTS, 2L);
|
||||
curl_easy_setopt(c, CURLOPT_NOSIGNAL, 1L);
|
||||
/* Give up on stalled transfers rather than using a total timeout. */
|
||||
curl_easy_setopt(c, CURLOPT_LOW_SPEED_LIMIT, 1L);
|
||||
curl_easy_setopt(c, CURLOPT_LOW_SPEED_TIME, 60L);
|
||||
#ifdef ROMM_NETLOG
|
||||
curl_easy_setopt(c, CURLOPT_DEBUGFUNCTION, curl_trace);
|
||||
curl_easy_setopt(c, CURLOPT_VERBOSE, 1L);
|
||||
#endif
|
||||
#ifdef ROMM_EMBED_CA
|
||||
if (g_ca_store) {
|
||||
curl_easy_setopt(c, CURLOPT_CAINFO, NULL);
|
||||
curl_easy_setopt(c, CURLOPT_CAPATH, NULL);
|
||||
curl_easy_setopt(c, CURLOPT_SSL_CTX_FUNCTION, use_ca_store);
|
||||
curl_easy_setopt(c, CURLOPT_SSL_CTX_DATA, g_ca_store);
|
||||
} else
|
||||
#endif
|
||||
if (g_ca_blob) {
|
||||
struct curl_blob blob = {g_ca_blob, g_ca_blob_len, CURL_BLOB_NOCOPY};
|
||||
curl_easy_setopt(c, CURLOPT_CAINFO_BLOB, &blob);
|
||||
@@ -138,11 +374,33 @@ static CURL *easy_new(const char *url, struct curl_slist **hdrs, const char *aut
|
||||
return c;
|
||||
}
|
||||
|
||||
static int finish(CURL *c, CURLcode rc, membuf *m, http_resp *out, const char *url) {
|
||||
static int finish(CURL *c, CURLcode rc, membuf *m, http_resp *out, const char *url, const char *errbuf, int probe) {
|
||||
memset(out, 0, sizeof *out);
|
||||
out->no_answer = rc != CURLE_OK && no_answer(c, rc);
|
||||
out->aborted = rc == CURLE_ABORTED_BY_CALLBACK;
|
||||
down_note(url, out->no_answer, probe);
|
||||
if (rc != CURLE_OK) {
|
||||
snprintf(out->error, sizeof out->error, "%s", curl_easy_strerror(rc));
|
||||
LOGW("HTTP %s: %s", url, out->error);
|
||||
if (out_of_memory(rc, m != NULL)) {
|
||||
pthread_mutex_lock(&g_oom_lock);
|
||||
g_oom_at = mono_sec();
|
||||
pthread_mutex_unlock(&g_oom_lock);
|
||||
}
|
||||
/* A download (CURLOPT_FAILONERROR) stopped by the server's answer:
|
||||
* say which, so a 404 isn't "HTTP response code said error". */
|
||||
if (rc == CURLE_HTTP_RETURNED_ERROR) {
|
||||
curl_easy_getinfo(c, CURLINFO_RESPONSE_CODE, &out->status);
|
||||
snprintf(out->error, sizeof out->error, "HTTP %ld", out->status);
|
||||
}
|
||||
/* "Timeout was reached" alone can't tell a server that never answered
|
||||
* from one that went quiet halfway, or a console with no network. */
|
||||
long os_err = 0;
|
||||
curl_easy_getinfo(c, CURLINFO_OS_ERRNO, &os_err);
|
||||
const char *detail = errbuf && errbuf[0] ? errbuf : out->error;
|
||||
/* curl's words for a reply we had no room for don't say so. */
|
||||
const char *oom = out_of_memory(rc, m != NULL) && rc != CURLE_OUT_OF_MEMORY ? " (out of memory)" : "";
|
||||
if (os_err) LOGW("HTTP %s: %s%s (errno %ld: %s)", url, detail, oom, os_err, strerror((int)os_err));
|
||||
else LOGW("HTTP %s: %s%s", url, detail, oom);
|
||||
free(m ? m->buf : NULL);
|
||||
return -1;
|
||||
}
|
||||
@@ -158,13 +416,34 @@ static int finish(CURL *c, CURLcode rc, membuf *m, http_resp *out, const char *u
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* A reply kept lean: the members named in drop never reach memory. */
|
||||
typedef struct {
|
||||
membuf m;
|
||||
jlean j;
|
||||
} lean_buf;
|
||||
|
||||
static int lean_put(void *ud, const char *bytes, size_t n) {
|
||||
return mem_write((char *)bytes, 1, n, ud) == n;
|
||||
}
|
||||
|
||||
static size_t lean_write(char *ptr, size_t size, size_t nmemb, void *ud) {
|
||||
lean_buf *l = ud;
|
||||
return jlean_feed(&l->j, ptr, size * nmemb, lean_put, &l->m) ? size * nmemb : 0;
|
||||
}
|
||||
|
||||
static int request_impl(const char *method, const char *url, const char *auth, const char *content_type,
|
||||
const void *body, size_t body_len, int verify, http_resp *out) {
|
||||
const void *body, size_t body_len, int verify, const char *const *drop, http_resp *out) {
|
||||
memset(out, 0, sizeof *out);
|
||||
int probe;
|
||||
if (down_gate(url, &probe, out)) return -1;
|
||||
struct curl_slist *hdrs = NULL;
|
||||
membuf m = {0};
|
||||
CURL *c = easy_new(url, &hdrs, auth, verify);
|
||||
if (!c) return -1;
|
||||
char errbuf[CURL_ERROR_SIZE];
|
||||
CURL *c = easy_new(url, &hdrs, auth, verify, errbuf);
|
||||
if (!c) {
|
||||
down_note(url, 0, probe);
|
||||
return -1;
|
||||
}
|
||||
if (content_type) {
|
||||
char h[256];
|
||||
snprintf(h, sizeof h, "Content-Type: %s", content_type);
|
||||
@@ -180,42 +459,61 @@ static int request_impl(const char *method, const char *url, const char *auth, c
|
||||
curl_easy_setopt(c, CURLOPT_POSTFIELDS, body ? body : "");
|
||||
curl_easy_setopt(c, CURLOPT_POSTFIELDSIZE, (long)(body ? body_len : 0));
|
||||
}
|
||||
curl_easy_setopt(c, CURLOPT_WRITEFUNCTION, mem_write);
|
||||
curl_easy_setopt(c, CURLOPT_WRITEDATA, &m);
|
||||
lean_buf lean;
|
||||
if (drop) {
|
||||
memset(&lean, 0, sizeof lean);
|
||||
jlean_init(&lean.j, drop);
|
||||
curl_easy_setopt(c, CURLOPT_WRITEFUNCTION, lean_write);
|
||||
curl_easy_setopt(c, CURLOPT_WRITEDATA, &lean);
|
||||
} else {
|
||||
curl_easy_setopt(c, CURLOPT_WRITEFUNCTION, mem_write);
|
||||
curl_easy_setopt(c, CURLOPT_WRITEDATA, &m);
|
||||
}
|
||||
CURLcode rc = curl_easy_perform(c);
|
||||
int r = finish(c, rc, &m, out, url);
|
||||
if (drop) m = lean.m;
|
||||
int r = finish(c, rc, &m, out, url, errbuf, probe);
|
||||
curl_slist_free_all(hdrs);
|
||||
curl_easy_cleanup(c);
|
||||
handle_give(c);
|
||||
return r;
|
||||
}
|
||||
|
||||
int http_request(const char *method, const char *url, const char *auth, const char *content_type,
|
||||
const void *body, size_t body_len, http_resp *out) {
|
||||
return request_impl(method, url, auth, content_type, body, body_len, g_tls_verify, out);
|
||||
return request_impl(method, url, auth, content_type, body, body_len, g_tls_verify, NULL, out);
|
||||
}
|
||||
|
||||
int http_get_lean(const char *url, const char *auth, const char *const *drop, http_resp *out) {
|
||||
return request_impl("GET", url, auth, NULL, NULL, 0, g_tls_verify, drop, out);
|
||||
}
|
||||
|
||||
int http_get_verified(const char *url, http_resp *out) {
|
||||
return request_impl("GET", url, NULL, NULL, NULL, 0, 1, out);
|
||||
return request_impl("GET", url, NULL, NULL, NULL, 0, 1, NULL, out);
|
||||
}
|
||||
|
||||
int http_tls_untrusted(const char *url) {
|
||||
struct curl_slist *hdrs = NULL;
|
||||
CURL *c = easy_new(url, &hdrs, NULL, 1);
|
||||
CURL *c = easy_new(url, &hdrs, NULL, 1, NULL);
|
||||
if (!c) return 0;
|
||||
curl_easy_setopt(c, CURLOPT_NOBODY, 1L);
|
||||
curl_easy_setopt(c, CURLOPT_TIMEOUT, 20L);
|
||||
CURLcode rc = curl_easy_perform(c);
|
||||
curl_slist_free_all(hdrs);
|
||||
curl_easy_cleanup(c);
|
||||
handle_give(c);
|
||||
return rc == CURLE_PEER_FAILED_VERIFICATION;
|
||||
}
|
||||
|
||||
int http_upload_file(const char *method, const char *url, const char *auth, const char *field,
|
||||
const char *file_path, const char *upload_name, http_resp *out) {
|
||||
int probe;
|
||||
if (down_gate(url, &probe, out)) return -1;
|
||||
struct curl_slist *hdrs = NULL;
|
||||
membuf m = {0};
|
||||
CURL *c = easy_new(url, &hdrs, auth, g_tls_verify);
|
||||
if (!c) return -1;
|
||||
char errbuf[CURL_ERROR_SIZE];
|
||||
CURL *c = easy_new(url, &hdrs, auth, g_tls_verify, errbuf);
|
||||
if (!c) {
|
||||
down_note(url, 0, probe);
|
||||
return -1;
|
||||
}
|
||||
curl_mime *mime = curl_mime_init(c);
|
||||
curl_mimepart *part = curl_mime_addpart(mime);
|
||||
curl_mime_name(part, field);
|
||||
@@ -230,10 +528,10 @@ int http_upload_file(const char *method, const char *url, const char *auth, cons
|
||||
curl_easy_setopt(c, CURLOPT_WRITEFUNCTION, mem_write);
|
||||
curl_easy_setopt(c, CURLOPT_WRITEDATA, &m);
|
||||
CURLcode rc = curl_easy_perform(c);
|
||||
int r = finish(c, rc, &m, out, url);
|
||||
int r = finish(c, rc, &m, out, url, errbuf, probe);
|
||||
curl_mime_free(mime);
|
||||
curl_slist_free_all(hdrs);
|
||||
curl_easy_cleanup(c);
|
||||
handle_give(c);
|
||||
return r;
|
||||
}
|
||||
|
||||
@@ -265,14 +563,10 @@ static int big_rcvbuf(void *ud, curl_socket_t fd, curlsocktype purpose) {
|
||||
return CURL_SOCKOPT_OK;
|
||||
}
|
||||
|
||||
static double mono_sec(void) {
|
||||
struct timespec ts;
|
||||
clock_gettime(CLOCK_MONOTONIC, &ts);
|
||||
return (double)ts.tv_sec + (double)ts.tv_nsec / 1e9;
|
||||
}
|
||||
|
||||
static int download_impl(const char *url, const char *auth, const char *dest, int resume, int verify,
|
||||
http_progress_fn progress, void *ud, http_resp *out) {
|
||||
int probe;
|
||||
if (down_gate(url, &probe, out)) return -1;
|
||||
char part[PATH_MAX_LEN];
|
||||
snprintf(part, sizeof part, "%s.part", dest);
|
||||
mkdir_parent(dest);
|
||||
@@ -281,6 +575,7 @@ static int download_impl(const char *url, const char *auth, const char *dest, in
|
||||
if (offset < 0) offset = 0;
|
||||
FILE *f = fopen(part, offset > 0 ? "ab" : "wb");
|
||||
if (!f) {
|
||||
down_note(url, 0, probe);
|
||||
memset(out, 0, sizeof *out);
|
||||
snprintf(out->error, sizeof out->error, "cannot open %s", part);
|
||||
return -1;
|
||||
@@ -288,8 +583,10 @@ static int download_impl(const char *url, const char *auth, const char *dest, in
|
||||
setvbuf(f, NULL, _IOFBF, DOWNLOAD_BUFFER);
|
||||
|
||||
struct curl_slist *hdrs = NULL;
|
||||
CURL *c = easy_new(url, &hdrs, auth, verify);
|
||||
char errbuf[CURL_ERROR_SIZE];
|
||||
CURL *c = easy_new(url, &hdrs, auth, verify, errbuf);
|
||||
if (!c) {
|
||||
down_note(url, 0, probe);
|
||||
fclose(f);
|
||||
return -1;
|
||||
}
|
||||
@@ -312,12 +609,12 @@ static int download_impl(const char *url, const char *auth, const char *dest, in
|
||||
if (rc == CURLE_OK && got > 1024 * 1024 && secs > 0)
|
||||
LOGI("downloaded %.1f MB in %.1fs (%.2f MB/s)", got / 1048576.0, secs, got / 1048576.0 / secs);
|
||||
|
||||
int r = finish(c, rc, NULL, out, url);
|
||||
int r = finish(c, rc, NULL, out, url, errbuf, probe);
|
||||
if (r == 0 && out->status == 200 && offset > 0) {
|
||||
/* The server ignored Range, so start over. */
|
||||
LOGW("server ignored Range for %s; restarting download", url);
|
||||
curl_slist_free_all(hdrs);
|
||||
curl_easy_cleanup(c);
|
||||
handle_give(c);
|
||||
unlink(part);
|
||||
return download_impl(url, auth, dest, 0, verify, progress, ud, out);
|
||||
}
|
||||
@@ -331,7 +628,7 @@ static int download_impl(const char *url, const char *auth, const char *dest, in
|
||||
}
|
||||
if (r != 0 && rc == CURLE_HTTP_RETURNED_ERROR) unlink(part);
|
||||
curl_slist_free_all(hdrs);
|
||||
curl_easy_cleanup(c);
|
||||
handle_give(c);
|
||||
return r;
|
||||
}
|
||||
|
||||
|
||||
+21
-1
@@ -1,4 +1,5 @@
|
||||
/* libcurl wrapper. Each call uses its own handle, so it is thread-safe. */
|
||||
/* libcurl wrapper, thread-safe. Handles are kept between requests, and with
|
||||
* them their open connections, so requests to the same server reuse them. */
|
||||
#ifndef ROMM_HTTP_H
|
||||
#define ROMM_HTTP_H
|
||||
|
||||
@@ -10,6 +11,8 @@ typedef struct {
|
||||
char *body; /* malloc'd, NUL-terminated (may be NULL) */
|
||||
size_t len;
|
||||
char error[256];
|
||||
int no_answer; /* no connection at all: refused, unknown host or timed out */
|
||||
int aborted; /* the progress callback stopped it */
|
||||
} http_resp;
|
||||
|
||||
/* Progress callback: return non-zero to abort. total may be 0 if unknown. */
|
||||
@@ -23,6 +26,10 @@ void http_resp_free(http_resp *r);
|
||||
int http_request(const char *method, const char *url, const char *auth, const char *content_type,
|
||||
const void *body, size_t body_len, http_resp *out);
|
||||
|
||||
/* A GET whose JSON reply drops the members named in drop (NULL-terminated)
|
||||
* as it arrives, so they never take memory: for big lists. */
|
||||
int http_get_lean(const char *url, const char *auth, const char *const *drop, http_resp *out);
|
||||
|
||||
/* multipart/form-data upload of one file. */
|
||||
int http_upload_file(const char *method, const char *url, const char *auth, const char *field,
|
||||
const char *file_path, const char *upload_name, http_resp *out);
|
||||
@@ -39,6 +46,19 @@ int http_get_verified(const char *url, http_resp *out);
|
||||
int http_download_verified(const char *url, const char *dest, http_progress_fn progress, void *ud,
|
||||
http_resp *out);
|
||||
|
||||
/* 1 while url's server isn't answering: requests to it fail at once, and one
|
||||
* at a time checks whether it's back (every 5 to 30 s). */
|
||||
int http_down(const char *url);
|
||||
|
||||
/* Called (on the thread that found out) when a server that wasn't answering
|
||||
* answers again, with its origin ("http://10.0.0.2:8080"). */
|
||||
typedef void (*http_back_fn)(const char *origin);
|
||||
void http_on_back(http_back_fn fn);
|
||||
|
||||
/* 1 if a request failed for want of memory in the last 30 s: smaller
|
||||
* requests or the same one again right away won't do better. */
|
||||
int http_out_of_memory(void);
|
||||
|
||||
/* URL-encode a query/path component into a malloc'd string. */
|
||||
char *http_escape(const char *s);
|
||||
|
||||
|
||||
+169
@@ -0,0 +1,169 @@
|
||||
#include "jsonlean.h"
|
||||
|
||||
#include <string.h>
|
||||
|
||||
void jlean_init(jlean *j, const char *const *drop) {
|
||||
memset(j, 0, sizeof *j);
|
||||
j->drop = drop;
|
||||
}
|
||||
|
||||
static int dropped(const jlean *j) {
|
||||
if (j->key_long) return 0;
|
||||
/* key holds the name with its quotes */
|
||||
for (const char *const *d = j->drop; d && *d; d++)
|
||||
if ((size_t)j->key_len == strlen(*d) + 2 && !memcmp(j->key + 1, *d, strlen(*d))) return 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* The output for one call goes through a small buffer, flushed when full. */
|
||||
typedef struct {
|
||||
char buf[512];
|
||||
size_t n;
|
||||
jlean_put put;
|
||||
void *ud;
|
||||
int ok;
|
||||
} sink;
|
||||
|
||||
static void out(sink *s, const char *b, size_t n) {
|
||||
if (!s->ok) return;
|
||||
if (s->n + n > sizeof s->buf) {
|
||||
if (s->n && !s->put(s->ud, s->buf, s->n)) s->ok = 0;
|
||||
s->n = 0;
|
||||
if (n > sizeof s->buf) {
|
||||
if (s->ok && !s->put(s->ud, b, n)) s->ok = 0;
|
||||
return;
|
||||
}
|
||||
}
|
||||
memcpy(s->buf + s->n, b, n);
|
||||
s->n += n;
|
||||
}
|
||||
|
||||
static void out1(sink *s, char c) { out(s, &c, 1); }
|
||||
|
||||
/* A member's name kept: its comma first, when one was written before it. */
|
||||
static void keep_key(jlean *j, sink *s) {
|
||||
if (j->kept[j->depth - 1]) out1(s, ',');
|
||||
j->kept[j->depth - 1] = 1;
|
||||
out(s, j->key, (size_t)j->key_len);
|
||||
}
|
||||
|
||||
int jlean_feed(jlean *j, const char *in, size_t n, jlean_put put, void *ud) {
|
||||
sink s = {.put = put, .ud = ud, .ok = 1};
|
||||
for (size_t i = 0; i < n && s.ok; i++) {
|
||||
const char c = in[i];
|
||||
if (j->skipping) {
|
||||
/* The value of a dropped member: up to where it ends. */
|
||||
if (j->skip_str) {
|
||||
if (j->skip_esc) j->skip_esc = 0;
|
||||
else if (c == '\\') j->skip_esc = 1;
|
||||
else if (c == '"') {
|
||||
j->skip_str = 0;
|
||||
if (!j->skip_nest) j->skipping = 0;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (!j->skip_started) {
|
||||
if (c == ':' || c == ' ' || c == '\t' || c == '\n' || c == '\r') continue;
|
||||
j->skip_started = 1;
|
||||
}
|
||||
if (c == '"') {
|
||||
j->skip_str = 1;
|
||||
continue;
|
||||
}
|
||||
if (c == '{' || c == '[') {
|
||||
j->skip_nest++;
|
||||
continue;
|
||||
}
|
||||
if (j->skip_nest && (c == '}' || c == ']')) {
|
||||
if (!--j->skip_nest) j->skipping = 0;
|
||||
continue;
|
||||
}
|
||||
if (!j->skip_nest && (c == ',' || c == '}' || c == ']')) {
|
||||
j->skipping = 0; /* a number or literal ended: the character is the object's */
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if (j->in_key) {
|
||||
if (j->key_len < JLEAN_KEY + 2) j->key[j->key_len++] = c;
|
||||
else j->key_long = 1;
|
||||
const int closing = !j->esc && c == '"';
|
||||
j->esc = !j->esc && c == '\\';
|
||||
if (j->key_long) {
|
||||
/* Longer than any name dropped: kept, written as it comes. */
|
||||
if (j->key_len == JLEAN_KEY + 2) {
|
||||
keep_key(j, &s);
|
||||
j->key_len++;
|
||||
}
|
||||
out1(&s, c);
|
||||
if (closing) j->in_key = j->expect_key = 0;
|
||||
continue;
|
||||
}
|
||||
if (!closing) continue;
|
||||
j->in_key = 0;
|
||||
j->expect_key = 0;
|
||||
if (dropped(j)) {
|
||||
j->skipping = 1;
|
||||
j->skip_started = j->skip_nest = j->skip_str = j->skip_esc = 0;
|
||||
} else {
|
||||
keep_key(j, &s);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (j->in_str) {
|
||||
out1(&s, c);
|
||||
if (j->esc) j->esc = 0;
|
||||
else if (c == '\\') j->esc = 1;
|
||||
else if (c == '"') j->in_str = 0;
|
||||
continue;
|
||||
}
|
||||
const int in_obj = j->depth > 0 && !j->deep && j->obj[j->depth - 1];
|
||||
switch (c) {
|
||||
case '{':
|
||||
case '[':
|
||||
if (j->deep || j->depth == JLEAN_DEPTH) {
|
||||
j->deep++;
|
||||
} else {
|
||||
j->obj[j->depth] = c == '{';
|
||||
j->kept[j->depth] = 0;
|
||||
j->depth++;
|
||||
j->expect_key = c == '{';
|
||||
}
|
||||
out1(&s, c);
|
||||
break;
|
||||
case '}':
|
||||
case ']':
|
||||
if (j->deep) j->deep--;
|
||||
else if (j->depth) j->depth--;
|
||||
j->expect_key = 0;
|
||||
out1(&s, c);
|
||||
break;
|
||||
case ',':
|
||||
if (in_obj) j->expect_key = 1; /* written with the next member kept */
|
||||
else out1(&s, c);
|
||||
break;
|
||||
case ':':
|
||||
j->expect_key = 0;
|
||||
out1(&s, c);
|
||||
break;
|
||||
case '"':
|
||||
if (in_obj && j->expect_key) {
|
||||
j->in_key = 1;
|
||||
j->key_len = 0;
|
||||
j->key_long = 0;
|
||||
j->esc = 0;
|
||||
j->key[j->key_len++] = c;
|
||||
} else {
|
||||
j->in_str = 1;
|
||||
j->esc = 0;
|
||||
out1(&s, c);
|
||||
}
|
||||
break;
|
||||
default:
|
||||
if (in_obj && j->expect_key && (c == ' ' || c == '\n' || c == '\r' || c == '\t')) break;
|
||||
out1(&s, c);
|
||||
}
|
||||
}
|
||||
if (s.ok && s.n && !put(ud, s.buf, s.n)) s.ok = 0;
|
||||
return s.ok;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
/* Drops object members by name from JSON as it streams in, so a big reply
|
||||
* (RomM's game lists, heavy with RetroAchievements and scraper metadata) never
|
||||
* has to be held whole. The output is the same JSON without those members. */
|
||||
#ifndef ROMM_JSONLEAN_H
|
||||
#define ROMM_JSONLEAN_H
|
||||
|
||||
#include <stddef.h>
|
||||
|
||||
#define JLEAN_DEPTH 64
|
||||
#define JLEAN_KEY 64
|
||||
|
||||
typedef struct {
|
||||
const char *const *drop; /* names to drop, NULL-terminated */
|
||||
unsigned char obj[JLEAN_DEPTH]; /* per level: 1 object, 0 array */
|
||||
unsigned char kept[JLEAN_DEPTH]; /* per object: a member was written */
|
||||
int depth, deep; /* deep: levels past JLEAN_DEPTH, passed through */
|
||||
int in_str, esc, expect_key;
|
||||
int in_key, key_len, key_long;
|
||||
char key[JLEAN_KEY + 2];
|
||||
int skipping, skip_started, skip_nest, skip_str, skip_esc;
|
||||
} jlean;
|
||||
|
||||
void jlean_init(jlean *j, const char *const *drop);
|
||||
|
||||
/* Filters n bytes of input, calling put for the bytes that stay (at most
|
||||
* n + JLEAN_KEY + 3 per call). put returns 0 to stop; so does jlean_feed. */
|
||||
typedef int (*jlean_put)(void *ud, const char *bytes, size_t n);
|
||||
int jlean_feed(jlean *j, const char *in, size_t n, jlean_put put, void *ud);
|
||||
|
||||
#endif
|
||||
+1133
-75
File diff suppressed because it is too large.
Load diff
+16
-2
@@ -11,6 +11,12 @@ cJSON *library_platforms(char *err, int en);
|
||||
/* Links the games a system already has into its extra emulators, in the background. */
|
||||
void library_link_extras(void);
|
||||
|
||||
/* An extra's games of its own, the only thing in the way of its mount
|
||||
* (profile_map.mount_blocked), by its root and system: "move" renames them into the main emulator's folder, "delete"
|
||||
* removes them. Games the main one has too are left: they're copies, cleared
|
||||
* once the mount is on. Returns how many, or -1 with err set. */
|
||||
int library_own_games(const char *root, const char *system, const char *action, char *err, int en);
|
||||
|
||||
/* 1 for a file RomM lists as a game that isn't one (metadata.txt, artwork...). */
|
||||
int library_not_game(const char *fs_name);
|
||||
/* platform_id 0 searches every platform. */
|
||||
@@ -20,11 +26,19 @@ cJSON *library_roms(int platform_id, const char *search, int offset, int limit,
|
||||
cJSON *library_details(int rom_id, char *err, int en);
|
||||
/* The games on this console, most recently played first. */
|
||||
cJSON *library_installed(int limit);
|
||||
/* Changes when a game appears on the console or goes from it, deleted by
|
||||
* hand included, so clients know to load their lists again. Cheap to ask
|
||||
* for: it looks at the disk at most every 15 s, and not while a game runs. */
|
||||
unsigned library_installed_sig(void);
|
||||
/* Whether a game is on the console now (RommPS asks as its sheet opens). */
|
||||
int library_on_console(int rom_id);
|
||||
|
||||
/* name: the game's name as the client shows it ("" for "Game #<id>" until the job runs). */
|
||||
int library_queue_rom(int rom_id, const char *name, char *err, int en);
|
||||
/* name: the name the client shows; cover: its copy of RomM's path_cover_small. */
|
||||
int library_queue_rom(int rom_id, const char *name, const char *cover, char *err, int en);
|
||||
int library_queue_firmware(int platform_id, char *err, int en);
|
||||
int library_cancel(int id, char *err, int en);
|
||||
/* A failed or cancelled download, queued again. 0, or -1 with err set. */
|
||||
int library_retry(int id, char *err, int en);
|
||||
int library_clear_finished(void);
|
||||
int library_delete_rom(int rom_id, char *err, int en);
|
||||
cJSON *library_downloads(void);
|
||||
|
||||
Loaded 100 of 140 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user