mirror of
https://github.com/s0liton/ps5-romm.git
synced 2026-10-28 23:02:57 +01:00
Compare commits
29
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
05719d8d64 | ||
|
|
37714135c5 | ||
|
|
a35d80210b | ||
|
|
92237e603e | ||
|
|
f78a02db27 | ||
|
|
77d81e4476 | ||
|
|
b738d7f470 | ||
|
|
5194001e91 | ||
|
|
217a9b6991 | ||
|
|
3421c41003 | ||
|
|
920fe9e819 | ||
|
|
b37cd7f290 | ||
|
|
224923aaae | ||
|
|
57c1d05847 | ||
|
|
382e799a4a | ||
|
|
6818392c80 | ||
|
|
acc15db685 | ||
|
|
b8748872b6 | ||
|
|
35ecb09a09 | ||
|
|
c65f6b178c | ||
|
|
e87b3f82a6 | ||
|
|
7e9ba03ccd | ||
|
|
fa0b13b028 | ||
|
|
1d8eeca542 | ||
|
|
2e35b40138 | ||
|
|
9b8a7bd4b1 | ||
|
|
48f1db129f | ||
|
|
04007c2f25 | ||
|
|
17917e30f7 |
No files matched your search
@@ -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.
|
||||
@@ -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/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/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/presets.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
|
||||
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,6 +36,11 @@ 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: unit all host ps5 ps4 tools deploy install install-ps4 run clean
|
||||
@@ -45,6 +51,12 @@ 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 $@)
|
||||
@@ -57,13 +69,18 @@ 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
|
||||
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 $@)
|
||||
|
||||
@@ -30,42 +30,52 @@ A [RomM](https://romm.app) companion client for jailbroken PS5 consoles (etaHEN
|
||||
|
||||
## Supported Emulators and Games
|
||||
- RommPS has built-in profiles for all 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.
|
||||
- You can also manually set the directories for each emulator/platform via the PS5 app or or the Web UI.
|
||||
|
||||
- You can also manually set the directories for each emulator/platform via the PS5 app or or the Web UI. **RommPS can support ANY emulator truly.**
|
||||
### 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.
|
||||
|
||||
| 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 | - |
|
||||
| [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 (PS5SX2 on RetroArch 2.0, LRPS2 before it), PSP (PPSSPP), GameCube and Wii (Dolphin). Any other core you install for a system RomM knows (PS1, N64, Dreamcast, Saturn, 3DS and friends) gets picked up too | 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 |
|
||||
| PSSwanStation | PS1 | Full Support | One memory card per game, and states | Its states only load in PSSwanStation |
|
||||
| [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 |
|
||||
| PSFlyCast | Dreamcast | Full Support | Each game's own VMU; the shared VMUs are backed up as RomM memory cards | Save states don't sync yet |
|
||||
| PS64 | Commodore 64 | Full Support | Save disks | Games save into their own disk images. No save states |
|
||||
| PS5CEMU-HAR | Wii U, 3DS, DS | Full Support | Wii U and 3DS saves, 3DS states | 3DS states only load in PS5CEMU-HAR. DS is games and BIOS files only, for now |
|
||||
| [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 | - |
|
||||
| XPSemu | Xbox | Full Support | Not synced | Games and BIOS files only: its saves live inside its hard disk image |
|
||||
| Seedee5 | Philips CD-i | Full Support | Not synced | Games and BIOS files only: it keeps saves as folders, and only after a clean exit |
|
||||
| [ProsperoEden](https://github.com/blackbearreloaded/ProsperoEden) | Switch | Full Support | Not synced | Games and keys only: saves are kept per Switch user profile |
|
||||
| [PS5X360](https://github.com/BrinooTk/PS5X360) | Xbox 360 | Manual Support | Researching | - |
|
||||
| Mednafen | PS1, NES, SNES, Game Boy, Game Boy Color, Game Boy Advance, Genesis/Mega Drive, Master System, Game Gear, PC Engine, Neo Geo Pocket, WonderSwan, Virtual Boy, Lynx | Full Support | Full Support | Built-in profile, off until you turn it on |
|
||||
| PS2 ISOs (for PS2 FPKG tools) | PS2 | Full Support | Not synced | Downloads PS2 ISOs to a USB drive (`/mnt/usb0/ps2`) for PS2 FPKG tools. Off until you turn it on |
|
||||
|
||||
|
||||
To give some meaning to the table above. **Full support** means RommPS can detect and properly set game/save directories for that emulator. **Manual Support** means you can use it to sync games and/or saves, but you'll need to point it to the directories yourself. This typically means that full support is coming shortly. Broken or N/A means there was a problem keeping us from sync games and saves, or the emulator just fall out doesn't work or crashes. **Researching** means I am seeing what is possible when it comes to that sync type for the given emulator or game system.
|
||||
|
||||
### Important: 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 file version, and version of the app.
|
||||
|
||||
**Simply put**:
|
||||
- Emulator core must be the same.
|
||||
- Different schema versions of save states are sometimes incompatible with each other.
|
||||
|
||||
For the 1.2.0 release I will be including some fixes as well as some conversion logic to try and convert save state formats for max compatibility whenever possible. This is also why we have an upload only or upload and download option for save states. Maybe you don't care about syncing, but want to back them up, in case something happens. That's a good use case. I want to be blunt with you: Save states are kind of useless. Yes, they're great for recovering your progress after you feel asleep and forgot to save, but you truly should be saving progress with the in-game saves, not save status.
|
||||
|
||||
### 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.
|
||||
| Emulator | Systems | Saves | Comments |
|
||||
| --- | --- | --- | --- |
|
||||
| RetroArch | The RetroArch systems above, wherever the PS4 build has the core | Saves and states | - |
|
||||
| pSNES | SNES | Saves and states | - |
|
||||
| pNES | NES, Famicom Disk System | Saves and states | - |
|
||||
| pGEN | Genesis/Mega Drive, Master System, Game Gear, Sega CD, SG-1000 | Saves and states | - |
|
||||
| pGBA | Game Boy Advance, Game Boy Color, Game Boy | Saves and states | Saves and states share its saves folder |
|
||||
| pFBN | Arcade (FinalBurn Neo), Neo Geo Pocket (Color), ColecoVision, MSX, ZX Spectrum, SuperGrafx, Channel F | Saves and states | Arcade saves are the games' own memory (EEPROMs), backed up as they are |
|
||||
| mGBA | Game Boy Advance, Game Boy Color, Game Boy | Saves and states | - |
|
||||
| SNESUI | SNES | Saves and states | - |
|
||||
| EmuSuNi | SNES, NES, N64 | Saves and states | Set its N64 folder to `/data/EmuSuNi/n64` in its settings |
|
||||
| NPS64 | N64 | Saves and states | `.z64` dumps |
|
||||
| DolphinPS4 | GameCube, Wii | Saves and states | - |
|
||||
| Resonance | Dreamcast | Each game's own VMU, and states; the shared VMUs are backed up as RomM memory cards | - |
|
||||
| PS4SX2 | PS2 | Its two shared memory cards, backed up to RomM (5.3 or newer) | No save states yet |
|
||||
| Mednafen, PS2 ISOs | As on the PS5 | As on the PS5 | Built-in profiles, off until you turn them on |
|
||||
|
||||
### Save states
|
||||
Save states only load in the emulator core that made them, sometimes only in the same version of it. RommPS backs every state up to RomM, and with **Save states: Back up and restore** in Settings it brings one down only to an emulator that can load it:
|
||||
@@ -97,9 +107,12 @@ If you are interested in downloading PS4 and PS5 games from your RomM server, Ro
|
||||
|
||||
Install **RommPS** from [ProsperoStore](https://homebrew.page) and open it. It'll set up the background daemon, and take you through a setup wizard. That's it: RommPS carries the RomM Sync payload and starts it for you. From then on it starts with your HEN, so after a jailbreak or waking the console it's ready to go.
|
||||
|
||||
Not using the store? Download `PPSA76677.zip` from the releases page, unzip it and copy the `PPSA76677` folder to `/data/homebrew/` on the console.
|
||||
### Manual Install
|
||||
|
||||
If you'd rather run just the payload, without the app:
|
||||
Not using the store? Download `PPSA76677.zip` from the releases page, unzip it and copy the `PPSA76677` folder to `/data/homebrew/` on the console. Just run the RommPS app and it'll copy the background service for you.
|
||||
|
||||
## Payload Only - No PS5 App
|
||||
If you'd rather run just the payload, without the app and adminster via the Web UI:
|
||||
|
||||
1. Download the latest `romm-sync.elf` from the releases page.
|
||||
2. Send it to the console (or use whichever favorite tool you wish):
|
||||
@@ -108,10 +121,6 @@ If you'd rather run just the payload, without the app:
|
||||
```
|
||||
3. Set it up at `http://<ps5-ip>:8780` from another device.
|
||||
|
||||
On the PS5 the payload doesn't update itself: new versions come with RommPS. Without the app, download the new `romm-sync.elf` yourself.
|
||||
|
||||
RomM Sync 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 RomM Sync 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).
|
||||
|
||||
### 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 unstable and there may be gremlins for now.
|
||||
@@ -145,13 +154,7 @@ The setup walks you through five steps:
|
||||
|
||||
**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.
|
||||
|
||||
## PS2 Memory Cards
|
||||
|
||||
LRPS2 keeps every game on one shared memory card by default. RomM stores saves per game, so pick one of these during setup:
|
||||
- **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 in the web UI. It isn't merged with other devices.
|
||||
|
||||
PS5SX2 used 2 shared memory cards in slots 1 and 2. While it does support per-game memory cards, they need to be specified in a per-game ini file. In 1.2.0, I plan on adding the ability to directly modify those .ini files in PS5SX2 to write the proper per-game memory card path, effectively making RommPS handle per-game memory cards for it gracefully. Until then, I suggest using whichever emulator fits your preferred method (PS5SX2 for shared mem cards or use LRPS2 in retroarch for per-game support). If you have both installed, RommPS will sync game and saves, but they will be separate and different saves (PS5SX2 will download your shared memory cards, LRPS2 will grab the memory card per game). I know...it's confusing, but this is just a side-effect of the boom in emulators being released.
|
||||
|
||||
## Other "Memory Card" Consoles
|
||||
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!
|
||||
|
||||
@@ -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). Saves sync for all three; 3DS and DS save states load only in PS5CEMU-HAR.
|
||||
|
||||
| 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` | `{root}/melonds/saves` | `.sav` | `{rom_stem}{ext}` | `{root}/melonds/states` | `.ml*` | |
|
||||
|
||||
### 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) | | | |
|
||||
@@ -22,13 +22,13 @@ For each one, start from the old version actually installed and set up, then upd
|
||||
|
||||
| From | How | Also check |
|
||||
| --- | --- | --- |
|
||||
| Nothing | ProsperoStore install, open RommPS | setup turns autostart on by default |
|
||||
| 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 | update | autostart stays off, nothing added to HEN folders |
|
||||
| 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
|
||||
|
||||
@@ -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.
|
||||
@@ -4,9 +4,11 @@
|
||||
* 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>
|
||||
@@ -274,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) {
|
||||
@@ -298,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];
|
||||
|
||||
@@ -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,7 +19,7 @@
|
||||
|
||||
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 plat_info_t INFO = {
|
||||
@@ -44,6 +46,11 @@ 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);
|
||||
@@ -95,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) {
|
||||
@@ -103,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 \
|
||||
|
||||
+164
-51
@@ -1,52 +1,165 @@
|
||||
[
|
||||
{
|
||||
"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": "",
|
||||
"platforms": [{"romm": ["snes", "sfam"], "dir": "snes"}]
|
||||
{
|
||||
"format": 1,
|
||||
"version": "2026.10.23.1",
|
||||
"console": "ps4",
|
||||
"emulators": [
|
||||
{
|
||||
"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": ".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"}]
|
||||
}
|
||||
},
|
||||
{
|
||||
"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": ".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"}]
|
||||
}
|
||||
},
|
||||
{
|
||||
"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": ".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"},
|
||||
{"romm": ["gamegear", "gg"], "dir": "gamegear"},
|
||||
{"romm": ["segacd", "sega-cd"], "dir": "megacd", "save_exts": ".brm"},
|
||||
{"romm": ["sg1000"], "dir": "sg1000"}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"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": ".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"},
|
||||
{"romm": ["gb"], "dir": "gb"}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"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}/{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"}]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"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": "",
|
||||
"platforms": [{"romm": ["nes", "famicom"], "dir": "nes"}, {"romm": ["fds"], "dir": "fds"}]
|
||||
}
|
||||
},
|
||||
{
|
||||
"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": "",
|
||||
"platforms": [
|
||||
{"romm": ["genesis", "genesis-slash-megadrive", "megadrive"], "dir": "megadrive"},
|
||||
{"romm": ["sms", "mastersystem"], "dir": "mastersystem"},
|
||||
{"romm": ["gamegear", "gg"], "dir": "gamegear"},
|
||||
{"romm": ["segacd", "sega-cd"], "dir": "megacd", "save_exts": ".brm"},
|
||||
{"romm": ["sg1000"], "dir": "sg1000"}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "pgba", "name": "pGBA", "title_ids": ["PGBA00001"], "detect": ["/data/pgba"],
|
||||
"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": "",
|
||||
"platforms": [
|
||||
{"romm": ["gba"], "dir": "gba"},
|
||||
{"romm": ["gbc"], "dir": "gbc"},
|
||||
{"romm": ["gb"], "dir": "gb"}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "pfbn", "name": "pFBN", "title_ids": ["PFBN00001"], "detect": ["/data/pfba"],
|
||||
"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"}]
|
||||
}
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
+12
-4
@@ -24,6 +24,7 @@
|
||||
#include <sys/stat.h>
|
||||
|
||||
#include "console.h"
|
||||
#include "util.h"
|
||||
#include "emulators_json.h" /* generated from platform/ps4/emulators.json */
|
||||
|
||||
#define LOCK_FILE CONSOLE_LOCK_FILE
|
||||
@@ -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",
|
||||
@@ -79,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);
|
||||
}
|
||||
|
||||
@@ -149,6 +153,10 @@ void plat_describe(char *buf, size_t n) {
|
||||
|
||||
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) {
|
||||
|
||||
+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
|
||||
|
||||
|
||||
@@ -26,8 +26,9 @@ using hui::gfx::Rect;
|
||||
|
||||
constexpr float kMargin = 96.0f;
|
||||
|
||||
// "On this console": covers side by side, each as wide as its shape
|
||||
constexpr float 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;
|
||||
@@ -82,10 +83,11 @@ 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
|
||||
@@ -118,11 +120,6 @@ Rect in_card(float shape, const Rect &card)
|
||||
return r;
|
||||
}
|
||||
|
||||
float shelf_width(App &app, const Game &g)
|
||||
{
|
||||
return kRecentH * cover_shape(app, &g);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
// ------------------------------------------------------------------ shared
|
||||
@@ -187,6 +184,16 @@ Rect fit_cover(float shape, const Rect &box)
|
||||
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)
|
||||
{
|
||||
@@ -223,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);
|
||||
@@ -265,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_;
|
||||
}
|
||||
@@ -282,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;
|
||||
@@ -298,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()))
|
||||
@@ -317,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);
|
||||
@@ -327,22 +326,13 @@ 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);
|
||||
const auto &recent = app.installed();
|
||||
if (recent_ < static_cast<int>(recent.size()))
|
||||
{
|
||||
float x = 0;
|
||||
for (int i = 0; i < recent_; ++i)
|
||||
x += shelf_width(app, recent[static_cast<std::size_t>(i)]) + kRecentGap;
|
||||
recent_scroll_.reveal(x, x + shelf_width(app, recent[static_cast<std::size_t>(recent_)]), 1728.0f, 120.0f);
|
||||
}
|
||||
recent_scroll_.update(dt, 14.0f);
|
||||
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);
|
||||
@@ -358,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.
|
||||
@@ -379,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))
|
||||
{
|
||||
@@ -483,55 +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);
|
||||
char here[64];
|
||||
std::snprintf(here, sizeof here, "On this console \xC2\xB7 %d", static_cast<int>(recent.size()));
|
||||
ui::text(list, fonts.semibold, here, kMargin, 190, 24, section_ == 0 ? look.text : look.muted);
|
||||
list.push_clip({0, 150, 1920, 330});
|
||||
float x = kMargin - recent_scroll_.offset();
|
||||
for (int i = 0; i < static_cast<int>(recent.size()) && x < 1920; ++i)
|
||||
{
|
||||
const Game &g = recent[static_cast<std::size_t>(i)];
|
||||
Rect r{x, 214, shelf_width(app, g), kRecentH};
|
||||
x += r.w + kRecentGap;
|
||||
if (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;
|
||||
@@ -543,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));
|
||||
@@ -562,6 +457,8 @@ void LibraryPage::draw_platforms(hui::gfx::DrawList &list, const hui::ui::Fonts
|
||||
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);
|
||||
@@ -809,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)
|
||||
@@ -846,12 +744,12 @@ void LibraryPage::draw_grid(hui::gfx::DrawList &list, const hui::ui::Fonts &font
|
||||
}
|
||||
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();
|
||||
}
|
||||
@@ -1084,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
|
||||
@@ -40,6 +39,9 @@ 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);
|
||||
@@ -80,10 +82,9 @@ 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);
|
||||
@@ -108,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,6 +4,8 @@
|
||||
// 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
|
||||
|
||||
@@ -31,8 +33,8 @@ 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
|
||||
@@ -55,6 +57,31 @@ int version_cmp(const std::string &a, const std::string &b)
|
||||
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";
|
||||
@@ -86,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;
|
||||
@@ -176,6 +429,13 @@ 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;
|
||||
@@ -189,7 +449,7 @@ App::App() : api_("127.0.0.1", payload_port())
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -248,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);
|
||||
@@ -275,8 +535,10 @@ 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;
|
||||
@@ -371,28 +633,14 @@ void App::repair_autostart()
|
||||
std::unique_ptr<cJSON, void (*)(cJSON *)> j(r.json(), cJSON_Delete);
|
||||
if (!r.ok() || !j || !flag(j.get(), "supported"))
|
||||
return;
|
||||
// Setup used to leave autostart off unless it was turned on, so most
|
||||
// people never did. It's switched on once for anyone who never chose
|
||||
// either way; after that their choice stands.
|
||||
if (!flag(j.get(), "enabled") && cJSON_IsFalse(cJSON_GetObjectItemCaseSensitive(j.get(), "chosen")) &&
|
||||
status_.setup_complete)
|
||||
{
|
||||
install_autostart([this](const Response &a) {
|
||||
if (a.ok())
|
||||
say("RommPS now starts with the console. You can turn this off in Set up again.");
|
||||
});
|
||||
return;
|
||||
}
|
||||
bool old = false;
|
||||
// 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)
|
||||
return;
|
||||
api_.post("/api/autostart/install", bundled_payload(), [this](const Response &a) {
|
||||
if (!a.ok())
|
||||
say("Couldn't update RommPS's autostart: " + a.message());
|
||||
});
|
||||
if (old)
|
||||
install_autostart(nullptr);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -463,7 +711,7 @@ void App::refresh_status()
|
||||
}
|
||||
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);
|
||||
@@ -475,7 +723,7 @@ void App::refresh_status()
|
||||
// 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_downloads_ == 0;
|
||||
const bool idle = !flag(cJSON_GetObjectItemCaseSensitive(j.get(), "sync"), "running") && active_queue_ == 0;
|
||||
if (payload_too_old() && !updated_payload_ && idle)
|
||||
{
|
||||
updated_payload_ = true;
|
||||
@@ -487,16 +735,22 @@ void App::refresh_status()
|
||||
repaired_ = true;
|
||||
repair_autostart();
|
||||
}
|
||||
|
||||
if (!presets_checked_ && !payload_too_old()) {
|
||||
presets_checked_ = true;
|
||||
api_.post("/api/presets/check", "{}", [](const Response &) {});
|
||||
}
|
||||
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"));
|
||||
@@ -504,7 +758,7 @@ 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");
|
||||
@@ -514,12 +768,20 @@ void App::refresh_status()
|
||||
cJSON_ArrayForEach(folder, cJSON_GetObjectItemCaseSensitive(move, "folders"))
|
||||
{
|
||||
const std::string from = str(folder, "from");
|
||||
status_.retroarch_folders += (status_.retroarch_folders.empty() ? "" : ", ") + from.substr(from.find_last_of('/') + 1);
|
||||
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
|
||||
@@ -527,7 +789,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");
|
||||
}
|
||||
@@ -562,7 +824,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));
|
||||
@@ -652,7 +914,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;
|
||||
@@ -691,6 +953,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"));
|
||||
}
|
||||
@@ -732,17 +995,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=1000", [this](const Response &r) { // every one: the shelf's count is theirs
|
||||
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())
|
||||
{
|
||||
@@ -754,12 +1037,47 @@ void App::refresh_installed()
|
||||
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;
|
||||
@@ -783,12 +1101,12 @@ float App::cover_aspect(const std::string &path, double *since) const
|
||||
return found->second.aspect;
|
||||
}
|
||||
|
||||
void App::refresh_downloads()
|
||||
void App::refresh_queue()
|
||||
{
|
||||
downloads_timer_ = 0;
|
||||
downloads_busy_ = true;
|
||||
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);
|
||||
@@ -804,7 +1122,7 @@ 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");
|
||||
@@ -814,13 +1132,13 @@ void App::refresh_downloads()
|
||||
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)
|
||||
{
|
||||
@@ -848,7 +1166,7 @@ std::string Download::saves_note() const
|
||||
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;
|
||||
@@ -866,16 +1184,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();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -886,7 +1204,18 @@ 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();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -903,7 +1232,7 @@ void App::remove_game(int rom_id)
|
||||
say("Game removed. Its saves are kept.");
|
||||
lists_.clear();
|
||||
refresh_installed();
|
||||
refresh_downloads();
|
||||
refresh_queue();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -971,8 +1300,8 @@ 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");
|
||||
@@ -1033,12 +1362,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
|
||||
{
|
||||
|
||||
@@ -55,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
|
||||
};
|
||||
@@ -71,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
|
||||
@@ -154,6 +174,7 @@ struct Download
|
||||
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;
|
||||
};
|
||||
|
||||
@@ -163,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;
|
||||
@@ -227,7 +249,7 @@ class App
|
||||
// (assets/rommps/romm-sync.elf, version kMinPayload): it starts it when
|
||||
// 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.2.1";
|
||||
static constexpr const char *kMinPayload = "1.3.0";
|
||||
bool payload_too_old() const;
|
||||
void start_payload(); // sends the payload to the HEN's loader
|
||||
// Sending it, or RomM Sync is running and not answering yet (starting up,
|
||||
@@ -237,7 +259,7 @@ class App
|
||||
// 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;
|
||||
@@ -249,6 +271,12 @@ 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();
|
||||
@@ -261,6 +289,8 @@ class App
|
||||
}
|
||||
// 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;
|
||||
@@ -268,8 +298,9 @@ class App
|
||||
|
||||
void download(int rom_id, const std::string &name);
|
||||
void cancel_download(int job_id);
|
||||
void retry_download(int job_id);
|
||||
void remove_game(int rom_id);
|
||||
void clear_finished_downloads();
|
||||
void clear_finished_queue();
|
||||
void sync_now();
|
||||
|
||||
const Config &config() const { return config_; }
|
||||
@@ -312,14 +343,13 @@ class App
|
||||
// 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();
|
||||
// Brings the copies the console starts RomM Sync from up to the one
|
||||
// RommPS carries, leaving autostart on or off as it is; turns it on once
|
||||
// for anyone who never chose either way (1.1.0's setup left it off).
|
||||
// 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
|
||||
@@ -338,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
|
||||
{
|
||||
@@ -364,18 +397,18 @@ class App
|
||||
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, repaired_ = false;
|
||||
bool tried_start_ = false, updated_payload_ = false, repaired_ = false, presets_checked_ = false;
|
||||
bool payload_waiting_ = false;
|
||||
int unanswered_ = 0; // status requests in a row RomM Sync didn't answer
|
||||
int active_downloads_ = 0;
|
||||
int active_queue_ = 0;
|
||||
std::string toast_;
|
||||
float toast_age_ = 0;
|
||||
};
|
||||
@@ -383,10 +416,24 @@ class App
|
||||
// The program's one App, created on first use.
|
||||
App &app();
|
||||
|
||||
// Text for the kit's fonts, which are baked for ASCII: accented letters fold
|
||||
// to plain ones ("Pokémon" reads "Pokemon").
|
||||
// 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,6 +3,8 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#include "rows.hpp"
|
||||
|
||||
#include "rommps_app.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstdio>
|
||||
#include <cctype>
|
||||
@@ -136,7 +138,7 @@ void RowList::draw(hui::gfx::DrawList &list, const hui::ui::Fonts &fonts, const
|
||||
continue;
|
||||
if (row.kind == RowKind::header)
|
||||
{
|
||||
std::string caps = row.label;
|
||||
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);
|
||||
@@ -164,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)
|
||||
@@ -196,10 +198,25 @@ void RowList::draw(hui::gfx::DrawList &list, const hui::ui::Fonts &fonts, const
|
||||
list.line(cx, cy + 4, cx + 8, cy - 3, 3.0f, c);
|
||||
}
|
||||
}
|
||||
const float room = std::max(200.0f, bounds.w - 64 - used - indent);
|
||||
ui::text(list, fonts.semibold, fonts.semibold.font->fit(row.label, 26, room), x + indent, label_y, 26, ink.with_alpha(dim));
|
||||
// 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 + indent, 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"
|
||||
@@ -38,6 +40,11 @@ struct Row
|
||||
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
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// RommPS - the screen: Home, Library, Downloads and Settings for the RomM Sync payload.
|
||||
// RommPS - the screen: Home, Library, Downloads, Queue and Settings for the RomM Sync payload.
|
||||
// Copyright (C) 2026 BlackBearReloaded (the Aurora Shelf design it grew from)
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
//
|
||||
@@ -7,14 +7,15 @@
|
||||
// from the payload through rommps_app.hpp; the screen only draws it and sends
|
||||
// what the pad asks for.
|
||||
//
|
||||
// - L1 / R1 move between Home, Library and Downloads.
|
||||
// - L1 / R1 move between Home, Library, Downloads, Queue and Settings.
|
||||
// - Home: the sync at a glance; Cross syncs now. Beside it: the conflicts
|
||||
// to settle, the sync log and the saves kept in sync (sync_views.hpp).
|
||||
// - Library (library_page.hpp): the games on this console and RomM's
|
||||
// platforms; a platform's games in a grid with an A to Z bar; search. The
|
||||
// backdrop eases toward the focused cover's colours; Cross opens a frosted
|
||||
// sheet to download the game.
|
||||
// - Downloads: what's queued and running; Cross cancels one.
|
||||
// - Library (library_page.hpp): RomM's platforms; a platform's games in a
|
||||
// grid with an A to Z bar; search. The backdrop eases toward the focused
|
||||
// cover's colours; Cross opens a frosted sheet to download the game.
|
||||
// - Downloads: the games on this console, most recently played first;
|
||||
// Cross opens one's details sheet, where a held Cross deletes it.
|
||||
// - Queue: what's queued and running; Cross cancels one.
|
||||
// - Settings: the look, the payload's settings, its updates, the setup.
|
||||
//
|
||||
// Until the payload answers the screen says so; until it's paired with RomM
|
||||
@@ -36,6 +37,7 @@
|
||||
#include "ui/motion.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cctype>
|
||||
#include <cmath>
|
||||
#include <cstdio>
|
||||
#include <ctime>
|
||||
@@ -62,10 +64,11 @@ enum Tab
|
||||
kHome,
|
||||
kLibrary,
|
||||
kDownloads,
|
||||
kQueue,
|
||||
kSettings,
|
||||
kTabs
|
||||
};
|
||||
constexpr const char *kTabNames[kTabs] = {"Home", "Library", "Downloads", "Settings"};
|
||||
constexpr const char *kTabNames[kTabs] = {"Home", "Library", "Downloads", "Queue", "Settings"};
|
||||
|
||||
constexpr const char *kTechniques[] = {
|
||||
"The background service's state, read through its local API on a worker thread",
|
||||
@@ -95,6 +98,8 @@ class RommPS final : public app::Concept
|
||||
public:
|
||||
explicit RommPS(app::Context &context) : context_(context), app_(rommps::app())
|
||||
{
|
||||
// Before the first answer is read (that's in update): names keep what these fonts can draw
|
||||
rommps::set_text_fonts(context.fonts);
|
||||
apply_palette(rommps::Palette{}, true);
|
||||
}
|
||||
|
||||
@@ -119,6 +124,15 @@ class RommPS final : public app::Concept
|
||||
}
|
||||
|
||||
void update(const InputFrame &input, float dt, app::Feedback &feedback) override
|
||||
{
|
||||
update_frame(input, dt, feedback);
|
||||
// Interface sounds turned off in Settings: what this frame asked for
|
||||
// isn't played. Rumble still is.
|
||||
if (!rommps::ui_sounds())
|
||||
feedback.cues.clear();
|
||||
}
|
||||
|
||||
void update_frame(const InputFrame &input, float dt, app::Feedback &feedback)
|
||||
{
|
||||
age_ += dt;
|
||||
clock_ += dt;
|
||||
@@ -139,6 +153,8 @@ class RommPS final : public app::Concept
|
||||
feedback.play(audio::Cue::tab);
|
||||
if (tab_ == kSettings)
|
||||
settings_.enter(app_);
|
||||
if (tab_ == kDownloads)
|
||||
app_.refresh_installed();
|
||||
if (tab_ == kLibrary)
|
||||
{
|
||||
library_.enter(app_);
|
||||
@@ -191,7 +207,14 @@ class RommPS final : public app::Concept
|
||||
open_sheet();
|
||||
}
|
||||
else if (tab_ == kDownloads)
|
||||
update_downloads(input, dt, feedback);
|
||||
{
|
||||
if (sheet_open_)
|
||||
update_sheet(input, dt, feedback);
|
||||
else
|
||||
update_downloads(input, dt, feedback);
|
||||
}
|
||||
else if (tab_ == kQueue)
|
||||
update_queue(input, dt, feedback);
|
||||
else
|
||||
settings_.update(app_, input, dt, feedback);
|
||||
|
||||
@@ -199,10 +222,12 @@ class RommPS final : public app::Concept
|
||||
tab_age_ += dt;
|
||||
tab_position_.target = static_cast<float>(tab_);
|
||||
tab_position_.update(dt, 16.0f);
|
||||
// The backdrop eases toward the focused game's cover (or platform's).
|
||||
if (tab_ == kLibrary)
|
||||
// The backdrop eases toward the focused game's cover (or the platform's).
|
||||
if (tab_ == kLibrary || tab_ == kDownloads)
|
||||
{
|
||||
const std::string cover = sheet_open_ ? sheet_game_.cover : library_.backdrop_cover(app_);
|
||||
const std::string cover = sheet_open_ ? sheet_game_.cover
|
||||
: tab_ == kLibrary ? library_.backdrop_cover(app_)
|
||||
: (downloaded_game() ? downloaded_game()->cover : "");
|
||||
if (!cover.empty())
|
||||
apply_palette(app_.cover(cover).palette, false);
|
||||
}
|
||||
@@ -213,14 +238,26 @@ class RommPS final : public app::Concept
|
||||
sheet_.update(dt, context_.settings.reduced_motion ? 40.0f : 13.0f);
|
||||
action_position_.target = static_cast<float>(action_);
|
||||
action_position_.update(dt, 22.0f);
|
||||
download_row_ = std::clamp(download_row_, 0, std::max(0, static_cast<int>(app_.downloads().size()) - 1));
|
||||
summary_scroll_.target = summary_target_;
|
||||
summary_scroll_.update(dt, 14.0f);
|
||||
queue_row_ = std::clamp(queue_row_, 0, std::max(0, static_cast<int>(app_.queue().size()) - 1));
|
||||
if (!cancel_seen_) // focus left the download (another row, tab or button): the hold is gone
|
||||
{
|
||||
cancel_hold_ = 0;
|
||||
cancel_job_ = 0;
|
||||
}
|
||||
download_position_.target = static_cast<float>(download_row_);
|
||||
download_position_.update(dt, 20.0f);
|
||||
queue_position_.target = static_cast<float>(queue_row_);
|
||||
queue_position_.update(dt, 20.0f);
|
||||
// The six-row window, eased so the list scrolls with the focus.
|
||||
const int qcount = static_cast<int>(app_.queue().size());
|
||||
queue_scroll_.target = static_cast<float>(std::clamp(queue_row_ - 5, 0, std::max(0, qcount - 6)));
|
||||
queue_scroll_.update(dt, 14.0f);
|
||||
|
||||
installed_position_.target = static_cast<float>(installed_row_);
|
||||
installed_position_.update(dt, 20.0f);
|
||||
const int icount = static_cast<int>(app_.installed().size());
|
||||
installed_scroll_.target = static_cast<float>(std::clamp(installed_row_ - 5, 0, std::max(0, icount - 6)));
|
||||
installed_scroll_.update(dt, 14.0f);
|
||||
}
|
||||
|
||||
void draw(app::Frame &frame) const override
|
||||
@@ -253,6 +290,8 @@ class RommPS final : public app::Concept
|
||||
draw_library(list);
|
||||
else if (tab_ == kDownloads)
|
||||
draw_downloads(list);
|
||||
else if (tab_ == kQueue)
|
||||
draw_queue(list);
|
||||
else
|
||||
settings_.draw(list, context_.fonts, L, app_, clock_, tab_age_);
|
||||
list.pop_transform();
|
||||
@@ -269,9 +308,11 @@ class RommPS final : public app::Concept
|
||||
}
|
||||
if (move_open_)
|
||||
draw_move(frame.overlay);
|
||||
draw_toast(back > 0.5f || move_open_ ? frame.overlay : frame.scene);
|
||||
// The hints first: the toast keeps clear of them.
|
||||
hints_left_ = 1920.0f;
|
||||
if (!move_open_)
|
||||
draw_hints(back > 0.5f ? frame.overlay : frame.scene);
|
||||
draw_toast(back > 0.5f || move_open_ ? frame.overlay : frame.scene);
|
||||
}
|
||||
|
||||
private:
|
||||
@@ -300,6 +341,15 @@ class RommPS final : public app::Concept
|
||||
return tab_ == kLibrary && !sheet_open_ ? library_.focused(app_) : nullptr;
|
||||
}
|
||||
|
||||
// The game the Downloads list's focus is on (nullptr when there is none).
|
||||
const rommps::Game *downloaded_game() const
|
||||
{
|
||||
const auto &games = app_.installed();
|
||||
return installed_row_ >= 0 && installed_row_ < static_cast<int>(games.size())
|
||||
? &games[static_cast<std::size_t>(installed_row_)]
|
||||
: nullptr;
|
||||
}
|
||||
|
||||
// The backdrop's four colours, from the focused cover (or the default).
|
||||
void apply_palette(const rommps::Palette &p, bool snap)
|
||||
{
|
||||
@@ -366,15 +416,23 @@ class RommPS final : public app::Concept
|
||||
feedback.play(audio::Cue::select);
|
||||
}
|
||||
|
||||
void open_sheet()
|
||||
void open_sheet(const rommps::Game &g)
|
||||
{
|
||||
const rommps::Game *g = library_.focused(app_);
|
||||
if (!g)
|
||||
return;
|
||||
sheet_game_ = *g;
|
||||
sheet_game_ = g;
|
||||
app_.check_on_console(g.id);
|
||||
sheet_open_ = true;
|
||||
addons_open_ = false;
|
||||
remove_hold_ = -1; // a hold from before doesn't carry over
|
||||
action_ = 0;
|
||||
action_position_.snap(0.0f);
|
||||
summary_scroll_.snap(0.0f);
|
||||
summary_target_ = summary_hold_ = summary_max_ = 0.0f;
|
||||
}
|
||||
|
||||
void open_sheet()
|
||||
{
|
||||
if (const rommps::Game *g = library_.focused(app_))
|
||||
open_sheet(*g);
|
||||
}
|
||||
|
||||
// The sheet's actions for the focused game, and whether each can be used.
|
||||
@@ -382,7 +440,7 @@ class RommPS final : public app::Concept
|
||||
{
|
||||
std::string label;
|
||||
bool enabled;
|
||||
int kind; // 0 download, 1 cancel, 3 remove, 4 saves and states (Circle closes the sheet)
|
||||
int kind; // 0 download, 1 cancel, 3 remove, 4 saves and states, 5 updates and DLC (Circle closes the sheet)
|
||||
};
|
||||
|
||||
std::vector<SheetAction> sheet_actions() const
|
||||
@@ -391,19 +449,27 @@ class RommPS final : public app::Concept
|
||||
const rommps::Game *g = &sheet_game_;
|
||||
const rommps::Platform *p = app_.platform_by_id(sheet_game_.platform_id);
|
||||
const rommps::Download *d = g ? app_.download_for(g->id) : nullptr;
|
||||
const rommps::Details &info = app_.details(sheet_game_.id);
|
||||
if (d && d->syncing())
|
||||
actions.push_back({"Syncing saves", false, 0});
|
||||
else if (d && d->active())
|
||||
actions.push_back({"Hold to cancel download", true, 1});
|
||||
else if (g && g->installed)
|
||||
else if (g && app_.on_console(*g))
|
||||
{
|
||||
actions.push_back({"Hold to delete this game", true, 3});
|
||||
actions.push_back({"Saves and states", true, 4});
|
||||
}
|
||||
else if (p && p->profile.empty())
|
||||
actions.push_back({"No emulator for this platform", false, 0});
|
||||
else if (g->archived || !info.archived.empty())
|
||||
actions.push_back({"Archived: can't download", false, 0});
|
||||
else
|
||||
actions.push_back({"Download", true, 0});
|
||||
// A Switch game's update and DLC, which come with it.
|
||||
if (!info.addons.empty())
|
||||
actions.push_back({std::string(info.bundle ? "In this folder (" : "Updates and DLC (") +
|
||||
std::to_string(info.addons.size()) + ")",
|
||||
true, 5});
|
||||
return actions;
|
||||
}
|
||||
|
||||
@@ -476,11 +542,43 @@ class RommPS final : public app::Concept
|
||||
return true;
|
||||
}
|
||||
|
||||
// Rows of the update and DLC list that fit the sheet at once.
|
||||
static constexpr int kAddonRows = 6;
|
||||
|
||||
void update_sheet(const InputFrame &input, float dt, app::Feedback &feedback)
|
||||
{
|
||||
if (addons_open_)
|
||||
{
|
||||
const int n = static_cast<int>(app_.details(sheet_game_.id).addons.size());
|
||||
if (input.nav == Direction::up || input.nav == Direction::down)
|
||||
{
|
||||
const int next = std::clamp(addons_top_ + (input.nav == Direction::down ? 1 : -1), 0,
|
||||
std::max(0, n - kAddonRows));
|
||||
if (next != addons_top_)
|
||||
{
|
||||
addons_top_ = next;
|
||||
feedback.play(audio::Cue::focus, 1.0f, 0.35f);
|
||||
}
|
||||
}
|
||||
if (input.is_pressed(Action::back) || input.is_pressed(Action::confirm))
|
||||
{
|
||||
feedback.play(audio::Cue::back);
|
||||
addons_open_ = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
const std::vector<SheetAction> actions = sheet_actions();
|
||||
const int count = static_cast<int>(actions.size());
|
||||
if (input.nav == Direction::up || input.nav == Direction::down)
|
||||
// A press of Up or Down moves between the actions; holding it (D-pad or
|
||||
// left stick) past the repeat delay scrolls a long description instead.
|
||||
const float held = input.is_held(Action::down) || input.stick_y > 0.5f ? 1.0f
|
||||
: input.is_held(Action::up) || input.stick_y < -0.5f ? -1.0f
|
||||
: 0.0f;
|
||||
summary_hold_ = held != 0.0f ? summary_hold_ + dt : 0.0f;
|
||||
const bool scrolling = summary_max_ > 0.0f && summary_hold_ > 0.35f;
|
||||
if (scrolling)
|
||||
summary_target_ = std::clamp(summary_target_ + held * 160.0f * dt, 0.0f, summary_max_);
|
||||
if ((input.nav == Direction::up || input.nav == Direction::down) && !(scrolling || (input.nav_repeat && summary_max_ > 0.0f)))
|
||||
{
|
||||
const int next = std::clamp(action_ + (input.nav == Direction::down ? 1 : -1), 0, count - 1);
|
||||
if (next != action_)
|
||||
@@ -521,6 +619,12 @@ class RommPS final : public app::Concept
|
||||
sheet_open_ = false;
|
||||
view_.open(app_, rommps::SyncView::kGame, g->id, g->name);
|
||||
}
|
||||
else if (a.kind == 5)
|
||||
{
|
||||
feedback.play(audio::Cue::modal_open);
|
||||
addons_open_ = true;
|
||||
addons_top_ = 0;
|
||||
}
|
||||
else
|
||||
{
|
||||
feedback.play(audio::Cue::modal_close);
|
||||
@@ -590,17 +694,18 @@ class RommPS final : public app::Concept
|
||||
clock_);
|
||||
}
|
||||
|
||||
void update_downloads(const InputFrame &input, float dt, app::Feedback &feedback)
|
||||
// The transfer tab: what's queued and running.
|
||||
void update_queue(const InputFrame &input, float dt, app::Feedback &feedback)
|
||||
{
|
||||
const int count = static_cast<int>(app_.downloads().size());
|
||||
const int count = static_cast<int>(app_.queue().size());
|
||||
// The list can get shorter at any tick (finished ones cleared, the payload restarted).
|
||||
download_row_ = std::clamp(download_row_, 0, std::max(0, count - 1));
|
||||
queue_row_ = std::clamp(queue_row_, 0, std::max(0, count - 1));
|
||||
if ((input.nav == Direction::up || input.nav == Direction::down) && count > 0)
|
||||
{
|
||||
const int next = std::clamp(download_row_ + (input.nav == Direction::down ? 1 : -1), 0, count - 1);
|
||||
if (next != download_row_)
|
||||
const int next = std::clamp(queue_row_ + (input.nav == Direction::down ? 1 : -1), 0, count - 1);
|
||||
if (next != queue_row_)
|
||||
{
|
||||
download_row_ = next;
|
||||
queue_row_ = next;
|
||||
feedback.play(audio::Cue::focus);
|
||||
}
|
||||
else if (!input.nav_repeat)
|
||||
@@ -608,26 +713,55 @@ class RommPS final : public app::Concept
|
||||
}
|
||||
if (input.is_pressed(Action::west) && count > 0)
|
||||
{
|
||||
const auto &downloads = app_.downloads();
|
||||
const auto &downloads = app_.queue();
|
||||
if (std::any_of(downloads.begin(), downloads.end(), [](const rommps::Download &d) { return d.finished(); }))
|
||||
{
|
||||
app_.clear_finished_downloads();
|
||||
download_row_ = 0;
|
||||
app_.clear_finished_queue();
|
||||
queue_row_ = 0;
|
||||
feedback.play(audio::Cue::back);
|
||||
}
|
||||
else
|
||||
refuse(feedback, 0.0f);
|
||||
}
|
||||
if (count > 0 && download_row_ < static_cast<int>(app_.downloads().size()))
|
||||
if (count > 0 && queue_row_ < static_cast<int>(app_.queue().size()))
|
||||
{
|
||||
const rommps::Download &d = app_.downloads()[static_cast<std::size_t>(download_row_)];
|
||||
const rommps::Download &d = app_.queue()[static_cast<std::size_t>(queue_row_)];
|
||||
if (d.cancellable())
|
||||
hold_to_cancel(input, dt, &d, feedback);
|
||||
else if (d.retryable() && input.is_pressed(Action::confirm))
|
||||
{
|
||||
app_.retry_download(d.id);
|
||||
feedback.play(audio::Cue::select);
|
||||
}
|
||||
else if (input.is_pressed(Action::confirm))
|
||||
refuse(feedback, 0.0f);
|
||||
}
|
||||
}
|
||||
|
||||
// The Downloads tab: the games on this console, one per row.
|
||||
void update_downloads(const InputFrame &input, float dt, app::Feedback &feedback)
|
||||
{
|
||||
const int count = static_cast<int>(app_.installed().size());
|
||||
// The list can get shorter at any tick (a game removed on its sheet).
|
||||
installed_row_ = std::clamp(installed_row_, 0, std::max(0, count - 1));
|
||||
if ((input.nav == Direction::up || input.nav == Direction::down) && count > 0)
|
||||
{
|
||||
const int next = std::clamp(installed_row_ + (input.nav == Direction::down ? 1 : -1), 0, count - 1);
|
||||
if (next != installed_row_)
|
||||
{
|
||||
installed_row_ = next;
|
||||
feedback.play(audio::Cue::focus);
|
||||
}
|
||||
else if (!input.nav_repeat)
|
||||
refuse(feedback, 0.0f);
|
||||
}
|
||||
if (input.is_pressed(Action::confirm) && count > 0)
|
||||
{
|
||||
feedback.play(audio::Cue::open);
|
||||
open_sheet(*downloaded_game());
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- drawing
|
||||
|
||||
void draw_top_bar(gfx::DrawList &list) const
|
||||
@@ -655,15 +789,47 @@ class RommPS final : public app::Concept
|
||||
// Right: the server, or what's wrong.
|
||||
const rommps::Status &s = app_.status();
|
||||
const bool old_payload = s.reachable && app_.payload_too_old();
|
||||
std::string where = !s.known ? "Connecting"
|
||||
: !s.reachable ? "The background service isn't running"
|
||||
: old_payload ? "Updating the background service " + s.version
|
||||
: s.paired ? s.user + " @ " + s.server
|
||||
: "Not paired";
|
||||
const float dot_x = 1824 - 12 - ui::text(list, fonts.regular, context_.fonts.regular.font->fit(where, 22, 640), 1824, 90,
|
||||
22, L.muted, gfx::Align::right);
|
||||
const bool connected = s.known && s.reachable && !old_payload && s.paired;
|
||||
float left = 1824; // where the line starts, for the dot
|
||||
if (connected)
|
||||
{
|
||||
// "<user> @ host:port", the user in bold, and a lock when it's HTTPS.
|
||||
const std::size_t scheme = s.server.find("://");
|
||||
std::string host = scheme == std::string::npos ? s.server : s.server.substr(scheme + 3);
|
||||
host = host.substr(0, host.find('/'));
|
||||
if (s.server.rfind("https://", 0) == 0)
|
||||
{
|
||||
const Color c = L.muted;
|
||||
list.bordered_rect({left - 11, 74, 8, 12}, 4, Color::rgb(0x000000, 0.0f), 2.0f, c); // shackle
|
||||
list.rounded_rect({left - 14, 80, 14, 11}, 2, c); // body
|
||||
left -= 14 + 10;
|
||||
}
|
||||
left -= ui::text(list, fonts.regular, fonts.regular.font->fit(rommps::plain_text((" @ " + host).c_str()), 22, 460), left,
|
||||
90, 22, L.muted, gfx::Align::right);
|
||||
left -= ui::text(list, fonts.semibold, fonts.semibold.font->fit(s.user, 22, 240), left, 90, 22, L.text,
|
||||
gfx::Align::right);
|
||||
}
|
||||
else
|
||||
{
|
||||
const std::string where = !s.known ? "Connecting"
|
||||
: !s.reachable ? "The background service isn't running"
|
||||
: old_payload ? "Updating the background service " + s.version
|
||||
: "Not paired";
|
||||
left -= ui::text(list, fonts.regular, context_.fonts.regular.font->fit(where, 22, 640), left, 90, 22, L.muted,
|
||||
gfx::Align::right);
|
||||
}
|
||||
const float dot_x = left - 12;
|
||||
// RomM not answering: the dot turns amber, and a line under it pulses.
|
||||
const bool trouble = s.reachable && s.paired && !old_payload && !s.server_trouble.empty();
|
||||
const float pulse = 0.55f + 0.45f * std::sin(age_ * 3.0f);
|
||||
list.circle(dot_x - 12, 83, 7,
|
||||
s.reachable && s.paired && !old_payload ? L.success : s.reachable ? L.warning : L.danger);
|
||||
trouble ? L.warning.with_alpha(pulse)
|
||||
: s.reachable && s.paired && !old_payload ? L.success
|
||||
: s.reachable ? L.warning
|
||||
: L.danger);
|
||||
if (trouble)
|
||||
ui::text(list, fonts.semibold, "Difficulties connecting to server", 1824, 122, 20, L.warning.with_alpha(pulse),
|
||||
gfx::Align::right);
|
||||
list.pop_opacity();
|
||||
}
|
||||
|
||||
@@ -694,8 +860,8 @@ class RommPS final : public app::Concept
|
||||
"with your HEN's payload loader.",
|
||||
x, panel.y + 190, 28, 1060, 42, L.panel_muted, 4);
|
||||
if (!starting && !app_.payload_error().empty())
|
||||
ui::text(list, fonts.regular, fonts.regular.font->fit(app_.payload_error(), 22, 1060), x, panel.y + 340, 22,
|
||||
L.panel_muted.with_alpha(0.8f));
|
||||
ui::text(list, fonts.regular, fonts.regular.font->fit(rommps::plain_text(app_.payload_error().c_str()), 22, 1060), x,
|
||||
panel.y + 340, 22, L.panel_muted.with_alpha(0.8f));
|
||||
if (s.known && !s.error.empty())
|
||||
ui::text(list, fonts.regular, fonts.regular.font->fit(s.error, 22, 1060), x, panel.y + 380, 22,
|
||||
L.panel_muted.with_alpha(0.6f));
|
||||
@@ -710,7 +876,6 @@ class RommPS final : public app::Concept
|
||||
const float in = tween::stagger(tab_age_, 1, 0.08f, 0.6f);
|
||||
list.push_opacity(in);
|
||||
const float x = kMargin + 30.0f * (1.0f - in);
|
||||
ui::text(list, fonts.semibold, "SAVE SYNC", x, 212, 20, accent(), gfx::Align::left, 4.0f);
|
||||
if (!app_.config().save_sync)
|
||||
{
|
||||
ui::text(list, fonts.display, "Save sync is off", x - 4, 304, 80, L.text);
|
||||
@@ -794,14 +959,14 @@ class RommPS final : public app::Concept
|
||||
library_.draw(list, context_.fonts, look(), app_, accent(), clock_, tab_age_);
|
||||
}
|
||||
|
||||
void draw_downloads(gfx::DrawList &list) const
|
||||
void draw_queue(gfx::DrawList &list) const
|
||||
{
|
||||
const ui::Fonts &fonts = context_.fonts;
|
||||
const rommps::Look &L = look();
|
||||
const auto &downloads = app_.downloads();
|
||||
const auto &downloads = app_.queue();
|
||||
const float in = tween::stagger(tab_age_, 1, 0.08f, 0.6f);
|
||||
list.push_opacity(in);
|
||||
ui::text(list, fonts.display, "Downloads", kMargin, 240, 64, L.text);
|
||||
ui::text(list, fonts.display, "Queue", kMargin, 240, 64, L.text);
|
||||
// Top right: everything downloading at once, and what's waiting.
|
||||
double total_speed = 0;
|
||||
int running = 0, queued = 0;
|
||||
@@ -838,15 +1003,20 @@ class RommPS final : public app::Concept
|
||||
}
|
||||
constexpr float kRowH = 110.0f;
|
||||
const float top = 300.0f;
|
||||
const int first = std::max(0, download_row_ - 5);
|
||||
list.rounded_rect({kMargin - 24, top + (download_position_.value - static_cast<float>(first)) * kRowH, 1776, kRowH - 14},
|
||||
const float scroll = queue_scroll_.value;
|
||||
// One row past the band on each side, hidden by the clip below.
|
||||
const int first = std::max(0, static_cast<int>(std::floor(scroll)) - 1);
|
||||
list.rounded_rect({kMargin - 24, top + (queue_position_.value - scroll) * kRowH, 1776, kRowH - 14},
|
||||
26, L.text.with_alpha(0.1f));
|
||||
for (int i = first; i < static_cast<int>(downloads.size()) && i < first + 6; ++i)
|
||||
list.push_clip({0, top, 1920, 6 * kRowH});
|
||||
for (int i = first; i < static_cast<int>(downloads.size()) && i < first + 9; ++i)
|
||||
{
|
||||
const rommps::Download &d = downloads[static_cast<std::size_t>(i)];
|
||||
const float y = top + static_cast<float>(i - first) * kRowH;
|
||||
const float y = top + (static_cast<float>(i) - scroll) * kRowH;
|
||||
ui::text(list, fonts.semibold, context_.fonts.semibold.font->fit(d.name, 28, 1100), kMargin, y + 42, 28, L.text);
|
||||
std::string meta = d.platform + " \xC2\xB7 ";
|
||||
// "PlayStation · Waiting". A download that failed before RomM said
|
||||
// which platform the game is on has none: no dot in front then.
|
||||
std::string meta = d.platform.empty() ? "" : d.platform + " \xC2\xB7 ";
|
||||
if (d.running())
|
||||
{
|
||||
char text[96];
|
||||
@@ -854,8 +1024,14 @@ class RommPS final : public app::Concept
|
||||
rommps::human_size(d.total).c_str());
|
||||
meta += text;
|
||||
}
|
||||
else if (d.status == "error")
|
||||
meta += d.error;
|
||||
else if (d.status == "error" && !d.error.empty())
|
||||
{
|
||||
// The service's errors are sentences in lower case; the row's
|
||||
// other states start with a capital.
|
||||
std::string error = d.error;
|
||||
error[0] = static_cast<char>(std::toupper(static_cast<unsigned char>(error[0])));
|
||||
meta += error;
|
||||
}
|
||||
else if (d.syncing())
|
||||
meta += "Syncing saves";
|
||||
else if (d.status == "done" && !d.saves_note().empty())
|
||||
@@ -864,7 +1040,7 @@ class RommPS final : public app::Concept
|
||||
{
|
||||
static const char *const kStates[][2] = {{"queued", "Waiting"},
|
||||
{"cancelling", "Cancelling"},
|
||||
{"cancelled", "Cancelled, nothing kept"},
|
||||
{"cancelled", "Cancelled"},
|
||||
{"done", "Downloaded"}};
|
||||
for (const auto &st : kStates)
|
||||
if (d.status == st[0])
|
||||
@@ -892,6 +1068,66 @@ class RommPS final : public app::Concept
|
||||
if (hold > 0)
|
||||
list.rounded_rect({bar.x, bar.y, std::max(10.0f, bar.w * hold), bar.h}, 5, L.danger);
|
||||
}
|
||||
list.pop_clip();
|
||||
list.pop_opacity();
|
||||
}
|
||||
|
||||
// The Downloads tab: every game this console keeps, as a list.
|
||||
void draw_downloads(gfx::DrawList &list) const
|
||||
{
|
||||
const ui::Fonts &fonts = context_.fonts;
|
||||
const rommps::Look &L = look();
|
||||
const auto &games = app_.installed();
|
||||
const float in = tween::stagger(tab_age_, 1, 0.08f, 0.6f);
|
||||
list.push_opacity(in);
|
||||
ui::text(list, fonts.display, "Downloads", kMargin, 240, 64, L.text);
|
||||
// Top right: how many games this console keeps.
|
||||
if (!games.empty())
|
||||
{
|
||||
char count[48];
|
||||
std::snprintf(count, sizeof count, games.size() == 1 ? "1 game" : "%d games",
|
||||
static_cast<int>(games.size()));
|
||||
ui::text(list, fonts.regular, count, 1824, 240, 24, L.muted, gfx::Align::right);
|
||||
}
|
||||
if (games.empty())
|
||||
{
|
||||
ui::text(list, fonts.regular, "Nothing on this console yet.", kMargin, 310, 28, L.muted);
|
||||
ui::text(list, fonts.regular, "Games you download from your library land here.", kMargin, 348, 22,
|
||||
L.muted.with_alpha(0.7f));
|
||||
list.pop_opacity();
|
||||
return;
|
||||
}
|
||||
constexpr float kRowH = 110.0f;
|
||||
const float top = 300.0f;
|
||||
const float scroll = installed_scroll_.value;
|
||||
// One row past the band on each side, hidden by the clip below.
|
||||
const int first = std::max(0, static_cast<int>(std::floor(scroll)) - 1);
|
||||
list.rounded_rect(
|
||||
{kMargin - 24, top + (installed_position_.value - scroll) * kRowH, 1776, kRowH - 14}, 26,
|
||||
L.text.with_alpha(0.1f));
|
||||
list.push_clip({0, top, 1920, 6 * kRowH});
|
||||
for (int i = first; i < static_cast<int>(games.size()) && i < first + 9; ++i)
|
||||
{
|
||||
const rommps::Game &g = games[static_cast<std::size_t>(i)];
|
||||
const float y = top + (static_cast<float>(i) - scroll) * kRowH;
|
||||
const Rect thumb{kMargin, y + 8, 66, 88};
|
||||
rommps::draw_cover(list, fonts, L, app_, &g, thumb, 1.0f, 10, false);
|
||||
ui::text(list, fonts.semibold, context_.fonts.semibold.font->fit(g.name, 28, 1160), kMargin + 96, y + 42,
|
||||
28, L.text);
|
||||
const rommps::Platform *p = app_.platform_by_id(g.platform_id);
|
||||
std::string meta = (p ? p->name + " \xC2\xB7 " : std::string()) + rommps::human_size(g.size);
|
||||
Color meta_colour = L.muted;
|
||||
if (const rommps::Download *d = app_.download_for(g.id); d && d->active())
|
||||
{
|
||||
char pct[16];
|
||||
std::snprintf(pct, sizeof pct, "%d%%", static_cast<int>(d->progress() * 100.0f));
|
||||
meta = (p ? p->name + " \xC2\xB7 " : std::string()) + "Downloading " + pct;
|
||||
meta_colour = L.text;
|
||||
}
|
||||
ui::text(list, fonts.regular, context_.fonts.regular.font->fit(meta, 22, 1200), kMargin + 96, y + 80, 22,
|
||||
meta_colour);
|
||||
}
|
||||
list.pop_clip();
|
||||
list.pop_opacity();
|
||||
}
|
||||
|
||||
@@ -906,7 +1142,7 @@ class RommPS final : public app::Concept
|
||||
const Color acc = cover_accent(palette);
|
||||
const rommps::Platform *p = app_.platform_by_id(sheet_game_.platform_id);
|
||||
const float t = sheet_.value;
|
||||
constexpr float kHeight = 520.0f;
|
||||
constexpr float kHeight = 620.0f;
|
||||
const Rect sheet{120, gfx::kVirtualHeight - kHeight * t - 40.0f * t + 60.0f * (1.0f - t), 1680, kHeight};
|
||||
list.push_opacity(tween::clamp01(t * 1.4f));
|
||||
list.shadow({sheet.x, sheet.y + 20, sheet.w, sheet.h}, 44, 60, Color::rgb(0x000000, 0.5f));
|
||||
@@ -915,27 +1151,107 @@ class RommPS final : public app::Concept
|
||||
list.bordered_rect(sheet, 44, Color::rgb(0x000000, 0.0f), 1.5f, L.outline);
|
||||
|
||||
// The cover in its own shape, as large as fits the space for it.
|
||||
const Rect space{sheet.x + 56, sheet.y + 56, 300, 400};
|
||||
const Rect space{sheet.x + 56, sheet.y + 64, 330, 480};
|
||||
rommps::draw_cover(list, fonts, L, app_, g, rommps::fit_cover(rommps::cover_shape(app_, g), space), 1.0f, 26);
|
||||
const float x = space.x + space.w + 56;
|
||||
ui::text(list, fonts.semibold, ui::upper(p ? p->name : ""), x, sheet.y + 92, 20, acc, gfx::Align::left, 4.0f);
|
||||
float y = ui::paragraph(list, fonts.display, g->name, x - 2, sheet.y + 156, 54, 640, 62, L.panel_text, 2) - 18;
|
||||
ui::text(list, fonts.semibold, ui::upper(p ? p->name : ""), x, sheet.y + 100, 20, acc, gfx::Align::left, 4.0f);
|
||||
float y = ui::paragraph(list, fonts.display, g->name, x - 2, sheet.y + 170, 54, 640, 62, L.panel_text, 2) - 10;
|
||||
if (g->versions > 1)
|
||||
{
|
||||
const std::string v = g->version + " \xC2\xB7 1 of " + std::to_string(g->versions) + " versions";
|
||||
ui::text(list, fonts.semibold, fonts.semibold.font->fit(v, 22, 660), x, y, 22, L.panel_text.with_alpha(0.7f));
|
||||
y += 38;
|
||||
y += 42;
|
||||
}
|
||||
// RomM's details: year, publisher, genres; the summary; then the numbers.
|
||||
const rommps::Details &info = app_.details(g->id);
|
||||
const std::string about = rommps::details_line(info);
|
||||
if (!about.empty())
|
||||
// A Switch game's title id, as a tag: what to look for in ProsperoEden.
|
||||
if (!info.title_id.empty())
|
||||
{
|
||||
ui::text(list, fonts.semibold, fonts.semibold.font->fit(about, 22, 660), x, y, 22, acc);
|
||||
y += 38;
|
||||
const std::string tag = "TITLE ID " + info.title_id;
|
||||
const float w = fonts.semibold.font->measure(tag, 18) + 28;
|
||||
list.bordered_rect({x, y - 24, w, 34}, 17, acc.with_alpha(0.12f), 1.5f, acc.with_alpha(0.5f));
|
||||
ui::text(list, fonts.semibold, tag, x + 14, y - 1, 18, L.panel_text.with_alpha(0.85f));
|
||||
y += 50;
|
||||
}
|
||||
if (addons_open_)
|
||||
draw_addons(list, info, x, y, sheet.y + sheet.h - 60.0f, acc);
|
||||
else
|
||||
draw_about(list, info, g, p, x, y, sheet, acc);
|
||||
draw_sheet_actions(list, g, sheet);
|
||||
list.pop_opacity();
|
||||
}
|
||||
|
||||
// A Switch game's update and DLC, a row each with its title id; then a
|
||||
// collection's other games and theirs.
|
||||
void draw_addons(gfx::DrawList &list, const rommps::Details &info, float x, float y, float bottom, Color acc) const
|
||||
{
|
||||
const ui::Fonts &fonts = context_.fonts;
|
||||
const rommps::Look &L = look();
|
||||
ui::text(list, fonts.semibold, info.bundle ? "IN THIS FOLDER" : "UPDATES AND DLC", x, y, 20, acc,
|
||||
gfx::Align::left, 4.0f);
|
||||
y += 46;
|
||||
const int n = static_cast<int>(info.addons.size());
|
||||
for (int i = addons_top_; i < n && i < addons_top_ + kAddonRows && y < bottom; ++i, y += 40)
|
||||
{
|
||||
const rommps::Details::Addon &a = info.addons[static_cast<std::size_t>(i)];
|
||||
const bool own = i < n - info.bundle;
|
||||
static const char *const kKind[] = {"Update", "DLC", "Game"};
|
||||
// From the right: the size, the title id, then the name in what's left.
|
||||
const std::string size = rommps::human_size(a.size);
|
||||
const float id_right = x + 660 - fonts.regular.font->measure(size, 20) - 24;
|
||||
const float id_left = a.title_id.empty() ? id_right : id_right - fonts.regular.font->measure(a.title_id, 18);
|
||||
ui::text(list, fonts.semibold, kKind[a.kind], x, y, 20, own ? acc : L.panel_muted);
|
||||
ui::text(list, fonts.regular, fonts.regular.font->fit(a.name, 20, std::max(80.0f, id_left - 20 - (x + 100))),
|
||||
x + 100, y, 20, L.panel_text);
|
||||
if (!a.title_id.empty())
|
||||
ui::text(list, fonts.regular, a.title_id, id_right, y, 18, L.panel_muted, gfx::Align::right);
|
||||
ui::text(list, fonts.regular, size, x + 660, y, 20, L.panel_muted, gfx::Align::right);
|
||||
}
|
||||
if (n > kAddonRows)
|
||||
ui::text(list, fonts.regular,
|
||||
std::to_string(addons_top_ + 1) + "-" + std::to_string(std::min(n, addons_top_ + kAddonRows)) +
|
||||
" of " + std::to_string(n),
|
||||
x, y + 6, 20, L.panel_muted);
|
||||
}
|
||||
|
||||
// RomM's details: year, publisher, genres; the summary; then the numbers.
|
||||
void draw_about(gfx::DrawList &list, const rommps::Details &info, const rommps::Game *g, const rommps::Platform *p,
|
||||
float x, float y, const Rect &sheet, Color acc) const
|
||||
{
|
||||
const ui::Fonts &fonts = context_.fonts;
|
||||
const rommps::Look &L = look();
|
||||
const std::string about = rommps::details_line(info);
|
||||
// RomM's rating closes the line, after a star.
|
||||
char rating[24] = "";
|
||||
if (info.rating > 0)
|
||||
std::snprintf(rating, sizeof rating, info.rating > 10 ? "Rating %.0f/100" : "Rating %.1f/10", info.rating);
|
||||
const float rating_w = rating[0] ? fonts.semibold.font->measure(rating, 22) + 30 : 0.0f;
|
||||
if (!about.empty() || rating[0])
|
||||
{
|
||||
float w = about.empty() ? -24.0f
|
||||
: ui::text(list, fonts.semibold, fonts.semibold.font->fit(about, 22, 660 - rating_w - 24),
|
||||
x, y, 22, acc);
|
||||
if (rating[0])
|
||||
{
|
||||
list.star(x + w + 24 + 9, y - 8, 10, acc);
|
||||
ui::text(list, fonts.semibold, rating, x + w + 24 + 26, y, 22, L.panel_text.with_alpha(0.85f));
|
||||
}
|
||||
y += 52;
|
||||
}
|
||||
if (!info.summary.empty())
|
||||
y = ui::paragraph(list, fonts.regular, info.summary, x, y, 20, 660, 28, L.panel_text.with_alpha(0.85f), 3) + 6;
|
||||
{
|
||||
// Three lines show; holding Up or Down scrolls the rest through.
|
||||
constexpr float kLine = 30.0f, kShown = 3 * kLine;
|
||||
const float lines = static_cast<float>(fonts.regular.font->wrap(info.summary, 20, 660).size());
|
||||
summary_max_ = std::max(0.0f, lines * kLine - kShown);
|
||||
list.push_clip({x - 4, y - 22, 680, kShown});
|
||||
ui::paragraph(list, fonts.regular, info.summary, x, y - summary_scroll_.value, 20, 660, kLine,
|
||||
L.panel_text.with_alpha(0.85f));
|
||||
list.pop_clip();
|
||||
// More below: a small marker under the window's right edge.
|
||||
if (summary_max_ > 0.0f && summary_scroll_.value < summary_max_ - 1.0f)
|
||||
list.triangle({x + 660 - 14, y - 22 + kShown + 2, 14, 8}, L.panel_text.with_alpha(0.5f), 0.0f, 3.14159f);
|
||||
y += std::min(lines, 3.0f) * kLine + 22;
|
||||
}
|
||||
std::vector<std::string> facts;
|
||||
if (info.play_ms > 0)
|
||||
facts.push_back("Played " + rommps::play_time(info.play_ms));
|
||||
@@ -951,28 +1267,31 @@ class RommPS final : public app::Concept
|
||||
facts.push_back("Main story ~" + std::to_string(static_cast<int>(info.main_story_s / 3600.0 + 0.5)) + " h");
|
||||
if (!info.players.empty())
|
||||
facts.push_back(info.players + (info.players == "1" ? " player" : " players"));
|
||||
if (info.rating > 0)
|
||||
{
|
||||
char r[24];
|
||||
std::snprintf(r, sizeof r, info.rating > 10 ? "%.0f/100" : "%.1f/10", info.rating);
|
||||
facts.push_back(r);
|
||||
}
|
||||
std::string line;
|
||||
for (const std::string &f : facts)
|
||||
line += (line.empty() ? "" : " \xC2\xB7 ") + f;
|
||||
// The file and emulator, last.
|
||||
// The file and emulator, last; for an archive, why it can't come down.
|
||||
std::string file = rommps::human_size(g->size) + (g->multi ? ", several files" : "");
|
||||
if (info.updates || info.dlc)
|
||||
file += " + " + std::string(info.updates ? "update" : "") + (info.updates && info.dlc ? " and " : "") +
|
||||
(info.dlc ? std::to_string(info.dlc) + " DLC" : "");
|
||||
if (p && !p->profile.empty())
|
||||
file += " \xC2\xB7 plays with " + p->profile;
|
||||
y = std::max(y, sheet.y + 330.0f);
|
||||
const bool archived = g->archived || !info.archived.empty();
|
||||
if (archived)
|
||||
file = info.archived.empty() ? "This download is archived. Decompress it on your RomM server first."
|
||||
: info.archived;
|
||||
if (!line.empty())
|
||||
{
|
||||
ui::paragraph(list, fonts.regular, line, x, y, 20, 660, 28, L.panel_muted, 2);
|
||||
y += 60;
|
||||
}
|
||||
ui::text(list, fonts.regular, context_.fonts.regular.font->fit(file, 20, 660), x, std::min(y, sheet.y + 470.0f), 20,
|
||||
L.panel_muted);
|
||||
ui::paragraph(list, fonts.regular, line, x, y, 20, 660, 32, L.panel_muted, 2);
|
||||
// The file line keeps to the sheet's foot.
|
||||
ui::text(list, fonts.regular, context_.fonts.regular.font->fit(file, 20, 660), x, sheet.y + sheet.h - 60, 20,
|
||||
archived ? L.danger : L.panel_muted);
|
||||
}
|
||||
|
||||
void draw_sheet_actions(gfx::DrawList &list, const rommps::Game *g, const Rect &sheet) const
|
||||
{
|
||||
const ui::Fonts &fonts = context_.fonts;
|
||||
const rommps::Look &L = look();
|
||||
const std::vector<SheetAction> actions = sheet_actions();
|
||||
const float ax = sheet.x + sheet.w - 56 - 460;
|
||||
const float ay = sheet.y + 76;
|
||||
@@ -993,7 +1312,6 @@ class RommPS final : public app::Concept
|
||||
ui::text(list, fonts.semibold, context_.fonts.semibold.font->fit(a.label, 26, 400), ax + 36, y + 46, 26,
|
||||
(focused ? L.on_highlight : L.panel_text).with_alpha(a.enabled ? 1.0f : 0.5f));
|
||||
}
|
||||
list.pop_opacity();
|
||||
}
|
||||
|
||||
void draw_toast(gfx::DrawList &list) const
|
||||
@@ -1004,15 +1322,32 @@ class RommPS final : public app::Concept
|
||||
const ui::Fonts &fonts = context_.fonts;
|
||||
const float a = tween::clamp01(app_.toast_age() * 5.0f) * tween::clamp01((4.0f - app_.toast_age()) * 2.0f);
|
||||
list.push_opacity(a);
|
||||
const std::string fitted = fonts.regular.font->fit(message, 24, 1100);
|
||||
// Beside the button hints, on their line, when there's room for it;
|
||||
// above them when they leave too little.
|
||||
const float room = std::min(1180.0f, hints_left_ - 24.0f - kMargin);
|
||||
const Rect r = room >= 480.0f ? Rect{kMargin, 978, room, 64} : Rect{kMargin, 892, 1180, 64};
|
||||
const std::string fitted = fonts.regular.font->fit(message, 24, r.w - 64);
|
||||
const rommps::Look &L = look();
|
||||
const Rect r{kMargin, 960, 1180, 64};
|
||||
list.rounded_rect(r, 32, L.covers ? Color::rgb(0x14182e, 0.92f) : L.page.with_alpha(0.95f));
|
||||
list.bordered_rect(r, 32, Color::rgb(0x000000, 0.0f), 1.5f, L.outline);
|
||||
ui::text(list, fonts.regular, fitted, r.x + 32, r.y + 41, 24, L.text);
|
||||
list.pop_opacity();
|
||||
}
|
||||
|
||||
// The hint row floats over the page's bottom corner: it gets the panel
|
||||
// treatment (draw_panel: shadow, fill, outline), so it reads over the
|
||||
// covers it crosses instead of hovering on them.
|
||||
void hint_row(gfx::DrawList &list, const ui::Fonts &fonts, const ui::GlyphStyle &style,
|
||||
const ui::Hint *hints, int count) const
|
||||
{
|
||||
const ui::HintLayout layout; // the kit's bottom row: glyphs 40 tall, cy 1010
|
||||
const float width = ui::measure_hints(fonts, hints, count, layout);
|
||||
const Rect plate{1824.0f - width - 30.0f, layout.cy - 38.0f, width + 60.0f, 76.0f};
|
||||
hints_left_ = std::min(hints_left_, plate.x);
|
||||
draw_panel(list, plate);
|
||||
ui::draw_hints(list, fonts, style, hints, count, 1824, true);
|
||||
}
|
||||
|
||||
void draw_hints(gfx::DrawList &list) const
|
||||
{
|
||||
const ui::Fonts &fonts = context_.fonts;
|
||||
@@ -1020,7 +1355,7 @@ class RommPS final : public app::Concept
|
||||
if (setting_up_)
|
||||
{
|
||||
const std::vector<ui::Hint> hints = setup_.hints();
|
||||
ui::draw_hints(list, fonts, style, hints.data(), static_cast<int>(hints.size()), 1824, true);
|
||||
hint_row(list, fonts, style, hints.data(), static_cast<int>(hints.size()));
|
||||
return;
|
||||
}
|
||||
const bool ready = app_.status().paired && app_.status().setup_complete;
|
||||
@@ -1028,14 +1363,18 @@ class RommPS final : public app::Concept
|
||||
return;
|
||||
if (sheet_open_)
|
||||
{
|
||||
const ui::Hint hints[] = {{ui::Button::cross, "Choose"}, {ui::Button::circle, "Back"}};
|
||||
ui::draw_hints(list, fonts, style, hints, 2, 1824, true);
|
||||
// Scrolling, with the D-pad or the left stick, when there's something to scroll.
|
||||
const bool scroll = addons_open_ || summary_max_ > 0.0f;
|
||||
const ui::Hint hints[] = {{ui::Button::dpad, addons_open_ ? "Scroll" : "Hold to scroll", ui::Button::left_stick},
|
||||
{ui::Button::cross, "Choose"},
|
||||
{ui::Button::circle, "Back"}};
|
||||
hint_row(list, fonts, style, scroll ? hints : hints + 1, scroll ? 3 : 2);
|
||||
return;
|
||||
}
|
||||
if (view_.active())
|
||||
{
|
||||
const std::vector<ui::Hint> hints = view_.hints();
|
||||
ui::draw_hints(list, fonts, style, hints.data(), static_cast<int>(hints.size()), 1824, true);
|
||||
hint_row(list, fonts, style, hints.data(), static_cast<int>(hints.size()));
|
||||
return;
|
||||
}
|
||||
list.push_opacity(tween::stagger(age_, 8, 0.08f, 0.5f) * (1.0f - sheet_.value));
|
||||
@@ -1046,20 +1385,27 @@ class RommPS final : public app::Concept
|
||||
std::vector<ui::Hint> hints{close, pages};
|
||||
if (app_.config().save_sync)
|
||||
hints.push_back({ui::Button::cross, home_focus_ == 0 ? "Sync now" : "Open"});
|
||||
ui::draw_hints(list, fonts, style, hints.data(), static_cast<int>(hints.size()), 1824, true);
|
||||
hint_row(list, fonts, style, hints.data(), static_cast<int>(hints.size()));
|
||||
}
|
||||
else if (tab_ == kLibrary)
|
||||
{
|
||||
const std::vector<ui::Hint> hints = library_.hints();
|
||||
ui::draw_hints(list, fonts, style, hints.data(), static_cast<int>(hints.size()), 1824, true);
|
||||
hint_row(list, fonts, style, hints.data(), static_cast<int>(hints.size()));
|
||||
}
|
||||
else if (tab_ == kDownloads)
|
||||
{
|
||||
const ui::Hint hints[] = {pages, {ui::Button::cross, "Details"}};
|
||||
hint_row(list, fonts, style, hints, 2);
|
||||
}
|
||||
else if (tab_ == kQueue)
|
||||
{
|
||||
// Cross on a download that's still going stops it and deletes what
|
||||
// came so far; Square takes the finished ones off the list.
|
||||
const auto &downloads = app_.downloads();
|
||||
const bool on_active = download_row_ < static_cast<int>(downloads.size()) &&
|
||||
downloads[static_cast<std::size_t>(download_row_)].cancellable();
|
||||
// came so far, on one that failed or was cancelled tries it again;
|
||||
// Square takes the finished ones off the list.
|
||||
const auto &downloads = app_.queue();
|
||||
const bool on_row = queue_row_ < static_cast<int>(downloads.size());
|
||||
const bool on_active = on_row && downloads[static_cast<std::size_t>(queue_row_)].cancellable();
|
||||
const bool on_failed = on_row && downloads[static_cast<std::size_t>(queue_row_)].retryable();
|
||||
const bool any_finished = std::any_of(downloads.begin(), downloads.end(),
|
||||
[](const rommps::Download &d) { return d.finished(); });
|
||||
std::vector<ui::Hint> hints{pages};
|
||||
@@ -1067,12 +1413,14 @@ class RommPS final : public app::Concept
|
||||
hints.push_back({ui::Button::square, "Clear finished"});
|
||||
if (on_active)
|
||||
hints.push_back({ui::Button::cross, "Hold to cancel and delete"});
|
||||
ui::draw_hints(list, fonts, style, hints.data(), static_cast<int>(hints.size()), 1824, true);
|
||||
if (on_failed)
|
||||
hints.push_back({ui::Button::cross, "Retry"});
|
||||
hint_row(list, fonts, style, hints.data(), static_cast<int>(hints.size()));
|
||||
}
|
||||
else
|
||||
{
|
||||
const ui::Hint hints[] = {close, pages, {ui::Button::cross, settings_.cross_hint(app_)}};
|
||||
ui::draw_hints(list, fonts, style, hints, 3, 1824, true);
|
||||
hint_row(list, fonts, style, hints, 3);
|
||||
}
|
||||
list.pop_opacity();
|
||||
}
|
||||
@@ -1090,16 +1438,30 @@ class RommPS final : public app::Concept
|
||||
ui::SpringColor palette_[4];
|
||||
ui::Pulse nudge_;
|
||||
float nudge_direction_ = 0.0f;
|
||||
mutable float hints_left_ = 1920.0f; // where this frame's button hints start, for the toast
|
||||
bool sheet_open_ = false;
|
||||
bool addons_open_ = false; // the sheet lists the game's update and DLC
|
||||
int addons_top_ = 0; // the first of them shown
|
||||
tween::Spring sheet_;
|
||||
int action_ = 0;
|
||||
tween::Spring action_position_;
|
||||
int download_row_ = 0;
|
||||
// The description, a few lines at a time: how far it's scrolled (eased),
|
||||
// where to, how long Up or Down has been held, and how far it can go (from
|
||||
// the last draw).
|
||||
tween::Spring summary_scroll_;
|
||||
float summary_target_ = 0.0f;
|
||||
float summary_hold_ = 0.0f;
|
||||
mutable float summary_max_ = 0.0f;
|
||||
int queue_row_ = 0;
|
||||
float cancel_hold_ = 0; // seconds Cross has been held to cancel cancel_job_; -1 once done
|
||||
float remove_hold_ = -1; // seconds Cross has been held on remove; -1 waits for a press
|
||||
int cancel_job_ = 0;
|
||||
bool cancel_seen_ = false; // hold_to_cancel ran this frame
|
||||
tween::Spring download_position_;
|
||||
tween::Spring queue_position_;
|
||||
tween::Spring queue_scroll_; // the window's first row, eased
|
||||
int installed_row_ = 0; // the Downloads list's focus
|
||||
tween::Spring installed_position_;
|
||||
tween::Spring installed_scroll_;
|
||||
rommps::SettingsPage settings_;
|
||||
rommps::LibraryPage library_;
|
||||
rommps::Game sheet_game_; // the game the details sheet is for
|
||||
|
||||
@@ -58,18 +58,19 @@ std::vector<Row> SettingsPage::rows(const App &app, std::vector<int> *ids) const
|
||||
const Look &l = look(current_look());
|
||||
r[kSaveSync] = {RowKind::toggle, "Save sync", "", c.save_sync ? "" : "RomM is only for games",
|
||||
c.save_sync};
|
||||
r[kLook] = {RowKind::choice, "Look", l.name, l.family};
|
||||
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), ""};
|
||||
r[kPolicy] = {RowKind::choice, "A save changed in both places", label_of(kPolicies, c.conflict_policy),
|
||||
""};
|
||||
r[kConcurrency] = {RowKind::choice, "Downloads at once", std::to_string(c.download_concurrency), ""};
|
||||
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, "Look for updates every day", "", "", c.update_check};
|
||||
r[kUpdateCheck] = {RowKind::toggle, "Check for updates daily", "", "", c.update_check};
|
||||
{
|
||||
const Status &s = app.status();
|
||||
char detail[96];
|
||||
@@ -129,14 +130,14 @@ std::vector<Row> SettingsPage::rows(const App &app, std::vector<int> *ids) const
|
||||
: "Up to date";
|
||||
}
|
||||
|
||||
r[kEmulators] = {RowKind::action, "Emulators", "", ""};
|
||||
r[kSetup] = {RowKind::action, "Set up again", "", ""};
|
||||
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"
|
||||
: "Logs for a bug report, nothing personal"};
|
||||
: "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";
|
||||
@@ -152,10 +153,10 @@ std::vector<Row> SettingsPage::rows(const App &app, std::vector<int> *ids) const
|
||||
} kSections[] = {
|
||||
{"Sync", {kSaveSync, kInterval, kOnExit, kOnStart, kOnChange, kPolicy, kStates}},
|
||||
{"Downloads", {kConcurrency, kCovers, -1}},
|
||||
{"Look and notifications", {kLook, kNotify, -1}},
|
||||
{"Emulators", {kEmulators, kSetup, -1}},
|
||||
{"Theme, sounds and notifications", {kLook, kSounds, kNotify, -1}},
|
||||
{"Emulators", {kEmulators, -1}},
|
||||
{"Updates", {kUpdate, kUpdateCheck, -1}},
|
||||
{"RomM", {kForget, -1}},
|
||||
{"RomM", {kSetup, kForget, -1}},
|
||||
{"Help", {kDiagnostics, -1}},
|
||||
};
|
||||
std::vector<Row> out;
|
||||
@@ -246,6 +247,9 @@ void SettingsPage::update(App &app, const hui::InputFrame &input, float dt, hui:
|
||||
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);
|
||||
@@ -348,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);
|
||||
@@ -359,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: turn it on in Set up again");
|
||||
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();
|
||||
|
||||
@@ -39,6 +39,7 @@ class SettingsPage
|
||||
{
|
||||
kSaveSync,
|
||||
kLook,
|
||||
kSounds,
|
||||
kInterval,
|
||||
kOnExit,
|
||||
kOnStart,
|
||||
|
||||
@@ -116,10 +116,11 @@ bool at_least(const std::string &version, int major, int minor, int patch)
|
||||
// The end of a text that fits a width, for a field that shows where typing is.
|
||||
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;
|
||||
}
|
||||
|
||||
hui::ui::KeyboardLayout url_layout()
|
||||
@@ -251,6 +252,17 @@ void SetupPage::follow_status(App &app)
|
||||
void SetupPage::update(App &app, const hui::InputFrame &input, float dt, hui::ui::Feedback &feedback)
|
||||
{
|
||||
step_age_ += dt;
|
||||
if (step_ == kEmulators && emulators_loaded_)
|
||||
{
|
||||
auto logo = [&](const std::string &key) {
|
||||
const Cover &c = app.platform_logo(key);
|
||||
logos_[key] = {c.texture, c.aspect > 0 ? c.aspect : 1.0f};
|
||||
};
|
||||
for (const Conflict &c : systems())
|
||||
logo(c.key);
|
||||
for (const auto &[key, o] : own_)
|
||||
logo(key);
|
||||
}
|
||||
follow_status(app);
|
||||
switch (step_)
|
||||
{
|
||||
@@ -323,10 +335,39 @@ void SetupPage::update_server(App &app, const hui::InputFrame &input, float dt,
|
||||
|
||||
// ------------------------------------------------------------------ emulators
|
||||
|
||||
void SetupPage::load_mounts(App &app)
|
||||
{
|
||||
app.get("/api/paths", [this](const Response &r) {
|
||||
if (!r.ok())
|
||||
return;
|
||||
Json j(r.json(), cJSON_Delete);
|
||||
mounts_.clear();
|
||||
cards_.clear();
|
||||
const cJSON *m;
|
||||
cJSON_ArrayForEach(m, j.get())
|
||||
{
|
||||
if (const cJSON *c = cJSON_GetObjectItemCaseSensitive(m, "cards"))
|
||||
{
|
||||
CardMethod &cm = cards_[str(m, "system")];
|
||||
cm.method = str(c, "method");
|
||||
if (!cJSON_IsTrue(cJSON_GetObjectItemCaseSensitive(c, "active")))
|
||||
cm.note = std::string(str(m, "profile")) + ": " + str(m, "sync_note");
|
||||
}
|
||||
if (!cJSON_IsTrue(cJSON_GetObjectItemCaseSensitive(m, "extra")))
|
||||
continue;
|
||||
MountStatus &st = mounts_[std::string(str(m, "system")) + "|" + str(m, "root")];
|
||||
st.mounted = *str(m, "mount") != 0;
|
||||
st.note = str(m, "mount_note");
|
||||
st.own = static_cast<int>(num(m, "own_games"));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void SetupPage::load_emulators(App &app)
|
||||
{
|
||||
busy_ = true;
|
||||
App *a = &app;
|
||||
load_mounts(app);
|
||||
// The server's systems, for those no emulator here plays.
|
||||
app.get("/api/platforms", [this](const Response &r) {
|
||||
if (!r.ok())
|
||||
@@ -421,7 +462,11 @@ void SetupPage::load_emulators(App &app)
|
||||
const cJSON *profiles = cJSON_GetObjectItemCaseSensitive(cfg.get(), "profiles"), *p;
|
||||
if (!cJSON_IsArray(profiles) || (!editing_ && !cJSON_IsTrue(cJSON_GetObjectItemCaseSensitive(cfg.get(), "profiles_custom"))))
|
||||
return;
|
||||
ps2_cards_ = std::string(str(cfg.get(), "ps2_cards")) == "backup" ? "backup" : "per_game";
|
||||
{
|
||||
// "" follows the emulator; 1.2 saved "backup" for shared.
|
||||
const std::string v = str(cfg.get(), "ps2_cards");
|
||||
ps2_cards_ = v == "backup" || v == "shared" ? "shared" : v == "per_game" ? "per_game" : "";
|
||||
}
|
||||
choices_.clear();
|
||||
also_.clear();
|
||||
custom_dirs_.clear();
|
||||
@@ -765,7 +810,7 @@ std::vector<Row> SetupPage::emulator_rows(const App &app, std::vector<RowRef> *r
|
||||
};
|
||||
auto shorten = [](std::string value) {
|
||||
if (value.size() > 34)
|
||||
value = "\xE2\x80\xA6" + value.substr(value.size() - 33);
|
||||
value = "\xE2\x80\xA6" + value.substr(char_boundary(value, value.size() - 33));
|
||||
return value;
|
||||
};
|
||||
// Something set up by its folders: a switch, then its three folders when on.
|
||||
@@ -845,7 +890,24 @@ std::vector<Row> SetupPage::emulator_rows(const App &app, std::vector<RowRef> *r
|
||||
continue;
|
||||
const auto set = also_.find(c.key);
|
||||
const bool also = set != also_.end() && set->second.count(o.root);
|
||||
add({RowKind::toggle, " Also in " + o.label, "", "", also}, {RowRef::kAlso, -1, c.key, o.root});
|
||||
// Saved as an extra: whether it plays the main one's games without copies.
|
||||
const auto ms = also ? mounts_.find(c.key + "|" + o.root) : mounts_.end();
|
||||
std::string how;
|
||||
if (ms != mounts_.end())
|
||||
how = ms->second.mounted ? "Uses " + label + "'s games, no copies" : ms->second.note;
|
||||
add({RowKind::toggle, " Also in " + o.label, "", how, also}, {RowRef::kAlso, -1, c.key, o.root});
|
||||
if (ms != mounts_.end() && ms->second.own > 0)
|
||||
{
|
||||
const int n = ms->second.own;
|
||||
const std::string games = std::to_string(n) + (n == 1 ? " game" : " games");
|
||||
add({RowKind::action, " Move its " + games + " to " + label, "", ""},
|
||||
{RowRef::kOwnMove, -1, c.key, o.root});
|
||||
Row del{RowKind::action,
|
||||
confirm_delete_ == c.key + "|" + o.root ? " Press again to delete them" : " Delete its " + games,
|
||||
"", ""};
|
||||
del.danger = true;
|
||||
add(del, {RowRef::kOwnDelete, -1, c.key, o.root});
|
||||
}
|
||||
if (also)
|
||||
on.push_back(&o);
|
||||
}
|
||||
@@ -890,12 +952,20 @@ std::vector<Row> SetupPage::emulator_rows(const App &app, std::vector<RowRef> *r
|
||||
// Then what's this platform's own: its save sync, PS2's memory cards.
|
||||
if (saves)
|
||||
add({RowKind::choice, " Sync", kSyncLabel[sync_index(c.key)], ""}, {RowRef::kSysSync, -1, c.key});
|
||||
if (saves && c.key == "ps2" && has_ps2())
|
||||
// Memory cards: one method per system, never both. PS2 can be chosen
|
||||
// when an emulator keeps either (LRPS2); otherwise it's the emulator's.
|
||||
if (saves && cards_.count(c.key))
|
||||
{
|
||||
const bool backup_ok = at_least(app.status().server_version, 5, 3, 0);
|
||||
Row r{RowKind::choice, " Memory cards", ps2_cards_ == "backup" ? "One card, backed up" : "One card per game",
|
||||
backup_ok ? " How RetroArch's PS2 core keeps its saves" : " Your RomM is older than 5.3: one card per game"};
|
||||
r.enabled = backup_ok;
|
||||
const CardMethod &cm = cards_.at(c.key);
|
||||
const bool choice = c.key == "ps2" && has_ps2();
|
||||
const std::string method = choice && !ps2_cards_.empty() ? ps2_cards_ : cm.method;
|
||||
const bool shared_ok = at_least(app.status().server_version, 5, 3, 0);
|
||||
std::string detail = !cm.note.empty() ? " " + cm.note
|
||||
: method == "shared" && !shared_ok ? " Shared cards need RomM 5.3 or newer"
|
||||
: method == "shared" ? " The whole cards sync between your consoles"
|
||||
: " Each game's card syncs as its save";
|
||||
Row r{RowKind::choice, " Memory cards", method == "shared" ? "Shared, synced" : "One per game", detail};
|
||||
r.enabled = choice;
|
||||
add(r, {RowRef::kPs2});
|
||||
}
|
||||
}
|
||||
@@ -1049,9 +1119,41 @@ void SetupPage::update_emulators(App &app, const hui::InputFrame &input, float d
|
||||
set.insert(ref.root);
|
||||
return;
|
||||
}
|
||||
case RowRef::kPs2:
|
||||
ps2_cards_ = ps2_cards_ == "backup" ? "per_game" : "backup";
|
||||
case RowRef::kOwnMove:
|
||||
case RowRef::kOwnDelete:
|
||||
{
|
||||
const std::string which = ref.key + "|" + ref.root;
|
||||
const bool del = ref.kind == RowRef::kOwnDelete;
|
||||
if (del && confirm_delete_ != which)
|
||||
{
|
||||
confirm_delete_ = which; // it can't be undone: a second Cross
|
||||
return;
|
||||
}
|
||||
confirm_delete_.clear();
|
||||
App *a = &app;
|
||||
Json body(cJSON_CreateObject(), cJSON_Delete);
|
||||
cJSON_AddStringToObject(body.get(), "root", ref.root.c_str());
|
||||
cJSON_AddStringToObject(body.get(), "system", ref.key.c_str());
|
||||
cJSON_AddStringToObject(body.get(), "action", del ? "delete" : "move");
|
||||
app.post("/api/emulators/own-games", print(body.get()), [this, a, del](const Response &r) {
|
||||
if (!r.ok())
|
||||
{
|
||||
a->say("Couldn't do it: " + r.message());
|
||||
return;
|
||||
}
|
||||
Json j(r.json(), cJSON_Delete);
|
||||
const int n = static_cast<int>(num(j.get(), "games"));
|
||||
a->say(std::to_string(n) + (n == 1 ? " game " : " games ") + (del ? "deleted" : "moved"));
|
||||
load_mounts(*a);
|
||||
});
|
||||
return;
|
||||
}
|
||||
case RowRef::kPs2:
|
||||
{
|
||||
const std::string now = !ps2_cards_.empty() ? ps2_cards_ : cards_.count("ps2") ? cards_.at("ps2").method : "";
|
||||
ps2_cards_ = now == "shared" ? "per_game" : "shared";
|
||||
return;
|
||||
}
|
||||
case RowRef::kOwn:
|
||||
own_[ref.key].on = !own_[ref.key].on;
|
||||
return;
|
||||
@@ -1181,7 +1283,7 @@ void SetupPage::save_emulators(App &app, hui::ui::Feedback &feedback)
|
||||
return;
|
||||
}
|
||||
busy_ = true;
|
||||
const std::string ps2 = "{\"ps2_cards\":\"" + (has_ps2() ? ps2_cards_ : std::string("per_game")) + "\"}";
|
||||
const std::string ps2 = "{\"ps2_cards\":\"" + (has_ps2() ? ps2_cards_ : std::string()) + "\"}";
|
||||
App *a = &app;
|
||||
app.post("/api/setup/emulators", save_body(), [this, a, ps2](const Response &r) {
|
||||
if (!r.ok())
|
||||
@@ -1199,6 +1301,7 @@ void SetupPage::save_emulators(App &app, hui::ui::Feedback &feedback)
|
||||
a->say("Emulators saved");
|
||||
a->refresh_config();
|
||||
a->reload_platforms();
|
||||
load_mounts(*a);
|
||||
return;
|
||||
}
|
||||
go(kPreview);
|
||||
@@ -1284,26 +1387,8 @@ void SetupPage::update_preview(App &app, const hui::InputFrame &input, float dt,
|
||||
|
||||
// ------------------------------------------------------------------ finish
|
||||
|
||||
void SetupPage::load_autostart(App &app)
|
||||
{
|
||||
app.get("/api/autostart", [this, &app](const Response &r) {
|
||||
Json j(r.json(), cJSON_Delete);
|
||||
autostart_supported_ = cJSON_IsTrue(cJSON_GetObjectItemCaseSensitive(j.get(), "supported"));
|
||||
autostart_installed_ = cJSON_IsTrue(cJSON_GetObjectItemCaseSensitive(j.get(), "installed"));
|
||||
autostart_on_ = cJSON_IsTrue(cJSON_GetObjectItemCaseSensitive(j.get(), "enabled"));
|
||||
// On by default the first time: RomM Sync then starts with the
|
||||
// console and nobody has to load it. Setting up again keeps the choice.
|
||||
if (autostart_supported_ && !autostart_on_ && !autostart_defaulted_ && !app.status().setup_complete)
|
||||
{
|
||||
autostart_defaulted_ = true;
|
||||
autostart_on_ = true;
|
||||
app.install_autostart([this, &app](const Response &) { load_autostart(app); });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Finish rows: 0 policy, 1 on change, 2 on exit, 3 on start, 4 notify,
|
||||
// 5 interval, 6 autostart, 7 finish. The sync ones only with save sync on.
|
||||
// 5 interval, 6 finish. The sync ones only with save sync on.
|
||||
std::vector<Row> SetupPage::finish_rows(std::vector<int> *ids) const
|
||||
{
|
||||
const Config &c = finish_;
|
||||
@@ -1315,7 +1400,8 @@ std::vector<Row> SetupPage::finish_rows(std::vector<int> *ids) const
|
||||
};
|
||||
if (save_sync_)
|
||||
{
|
||||
add({RowKind::choice, "A save changed in both places", label_of(kPolicies, c.conflict_policy), ""}, 0);
|
||||
add({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"}, 0);
|
||||
add({RowKind::toggle, "Upload saves as they change", "", "While you play", c.sync_on_change}, 1);
|
||||
add({RowKind::toggle, "Sync when a game closes", "", "", c.sync_on_game_exit}, 2);
|
||||
add({RowKind::toggle, "Sync when a game starts", "", "", c.sync_on_game_start}, 3);
|
||||
@@ -1323,11 +1409,9 @@ std::vector<Row> SetupPage::finish_rows(std::vector<int> *ids) const
|
||||
add({RowKind::toggle, "Notifications", "", "", c.notify}, 4);
|
||||
if (save_sync_)
|
||||
add({RowKind::choice, "Sync every", interval_label(c.sync_interval_min), ""}, 5);
|
||||
if (autostart_supported_)
|
||||
add({RowKind::toggle, "Start RommPS with the console", "", "With your HEN's autostart", autostart_on_}, 6);
|
||||
Row done{RowKind::action, save_sync_ ? "Finish and sync" : "Finish", "", ""};
|
||||
done.enabled = !busy_;
|
||||
add(done, 7);
|
||||
add(done, 6);
|
||||
return rows;
|
||||
}
|
||||
|
||||
@@ -1337,7 +1421,6 @@ void SetupPage::update_finish(App &app, const hui::InputFrame &input, float dt,
|
||||
{
|
||||
finish_ = app.config();
|
||||
finish_known_ = true;
|
||||
load_autostart(app);
|
||||
}
|
||||
std::vector<int> ids;
|
||||
const std::vector<Row> rows = finish_rows(&ids);
|
||||
@@ -1349,7 +1432,6 @@ void SetupPage::update_finish(App &app, const hui::InputFrame &input, float dt,
|
||||
go(kPreview);
|
||||
return;
|
||||
}
|
||||
const bool autostart_row = autostart_supported_;
|
||||
switch (e.row >= 0 ? ids[static_cast<std::size_t>(e.row)] : -1)
|
||||
{
|
||||
case 0:
|
||||
@@ -1371,26 +1453,6 @@ void SetupPage::update_finish(App &app, const hui::InputFrame &input, float dt,
|
||||
finish_.sync_interval_min = step_interval(finish_.sync_interval_min, e.step);
|
||||
break;
|
||||
case 6:
|
||||
if (autostart_row)
|
||||
{
|
||||
autostart_on_ = !autostart_on_;
|
||||
// On: the payload RommPS carries goes to every HEN's folder that
|
||||
// lacks it or has an older one, and autostart is turned on there.
|
||||
if (autostart_on_)
|
||||
{
|
||||
app.install_autostart([this, &app](const Response &) { load_autostart(app); });
|
||||
break;
|
||||
}
|
||||
app.post("/api/autostart", "{\"enable\":false}",
|
||||
[this, &app](const Response &r) {
|
||||
if (!r.ok())
|
||||
app.say("Couldn't change the autostart: " + r.message());
|
||||
load_autostart(app);
|
||||
});
|
||||
break;
|
||||
}
|
||||
[[fallthrough]];
|
||||
case 7:
|
||||
{
|
||||
if (e.row < 0)
|
||||
break;
|
||||
@@ -1464,8 +1526,9 @@ void SetupPage::draw_heading(hui::gfx::DrawList &list, const hui::ui::Fonts &fon
|
||||
else
|
||||
std::snprintf(label, sizeof label, "STEP %d OF %d", static_cast<int>(step_) + 1, kSteps);
|
||||
ui::text(list, fonts.semibold, label, x, 196, 20, look.accent, Align::left, 4.0f);
|
||||
ui::text(list, fonts.display, title, x - 3, 262, 56, look.text);
|
||||
ui::paragraph(list, fonts.regular, text, x, 312, 26, 1500, 36, look.muted, 2);
|
||||
// The picker's heading names a platform as RomM spells it, and the folder
|
||||
ui::text(list, fonts.display, plain_text(title), x - 3, 262, 56, look.text);
|
||||
ui::paragraph(list, fonts.regular, plain_text(text.c_str()), x, 312, 26, 1500, 36, look.muted, 2);
|
||||
list.pop_opacity();
|
||||
}
|
||||
|
||||
@@ -1506,7 +1569,8 @@ void SetupPage::draw_server(hui::gfx::DrawList &list, const hui::ui::Fonts &font
|
||||
kField.x + 4, y, 24, look.text);
|
||||
}
|
||||
else if (!error_.empty())
|
||||
ui::text(list, fonts.semibold, fonts.semibold.font->fit(error_, 24, kField.w), kField.x + 4, y, 24, look.danger);
|
||||
ui::text(list, fonts.semibold, fonts.semibold.font->fit(plain_text(error_.c_str()), 24, kField.w), kField.x + 4, y, 24,
|
||||
look.danger);
|
||||
else
|
||||
{
|
||||
ui::draw_button(list, fonts, look.glyphs(), ui::Button::triangle, kField.x + 4, y - 9, 30);
|
||||
@@ -1543,7 +1607,8 @@ void SetupPage::draw_approve(hui::gfx::DrawList &list, const hui::ui::Fonts &fon
|
||||
if (has_qr)
|
||||
draw_qr(list, qr, {panel.x + panel.w - 330, panel.y + 70, 290, 290});
|
||||
const float cx = has_qr ? panel.x + (panel.w - 360) * 0.5f : panel.cx();
|
||||
ui::text(list, fonts.semibold, fonts.semibold.font->fit(s.verification_url, 30, has_qr ? panel.w - 460 : panel.w - 120),
|
||||
ui::text(list, fonts.semibold,
|
||||
fonts.semibold.font->fit(plain_text(s.verification_url.c_str()), 30, has_qr ? panel.w - 460 : panel.w - 120),
|
||||
cx, panel.y + 92, 30,
|
||||
look.accent, Align::center);
|
||||
std::string code;
|
||||
@@ -1594,6 +1659,14 @@ void SetupPage::draw(hui::gfx::DrawList &list, const hui::ui::Fonts &fonts, cons
|
||||
dirty() ? "Unsaved changes. Press Triangle to save them." : "Choose the emulator for each platform.");
|
||||
std::vector<RowRef> refs;
|
||||
rows = emulator_rows(app, &refs);
|
||||
// Each platform's row, with its logo before its name.
|
||||
for (std::size_t i = 0; i < rows.size() && i < refs.size(); ++i)
|
||||
if (refs[i].kind == RowRef::kMain || refs[i].kind == RowRef::kOwn)
|
||||
{
|
||||
rows[i].logo_slot = true;
|
||||
if (const auto it = logos_.find(refs[i].key); it != logos_.end())
|
||||
rows[i].logo = it->second.first, rows[i].logo_aspect = it->second.second;
|
||||
}
|
||||
}
|
||||
else if (step_ == kPreview)
|
||||
{
|
||||
|
||||
@@ -99,6 +99,8 @@ class SetupPage
|
||||
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)
|
||||
@@ -128,8 +130,10 @@ 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; // 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
|
||||
@@ -167,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_;
|
||||
@@ -197,6 +218,9 @@ class SetupPage
|
||||
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;
|
||||
@@ -217,8 +241,6 @@ class SetupPage
|
||||
// 5
|
||||
Config finish_;
|
||||
bool finish_known_ = false;
|
||||
bool autostart_supported_ = false, autostart_installed_ = false, autostart_on_ = false;
|
||||
bool autostart_defaulted_ = false; // turned on for a first setup already
|
||||
};
|
||||
|
||||
} // namespace rommps
|
||||
@@ -140,19 +140,21 @@ std::string platform_name(const App &app, const std::string &dir)
|
||||
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") : capital(kind);
|
||||
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 && kind != "card")
|
||||
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 && kind != "card" ? what : std::string();
|
||||
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(str(e, "detail"));
|
||||
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")));
|
||||
|
||||
@@ -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.002.001",
|
||||
"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
|
||||
|
||||
@@ -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"
|
||||
+199
-80
@@ -1,82 +1,201 @@
|
||||
[
|
||||
{
|
||||
"format": 1,
|
||||
"version": "2026.10.23.1",
|
||||
"console": "ps5",
|
||||
"emulators": [
|
||||
{
|
||||
"id": "psxs5", "name": "PSXS5",
|
||||
"title_ids": ["PPSA97510"],
|
||||
"detect": ["/data/PSXS5", "/data/homebrew/PPSA97510"],
|
||||
"note": "One memory card per game, named after the disc (after the game file with the Beetle core).",
|
||||
"profile": {
|
||||
"root": "/data/PSXS5", "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}",
|
||||
"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": "pcsx_rearmed", "1": "pcsx_rearmed", "2": "mednafen_psx_hw"}},
|
||||
"platforms": [{"romm": ["psx", "ps1", "playstation"], "dir": "psx", "save_format": "psx-card"}]
|
||||
"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", "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}",
|
||||
"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"}]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "ps5sx2", "name": "PS5SX2",
|
||||
"title_ids": ["PPSA99203"],
|
||||
"detect": ["/data/PCSX2", "/data/homebrew/PPSA99203"],
|
||||
"note": "Two memory cards for every game, backed up to RomM (5.3 or newer).",
|
||||
"profile": {
|
||||
"root": "/data/PCSX2", "rom_dir": "{root}/games", "bios_dir": "{root}",
|
||||
"sync_saves": false, "memcards": "{root}/Mcd001.ps2,{root}/Mcd002.ps2",
|
||||
"firmware": {"pick_one": ["21038400dc633070a78ad53090c53017", "dc69f0643a3030aaa4797501b483d6c4", "80ac46fa7e77b8ab4366e86948e54f83",
|
||||
"93ea3bcee4252627919175ff1b16a1d9", "d3e81e95db25f5a86a7b7474550a2155"]},
|
||||
"platforms": [{"romm": ["ps2"], "dir": "ps2", "cards": {"default": "shared", "modes": "shared"}}]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "ps5n64", "name": "PS5N64",
|
||||
"title_ids": ["PPSA99206"],
|
||||
"detect": ["/data/PS5N64", "/data/homebrew/PPSA99206"],
|
||||
"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}", "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/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"}]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "porpoise", "name": "Porpoise",
|
||||
"title_ids": ["PPSA99764"],
|
||||
"detect": ["/data/porpoise", "/data/homebrew/PPSA99764"],
|
||||
"note": "Dolphin, with its user folder in {root}/saves/User.",
|
||||
"profile": {
|
||||
"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). Saves sync for all three; 3DS and DS save states load only in PS5CEMU-HAR.",
|
||||
"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", "save_exts": ".sav", "bios_dir": "{root}/melonds/bios", "sync_saves": true,
|
||||
"state_dir": "{root}/melonds/states", "state_exts": ".ml*", "state_format": "raw", "state_cores": "ps5cemu-melonds",
|
||||
"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"}]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"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", "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"}]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "ps5sx2", "name": "PS5SX2",
|
||||
"title_ids": ["PPSA99203"],
|
||||
"detect": ["/data/PCSX2", "/data/homebrew/PPSA99203"],
|
||||
"note": "Two memory cards for every game, backed up to RomM (5.3 or newer).",
|
||||
"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"}]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "ps5n64", "name": "PS5N64",
|
||||
"title_ids": ["PPSA99206"],
|
||||
"detect": ["/data/PS5N64", "/data/homebrew/PPSA99206"],
|
||||
"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}", "state_format": "raw",
|
||||
"platforms": [{"romm": ["n64"], "dir": "n64", "save_format": "n64-srm"}]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "ps5nes", "name": "PS5NES",
|
||||
"title_ids": ["PPSA99204"],
|
||||
"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}",
|
||||
"platforms": [{"romm": ["nes", "famicom"], "dir": "nes"}]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "porpoise", "name": "Porpoise",
|
||||
"title_ids": ["PPSA99764"],
|
||||
"detect": ["/data/porpoise", "/data/homebrew/PPSA99764"],
|
||||
"note": "Dolphin, with its user folder in {root}/saves/User.",
|
||||
"profile": {
|
||||
"root": "/data/porpoise", "rom_dir": "{root}/games", "save_dir": "{root}/saves/User",
|
||||
"state_dir": "{root}/states/{game_id}", "state_exts": ".state", "state_format": "raw",
|
||||
"platforms": [
|
||||
{"romm": ["ngc", "gamecube"], "dir": "gamecube", "emulator": "dolphin", "save_layout": "gci", "bundle_dirs": "{root}/saves/User/GC"},
|
||||
{"romm": ["wii"], "dir": "wii", "emulator": "dolphin", "save_layout": "wii", "bundle_dirs": "{root}/saves/User/Wii"}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
+17
-8
@@ -20,6 +20,7 @@
|
||||
#include <ps5/kernel.h>
|
||||
|
||||
#include "console.h"
|
||||
#include "ps5.h"
|
||||
#include "util.h" /* APP_VERSION */
|
||||
|
||||
/* The "RomM Sync" tile versions before 1.1.1 installed, opening the web UI,
|
||||
@@ -31,8 +32,9 @@
|
||||
#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;
|
||||
@@ -86,6 +88,7 @@ static plat_info_t INFO = {
|
||||
.retroarch_root = "/data/homebrew/PPSA99169",
|
||||
.mednafen_root = "/data/homebrew/Mednafen",
|
||||
.can_relaunch = 1,
|
||||
.can_mount = 1,
|
||||
.self_update = 0,
|
||||
};
|
||||
|
||||
@@ -123,17 +126,23 @@ int plat_init(void) {
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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"}}
|
||||
]
|
||||
}
|
||||
+22
-44
@@ -182,16 +182,23 @@ static int put_everywhere(const void *data, size_t len, int existing_only) {
|
||||
return failed ? -1 : done;
|
||||
}
|
||||
|
||||
/* Turns autostart on in every installed loader's folder that has the payload. */
|
||||
/* 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) || file_exists(p[i].flag)) continue;
|
||||
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;
|
||||
}
|
||||
LOGI("turned on autostart in %s", p[i].flag);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
@@ -204,7 +211,7 @@ static int check_elf(const void *data, size_t len, char *err, int en) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
int autostart_set(int enable, const void *data, size_t len, char *err, int en) {
|
||||
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) {
|
||||
@@ -222,40 +229,15 @@ int autostart_set(int enable, const void *data, size_t len, char *err, int en) {
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
if (enable) {
|
||||
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);
|
||||
if (enable_all(err, en)) return -1;
|
||||
LOGI("autostart enabled");
|
||||
return 0;
|
||||
}
|
||||
/* Off: nothing of ours is left in any loader's folder to start later. */
|
||||
for (int i = 0; i < n; i++) {
|
||||
if (p[i].flag[0]) unlink(p[i].flag);
|
||||
if (file_exists(p[i].elf) && unlink(p[i].elf) == 0) LOGI("removed %s", p[i].elf);
|
||||
remove_bak(&p[i]);
|
||||
}
|
||||
LOGI("autostart disabled");
|
||||
return 0;
|
||||
}
|
||||
|
||||
int autostart_install(const void *data, size_t len, char *err, int en) {
|
||||
place p[MAX_PLACES];
|
||||
int n = places(p, MAX_PLACES);
|
||||
if (check_elf(data, len, err, en)) return -1;
|
||||
int on = 0;
|
||||
for (int i = 0; i < n; i++) on |= p[i].present && enabled_at(&p[i]);
|
||||
if (put_everywhere(data, len, !on) < 0) {
|
||||
snprintf(err, (size_t)en, "cannot write the payload to %s's folder", plat_info()->loader);
|
||||
if (!file_exists(p[0].elf)) {
|
||||
snprintf(err, (size_t)en, "upload %s first", plat_info()->payload);
|
||||
return -1;
|
||||
}
|
||||
return on ? enable_all(err, en) : 0;
|
||||
/* 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) {
|
||||
@@ -273,15 +255,13 @@ void autostart_repair(void) {
|
||||
char extra[MAX_PLACES][PATH_MAX_LEN];
|
||||
int ne = copies(extra, MAX_PLACES);
|
||||
|
||||
/* The newest copy on the console, and whether autostart is on anywhere. */
|
||||
/* The newest copy on the console. */
|
||||
char best[PATH_MAX_LEN] = "", best_v[32] = "";
|
||||
int on = 0;
|
||||
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;
|
||||
on |= enabled_at(&p[i]);
|
||||
}
|
||||
if (!file_exists(path)) continue;
|
||||
char v[32];
|
||||
@@ -297,8 +277,8 @@ void autostart_repair(void) {
|
||||
const char *path = i < n ? p[i].elf : extra[i - n];
|
||||
if (i < n && !p[i].present) continue;
|
||||
int have = file_exists(path);
|
||||
/* Missing copies are only added to loaders, and only while it's on. */
|
||||
if (!have && !(i < n && on)) continue;
|
||||
/* 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);
|
||||
@@ -311,8 +291,6 @@ void autostart_repair(void) {
|
||||
LOGW("could not copy %s to %s", best, path);
|
||||
}
|
||||
}
|
||||
if (on) {
|
||||
char err[256];
|
||||
if (enable_all(err, sizeof err)) LOGW("%s", err);
|
||||
}
|
||||
char err[256];
|
||||
if (enable_all(err, sizeof err)) LOGW("%s", err);
|
||||
}
|
||||
+10
-12
@@ -1,9 +1,10 @@
|
||||
/* Starting with the console through the loader (etaHEN or onionHEN on the
|
||||
* PS5). Consoles whose loader can't do that report supported: false.
|
||||
*
|
||||
* 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. */
|
||||
* 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
|
||||
|
||||
@@ -15,18 +16,15 @@
|
||||
* version, copies: [{path, version, enabled}]}. installed, enabled, path and
|
||||
* version are about the running loader's folder. */
|
||||
cJSON *autostart_status(void);
|
||||
/* Turns autostart on or off, installing the payload first if one is given.
|
||||
* Off removes the payload from every loader's folder. */
|
||||
int autostart_set(int enable, const void *elf, size_t elf_len, char *err, int en);
|
||||
/* Installs the payload wherever it belongs without turning autostart on or
|
||||
* off: in every loader's folder when it's on, else only over existing copies. */
|
||||
int autostart_install(const void *elf, size_t elf_len, char *err, int en);
|
||||
/* 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, sets up
|
||||
* any installed loader that's missing autostart while it's on, and removes
|
||||
* the .bak files older versions left. */
|
||||
/* 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);
|
||||
|
||||
|
||||
+35
-7
@@ -47,7 +47,6 @@ 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;
|
||||
@@ -65,16 +64,39 @@ 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, "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) {
|
||||
char pol[16] = "";
|
||||
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);
|
||||
@@ -92,7 +114,6 @@ void config_apply_json(const cJSON *j) {
|
||||
get_int(j, "notify", &g_cfg.notify);
|
||||
get_int(j, "update_check", &g_cfg.update_check);
|
||||
get_int(j, "cover_cache", &g_cfg.cover_cache);
|
||||
get_int(j, "autostart_chosen", &g_cfg.autostart_chosen);
|
||||
get_int(j, "keep_backups", &g_cfg.keep_backups);
|
||||
get_int(j, "server_versions", &g_cfg.server_versions);
|
||||
if (g_cfg.server_versions < 0) g_cfg.server_versions = 0;
|
||||
@@ -102,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);
|
||||
@@ -139,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. */
|
||||
@@ -156,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();
|
||||
@@ -164,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);
|
||||
@@ -181,7 +208,6 @@ cJSON *config_to_json(int include_secrets) {
|
||||
cJSON_AddBoolToObject(j, "notify", g_cfg.notify);
|
||||
cJSON_AddBoolToObject(j, "update_check", g_cfg.update_check);
|
||||
cJSON_AddBoolToObject(j, "cover_cache", g_cfg.cover_cache);
|
||||
cJSON_AddBoolToObject(j, "autostart_chosen", g_cfg.autostart_chosen);
|
||||
cJSON_AddNumberToObject(j, "keep_backups", g_cfg.keep_backups);
|
||||
cJSON_AddNumberToObject(j, "server_versions", g_cfg.server_versions);
|
||||
cJSON_AddNumberToObject(j, "download_concurrency", g_cfg.download_concurrency);
|
||||
@@ -209,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) {
|
||||
|
||||
+7
-3
@@ -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;
|
||||
@@ -34,10 +36,9 @@ 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) */
|
||||
int autostart_chosen; /* someone turned autostart on or off here (1.1.1 on) */
|
||||
char server_version[32];
|
||||
} config_t;
|
||||
|
||||
@@ -47,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. */
|
||||
|
||||
+341
-112
@@ -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,81 +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(); }
|
||||
|
||||
struct cover_list { char **list; int n, cap; };
|
||||
|
||||
/* The covers seen first go first: the games on this console, then each
|
||||
* platform's tile, then the rest in RomM's order. */
|
||||
static void prioritize(struct cover_list *c, const char *heads) {
|
||||
char **out = malloc(sizeof *out * (size_t)(c->n ? c->n : 1));
|
||||
char *taken = calloc((size_t)(c->n ? c->n : 1), 1);
|
||||
if (!out || !taken) { free(out); free(taken); return; }
|
||||
int n = 0;
|
||||
state_lock();
|
||||
const cJSON *e;
|
||||
cJSON_ArrayForEach(e, state_section("roms")) {
|
||||
const char *cover = jget_str(e, "cover", "");
|
||||
if (!cover[0] || !(file_exists(e->string) || dir_exists(e->string))) continue;
|
||||
for (int i = 0; i < c->n; i++)
|
||||
if (!taken[i] && !strcmp(c->list[i], cover)) { taken[i] = 1; out[n++] = c->list[i]; break; }
|
||||
}
|
||||
state_unlock();
|
||||
for (int pass = 0; pass < 2; pass++)
|
||||
for (int i = 0; i < c->n; i++)
|
||||
if (!taken[i] && (pass || heads[i])) { taken[i] = 1; out[n++] = c->list[i]; }
|
||||
free(taken);
|
||||
free(c->list);
|
||||
c->list = out;
|
||||
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 cover_list *c = ctx;
|
||||
if (c->n >= 100000) return 0;
|
||||
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 cover_list c = {0};
|
||||
static char heads[100000]; /* a platform's first covers (its tile) */
|
||||
memset(heads, 0, sizeof heads);
|
||||
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));
|
||||
int before = c.n;
|
||||
if (romm_list(path, 0, 100000, collect_cover, &c, &st) < 0) ok = 0;
|
||||
for (int k = before; k < c.n && k < before + 3; k++) heads[k] = 1;
|
||||
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);
|
||||
prioritize(&c, heads);
|
||||
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;
|
||||
@@ -345,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);
|
||||
}
|
||||
@@ -367,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;
|
||||
@@ -383,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");
|
||||
@@ -400,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);
|
||||
@@ -415,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;
|
||||
@@ -430,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);
|
||||
@@ -439,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();
|
||||
@@ -467,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. */
|
||||
|
||||
+203
-78
@@ -23,55 +23,79 @@
|
||||
#include "ra2.h"
|
||||
#include "state.h"
|
||||
#include "util.h"
|
||||
#include "presets.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;
|
||||
@@ -123,16 +147,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. */
|
||||
@@ -163,23 +214,9 @@ static void add_core_platforms(cJSON *plats, cJSON *cores, const char *core_id,
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -227,6 +264,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();
|
||||
@@ -298,7 +338,7 @@ static cJSON *retroarch_candidate(const char *root) {
|
||||
} else {
|
||||
cJSON_Delete(plats); /* no .info files, keep the default platforms */
|
||||
}
|
||||
ra2_update_profile(profile);
|
||||
ra2_update_profile(profile, NULL);
|
||||
|
||||
cJSON *c = cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(c, "kind", "retroarch");
|
||||
@@ -343,6 +383,14 @@ static int app_installed(const char *title_id) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* Presets typically are {"format", "version", "console" "emulators": [....]}*/
|
||||
static cJSON *catalog_from(cJSON *file) {
|
||||
if (cJSON_IsArray(file)) return file;
|
||||
cJSON *list = cJSON_DetachItemFromObjectCaseSensitive(file, "emulators");
|
||||
cJSON_Delete(file);
|
||||
return list;
|
||||
}
|
||||
|
||||
/* The catalog: ROMM_SYNC_CATALOG names a file (for tests), else the console's. */
|
||||
static cJSON *emulator_catalog(void) {
|
||||
const char *env = getenv("ROMM_SYNC_CATALOG");
|
||||
@@ -350,9 +398,9 @@ static cJSON *emulator_catalog(void) {
|
||||
char *txt = read_file(env, NULL);
|
||||
cJSON *j = txt ? cJSON_Parse(txt) : NULL;
|
||||
free(txt);
|
||||
return j;
|
||||
return catalog_from(j);
|
||||
}
|
||||
return plat_info()->emulator_catalog ? cJSON_Parse(plat_info()->emulator_catalog) : NULL;
|
||||
return cJSON_Duplicate(presets_catalog(), 1);
|
||||
}
|
||||
|
||||
static cJSON *g_catalog;
|
||||
@@ -360,9 +408,13 @@ 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)
|
||||
const char *env = getenv("ROMM_SYNC_CATALOG");
|
||||
const cJSON *catalog = presets_catalog(), *e;
|
||||
if (env && *env) {
|
||||
pthread_once(&once, load_catalog);
|
||||
catalog = g_catalog;
|
||||
}
|
||||
cJSON_ArrayForEach(e, catalog)
|
||||
if (!strcmp(jget_str(e, "id", ""), id)) return e;
|
||||
return NULL;
|
||||
}
|
||||
@@ -481,18 +533,91 @@ cJSON *detect_emulators(void) {
|
||||
|
||||
/* 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. */
|
||||
* 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"};
|
||||
|
||||
/* "sync_saves" says whether an emulator's saves can sync, and only the preset
|
||||
* gets to say it now (profiles.c). Saved profiles still carry copies from
|
||||
* before: a "false" the preset never shipped was the user switching sync off,
|
||||
* which "sync": "off" says now; one it did ship (and has since dropped, the
|
||||
* saves turning out fine) is simply gone. Every "false" ever shipped, by
|
||||
* emulator and system ("*": all of them): */
|
||||
static const struct {
|
||||
const char *profile, *system;
|
||||
} SHIPPED_NO_SYNC[] = {
|
||||
{"ps5cemu", "nds"}, {"prosperoeden", "*"}, {"ps4sx2", "*"}, {"ps5sx2", "*"},
|
||||
{"resonance", "*"}, {"seedee5", "*"}, {"xpsemu", "*"},
|
||||
};
|
||||
|
||||
static int shipped_no_sync(const char *profile, const char *system) {
|
||||
for (size_t i = 0; i < sizeof SHIPPED_NO_SYNC / sizeof SHIPPED_NO_SYNC[0]; i++)
|
||||
if (!strcmp(SHIPPED_NO_SYNC[i].profile, profile) &&
|
||||
(!strcmp(SHIPPED_NO_SYNC[i].system, "*") || !strcmp(SHIPPED_NO_SYNC[i].system, system)))
|
||||
return 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* One level's saved copy (the profile's, system "*", or a platform's) gone;
|
||||
* the user's own "off" kept as "sync". `preset` is what the preset says for
|
||||
* that level now: a copy of it isn't anyone's choice either. */
|
||||
static int drop_saved_sync_saves(cJSON *o, const cJSON *preset, const char *profile, const char *system, const char *name) {
|
||||
const cJSON *v = cJSON_GetObjectItemCaseSensitive(o, "sync_saves");
|
||||
if (!v) return 0;
|
||||
if (cJSON_IsFalse(v) && !cJSON_IsFalse(preset) && !shipped_no_sync(profile, system)) {
|
||||
cJSON_DeleteItemFromObjectCaseSensitive(o, "sync");
|
||||
cJSON_AddStringToObject(o, "sync", "off");
|
||||
LOGI("the %s profile: sync turned off for %s, as its saved profile had it", name, !strcmp(system, "*") ? "all its systems" : system);
|
||||
}
|
||||
cJSON_DeleteItemFromObjectCaseSensitive(o, "sync_saves");
|
||||
return 1;
|
||||
}
|
||||
|
||||
static int drop_saved_sync(cJSON *p, const cJSON *preset) {
|
||||
const char *id = jget_str(p, "id", ""), *name = jget_str(p, "name", "");
|
||||
const cJSON *top = cJSON_GetObjectItemCaseSensitive(preset, "sync_saves");
|
||||
int n = drop_saved_sync_saves(p, top, id, "*", name);
|
||||
cJSON *pe;
|
||||
cJSON_ArrayForEach(pe, cJSON_GetObjectItemCaseSensitive(p, "platforms")) {
|
||||
const cJSON *ce, *says = top;
|
||||
cJSON_ArrayForEach(ce, cJSON_GetObjectItemCaseSensitive(preset, "platforms"))
|
||||
if (!strcmp(profiles_system_key(ce), profiles_system_key(pe)) && cJSON_GetObjectItemCaseSensitive(ce, "sync_saves"))
|
||||
says = cJSON_GetObjectItemCaseSensitive(ce, "sync_saves");
|
||||
n += drop_saved_sync_saves(pe, says, id, profiles_system_key(pe), name);
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
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;
|
||||
if (!preset) return 0;
|
||||
int added = 0;
|
||||
cJSON_ArrayForEach(k, preset)
|
||||
if (strcmp(k->string, "platforms") && !cJSON_GetObjectItemCaseSensitive(p, k->string)) {
|
||||
/* 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") || !strcmp(k->string, "sync_saves")) continue; /* (the preset's alone) */
|
||||
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++;
|
||||
}
|
||||
}
|
||||
const int dropped = drop_saved_sync(p, preset);
|
||||
cJSON_ArrayForEach(pe, cJSON_GetObjectItemCaseSensitive(p, "platforms")) {
|
||||
const cJSON *first = cJSON_GetArrayItem(cJSON_GetObjectItemCaseSensitive(pe, "romm"), 0), *ce;
|
||||
if (!cJSON_IsString(first)) continue;
|
||||
@@ -500,14 +625,14 @@ static int fill_from_catalog(cJSON *p) {
|
||||
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)) {
|
||||
if (strcmp(k->string, "sync_saves") != 0 && !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;
|
||||
return added + dropped;
|
||||
}
|
||||
|
||||
int detect_refresh_profiles(cJSON *profiles) {
|
||||
|
||||
@@ -19,5 +19,7 @@ const cJSON *detect_catalog_entry(const char *id);
|
||||
* 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
|
||||
+7
-3
@@ -2,12 +2,16 @@
|
||||
* newest last, the last EVENTS_MAX kept in events.json.
|
||||
*
|
||||
* {"t": 1791936000, "sync": "game exited", "rom_id": 878,
|
||||
* "kind": "save" | "state" | "card",
|
||||
* "kind": "save" | "state" | "card" | "bios" | "game" | "emulator",
|
||||
* "action": "uploaded" | "downloaded" | "conflict" | "resolved" |
|
||||
* "skipped" | "restored" | "backed up" | "failed",
|
||||
* "skipped" | "restored" | "backed up" | "failed" |
|
||||
* "moved" | "removed",
|
||||
* "path": "/data/.../Game.srm", "emulator": "mgba", "detail": "..."}
|
||||
*
|
||||
* The game's name and platform are filled in when the log is read. */
|
||||
* "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
|
||||
|
||||
|
||||
+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);
|
||||
|
||||
|
||||
+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
|
||||
+1050
-87
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);
|
||||
|
||||
+47
-17
@@ -25,6 +25,7 @@
|
||||
#include "util.h"
|
||||
#include "watch.h"
|
||||
#include "web.h"
|
||||
#include "presets.h"
|
||||
|
||||
#define MONITOR_INTERVAL_SEC 3
|
||||
/* Failed foreground reads in a row before game detection is given up. */
|
||||
@@ -54,22 +55,6 @@ static void *tile_thread(void *arg) {
|
||||
return NULL;
|
||||
}
|
||||
|
||||
/* The largest block malloc hands out, in MB up to 1024, for the log: on the PS4
|
||||
* the payload shares GoldHEN's process, which has little memory to spare. */
|
||||
static int largest_block_mb(void) {
|
||||
int lo = 0, hi = 1024;
|
||||
while (lo < hi) {
|
||||
int mid = (lo + hi + 1) / 2;
|
||||
void *p = malloc((size_t)mid << 20);
|
||||
if (p) {
|
||||
free(p);
|
||||
lo = mid;
|
||||
} else {
|
||||
hi = mid - 1;
|
||||
}
|
||||
}
|
||||
return lo;
|
||||
}
|
||||
|
||||
/* Lets the PS4 app read which version an installed payload is, so it never
|
||||
* replaces a newer one with the copy it carries (platform/ps4/app). */
|
||||
@@ -94,6 +79,41 @@ static void on_crash(int sig) {
|
||||
raise(sig);
|
||||
}
|
||||
|
||||
/* Emulators found on the console that setup never saw (installed later, or
|
||||
* new to this version): said once, so they don't go unnoticed. Ones the user
|
||||
* turned off still have a profile, and aren't news. */
|
||||
static void announce_new_emulators(void) {
|
||||
if (!g_cfg.profiles_custom) return; /* setup offers them all anyway */
|
||||
cJSON *found = detect_emulators();
|
||||
char names[160] = "";
|
||||
int fresh = 0;
|
||||
const cJSON *c, *p;
|
||||
state_lock();
|
||||
cJSON *seen = state_section("emulators_seen");
|
||||
cJSON_ArrayForEach(c, found) {
|
||||
const char *id = jget_str(c, "id", jget_str(c, "kind", "")), *root = jget_str(c, "root", "");
|
||||
int known = cJSON_GetObjectItemCaseSensitive(seen, root) != NULL;
|
||||
config_lock();
|
||||
cJSON_ArrayForEach(p, g_cfg.profiles)
|
||||
known |= !strcmp(jget_str(p, "id", ""), id) || (root[0] && !strcmp(jget_str(p, "root", ""), root));
|
||||
config_unlock();
|
||||
if (known) continue;
|
||||
cJSON_AddTrueToObject(seen, root);
|
||||
const char *name = jget_str(c, "name", id);
|
||||
if (++fresh <= 3) snprintf(names + strlen(names), sizeof names - strlen(names), "%s%s", fresh > 1 ? ", " : "", name);
|
||||
events_add("emulator", "found", 0, root, name, "Turn it on in Emulator Settings");
|
||||
}
|
||||
if (fresh) state_save();
|
||||
state_unlock();
|
||||
cJSON_Delete(found);
|
||||
if (!fresh) return;
|
||||
events_save();
|
||||
LOGI("new emulators found: %s%s", names, fresh > 3 ? " and more" : "");
|
||||
if (fresh > 3) plat_notify("RommPS found new emulators: %s and %d more. Turn them on in Emulator Settings.", names, fresh - 3);
|
||||
else plat_notify("RommPS found %s: %s. Turn %s on in Emulator Settings.", fresh == 1 ? "a new emulator" : "new emulators",
|
||||
names, fresh == 1 ? "it" : "them");
|
||||
}
|
||||
|
||||
static void *app_main(void *arg) {
|
||||
(void)arg;
|
||||
int init = plat_init();
|
||||
@@ -117,7 +137,7 @@ static void *app_main(void *arg) {
|
||||
plat_describe(about, sizeof about);
|
||||
iso8601_utc(time(NULL), now_iso, sizeof now_iso);
|
||||
LOGI("%s %s (build %s) starting (%s), data dir %s", APP_NAME, APP_VERSION, APP_BUILD, plat_name(), plat_data_dir());
|
||||
LOGI("%s; clock %s; largest free block %d MB", about, now_iso, largest_block_mb());
|
||||
LOGI("%s; clock %s; largest free block %d MB", about, now_iso, mem_largest_mb());
|
||||
/* Jailbroken consoles kept offline often run with the clock at 1970, which
|
||||
* breaks HTTPS certificate checks and save timestamps. */
|
||||
if (time(NULL) < 1577836800) { /* 2020-01-01 */
|
||||
@@ -125,12 +145,14 @@ static void *app_main(void *arg) {
|
||||
plat_notify("RommPS: the console's date and time aren't set. Set them in the console's Settings for syncing to work.");
|
||||
}
|
||||
|
||||
presets_init();
|
||||
config_load();
|
||||
if (g_cfg.profiles_custom && detect_refresh_profiles(g_cfg.profiles) > 0) config_save();
|
||||
state_load();
|
||||
/* RetroArch 2.0 keeps games in new folders. */
|
||||
ra2_check(1);
|
||||
events_load();
|
||||
announce_new_emulators();
|
||||
/* Copies of older versions left on the console are brought up to date. */
|
||||
autostart_repair();
|
||||
http_global_init(plat_ca_bundle(), g_cfg.tls_verify);
|
||||
@@ -168,7 +190,14 @@ static void *app_main(void *arg) {
|
||||
plat_notify("RommPS: open http://%s:%d to pair with RomM", ip[0] ? ip : "127.0.0.1", port);
|
||||
}
|
||||
|
||||
/* The extras' mounts are watched for from the start: an emulator opened
|
||||
* right after boot gets them too, and ones an earlier copy left on come
|
||||
* off when it closes. */
|
||||
library_link_extras();
|
||||
|
||||
sleep(5); /* wait for the network after boot */
|
||||
presets_check();
|
||||
|
||||
if (config_is_paired()) {
|
||||
char base[512], version[32] = "";
|
||||
config_lock();
|
||||
@@ -257,6 +286,7 @@ static void *app_main(void *arg) {
|
||||
now - sync_last_run() >= (time_t)interval * 60) {
|
||||
sync_request("periodic");
|
||||
}
|
||||
if (!sync_is_running() && sync_retry_due()) sync_request("retry");
|
||||
update_tick();
|
||||
}
|
||||
return NULL;
|
||||
|
||||
+376
-152
@@ -2,9 +2,12 @@
|
||||
|
||||
#include "events.h"
|
||||
|
||||
#include <dirent.h>
|
||||
#include <stdint.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <sys/stat.h>
|
||||
#include <time.h>
|
||||
#include <unistd.h>
|
||||
|
||||
@@ -18,56 +21,53 @@
|
||||
#include "util.h"
|
||||
#include "zip.h"
|
||||
|
||||
/* The shared memory cards backed up: LRPS2's (<system>/pcsx2/memcards/
|
||||
* Mcd00N.ps2, when its "backup" mode is on), RetroArch 2.0's PS5SX2's (the
|
||||
* same two, always: it has no per-game cards) and those a profile lists in
|
||||
* "memcards" (PS5SX2's Mcd001.ps2 and Mcd002.ps2). RomM only takes cards as
|
||||
* zips, so each goes up as a zip holding exactly one file. */
|
||||
#define MAX_CARDS 8
|
||||
/* Shared memory cards: the ones an emulator lists in "memcards" (PS5SX2's
|
||||
* Mcd001.ps2 and Mcd002.ps2, the Flycasts' vmu_save_A1.bin...), while their
|
||||
* system uses shared cards (profiles.c, card_methods).
|
||||
*
|
||||
* A card on RomM is one card for every console that keeps it: emulator
|
||||
* "rommps-<system>", named after the file ("Mcd001.ps2"; RetroArch's cores
|
||||
* "RetroArch Mcd001.ps2"), so PS4SX2 on a PS4 and PS5SX2 on a PS5 play on the
|
||||
* same card, and RetroArch's PS5SX2 on its own. It syncs both ways: the side that changed goes to the
|
||||
* other; when both did, the newer wins and the other stays (this console's in
|
||||
* backups/memcards, RomM's in the card's history). A card isn't replaced
|
||||
* while a game runs. RomM takes cards as zips: each holds the one file. */
|
||||
#define MAX_CARDS 16
|
||||
|
||||
typedef struct {
|
||||
char path[PATH_MAX_LEN];
|
||||
char emulator[32]; /* RomM's emulator field: "lrps2", or the profile's id */
|
||||
char label[64]; /* in the card's name on RomM; "" for LRPS2's */
|
||||
int slot; /* 1-based, per emulator */
|
||||
char key[48]; /* RomM's emulator field: "rommps-ps2" */
|
||||
char name[64]; /* the card's name on RomM: the file's ("Mcd001.ps2") */
|
||||
char label[64]; /* the emulator, for the UIs */
|
||||
} card;
|
||||
|
||||
static int cards_list(card *out, int max) {
|
||||
char mode[16];
|
||||
config_lock();
|
||||
str_copy(mode, sizeof mode, g_cfg.ps2_cards);
|
||||
profile_map *maps = NULL;
|
||||
int cnt = profiles_expand(g_cfg.profiles, &maps);
|
||||
config_unlock();
|
||||
int n = 0;
|
||||
for (int i = 0; i < cnt && n < max; i++) {
|
||||
const profile_map *m = &maps[i];
|
||||
if (m->sync_off) continue;
|
||||
const int lrps2 = !strcmp(mode, "backup") && str_ieq(m->core_name, "LRPS2");
|
||||
const int ps5sx2 = str_ieq(m->core_name, "PS5SX2");
|
||||
if ((lrps2 || ps5sx2) && profile_map_handles(m, "ps2", NULL)) {
|
||||
for (int s = 1; s <= 2 && n < max; s++) {
|
||||
card *c = &out[n++];
|
||||
snprintf(c->path, sizeof c->path, "%s/pcsx2/memcards/Mcd%03d.ps2", m->bios_dir, s);
|
||||
/* Not "ps5sx2": standalone PS5SX2's cards are RomM's "ps5sx2". */
|
||||
str_copy(c->emulator, sizeof c->emulator, lrps2 ? "lrps2" : "ps5sx2-retroarch");
|
||||
str_copy(c->label, sizeof c->label, lrps2 ? "" : "RetroArch PS5SX2");
|
||||
c->slot = s;
|
||||
}
|
||||
}
|
||||
char list[512], *save = NULL;
|
||||
if (m->sync_off || !m->memcards[0]) continue;
|
||||
/* Only while the system uses shared cards and this emulator keeps them. */
|
||||
if (m->cards_mode[0] && (!m->cards_active || strcmp(m->cards_mode, "shared") != 0)) continue;
|
||||
char sys[64];
|
||||
str_copy(sys, sizeof sys, m->romm_slugs);
|
||||
sys[strcspn(sys, ",")] = 0;
|
||||
char list[sizeof m->memcards], *save = NULL;
|
||||
str_copy(list, sizeof list, m->memcards);
|
||||
int s = 0;
|
||||
for (char *t = strtok_r(list, ",", &save); t && n < max; t = strtok_r(NULL, ",", &save)) {
|
||||
s++;
|
||||
int dup = 0;
|
||||
for (int k = 0; k < n; k++) dup |= !strcmp(out[k].path, t);
|
||||
if (dup) continue;
|
||||
card *c = &out[n++];
|
||||
str_copy(c->path, sizeof c->path, t);
|
||||
str_copy(c->emulator, sizeof c->emulator, m->profile_id);
|
||||
snprintf(c->key, sizeof c->key, "rommps-%s", sys);
|
||||
/* RetroArch's cores apart from the standalone emulators: two
|
||||
* different emulators on one console never share a card unasked. */
|
||||
snprintf(c->name, sizeof c->name, "%s%s", m->core_name[0] ? "RetroArch " : "", path_basename(t));
|
||||
str_copy(c->label, sizeof c->label, m->profile_name);
|
||||
c->slot = s;
|
||||
}
|
||||
}
|
||||
free(maps);
|
||||
@@ -95,163 +95,373 @@ int memcard_dirs(char dirs[][1024], int max) {
|
||||
return nd;
|
||||
}
|
||||
|
||||
static int find_or_create_card(const card *cd) {
|
||||
char name[160], device[64], api[128];
|
||||
config_lock();
|
||||
str_copy(device, sizeof device, g_cfg.device_name);
|
||||
config_unlock();
|
||||
if (cd->label[0]) snprintf(name, sizeof name, "%s %s slot %d", device, cd->label, cd->slot);
|
||||
else snprintf(name, sizeof name, "%s slot %d", device, cd->slot);
|
||||
/* "rommps-ps2|Mcd001.ps2": what a state entry's card was found as. Entries
|
||||
* from before 1.3 have none: their card was this console's own. */
|
||||
static void card_identity(const card *cd, char *out, size_t n) { snprintf(out, n, "%s|%s", cd->key, cd->name); }
|
||||
|
||||
/* The card's id on RomM, 0 if it has none yet (create: make it). */
|
||||
static int find_card(const card *cd, int create) {
|
||||
char api[128];
|
||||
long st = 0;
|
||||
char *q = http_escape(cd->emulator);
|
||||
char *q = http_escape(cd->key);
|
||||
snprintf(api, sizeof api, "/api/memory-cards?emulator=%s", q);
|
||||
free(q);
|
||||
cJSON *list = romm_call("GET", api, NULL, &st);
|
||||
int id = 0;
|
||||
const cJSON *c;
|
||||
cJSON_ArrayForEach(c, list) if (!strcmp(jget_str(c, "name", ""), name)) id = (int)jget_num(c, "id", 0);
|
||||
cJSON_ArrayForEach(c, list) if (!id && !strcmp(jget_str(c, "name", ""), cd->name)) id = (int)jget_num(c, "id", 0);
|
||||
cJSON_Delete(list);
|
||||
if (id) return id;
|
||||
if (id || !create) return id;
|
||||
|
||||
cJSON *body = cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(body, "name", name);
|
||||
cJSON_AddStringToObject(body, "emulator", cd->emulator);
|
||||
cJSON_AddStringToObject(body, "name", cd->name);
|
||||
cJSON_AddStringToObject(body, "emulator", cd->key);
|
||||
cJSON *made = romm_call("POST", "/api/memory-cards", body, &st);
|
||||
cJSON_Delete(body);
|
||||
id = (st == 200 || st == 201) ? (int)jget_num(made, "id", 0) : 0;
|
||||
cJSON_Delete(made);
|
||||
if (id) LOGI("created memory card \"%s\" on RomM", name);
|
||||
if (id) LOGI("created memory card \"%s\" (%s) on RomM", cd->name, cd->key);
|
||||
return id;
|
||||
}
|
||||
|
||||
static int upload_slot(const card *cd, const char *hash) {
|
||||
const char *path = cd->path;
|
||||
state_lock();
|
||||
cJSON *e = state_entry_ensure("memcards", path);
|
||||
int card_id = (int)jget_num(e, "card_id", 0);
|
||||
state_unlock();
|
||||
|
||||
for (int attempt = 0; attempt < 2; attempt++) {
|
||||
if (!card_id) card_id = find_or_create_card(cd);
|
||||
if (!card_id) {
|
||||
LOGE("could not create a memory card on RomM");
|
||||
return -1;
|
||||
}
|
||||
char zip[PATH_MAX_LEN], entry[PATH_MAX_LEN], api[128];
|
||||
snprintf(zip, sizeof zip, "%s/tmp/card-%s-%d.zip", plat_data_dir(), cd->emulator, cd->slot);
|
||||
mkdir_parent(zip);
|
||||
str_copy(entry, sizeof entry, path);
|
||||
const char *files[] = {entry};
|
||||
if (zip_store_files(zip, files, 1) != 0) {
|
||||
LOGE("could not pack %s", path);
|
||||
return -1;
|
||||
}
|
||||
snprintf(api, sizeof api, "/api/memory-cards/%d/versions", card_id);
|
||||
long st = 0;
|
||||
cJSON *v = romm_upload("POST", api, "cardFile", zip, path_basename(zip), &st);
|
||||
unlink(zip);
|
||||
if (st == 200 || st == 201) {
|
||||
state_lock();
|
||||
cJSON *s = state_entry_ensure("memcards", path);
|
||||
jset_num(s, "card_id", card_id);
|
||||
jset_str(s, "hash", hash);
|
||||
jset_num(s, "version_id", jget_num(v, "id", 0));
|
||||
jset_num(s, "backed_up_at", (double)time(NULL));
|
||||
state_save();
|
||||
state_unlock();
|
||||
cJSON_Delete(v);
|
||||
LOGI("backed up memory card %s", path_basename(path));
|
||||
events_add("card", "backed up", 0, path, "", NULL);
|
||||
return 0;
|
||||
}
|
||||
cJSON_Delete(v);
|
||||
if (st != 404) return -1;
|
||||
card_id = 0; /* deleted on the server, create it again */
|
||||
/* The card's newest version on RomM that can be downloaded: 0 if none, -1 if
|
||||
* the card is gone (404), -2 if RomM couldn't be asked. Newest is the highest
|
||||
* id, whatever order RomM lists them in. */
|
||||
static int latest_version(int card_id, time_t *at) {
|
||||
char api[96];
|
||||
long st = 0;
|
||||
snprintf(api, sizeof api, "/api/memory-cards/%d/versions", card_id);
|
||||
cJSON *list = romm_call("GET", api, NULL, &st);
|
||||
int id = st == 404 ? -1 : st == 200 ? 0 : -2;
|
||||
const cJSON *v;
|
||||
cJSON_ArrayForEach(v, list) {
|
||||
if (cJSON_IsTrue(cJSON_GetObjectItemCaseSensitive(v, "missing_from_fs"))) continue;
|
||||
const int vid = (int)jget_num(v, "id", 0);
|
||||
if (vid <= id) continue;
|
||||
id = vid;
|
||||
*at = iso8601_parse(jget_str(v, "created_at", ""));
|
||||
}
|
||||
return -1;
|
||||
cJSON_Delete(list);
|
||||
return id;
|
||||
}
|
||||
|
||||
int memcard_backup(void) {
|
||||
static uint32_t le16(const unsigned char *p) { return (uint32_t)(p[0] | p[1] << 8); }
|
||||
static uint32_t le32(const unsigned char *p) { return le16(p) | le16(p + 2) << 16; }
|
||||
|
||||
/* A card file that's one byte over and over: one never formatted (PCSX2
|
||||
* makes them all 0xFF), or zeroed. */
|
||||
static int unformatted(FILE *fp) {
|
||||
unsigned char b[65536];
|
||||
int first = -1;
|
||||
size_t n;
|
||||
rewind(fp);
|
||||
while ((n = fread(b, 1, sizeof b, fp)) > 0)
|
||||
for (size_t i = 0; i < n; i++) {
|
||||
if (first < 0) first = b[i];
|
||||
if (b[i] != first || (first != 0xff && first != 0)) return 0;
|
||||
}
|
||||
return first >= 0;
|
||||
}
|
||||
|
||||
/* How many saves a card holds: 0 for a freshly formatted or never formatted
|
||||
* one, -1 when it's no card we can read (then it's never called blank). PS1
|
||||
* cards, PS2 cards (with or without ECC) and Dreamcast VMUs. */
|
||||
static int card_saves(const char *path) {
|
||||
FILE *fp = fopen(path, "rb");
|
||||
if (!fp) return -1;
|
||||
unsigned char b[2048];
|
||||
const int64_t size = file_size(path);
|
||||
int saves = -1;
|
||||
size_t n = fread(b, 1, sizeof b, fp);
|
||||
if (n == sizeof b && b[0] == 'M' && b[1] == 'C' && size == 128 * 1024) {
|
||||
/* PS1: directory frames 1-15, 0x51 starts a save */
|
||||
saves = 0;
|
||||
for (int i = 1; i < 16; i++) saves += b[i * 128] == 0x51;
|
||||
} else if (n >= 0x40 && !memcmp(b, "Sony PS2 Memory Card Format ", 28)) {
|
||||
/* PS2: the root directory's "." entry counts its entries, "." and ".." included */
|
||||
const uint32_t page = le16(b + 0x28), ppc = le16(b + 0x2a), clusters = le32(b + 0x30);
|
||||
const uint32_t alloc = le32(b + 0x34), root = le32(b + 0x3c);
|
||||
const int64_t raw = (int64_t)clusters * ppc * page, ecc = (int64_t)clusters * ppc * (page + page / 32);
|
||||
const int64_t stride = size == ecc ? page + page / 32 : size == raw ? page : 0;
|
||||
unsigned char e[8];
|
||||
if (page == 512 && ppc && stride && fseek(fp, (long)((int64_t)(alloc + root) * ppc * stride), SEEK_SET) == 0 &&
|
||||
fread(e, 1, sizeof e, fp) == sizeof e && (le16(e) & 0x8020) == 0x8020)
|
||||
saves = le32(e + 4) >= 2 ? (int)le32(e + 4) - 2 : 0;
|
||||
} else if (size == 128 * 1024) {
|
||||
/* Dreamcast VMU: the root block (255) says where the directory is;
|
||||
* each 32-byte entry's first byte is 0 when unused */
|
||||
unsigned char rb[512], dir[512];
|
||||
if (fseek(fp, 255 * 512, SEEK_SET) == 0 && fread(rb, 1, sizeof rb, fp) == sizeof rb) {
|
||||
const uint32_t at = le16(rb + 0x4a), blocks = le16(rb + 0x4c);
|
||||
if (at < 255 && blocks && blocks <= at + 1 && blocks <= 16) {
|
||||
saves = 0;
|
||||
for (uint32_t k = 0; k < blocks && saves >= 0; k++) {
|
||||
if (fseek(fp, (long)(at - k) * 512, SEEK_SET) != 0 || fread(dir, 1, sizeof dir, fp) != sizeof dir) saves = -1;
|
||||
for (int i = 0; i < 16 && saves >= 0; i++) saves += dir[i * 32] != 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (saves < 0 && unformatted(fp)) saves = 0;
|
||||
fclose(fp);
|
||||
return saves;
|
||||
}
|
||||
|
||||
static void remember(const card *cd, int card_id, const char *hash, int version_id) {
|
||||
char ident[128];
|
||||
card_identity(cd, ident, sizeof ident);
|
||||
state_lock();
|
||||
cJSON *s = state_entry_ensure("memcards", cd->path);
|
||||
jset_num(s, "card_id", card_id);
|
||||
jset_str(s, "card", ident);
|
||||
jset_str(s, "hash", hash);
|
||||
jset_num(s, "version_id", version_id);
|
||||
jset_num(s, "synced_at", (double)time(NULL));
|
||||
state_save();
|
||||
state_unlock();
|
||||
}
|
||||
|
||||
/* This console's card goes up as a new version. The version's id, or -1. */
|
||||
static int upload_card(const card *cd, int card_id) {
|
||||
char zip[PATH_MAX_LEN], api[128], entry[PATH_MAX_LEN];
|
||||
snprintf(zip, sizeof zip, "%s/tmp/card-%d.zip", plat_data_dir(), card_id);
|
||||
mkdir_parent(zip);
|
||||
str_copy(entry, sizeof entry, cd->path);
|
||||
const char *files[] = {entry};
|
||||
if (zip_store_files(zip, files, 1) != 0) {
|
||||
LOGE("could not pack %s", cd->path);
|
||||
return -1;
|
||||
}
|
||||
snprintf(api, sizeof api, "/api/memory-cards/%d/versions", card_id);
|
||||
long st = 0;
|
||||
cJSON *v = romm_upload("POST", api, "cardFile", zip, path_basename(zip), &st);
|
||||
unlink(zip);
|
||||
int id = (st == 200 || st == 201) ? (int)jget_num(v, "id", 0) : -1;
|
||||
cJSON_Delete(v);
|
||||
return id;
|
||||
}
|
||||
|
||||
static void backup_local(const char *path) {
|
||||
if (!file_exists(path)) return;
|
||||
char bak[PATH_MAX_LEN], ts[32];
|
||||
time_t now = time(NULL);
|
||||
struct tm tm;
|
||||
localtime_r(&now, &tm);
|
||||
strftime(ts, sizeof ts, "%Y%m%d-%H%M%S", &tm);
|
||||
snprintf(bak, sizeof bak, "%s/backups/memcards/%s.%s", plat_data_dir(), path_basename(path), ts);
|
||||
mkdir_parent(bak);
|
||||
copy_file(path, bak);
|
||||
}
|
||||
|
||||
/* RomM's version, fetched into tmp/card-v<id>/ (drop it with fetched_drop):
|
||||
* got is the card in it, written when the card was last written (0 if the
|
||||
* zip doesn't say). 0, or -1 with err set. */
|
||||
static int fetch_card(int version_id, char *got, size_t gn, time_t *written, char *err, int en) {
|
||||
char api[128], zip[PATH_MAX_LEN], dir[PATH_MAX_LEN];
|
||||
snprintf(api, sizeof api, "/api/memory-cards/versions/%d/content", version_id);
|
||||
snprintf(zip, sizeof zip, "%s/tmp/card-v%d.zip", plat_data_dir(), version_id);
|
||||
snprintf(dir, sizeof dir, "%s/tmp/card-v%d", plat_data_dir(), version_id);
|
||||
got[0] = 0;
|
||||
*written = 0;
|
||||
mkdir_parent(zip);
|
||||
http_resp r;
|
||||
if (romm_download(api, zip, NULL, NULL, &r) != 0) {
|
||||
snprintf(err, (size_t)en, r.status == 404 ? "that card has no data on RomM" : "download failed");
|
||||
unlink(zip);
|
||||
return -1;
|
||||
}
|
||||
/* The one file in it, whatever another emulator named it. */
|
||||
remove_tree(dir);
|
||||
mkdir_p(dir);
|
||||
int rc = zip_extract_all(zip, dir); /* how many files it wrote */
|
||||
*written = zip_first_time(zip);
|
||||
unlink(zip);
|
||||
DIR *d = rc > 0 ? opendir(dir) : NULL;
|
||||
struct dirent *de;
|
||||
while (d && (de = readdir(d)))
|
||||
if (de->d_name[0] != '.' && !got[0]) path_join(got, gn, dir, de->d_name);
|
||||
if (d) closedir(d);
|
||||
/* A card is the same file wherever it's kept (its RomM name is the file's,
|
||||
* under its system): only an empty one is refused. */
|
||||
if (!got[0] || file_size(got) <= 0) {
|
||||
snprintf(err, (size_t)en, "RomM's copy of the card is empty");
|
||||
remove_tree(dir);
|
||||
return -1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
static void fetched_drop(const char *got) {
|
||||
char dir[PATH_MAX_LEN];
|
||||
str_copy(dir, sizeof dir, got);
|
||||
char *slash = strrchr(dir, '/');
|
||||
if (slash) *slash = 0;
|
||||
remove_tree(dir);
|
||||
}
|
||||
|
||||
/* A fetched card replaces this console's (kept in backups first). 0, or -1
|
||||
* with err set. */
|
||||
static int install_card(const card *cd, const char *got, char *err, int en) {
|
||||
char tmp[PATH_MAX_LEN];
|
||||
backup_local(cd->path);
|
||||
snprintf(tmp, sizeof tmp, "%s.rommps", cd->path);
|
||||
mkdir_parent(cd->path);
|
||||
const int rc = copy_file(got, tmp) == 0 && rename(tmp, cd->path) == 0 ? 0 : -1;
|
||||
fetched_drop(got);
|
||||
if (rc) {
|
||||
unlink(tmp);
|
||||
snprintf(err, (size_t)en, "could not write %s", cd->path);
|
||||
}
|
||||
return rc;
|
||||
}
|
||||
|
||||
static int download_card(const card *cd, int version_id, char *err, int en) {
|
||||
char got[PATH_MAX_LEN];
|
||||
time_t written;
|
||||
if (fetch_card(version_id, got, sizeof got, &written, err, en) != 0) return -1;
|
||||
return install_card(cd, got, err, en);
|
||||
}
|
||||
|
||||
/* One card, both ways. 1 if one went up, 2 if one came down, 0 for nothing,
|
||||
* -1 on an error. upload_only: a game runs and may have the card open. */
|
||||
static int sync_card(const card *cd, int upload_only) {
|
||||
char ident[128], hash[33] = "", err[128];
|
||||
card_identity(cd, ident, sizeof ident);
|
||||
const int here = file_exists(cd->path) && md5_file_hex(cd->path, hash) == 0;
|
||||
state_lock();
|
||||
cJSON *e = state_entry("memcards", cd->path);
|
||||
const int same_card = e && !strcmp(jget_str(e, "card", ""), ident);
|
||||
int card_id = same_card ? (int)jget_num(e, "card_id", 0) : 0;
|
||||
int synced_version = same_card ? (int)jget_num(e, "version_id", 0) : 0;
|
||||
char synced_hash[33] = "";
|
||||
if (same_card) str_copy(synced_hash, sizeof synced_hash, jget_str(e, "hash", ""));
|
||||
state_unlock();
|
||||
|
||||
if (!card_id) card_id = find_card(cd, 0);
|
||||
time_t remote_at = 0;
|
||||
int remote = card_id ? latest_version(card_id, &remote_at) : 0;
|
||||
if (remote == -2) return -1;
|
||||
if (remote == -1) card_id = 0, remote = 0; /* deleted on RomM: made again below */
|
||||
|
||||
/* Versions only count up: an older one (ours went missing on RomM) is no news. */
|
||||
const int local_changed = here && strcmp(hash, synced_hash) != 0;
|
||||
const int remote_changed = remote > synced_version;
|
||||
const int both = local_changed && remote_changed; /* also every card's first sync since 1.3 */
|
||||
/* A card wiped or made fresh here comes back from RomM instead of going up. */
|
||||
const int blank_here = here && card_saves(cd->path) == 0;
|
||||
int up = local_changed && !remote_changed && !(blank_here && remote > 0);
|
||||
int down = remote > 0 && !up && (remote_changed || (local_changed && blank_here));
|
||||
/* Not while any game runs, the sync at its start included: an emulator
|
||||
* that has the card open keeps writing to the file it opened, and one
|
||||
* swapped in under it would lose everything saved after. (Waking from
|
||||
* rest also looks like a game starting.) */
|
||||
if (down && (upload_only || sync_game_running())) return 0;
|
||||
if (down && !here) {
|
||||
/* A card this console doesn't have yet, for an emulator that's here. */
|
||||
char parent[PATH_MAX_LEN];
|
||||
str_copy(parent, sizeof parent, cd->path);
|
||||
char *slash = strrchr(parent, '/');
|
||||
if (slash) *slash = 0;
|
||||
if (!dir_exists(parent)) return 0;
|
||||
}
|
||||
char got[PATH_MAX_LEN] = "";
|
||||
if (down) {
|
||||
/* RomM's copy first, to see what it is. */
|
||||
time_t written = 0;
|
||||
if (fetch_card(remote, got, sizeof got, &written, err, sizeof err) != 0) {
|
||||
LOGE("memory card %s: %s", cd->path, err);
|
||||
return -1;
|
||||
}
|
||||
char theirs[33] = "";
|
||||
md5_file_hex(got, theirs);
|
||||
if (here && !strcmp(theirs, hash)) { /* the same card: nothing to move */
|
||||
fetched_drop(got);
|
||||
remember(cd, card_id, hash, remote);
|
||||
return 0;
|
||||
}
|
||||
if (here) {
|
||||
/* A blank card never beats one with saves; otherwise the one
|
||||
* written last wins (zips older than 1.3 only say when they went up). */
|
||||
const int mine = card_saves(cd->path), theirs_n = card_saves(got);
|
||||
if (mine == 0 && theirs_n > 0) down = 1;
|
||||
else if (theirs_n == 0 && mine > 0) down = 0;
|
||||
else if (both) down = (written ? written : remote_at) > file_mtime(cd->path);
|
||||
if (both) LOGI("memory card %s changed here and on RomM: keeping %s", cd->name, down ? "RomM's" : "this console's");
|
||||
else if (!down) LOGI("memory card %s: RomM's copy is blank, this console's goes up instead", cd->name);
|
||||
up = !down;
|
||||
}
|
||||
if (!down) fetched_drop(got);
|
||||
}
|
||||
if (up) {
|
||||
if (!card_id) card_id = find_card(cd, 1);
|
||||
int v = card_id ? upload_card(cd, card_id) : -1;
|
||||
if (v < 0) {
|
||||
LOGE("could not upload memory card %s", cd->path);
|
||||
return -1;
|
||||
}
|
||||
remember(cd, card_id, hash, v);
|
||||
LOGI("memory card %s up to RomM", cd->path);
|
||||
events_add("card", "uploaded", 0, cd->path, cd->label, NULL);
|
||||
return 1;
|
||||
}
|
||||
if (down) {
|
||||
if (install_card(cd, got, err, sizeof err) != 0) {
|
||||
LOGE("memory card %s: %s", cd->path, err);
|
||||
return -1;
|
||||
}
|
||||
md5_file_hex(cd->path, hash);
|
||||
remember(cd, card_id, hash, remote);
|
||||
LOGI("memory card %s down from RomM", cd->path);
|
||||
events_add("card", "downloaded", 0, cd->path, cd->label, NULL);
|
||||
return 2;
|
||||
}
|
||||
/* Nothing to move; a card from before 1.3 is now this one. */
|
||||
if (here && card_id && !same_card) remember(cd, card_id, hash, remote);
|
||||
return 0;
|
||||
}
|
||||
|
||||
int memcard_sync(int upload_only, int *down) {
|
||||
*down = 0;
|
||||
if (!memcard_enabled()) return 0;
|
||||
card cards[MAX_CARDS];
|
||||
int n = cards_list(cards, MAX_CARDS), uploaded = 0;
|
||||
int n = cards_list(cards, MAX_CARDS), up = 0;
|
||||
for (int i = 0; i < n; i++) {
|
||||
char hash[33];
|
||||
if (!file_exists(cards[i].path) || md5_file_hex(cards[i].path, hash) != 0) continue;
|
||||
state_lock();
|
||||
cJSON *e = state_entry("memcards", cards[i].path);
|
||||
int same = e && !strcmp(jget_str(e, "hash", ""), hash);
|
||||
state_unlock();
|
||||
if (!same && upload_slot(&cards[i], hash) == 0) uploaded++;
|
||||
int r = sync_card(&cards[i], upload_only);
|
||||
if (r == 1) up++;
|
||||
if (r == 2) (*down)++;
|
||||
}
|
||||
return uploaded;
|
||||
return up;
|
||||
}
|
||||
|
||||
int memcard_restore(int slot, char *err, int en) {
|
||||
card cards[MAX_CARDS];
|
||||
int n = memcard_enabled() ? cards_list(cards, MAX_CARDS) : 0;
|
||||
if (!n) {
|
||||
snprintf(err, (size_t)en, "memory card backup is not turned on");
|
||||
snprintf(err, (size_t)en, "no shared memory cards sync here");
|
||||
return -1;
|
||||
}
|
||||
if (slot < 1 || slot > n) {
|
||||
snprintf(err, (size_t)en, "no such slot");
|
||||
return -1;
|
||||
}
|
||||
const card *cd = &cards[slot - 1];
|
||||
char path[PATH_MAX_LEN];
|
||||
if (sync_game_running()) {
|
||||
snprintf(err, (size_t)en, "close the emulator before restoring a memory card");
|
||||
return -1;
|
||||
}
|
||||
str_copy(path, sizeof path, cd->path);
|
||||
state_lock();
|
||||
cJSON *e = state_entry("memcards", path);
|
||||
int card_id = e ? (int)jget_num(e, "card_id", 0) : 0;
|
||||
state_unlock();
|
||||
if (!card_id) card_id = find_or_create_card(cd);
|
||||
|
||||
char api[128], zip[PATH_MAX_LEN], tmp[PATH_MAX_LEN];
|
||||
snprintf(api, sizeof api, "/api/memory-cards/%d/content", card_id);
|
||||
snprintf(zip, sizeof zip, "%s/tmp/restore-%d.zip", plat_data_dir(), slot);
|
||||
snprintf(tmp, sizeof tmp, "%s/tmp/restore-%d.ps2", plat_data_dir(), slot);
|
||||
http_resp r;
|
||||
if (romm_download(api, zip, NULL, NULL, &r) != 0) {
|
||||
snprintf(err, (size_t)en, r.status == 404 ? "no backup on RomM yet" : "download failed");
|
||||
unlink(zip);
|
||||
return -1;
|
||||
}
|
||||
int rc = zip_extract_entry(zip, path_basename(path), tmp);
|
||||
unlink(zip);
|
||||
if (rc != 0) {
|
||||
snprintf(err, (size_t)en, "the backup on RomM isn't a card for this slot");
|
||||
return -1;
|
||||
}
|
||||
if (file_exists(path)) {
|
||||
char bak[PATH_MAX_LEN], ts[32];
|
||||
time_t now = time(NULL);
|
||||
struct tm tm;
|
||||
localtime_r(&now, &tm);
|
||||
strftime(ts, sizeof ts, "%Y%m%d-%H%M%S", &tm);
|
||||
snprintf(bak, sizeof bak, "%s/backups/memcards/%s.%s", plat_data_dir(), path_basename(path), ts);
|
||||
copy_file(path, bak);
|
||||
}
|
||||
mkdir_parent(path);
|
||||
if (move_file(tmp, path) != 0) {
|
||||
snprintf(err, (size_t)en, "could not write %s", path);
|
||||
const card *cd = &cards[slot - 1];
|
||||
int card_id = find_card(cd, 0);
|
||||
time_t at = 0;
|
||||
int version = card_id ? latest_version(card_id, &at) : 0;
|
||||
if (version <= 0) {
|
||||
snprintf(err, (size_t)en, "no copy of it on RomM yet");
|
||||
return -1;
|
||||
}
|
||||
if (download_card(cd, version, err, en) != 0) return -1;
|
||||
char hash[33];
|
||||
md5_file_hex(path, hash);
|
||||
state_lock();
|
||||
cJSON *s = state_entry_ensure("memcards", path);
|
||||
jset_num(s, "card_id", card_id);
|
||||
jset_str(s, "hash", hash);
|
||||
state_save();
|
||||
state_unlock();
|
||||
LOGI("restored memory card %s from RomM", path_basename(path));
|
||||
events_add("card", "restored", 0, path, "", "Older version");
|
||||
md5_file_hex(cd->path, hash);
|
||||
remember(cd, card_id, hash, version);
|
||||
LOGI("restored memory card %s from RomM", path_basename(cd->path));
|
||||
events_add("card", "restored", 0, cd->path, cd->label, "Latest version");
|
||||
events_save();
|
||||
return 0;
|
||||
}
|
||||
@@ -261,28 +471,42 @@ cJSON *memcard_status(void) {
|
||||
config_lock();
|
||||
str_copy(mode, sizeof mode, g_cfg.ps2_cards);
|
||||
str_copy(version, sizeof version, g_cfg.server_version);
|
||||
profile_map *maps = NULL;
|
||||
int cnt = profiles_expand(g_cfg.profiles, &maps);
|
||||
config_unlock();
|
||||
card cards[MAX_CARDS];
|
||||
int n = cards_list(cards, MAX_CARDS), lrps2 = 0;
|
||||
for (int i = 0; i < n; i++) lrps2 |= !strcmp(cards[i].emulator, "lrps2");
|
||||
/* PS2's method, and whether its emulators here let the user choose. */
|
||||
const char *method = "", *choice = NULL;
|
||||
for (int i = 0; i < cnt; i++) {
|
||||
if (!maps[i].cards_mode[0] || !profile_map_handles(&maps[i], "ps2", NULL)) continue;
|
||||
if (!method[0]) method = maps[i].cards_method;
|
||||
if (strchr(maps[i].cards_modes, ',')) choice = maps[i].profile_name;
|
||||
}
|
||||
cJSON *j = cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(j, "mode", mode);
|
||||
cJSON_AddStringToObject(j, "mode", method[0] ? method : mode);
|
||||
cJSON_AddStringToObject(j, "chosen", mode);
|
||||
cJSON_AddStringToObject(j, "server_version", version);
|
||||
cJSON_AddBoolToObject(j, "supported", config_server_at_least(5, 3, 0));
|
||||
cJSON_AddBoolToObject(j, "lrps2", lrps2);
|
||||
cJSON_AddBoolToObject(j, "enabled", memcard_enabled());
|
||||
/* An emulator that keeps either: PS2's method can be chosen (lrps2: 1.2's name). */
|
||||
cJSON_AddBoolToObject(j, "choice", choice != NULL);
|
||||
cJSON_AddBoolToObject(j, "lrps2", choice != NULL);
|
||||
free(maps);
|
||||
card cards[MAX_CARDS];
|
||||
int n = cards_list(cards, MAX_CARDS);
|
||||
cJSON_AddBoolToObject(j, "enabled", n > 0 && config_server_at_least(5, 3, 0));
|
||||
cJSON *slots = cJSON_AddArrayToObject(j, "slots");
|
||||
for (int i = 0; i < n; i++) {
|
||||
cJSON *o = cJSON_CreateObject();
|
||||
cJSON_AddNumberToObject(o, "slot", i + 1);
|
||||
cJSON_AddStringToObject(o, "emulator", cards[i].label[0] ? cards[i].label : "LRPS2");
|
||||
cJSON_AddNumberToObject(o, "card", cards[i].slot);
|
||||
cJSON_AddStringToObject(o, "emulator", cards[i].label);
|
||||
cJSON_AddStringToObject(o, "name", cards[i].name);
|
||||
cJSON_AddStringToObject(o, "path", cards[i].path);
|
||||
cJSON_AddBoolToObject(o, "exists", file_exists(cards[i].path));
|
||||
state_lock();
|
||||
cJSON *e = state_entry("memcards", cards[i].path);
|
||||
cJSON_AddNumberToObject(o, "backed_up_at", e ? jget_num(e, "backed_up_at", 0) : 0);
|
||||
double at = e ? jget_num(e, "synced_at", jget_num(e, "backed_up_at", 0)) : 0;
|
||||
state_unlock();
|
||||
cJSON_AddNumberToObject(o, "synced_at", at);
|
||||
cJSON_AddNumberToObject(o, "backed_up_at", at);
|
||||
cJSON_AddItemToArray(slots, o);
|
||||
}
|
||||
return j;
|
||||
|
||||
+6
-4
@@ -1,4 +1,5 @@
|
||||
/* Backs up LRPS2's shared memory cards with RomM's memory card API (5.3.0+). */
|
||||
/* Shared memory cards, synced both ways with RomM's memory card API (5.3.0+):
|
||||
* one card on RomM for every console and emulator that keeps it (memcard.c). */
|
||||
#ifndef ROMM_MEMCARD_H
|
||||
#define ROMM_MEMCARD_H
|
||||
|
||||
@@ -7,9 +8,10 @@
|
||||
int memcard_enabled(void);
|
||||
/* Folders holding the cards, for the save watcher. Returns the count. */
|
||||
int memcard_dirs(char dirs[][1024], int max);
|
||||
/* Uploads cards that changed. Returns how many were uploaded. */
|
||||
int memcard_backup(void);
|
||||
/* Replaces a card with its latest backup on RomM. */
|
||||
/* Syncs every card that changed, here or on RomM. Returns how many went up;
|
||||
* *down says how many came down. upload_only: a game runs, nothing comes down. */
|
||||
int memcard_sync(int upload_only, int *down);
|
||||
/* Replaces a card with its latest version on RomM. */
|
||||
int memcard_restore(int slot, char *err, int en);
|
||||
cJSON *memcard_status(void);
|
||||
|
||||
|
||||
@@ -70,6 +70,7 @@ static void finish_pairing(const char *base, const char *token, const char *devi
|
||||
str_copy(g_cfg.server_url, sizeof g_cfg.server_url, base);
|
||||
str_copy(g_cfg.token, sizeof g_cfg.token, token);
|
||||
str_copy(g_cfg.device_id, sizeof g_cfg.device_id, device_id);
|
||||
g_cfg.romm_device_name[0] = 0; /* told at the first sync: RomM keeps an existing device's old name */
|
||||
config_unlock();
|
||||
long st = 0;
|
||||
cJSON *me = romm_call("GET", "/api/users/me", NULL, &st);
|
||||
|
||||
@@ -39,6 +39,9 @@ typedef struct {
|
||||
const char *mednafen_root;
|
||||
/* Standalone emulators this console has, as a JSON array (detect.c), or NULL. */
|
||||
const char *emulator_catalog;
|
||||
/* plat_mounts_set works: an app can see another's folder through a mount
|
||||
* in its sandbox (bfs on the PS5 has no hard links or symlinks). */
|
||||
int can_mount;
|
||||
int detach; /* main() must return to the loader: the app runs on a thread */
|
||||
int can_relaunch; /* plat_launch_elf works, so an update can restart in place */
|
||||
/* The payload updates itself from GitHub releases. Off on the PS5, where
|
||||
@@ -80,12 +83,37 @@ void plat_app_version(char *out, size_t n);
|
||||
* Host: writes <data>/launched.elf. */
|
||||
int plat_launch_elf(const void *elf, size_t len);
|
||||
|
||||
/* A folder shown inside an app's sandbox while the app runs: "from" (a real
|
||||
* folder) mounted on "into" (relative to the sandbox: "app0/games" is what
|
||||
* the app sees as /app0/games), or on a real folder ("/data/PSXS5/games")
|
||||
* for an app that leaves its sandbox to read its files. */
|
||||
typedef struct {
|
||||
char title[16];
|
||||
char name[64]; /* the emulator, for messages */
|
||||
char from[1024];
|
||||
char into[256];
|
||||
} plat_mount;
|
||||
|
||||
/* Replaces the list of mounts. Each app's go on when it starts, before it
|
||||
* reads its folders, and come off the moment its process exits: one left on
|
||||
* when the system takes the sandbox down crashes the console. A list changed
|
||||
* while the app runs applies from its next launch. No-op unless can_mount. */
|
||||
void plat_mounts_set(const plat_mount *mounts, int n);
|
||||
|
||||
/* Whether a folder has something mounted on it (one of plat_mounts_set's
|
||||
* on the real folder: what's in it then is another folder's). */
|
||||
int plat_is_mount(const char *path);
|
||||
|
||||
/* plat_info()->name. */
|
||||
const char *plat_name(void);
|
||||
|
||||
/* The console's LAN address, or "". */
|
||||
void plat_local_ip(char *buf, size_t n);
|
||||
|
||||
/* The name the console was given in its settings (Console Name), or "" when
|
||||
* it can't be read. ROMM_SYNC_CONSOLE_NAME stands in for it on the host. */
|
||||
void plat_console_name(char *buf, size_t n);
|
||||
|
||||
/* One line for logs and diagnostics: firmware, and the process the payload
|
||||
* runs in. */
|
||||
void plat_describe(char *buf, size_t n);
|
||||
|
||||
+220
@@ -0,0 +1,220 @@
|
||||
#include "presets.h"
|
||||
#include <pthread.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <unistd.h>
|
||||
#include <stdint.h>
|
||||
#include <time.h>
|
||||
|
||||
#include "platform.h"
|
||||
#include "util.h"
|
||||
#include "config.h"
|
||||
#include "detect.h"
|
||||
#include "http.h"
|
||||
#include "monocypher-ed25519.h"
|
||||
#include "update_keys.h"
|
||||
#include "state.h"
|
||||
|
||||
#ifndef PRESETS_URL
|
||||
#define PRESETS_URL "https://raw.githubusercontent.com/s0liton/RommPS/presets"
|
||||
#endif
|
||||
|
||||
static pthread_mutex_t g_lock = PTHREAD_MUTEX_INITIALIZER;
|
||||
|
||||
static cJSON *g_file;
|
||||
|
||||
static char g_source[16] = "built-in";
|
||||
|
||||
static int g_checking;
|
||||
static char g_error[256];
|
||||
static time_t g_checked_at;
|
||||
|
||||
|
||||
|
||||
int presets_version_cmp(const char *a, const char *b) {
|
||||
int x[4] = {0}, y[4] = {0};
|
||||
sscanf(a, "%d.%d.%d.%d", &x[0], &x[1], &x[2], &x[3]);
|
||||
sscanf(b, "%d.%d.%d.%d", &y[0], &y[1], &y[2], &y[3]);
|
||||
for (int i = 0; i < 4; i++)
|
||||
if (x[i] != y[i]) return x[i] < y[i] ? -1 : 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
static void download_path(const char *name, char *out, size_t n) {
|
||||
char dir[PATH_MAX_LEN];
|
||||
path_join(dir, sizeof dir, plat_data_dir(), "presets");
|
||||
path_join(out, n, dir, name);
|
||||
}
|
||||
|
||||
static cJSON *usable(cJSON *f, const char *from) {
|
||||
const char *why = !cJSON_IsObject(f) ? "Not valid JSON"
|
||||
: jget_num(f, "format", 0) != PRESETS_FORMAT ? "In a format this version cannot read"
|
||||
: strcmp(jget_str(f, "console", ""), plat_info()->name) != 0 ? "Not for this console"
|
||||
: presets_version_cmp(jget_str(f, "version", ""), "0") <= 0 ? "Missing its version value"
|
||||
: !cJSON_IsArray(cJSON_GetObjectItemCaseSensitive(f, "emulators")) ? "Missing emulators section."
|
||||
: NULL;
|
||||
if (!why) return f;
|
||||
LOGW("Presets: %s is %s; Not using it", from, why);
|
||||
cJSON_Delete(f);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
static cJSON *read_download(const char *name) {
|
||||
char path[PATH_MAX_LEN];
|
||||
download_path(name, path, sizeof path);
|
||||
char *txt = read_file(path, NULL);
|
||||
if (!txt) return NULL; /* A presets file was never downloaded. */
|
||||
cJSON *f = cJSON_Parse(txt);
|
||||
free(txt);
|
||||
return usable(f, name);
|
||||
}
|
||||
|
||||
static void remove_downloads(void) {
|
||||
char path[PATH_MAX_LEN];
|
||||
download_path("current.json", path, sizeof path);
|
||||
unlink(path);
|
||||
download_path("previous.json", path, sizeof path);
|
||||
unlink(path);
|
||||
}
|
||||
|
||||
void presets_init(void) {
|
||||
const char *embedded = plat_info()->emulator_catalog;
|
||||
cJSON *best = embedded ? usable (cJSON_Parse(embedded), "The built-in presets file") : NULL;
|
||||
cJSON *dl = read_download("current.json");
|
||||
if (!dl) dl = read_download("previous.json");
|
||||
if (dl && (!best || presets_version_cmp(jget_str(dl, "version", ""), jget_str(best, "version", "")) > 0)) {
|
||||
cJSON_Delete(best);
|
||||
best = dl;
|
||||
str_copy(g_source, sizeof g_source, "download");
|
||||
} else if (dl) {
|
||||
/* A newer RommPS brought newer presets than the last download */
|
||||
LOGI("presets: built-in %s is newer than the download (%s), let's remove it", jget_str(best, "version", ""), jget_str(dl, "version", ""));
|
||||
cJSON_Delete(dl);
|
||||
remove_downloads();
|
||||
}
|
||||
pthread_mutex_lock(&g_lock);
|
||||
g_file = best;
|
||||
pthread_mutex_unlock(&g_lock);
|
||||
LOGI("presets: %s (%s)", best ? jget_str(best, "version", "") : "none", g_source);
|
||||
}
|
||||
|
||||
const cJSON *presets_catalog(void) {
|
||||
pthread_mutex_lock(&g_lock);
|
||||
const cJSON *c = cJSON_GetObjectItemCaseSensitive(g_file, "emulators");
|
||||
pthread_mutex_unlock(&g_lock);
|
||||
return c;
|
||||
}
|
||||
|
||||
/* Where presets-<console-name>.json and .sig file are located. */
|
||||
static const char *presets_url(void) {
|
||||
const char *u = getenv("ROMM_SYNC_PRESETS_URL"); /* Used for tests. */
|
||||
return u && *u ? u: PRESETS_URL;
|
||||
}
|
||||
|
||||
static int fetch(const char *url, http_resp *r, char *err, size_t en) {
|
||||
if (http_get_verified(url, r) != 0) snprintf( err, en, "Can't reach %s: %s", url, r->error);
|
||||
else if (r->status != 200) snprintf(err, en, "%s answered with HTTP %ld", url, r->status);
|
||||
else return 0;
|
||||
return -1;
|
||||
}
|
||||
|
||||
/* Check to see if the presets file is signed by our release key */
|
||||
static int signed_by_us(const http_resp *file, const http_resp *sig) {
|
||||
if (sig->len != 64) return 0;
|
||||
for (size_t i = 0; i < sizeof UPDATE_KEYS / sizeof UPDATE_KEYS[0]; i++)
|
||||
if (crypto_ed25519_check((const uint8_t *)sig->body, UPDATE_KEYS[i], (const uint8_t *)file->body, file->len) == 0)
|
||||
return 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int save_download(const http_resp *file, char *err, size_t en) {
|
||||
char cur[PATH_MAX_LEN], prev[PATH_MAX_LEN];
|
||||
download_path("current.json", cur, sizeof cur);
|
||||
download_path("previous.json", prev, sizeof prev);
|
||||
mkdir_parent(cur);
|
||||
if (file_exists(cur) && move_file(cur, prev) !=0) LOGW("presets: can't keep the previous download");
|
||||
if (write_file_atomic(cur, file->body, file->len) == 0) return 0;
|
||||
snprintf(err, en, "can't save the presets to %s", cur);
|
||||
return -1;
|
||||
}
|
||||
|
||||
static void apply(cJSON *f) {
|
||||
pthread_mutex_lock(&g_lock);
|
||||
g_file = f;
|
||||
str_copy(g_source, sizeof g_source, "download");
|
||||
pthread_mutex_unlock(&g_lock);
|
||||
config_lock();
|
||||
int changed = g_cfg.profiles_custom && detect_refresh_profiles(g_cfg.profiles) > 0;
|
||||
if (changed) config_save();
|
||||
config_unlock();
|
||||
LOGI("presets: now %s%s", jget_str(f, "version", ""), changed ? ", saved profiles updated" : "");
|
||||
}
|
||||
|
||||
static void *check_thread(void *arg) {
|
||||
(void)arg;
|
||||
char url[600], sig_url[600], have[32], err[256] = "";
|
||||
http_resp file = {0}, sig = {0};
|
||||
cJSON *f = NULL;
|
||||
|
||||
snprintf(url, sizeof url, "%s/presets-%s.json", presets_url(), plat_info()->name);
|
||||
snprintf(sig_url, sizeof sig_url, "%s/presets-%s.sig", presets_url(), plat_info()->name);
|
||||
|
||||
if(fetch(url, &file, err, sizeof err) == 0 && fetch(sig_url, &sig, err, sizeof err) == 0) {
|
||||
if (!signed_by_us(&file, &sig)) snprintf(err, sizeof err, "the presets signature doesn't match, so not using them");
|
||||
else if (!(f = usable(cJSON_ParseWithLength(file.body, file.len), "the download")))
|
||||
snprintf(err, sizeof err, "the published presets can't be used here");
|
||||
}
|
||||
if (f) {
|
||||
pthread_mutex_lock(&g_lock);
|
||||
str_copy(have, sizeof have, g_file ? jget_str(g_file, "version", "0") : "0");
|
||||
pthread_mutex_unlock(&g_lock);
|
||||
if (presets_version_cmp(jget_str(f, "version", ""), have) <= 0) {
|
||||
LOGI("presets: %s is current", have);
|
||||
cJSON_Delete(f);
|
||||
} else if (save_download(&file, err, sizeof err) == 0) {
|
||||
apply(f);
|
||||
} else {
|
||||
cJSON_Delete(f);
|
||||
}
|
||||
|
||||
}
|
||||
if (err[0]) LOGW("presets: %s", err);
|
||||
http_resp_free(&file);
|
||||
http_resp_free(&sig);
|
||||
|
||||
pthread_mutex_lock(&g_lock);
|
||||
g_checking = 0;
|
||||
g_checked_at = time(NULL);
|
||||
str_copy(g_error, sizeof g_error, err);
|
||||
pthread_mutex_unlock(&g_lock);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
int presets_check(void) {
|
||||
pthread_mutex_lock(&g_lock);
|
||||
int busy = g_checking;
|
||||
g_checking = 1;
|
||||
pthread_mutex_unlock(&g_lock);
|
||||
if (busy) return -1;
|
||||
if (thread_start(check_thread, NULL) != 0) {
|
||||
pthread_mutex_lock(&g_lock);
|
||||
g_checking = 0;
|
||||
str_copy(g_error, sizeof g_error, "cannot start the presets check");
|
||||
pthread_mutex_unlock(&g_lock);
|
||||
return -1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
cJSON *presets_status(void) {
|
||||
cJSON *j = cJSON_CreateObject();
|
||||
pthread_mutex_lock(&g_lock);
|
||||
cJSON_AddStringToObject(j, "version", g_file ? jget_str(g_file, "version", "") : "");
|
||||
cJSON_AddStringToObject(j, "source", g_source);
|
||||
cJSON_AddStringToObject(j, "state", g_checking ? "checking" : g_error[0] ? "error" : "idle");
|
||||
cJSON_AddStringToObject(j, "error", g_error);
|
||||
cJSON_AddNumberToObject(j, "checked_at", (double)g_checked_at);
|
||||
pthread_mutex_unlock(&g_lock);
|
||||
return j;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
#ifndef ROMM_PRESETS_H
|
||||
#define ROMM_PRESETS_H
|
||||
|
||||
#include "cJSON.h"
|
||||
|
||||
#define PRESETS_FORMAT 1
|
||||
|
||||
/* Pick up the latest valid presets. Called at startup. */
|
||||
void presets_init(void);
|
||||
|
||||
/* The catalog currently in use. Newer file takes its place and the old one stays valid in case it's still being used. */
|
||||
const cJSON *presets_catalog(void);
|
||||
|
||||
/* Looks for newer presets in the background, and downloads, verifies, and applies them.*/
|
||||
int presets_check(void);
|
||||
|
||||
/* Tells preset version, source (built-in) or (downloaded), info about the state, errors, and last checked time */
|
||||
cJSON *presets_status(void);
|
||||
|
||||
/* Compare the version code within date to see if this contains newer presets. */
|
||||
int presets_version_cmp(const char *a, const char *b);
|
||||
|
||||
#endif
|
||||
+309
-25
@@ -1,9 +1,12 @@
|
||||
#include "profiles.h"
|
||||
|
||||
#include <dirent.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#include "config.h"
|
||||
#include "detect.h"
|
||||
#include "platform.h"
|
||||
|
||||
@@ -29,7 +32,7 @@ static const char *DEFAULT_PROFILES_JSON =
|
||||
"{\"romm\":[\"gba\"],\"dir\":\"gba\",\"emulator\":\"mgba\",\"core_name\":\"mGBA\"},"
|
||||
"{\"romm\":[\"gb\"],\"dir\":\"gb\",\"emulator\":\"mgba\",\"core_name\":\"mGBA\"},"
|
||||
"{\"romm\":[\"gbc\"],\"dir\":\"gbc\",\"emulator\":\"mgba\",\"core_name\":\"mGBA\"},"
|
||||
"{\"romm\":[\"genesis-slash-megadrive\",\"genesis\",\"megadrive\"],\"dir\":\"megadrive\",\"emulator\":\"genesis_plus_gx\",\"core_name\":\"Genesis Plus GX\"},"
|
||||
"{\"romm\":[\"genesis\",\"genesis-slash-megadrive\",\"megadrive\"],\"dir\":\"megadrive\",\"emulator\":\"genesis_plus_gx\",\"core_name\":\"Genesis Plus GX\"},"
|
||||
"{\"romm\":[\"sms\",\"mastersystem\"],\"dir\":\"mastersystem\",\"emulator\":\"genesis_plus_gx\",\"core_name\":\"Genesis Plus GX\"},"
|
||||
"{\"romm\":[\"gamegear\",\"gg\"],\"dir\":\"gamegear\",\"emulator\":\"genesis_plus_gx\",\"core_name\":\"Genesis Plus GX\"},"
|
||||
"{\"romm\":[\"segacd\",\"sega-cd\"],\"dir\":\"segacd\",\"emulator\":\"genesis_plus_gx\",\"core_name\":\"Genesis Plus GX\"},"
|
||||
@@ -37,8 +40,9 @@ static const char *DEFAULT_PROFILES_JSON =
|
||||
"{\"romm\":[\"arcade\",\"fbneo\",\"neogeoaes\",\"neogeomvs\",\"cps1\",\"cps2\",\"cps3\"],\"dir\":\"fbneo\",\"emulator\":\"fbneo\",\"core_name\":\"FinalBurn Neo\"},"
|
||||
/* Saves made of several files, synced as one zip per game. */
|
||||
"{\"romm\":[\"ps2\"],\"dir\":\"ps2\",\"emulator\":\"pcsx2\",\"core_name\":\"LRPS2\",\"save_exts\":\".ps2\","
|
||||
"\"sync_requires_option\":{\"key\":\"pcsx2_shared_memory_cards\",\"value\":\"disabled\",\"default\":\"enabled\","
|
||||
"\"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.\"}},"
|
||||
"\"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\"}}},"
|
||||
"{\"romm\":[\"psp\"],\"dir\":\"psp\",\"emulator\":\"ppsspp\",\"core_name\":\"PPSSPP\",\"save_layout\":\"psp\"},"
|
||||
"{\"romm\":[\"ngc\",\"gamecube\"],\"dir\":\"gamecube\",\"emulator\":\"dolphin\",\"core_name\":\"Dolphin\",\"save_layout\":\"gci\"},"
|
||||
"{\"romm\":[\"wii\"],\"dir\":\"wii\",\"emulator\":\"dolphin\",\"core_name\":\"Dolphin\",\"save_layout\":\"wii\"}"
|
||||
@@ -59,7 +63,7 @@ static const char *DEFAULT_PROFILES_JSON =
|
||||
"{\"romm\":[\"snes\",\"sfam\"],\"emulator\":\"mednafen_snes\"},"
|
||||
"{\"romm\":[\"gb\",\"gbc\"],\"emulator\":\"mednafen_gb\"},"
|
||||
"{\"romm\":[\"gba\"],\"emulator\":\"mednafen_gba\"},"
|
||||
"{\"romm\":[\"genesis-slash-megadrive\",\"genesis\",\"megadrive\",\"sms\",\"gamegear\"],\"emulator\":\"mednafen_md\"},"
|
||||
"{\"romm\":[\"genesis\",\"genesis-slash-megadrive\",\"megadrive\",\"sms\",\"gamegear\"],\"emulator\":\"mednafen_md\"},"
|
||||
"{\"romm\":[\"tg16\",\"turbografx16--1\",\"pce\",\"pcengine\"],\"emulator\":\"mednafen_pce\"},"
|
||||
"{\"romm\":[\"ngp\",\"ngpc\",\"neo-geo-pocket\",\"neo-geo-pocket-color\"],\"emulator\":\"mednafen_ngp\"},"
|
||||
"{\"romm\":[\"wonderswan\",\"wonderswan-color\",\"ws\",\"wsc\"],\"emulator\":\"mednafen_wswan\"},"
|
||||
@@ -90,13 +94,12 @@ cJSON *profiles_default(void) {
|
||||
return defs;
|
||||
}
|
||||
|
||||
/* A save state setting: the profile's own, else the built-in catalog's for
|
||||
* the same emulator and system (profiles saved before these settings existed). */
|
||||
static const cJSON *state_setting(const cJSON *p, const cJSON *e, const char *key) {
|
||||
const cJSON *v = cJSON_GetObjectItemCaseSensitive(e, key);
|
||||
if (!v) v = cJSON_GetObjectItemCaseSensitive(p, key);
|
||||
if (v) return v;
|
||||
const cJSON *cat = cJSON_GetObjectItemCaseSensitive(detect_catalog_entry(jstr(p, NULL, "id", "")), "profile"), *ce;
|
||||
/* The built-in catalog's setting for the same emulator and system: its
|
||||
* platform's, else its profile's. *known says whether the catalog has the
|
||||
* emulator at all (RetroArch and hand-made profiles aren't in it). */
|
||||
static const cJSON *catalog_setting(const cJSON *p, const cJSON *e, const char *key, int *known) {
|
||||
const cJSON *cat = cJSON_GetObjectItemCaseSensitive(detect_catalog_entry(jstr(p, NULL, "id", "")), "profile"), *ce, *v;
|
||||
if (known) *known = cat != NULL;
|
||||
if (!cat) return NULL;
|
||||
cJSON_ArrayForEach(ce, cJSON_GetObjectItemCaseSensitive(cat, "platforms")) {
|
||||
if (strcmp(profiles_system_key(ce), profiles_system_key(e)) != 0) continue;
|
||||
@@ -105,6 +108,14 @@ static const cJSON *state_setting(const cJSON *p, const cJSON *e, const char *ke
|
||||
return cJSON_GetObjectItemCaseSensitive(cat, key);
|
||||
}
|
||||
|
||||
/* A save state setting: the profile's own, else the built-in catalog's for
|
||||
* the same emulator and system (profiles saved before these settings existed). */
|
||||
static const cJSON *state_setting(const cJSON *p, const cJSON *e, const char *key) {
|
||||
const cJSON *v = cJSON_GetObjectItemCaseSensitive(e, key);
|
||||
if (!v) v = cJSON_GetObjectItemCaseSensitive(p, key);
|
||||
return v ? v : catalog_setting(p, e, key, NULL);
|
||||
}
|
||||
|
||||
static const char *state_str(const cJSON *p, const cJSON *e, const char *key, const char *def) {
|
||||
const cJSON *v = state_setting(p, e, key);
|
||||
return cJSON_IsString(v) ? v->valuestring : def;
|
||||
@@ -121,7 +132,7 @@ static int ra_cfg_get(const char *cfg, const char *key, char *out, size_t n) {
|
||||
size_t kl = strlen(key);
|
||||
for (const char *line = cfg; line && *line;) {
|
||||
while (*line == ' ' || *line == '\t') line++;
|
||||
if (!strncmp(line, key, kl) && (line[kl] == ' ' || line[kl] == '=')) {
|
||||
if (!strncmp(line, key, kl) && (line[kl] == ' ' || line[kl] == '\t' || line[kl] == '=')) {
|
||||
const char *q = strchr(line, '"');
|
||||
const char *eol = strchr(line, '\n');
|
||||
if (q && (!eol || q < eol)) {
|
||||
@@ -134,6 +145,18 @@ static int ra_cfg_get(const char *cfg, const char *key, char *out, size_t n) {
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
/* An ini's unquoted value (Flycast's emu.cfg: "PerGameVmu = yes"). */
|
||||
const char *eq = strchr(line, '=');
|
||||
if (eq && (!eol || eq < eol)) {
|
||||
const char *v = eq + 1, *end = eol ? eol : v + strlen(v);
|
||||
while (v < end && (*v == ' ' || *v == '\t')) v++;
|
||||
while (end > v && (end[-1] == ' ' || end[-1] == '\t' || end[-1] == '\r')) end--;
|
||||
size_t l = (size_t)(end - v);
|
||||
if (l >= n) l = n - 1;
|
||||
memcpy(out, v, l);
|
||||
out[l] = 0;
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
line = strchr(line, '\n');
|
||||
if (line) line++;
|
||||
@@ -191,7 +214,7 @@ static void override_names(const char *rom_path, char *game, size_t gn, char *cd
|
||||
|
||||
/* A core option, looked up the way RetroArch does: per game, per content
|
||||
* folder, per core, then the global options file. */
|
||||
static int core_option(const profile_map *m, const char *game, const char *cdir, char *out, size_t n) {
|
||||
static int core_option(const profile_map *m, const char *key, const char *game, const char *cdir, char *out, size_t n) {
|
||||
const char *names[3] = {game, cdir, m->core_name};
|
||||
char path[PATH_MAX_LEN];
|
||||
for (int i = 0; i < 4; i++) {
|
||||
@@ -202,13 +225,39 @@ static int core_option(const profile_map *m, const char *game, const char *cdir,
|
||||
snprintf(path, sizeof path, "%s/retroarch-core-options.cfg", m->ra_cfg_dir);
|
||||
}
|
||||
char *txt = read_file(path, NULL);
|
||||
int found = txt && ra_cfg_get(txt, m->req_key, out, n);
|
||||
int found = txt && ra_cfg_get(txt, key, out, n);
|
||||
free(txt);
|
||||
if (found) return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* val is one of the comma separated list's values. */
|
||||
static int in_list_ci(const char *val, const char *list) {
|
||||
char buf[256], *save = NULL;
|
||||
str_copy(buf, sizeof buf, list);
|
||||
for (char *t = strtok_r(buf, ",", &save); t; t = strtok_r(NULL, ",", &save))
|
||||
if (str_ieq(t, val)) return 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* Which memory cards the emulator keeps now, "per_game" or "shared", from
|
||||
* the setting the platform's "cards" names (a core option, or a line of the
|
||||
* emulator's own settings file), else its default. */
|
||||
static void cards_mode_now(profile_map *m) {
|
||||
char val[96] = "";
|
||||
int found = 0;
|
||||
if (m->cards_option[0] && m->ra_cfg_dir[0]) found = core_option(m, m->cards_option, NULL, NULL, val, sizeof val);
|
||||
else if (m->cards_file[0] && m->cards_key[0]) {
|
||||
char *txt = read_file(m->cards_file, NULL);
|
||||
found = txt && ra_cfg_get(txt, m->cards_key, val, sizeof val);
|
||||
free(txt);
|
||||
}
|
||||
str_copy(m->cards_mode, sizeof m->cards_mode, m->cards_default);
|
||||
if (found && in_list_ci(val, m->cards_per_game)) str_copy(m->cards_mode, sizeof m->cards_mode, "per_game");
|
||||
else if (found && in_list_ci(val, m->cards_shared)) str_copy(m->cards_mode, sizeof m->cards_mode, "shared");
|
||||
}
|
||||
|
||||
static void check_requirement(const profile_map *m, const char *game, const char *cdir, int *sync, char *note,
|
||||
size_t nn) {
|
||||
*sync = m->base_sync_saves;
|
||||
@@ -216,8 +265,8 @@ static void check_requirement(const profile_map *m, const char *game, const char
|
||||
if (m->sync_off) str_copy(note, nn, "save sync is off for this platform");
|
||||
if (!m->req_key[0] || !m->base_sync_saves) return;
|
||||
char val[64];
|
||||
if (!m->ra_cfg_dir[0] || !core_option(m, game, cdir, val, sizeof val)) str_copy(val, sizeof val, m->req_default);
|
||||
if (str_ieq(val, m->req_value)) return;
|
||||
if (!m->ra_cfg_dir[0] || !core_option(m, m->req_key, game, cdir, val, sizeof val)) str_copy(val, sizeof val, m->req_default);
|
||||
if (in_list_ci(val, m->req_value)) return;
|
||||
*sync = 0;
|
||||
if (m->req_hint[0])
|
||||
str_copy(note, nn, m->req_hint);
|
||||
@@ -225,6 +274,17 @@ static void check_requirement(const profile_map *m, const char *game, const char
|
||||
snprintf(note, nn, "saves sync when core option %s is \"%s\" (now \"%s\")", m->req_key, m->req_value, val);
|
||||
}
|
||||
|
||||
int profile_ra_dir(const profile_map *m, const char *key, char *out, size_t n) {
|
||||
char path[PATH_MAX_LEN];
|
||||
out[0] = 0;
|
||||
if (!m->ra_cfg_dir[0]) return 0;
|
||||
path_join(path, sizeof path, m->ra_cfg_dir, "retroarch.cfg");
|
||||
char *cfg = read_file(path, NULL);
|
||||
if (cfg) ra_apply(cfg, key, m->root, out, n);
|
||||
free(cfg);
|
||||
return out[0] != 0;
|
||||
}
|
||||
|
||||
void profile_rules_for(const profile_map *m, const char *rom_path, rom_rules *out) {
|
||||
memset(out, 0, sizeof *out);
|
||||
str_copy(out->save_dir, sizeof out->save_dir, m->save_dir);
|
||||
@@ -276,6 +336,135 @@ static int profile_lists(const cJSON *profile, const char *list, const char *key
|
||||
|
||||
int profiles_excludes(const cJSON *profile, const char *key) { return profile_lists(profile, "exclude", key); }
|
||||
|
||||
/* Where an app folder's path is inside the app's sandbox: a folder in
|
||||
* <homebrew>/<title>/ is <sandbox>/app0/... ("PPSA99248", "app0/games"). */
|
||||
static int app0_path(const char *dir, char *title, size_t tn, char *into, size_t in) {
|
||||
const char *const *hb = plat_info()->homebrew_dirs;
|
||||
for (int i = 0; hb && hb[i]; i++) {
|
||||
size_t l = strlen(hb[i]);
|
||||
if (strncmp(dir, hb[i], l) != 0 || dir[l] != '/') continue;
|
||||
const char *t = dir + l + 1, *rest = strchr(t, '/');
|
||||
if (!rest || rest - t != 9 || !rest[1] || tn < 10) return 0;
|
||||
for (int k = 0; k < 9; k++)
|
||||
if (k < 4 ? !(t[k] >= 'A' && t[k] <= 'Z') : !(t[k] >= '0' && t[k] <= '9')) return 0;
|
||||
memcpy(title, t, 9);
|
||||
title[9] = 0;
|
||||
return snprintf(into, in, "app0%s", rest) < (int)in;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* An extra with "mount_games" plays the main emulator's games through a mount
|
||||
* in its sandbox, so they aren't copied (bfs has no hard links). Not when the
|
||||
* main one's folder holds other systems' games too (Porpoise: GameCube and
|
||||
* Wii), or the extra's own folder has games that aren't the main one's: the
|
||||
* mount would hide them. Copies the main one has too are RommPS's, from
|
||||
* before mounts (library.c clears them out). */
|
||||
static void mount_main_games(profile_map *maps, int n, profile_map *x) {
|
||||
if (!x->extra || !x->mount_games || !plat_info()->can_mount) return;
|
||||
char sys[64], title[16], into[256];
|
||||
str_copy(sys, sizeof sys, x->romm_slugs);
|
||||
sys[strcspn(sys, ",")] = 0;
|
||||
const profile_map *m = profile_find_for_platform(maps, n, sys, NULL);
|
||||
if (!m || m == x || m->extra || !strcmp(m->rom_dir, x->rom_dir)) return;
|
||||
if (x->mount_games == 2) {
|
||||
/* It leaves its sandbox and reads the real folder under /data. */
|
||||
const cJSON *ids = cJSON_GetObjectItemCaseSensitive(detect_catalog_entry(x->profile_id), "title_ids");
|
||||
const char *tid = cJSON_IsString(cJSON_GetArrayItem(ids, 0)) ? cJSON_GetArrayItem(ids, 0)->valuestring : "";
|
||||
if (strlen(tid) != 9 || strncmp(x->rom_dir, "/data/", 6) != 0 || !x->rom_dir[6]) return;
|
||||
str_copy(title, sizeof title, tid);
|
||||
if (snprintf(into, sizeof into, "%s", x->rom_dir) >= (int)sizeof into) return;
|
||||
} else if (!app0_path(x->rom_dir, title, sizeof title, into, sizeof into)) {
|
||||
return;
|
||||
}
|
||||
/* Another emulator playing the games in this one's folder (SwanStationPS5
|
||||
* in PSXS5's) would see them go while the mount is on: both keep copies. */
|
||||
for (int k = 0; k < n; k++) {
|
||||
const char *theirs = maps[k].own_rom_dir[0] ? maps[k].own_rom_dir : maps[k].rom_dir;
|
||||
/* Unless it gets the same mount there too (PSXS5 and SwanStationPS5
|
||||
* on /data/PSXS5/games, each while it runs). */
|
||||
const int mounts_too = maps[k].extra && maps[k].mount_games == x->mount_games && x->mount_games == 2;
|
||||
if (&maps[k] != x && strcmp(maps[k].profile_id, x->profile_id) != 0 && !strcmp(theirs, x->rom_dir) && !mounts_too) {
|
||||
snprintf(x->mount_note, sizeof x->mount_note, "%s plays the games in its folder too: they're copied from %s",
|
||||
maps[k].profile_name, m->profile_name);
|
||||
return;
|
||||
}
|
||||
}
|
||||
for (int k = 0; k < n; k++)
|
||||
if (&maps[k] != m && !strcmp(maps[k].rom_dir, m->rom_dir) && strcmp(maps[k].profile_id, m->profile_id) == 0) {
|
||||
snprintf(x->mount_note, sizeof x->mount_note, "%s keeps other systems' games in the same folder",
|
||||
m->profile_name);
|
||||
return;
|
||||
}
|
||||
/* With our mount on it (the app runs), what's there is the main one's. */
|
||||
DIR *d = plat_is_mount(x->rom_dir) ? NULL : opendir(x->rom_dir);
|
||||
struct dirent *de;
|
||||
int own = 0;
|
||||
while (d && (de = readdir(d))) {
|
||||
if (de->d_name[0] == '.' || ext_in_list(de->d_name, ".part,.tmp")) continue;
|
||||
char there[PATH_MAX_LEN];
|
||||
path_join(there, sizeof there, m->rom_dir, de->d_name);
|
||||
own += !file_exists(there) && !dir_exists(there);
|
||||
}
|
||||
if (d) closedir(d);
|
||||
if (own) {
|
||||
x->mount_blocked = own;
|
||||
snprintf(x->mount_note, sizeof x->mount_note, "%d game%s of its own in %s: they're copied from %s instead", own,
|
||||
own == 1 ? "" : "s", x->rom_dir, m->profile_name);
|
||||
return;
|
||||
}
|
||||
str_copy(x->own_rom_dir, sizeof x->own_rom_dir, x->rom_dir);
|
||||
str_copy(x->rom_dir, sizeof x->rom_dir, m->rom_dir);
|
||||
str_copy(x->mount_title, sizeof x->mount_title, title);
|
||||
str_copy(x->mount_into, sizeof x->mount_into, into);
|
||||
}
|
||||
|
||||
/* One memory card method per system, never both: the one the user chose
|
||||
* (config_card_mode) if the system's main emulator can keep it, else the one
|
||||
* the main emulator keeps now. An emulator keeping the other method syncs no
|
||||
* cards at all: shared ones aren't backed up, and its own per game aren't
|
||||
* synced, with a note saying why. */
|
||||
static void card_methods(profile_map *maps, int cnt) {
|
||||
for (int i = 0; i < cnt; i++) {
|
||||
profile_map *m = &maps[i];
|
||||
if (!m->cards_mode[0]) continue;
|
||||
char sys[64];
|
||||
str_copy(sys, sizeof sys, m->romm_slugs);
|
||||
sys[strcspn(sys, ",")] = 0;
|
||||
const profile_map *main = m;
|
||||
for (int k = 0; k < cnt; k++) {
|
||||
char sk[64];
|
||||
str_copy(sk, sizeof sk, maps[k].romm_slugs);
|
||||
sk[strcspn(sk, ",")] = 0;
|
||||
if (!maps[k].extra && maps[k].cards_mode[0] && !strcmp(sk, sys)) {
|
||||
main = &maps[k];
|
||||
break;
|
||||
}
|
||||
}
|
||||
const char *chosen = config_card_mode(sys);
|
||||
const char *method = chosen[0] && in_list_ci(chosen, main->cards_modes) ? chosen : main->cards_mode;
|
||||
str_copy(m->cards_method, sizeof m->cards_method, method);
|
||||
m->cards_active = !strcmp(m->cards_mode, method);
|
||||
/* Keeping shared cards, a game's own card files (left from before) don't sync. */
|
||||
if (m->cards_active && !strcmp(method, "shared")) {
|
||||
m->sync_saves = m->base_sync_saves = 0;
|
||||
if (!m->sync_note[0]) str_copy(m->sync_note, sizeof m->sync_note, "Shared memory cards: synced whole, as RomM memory cards.");
|
||||
}
|
||||
if (m->cards_active) continue;
|
||||
if (!strcmp(method, "shared")) {
|
||||
m->sync_saves = m->base_sync_saves = 0;
|
||||
str_copy(m->sync_note, sizeof m->sync_note,
|
||||
"Its memory cards don't sync: it keeps one per game, and this system uses shared cards.");
|
||||
} else if (m->cards_change[0]) {
|
||||
snprintf(m->sync_note, sizeof m->sync_note, "Its memory cards don't sync: they're shared, and this system uses one per game. %s.",
|
||||
m->cards_change);
|
||||
} else {
|
||||
str_copy(m->sync_note, sizeof m->sync_note,
|
||||
"Its memory cards don't sync: they're shared, and this system uses one per game.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
int profiles_expand(const cJSON *profiles, profile_map **out) {
|
||||
int cap = 0, cnt = 0;
|
||||
profile_map *maps = NULL;
|
||||
@@ -286,7 +475,6 @@ int profiles_expand(const cJSON *profiles, profile_map **out) {
|
||||
str_copy(root, sizeof root, jstr(p, NULL, "root", ""));
|
||||
|
||||
char ra_save[PATH_MAX_LEN] = "", ra_state[PATH_MAX_LEN] = "", ra_sys[PATH_MAX_LEN] = "";
|
||||
char ra_playlists[PATH_MAX_LEN] = "";
|
||||
int ra_in_content[2] = {0, 0}, ra_by_content[2] = {0, 0}, ra_by_core[2] = {0, 0};
|
||||
char ra_cfg_dir[PATH_MAX_LEN] = "";
|
||||
const char *ra_tpl = jstr(p, NULL, "retroarch_cfg", NULL);
|
||||
@@ -304,7 +492,6 @@ int profiles_expand(const cJSON *profiles, profile_map **out) {
|
||||
if (slash) *slash = 0;
|
||||
apply_ra_cfg(cfg, root, none, ra_save, ra_state, sizeof ra_save, ra_in_content, ra_by_content, ra_by_core);
|
||||
ra_apply(cfg, "system_directory", root, ra_sys, sizeof ra_sys);
|
||||
ra_apply(cfg, "playlist_directory", root, ra_playlists, sizeof ra_playlists);
|
||||
free(cfg);
|
||||
break;
|
||||
}
|
||||
@@ -315,8 +502,32 @@ int profiles_expand(const cJSON *profiles, profile_map **out) {
|
||||
cJSON_ArrayForEach(e, plats) {
|
||||
if (profiles_excludes(p, profiles_system_key(e))) continue; /* another emulator plays it */
|
||||
if (cnt == cap) {
|
||||
cap = cap ? cap * 2 : 16;
|
||||
maps = realloc(maps, sizeof *maps * (size_t)cap);
|
||||
/* Room for every entry at once: each is 13 KB, and the PS4's
|
||||
* payload can't always find room to grow a big array. */
|
||||
int want = 0;
|
||||
const cJSON *q, *f;
|
||||
cJSON_ArrayForEach(q, profiles) {
|
||||
if (!jbool(q, NULL, "enabled", 1)) continue;
|
||||
cJSON_ArrayForEach(f, cJSON_GetObjectItemCaseSensitive(q, "platforms"))
|
||||
want += !profiles_excludes(q, profiles_system_key(f));
|
||||
}
|
||||
/* Short of memory for a moment (the PS4 payload, while downloads
|
||||
* finish together): it frees up, so wait for it a little. */
|
||||
const size_t bytes = sizeof *maps * (size_t)(want > cap ? want : cap + 1);
|
||||
profile_map *grown = realloc(maps, bytes);
|
||||
for (int tries = 1; !grown && tries < 10; tries++) {
|
||||
usleep(200 * 1000);
|
||||
grown = realloc(maps, bytes);
|
||||
if (grown) LOGI("memory for %d emulator platforms after %d tries", want, tries + 1);
|
||||
}
|
||||
if (!grown) {
|
||||
LOGE("no memory for %d emulator platforms (largest free block %d MB)", want, mem_largest_mb());
|
||||
free(maps);
|
||||
*out = NULL;
|
||||
return 0;
|
||||
}
|
||||
maps = grown;
|
||||
cap = want > cap ? want : cap + 1;
|
||||
}
|
||||
profile_map *m = &maps[cnt];
|
||||
memset(m, 0, sizeof *m);
|
||||
@@ -336,6 +547,8 @@ int profiles_expand(const cJSON *profiles, profile_map **out) {
|
||||
plat_data_dir(), "rom_dir", NULL, NULL};
|
||||
str_template(m->rom_dir, sizeof m->rom_dir, jstr(p, e, "rom_dir", "{root}/roms/{platform}"), vars);
|
||||
vars[7] = m->rom_dir;
|
||||
str_template(m->update_dir, sizeof m->update_dir, jstr(p, e, "update_dir", "{rom_dir}"), vars);
|
||||
str_template(m->dlc_dir, sizeof m->dlc_dir, jstr(p, e, "dlc_dir", jstr(p, e, "update_dir", "{rom_dir}")), vars);
|
||||
str_template(m->save_dir, sizeof m->save_dir, jstr(p, e, "save_dir", "{rom_dir}"), vars);
|
||||
str_template(m->state_dir, sizeof m->state_dir, jstr(p, e, "state_dir", "{rom_dir}"), vars);
|
||||
str_template(m->bios_dir, sizeof m->bios_dir, jstr(p, e, "bios_dir", "{root}/bios"), vars);
|
||||
@@ -350,8 +563,7 @@ int profiles_expand(const cJSON *profiles, profile_map **out) {
|
||||
const char *core = jstr(p, e, "core_name", "");
|
||||
const int pcsx2 = str_ieq(core, "LRPS2") || str_ieq(core, "PCSX2") || str_ieq(core, "PS5SX2");
|
||||
const char *sub = jstr(p, e, "firmware_subdir", pcsx2 && ra_tpl ? "pcsx2/bios" : "");
|
||||
if (sub[0]) path_join(m->firmware_dir, sizeof m->firmware_dir, m->bios_dir, sub);
|
||||
else str_copy(m->firmware_dir, sizeof m->firmware_dir, m->bios_dir);
|
||||
str_copy(m->firmware_subdir, sizeof m->firmware_subdir, sub);
|
||||
for (int k = 0; k < 2; k++) {
|
||||
m->explicit_dir[k] = cJSON_GetObjectItemCaseSensitive(e, k ? "state_dir" : "save_dir") != NULL;
|
||||
if (m->explicit_dir[k]) continue;
|
||||
@@ -360,7 +572,6 @@ int profiles_expand(const cJSON *profiles, profile_map **out) {
|
||||
m->by_core[k] = ra_by_core[k];
|
||||
}
|
||||
str_copy(m->core_name, sizeof m->core_name, jstr(p, e, "core_name", ""));
|
||||
str_copy(m->playlist_dir, sizeof m->playlist_dir, ra_playlists);
|
||||
str_copy(m->save_layout, sizeof m->save_layout, jstr(p, e, "save_layout", ""));
|
||||
str_copy(m->bundle_dirs, sizeof m->bundle_dirs, jstr(p, e, "bundle_dirs", ""));
|
||||
|
||||
@@ -399,16 +610,67 @@ int profiles_expand(const cJSON *profiles, profile_map **out) {
|
||||
if (w < 0 || (size_t)w >= sizeof m->core_values - o) break;
|
||||
o += (size_t)w;
|
||||
}
|
||||
str_copy(m->core_auto, sizeof m->core_auto, jstr(cs, NULL, "auto", ""));
|
||||
str_template(m->core_log, sizeof m->core_log, jstr(cs, NULL, "log", ""), vars);
|
||||
str_copy(m->core_log_line, sizeof m->core_log_line, jstr(cs, NULL, "log_line", ""));
|
||||
o = 0;
|
||||
cJSON_ArrayForEach(v, cJSON_GetObjectItemCaseSensitive(cs, "log_names")) {
|
||||
if (!cJSON_IsString(v)) continue;
|
||||
int w = snprintf(m->core_log_names + o, sizeof m->core_log_names - o, "%s%s=%s", o ? "," : "",
|
||||
v->string, v->valuestring);
|
||||
if (w < 0 || (size_t)w >= sizeof m->core_log_names - o) break;
|
||||
o += (size_t)w;
|
||||
}
|
||||
}
|
||||
str_template(m->memcards, sizeof m->memcards, jstr(p, e, "memcards", ""), vars);
|
||||
/* Shared cards can sit in the BIOS folder RetroArch's config names
|
||||
* (Flycast's <system>/dc): {bios}. */
|
||||
const char *cvars[] = {"root", root, "bios", m->bios_dir, "data", plat_data_dir(), NULL};
|
||||
str_template(m->memcards, sizeof m->memcards, jstr(p, e, "memcards", ""), cvars);
|
||||
str_template(m->game_ids, sizeof m->game_ids, jstr(p, e, "game_ids", ""), vars);
|
||||
str_copy(m->disc_id, sizeof m->disc_id, jstr(p, e, "disc_id", ""));
|
||||
m->extra = profile_lists(p, "extra", profiles_system_key(e));
|
||||
m->sync_saves = m->base_sync_saves = jbool(p, e, "sync_saves", 1);
|
||||
const cJSON *mg = state_setting(p, e, "mount_games");
|
||||
m->mount_games = cJSON_IsTrue(mg) ? 1 : cJSON_IsString(mg) && !strcmp(mg->valuestring, "data") ? 2 : 0;
|
||||
/* Whether its saves can sync at all is the preset's call, never a
|
||||
* saved copy's: a preset turns them on once they're tested, or off
|
||||
* when an emulator update breaks them. The user's say is "sync". */
|
||||
int in_catalog = 0;
|
||||
const cJSON *can = catalog_setting(p, e, "sync_saves", &in_catalog);
|
||||
m->sync_saves = m->base_sync_saves = in_catalog ? !cJSON_IsFalse(can) : jbool(p, e, "sync_saves", 1);
|
||||
const char *mode = jstr(p, e, "sync", "all"); /* per platform, or the emulator's (before 1.2) */
|
||||
m->sync_off = !strcmp(mode, "off");
|
||||
m->sync_states = !m->sync_off && strcmp(mode, "saves") != 0;
|
||||
if (m->sync_off) m->sync_saves = m->base_sync_saves = 0;
|
||||
str_copy(m->root, sizeof m->root, root);
|
||||
str_copy(m->ra_cfg_dir, sizeof m->ra_cfg_dir, ra_cfg_dir);
|
||||
/* "cards": which memory cards the emulator keeps for the system:
|
||||
* {"default": "per_game" | "shared", "modes": "per_game,shared",
|
||||
* "change": how the user gets one card per game, "setting": how to tell which
|
||||
* is on: {"core_option": key} or {"file", "key"}, with the
|
||||
* "per_game" and "shared" values (comma separated) and the
|
||||
* core option's "default"}. */
|
||||
const cJSON *cards = cJSON_GetObjectItemCaseSensitive(e, "cards");
|
||||
if (!cards) cards = cJSON_GetObjectItemCaseSensitive(p, "cards");
|
||||
if (cJSON_IsObject(cards)) {
|
||||
const cJSON *set = cJSON_GetObjectItemCaseSensitive(cards, "setting");
|
||||
str_copy(m->cards_default, sizeof m->cards_default, jstr(cards, NULL, "default", "per_game"));
|
||||
str_copy(m->cards_modes, sizeof m->cards_modes, jstr(cards, NULL, "modes", m->cards_default));
|
||||
str_copy(m->cards_change, sizeof m->cards_change, jstr(cards, NULL, "change", ""));
|
||||
str_copy(m->cards_option, sizeof m->cards_option, jstr(set, NULL, "core_option", ""));
|
||||
str_template(m->cards_file, sizeof m->cards_file, jstr(set, NULL, "file", ""), vars);
|
||||
str_copy(m->cards_key, sizeof m->cards_key, jstr(set, NULL, "key", ""));
|
||||
str_copy(m->cards_per_game, sizeof m->cards_per_game, jstr(set, NULL, "per_game", ""));
|
||||
str_copy(m->cards_shared, sizeof m->cards_shared, jstr(set, NULL, "shared", ""));
|
||||
/* A core's per-game cards sync only while its option says so. */
|
||||
if (m->cards_option[0] && !m->req_key[0]) {
|
||||
str_copy(m->req_key, sizeof m->req_key, m->cards_option);
|
||||
str_copy(m->req_value, sizeof m->req_value, m->cards_per_game);
|
||||
str_copy(m->req_default, sizeof m->req_default, jstr(set, NULL, "default", ""));
|
||||
if (m->cards_change[0])
|
||||
snprintf(m->req_hint, sizeof m->req_hint, "The memory cards are shared by every game. %s to give each game its own card, which then syncs.", m->cards_change);
|
||||
}
|
||||
cards_mode_now(m);
|
||||
}
|
||||
const cJSON *req = cJSON_GetObjectItemCaseSensitive(e, "sync_requires_option");
|
||||
if (cJSON_IsObject(req)) {
|
||||
str_copy(m->req_key, sizeof m->req_key, jstr(req, NULL, "key", ""));
|
||||
@@ -441,10 +703,32 @@ int profiles_expand(const cJSON *profiles, profile_map **out) {
|
||||
maps[i].own_slot = 0;
|
||||
}
|
||||
}
|
||||
for (int i = 0; i < cnt; i++) mount_main_games(maps, cnt, &maps[i]);
|
||||
card_methods(maps, cnt);
|
||||
*out = maps;
|
||||
return cnt;
|
||||
}
|
||||
|
||||
cJSON *profile_firmware_rules(const cJSON *profiles, const profile_map *m) {
|
||||
char sys[64];
|
||||
str_copy(sys, sizeof sys, m->romm_slugs);
|
||||
sys[strcspn(sys, ",")] = 0;
|
||||
const cJSON *p, *e;
|
||||
cJSON_ArrayForEach(p, profiles) {
|
||||
if (strcmp(jstr(p, NULL, "id", "custom"), m->profile_id) != 0) continue;
|
||||
cJSON_ArrayForEach(e, cJSON_GetObjectItemCaseSensitive(p, "platforms")) {
|
||||
if (strcmp(profiles_system_key(e), sys) != 0) continue;
|
||||
const cJSON *rules = state_setting(p, e, "firmware");
|
||||
if (cJSON_IsObject(rules)) return cJSON_Duplicate(rules, 1);
|
||||
/* Folders the user picked, with no preset: BIOS files only where
|
||||
* a BIOS folder was picked too, not guessed below the games. */
|
||||
if (!strncmp(m->profile_id, "custom", 6) && !jstr(p, e, "bios_dir", NULL)) return cJSON_Parse("{\"skip\":true}");
|
||||
return NULL;
|
||||
}
|
||||
}
|
||||
return NULL;
|
||||
}
|
||||
|
||||
int profile_map_handles(const profile_map *m, const char *slug, const char *fs_slug) {
|
||||
char buf[256];
|
||||
str_copy(buf, sizeof buf, m->romm_slugs);
|
||||
|
||||
+67
-4
@@ -38,6 +38,18 @@
|
||||
* that platform entry from them, or in "extra": ["psx"], which keeps it as
|
||||
* an extra emulator for the system (see profile_map.extra).
|
||||
*
|
||||
* "mount_games": true says the emulator reads its games through its app
|
||||
* folder (/app0), "data" through the real /data out of its sandbox, where a
|
||||
* mount can show it the main
|
||||
* emulator's (see profile_map.mount_games).
|
||||
*
|
||||
* "update_dir" and "dlc_dir" are where a Switch game's update and DLC go,
|
||||
* downloaded with the game (library.c); the game's own files go straight
|
||||
* into rom_dir, never a folder of their own.
|
||||
*
|
||||
* "firmware" says how RomM's BIOS files for a platform reach the emulator
|
||||
* (firmware.h).
|
||||
*
|
||||
* "sync" is the user's choice for the emulator: "all" (saves and states, the
|
||||
* default), "saves" (no states) or "off" (games only). */
|
||||
#ifndef ROMM_PROFILES_H
|
||||
@@ -53,12 +65,16 @@ typedef struct {
|
||||
char platform_dir[64]; /* {platform} value */
|
||||
char romm_slugs[256]; /* comma separated RomM slugs / fs_slugs */
|
||||
char rom_dir[PATH_MAX_LEN];
|
||||
/* Where a Switch game's update and DLC go ("update_dir", "dlc_dir"; the
|
||||
* DLC's defaults to the update's, and both to rom_dir). */
|
||||
char update_dir[PATH_MAX_LEN];
|
||||
char dlc_dir[PATH_MAX_LEN];
|
||||
char save_dir[PATH_MAX_LEN];
|
||||
char state_dir[PATH_MAX_LEN];
|
||||
char bios_dir[PATH_MAX_LEN];
|
||||
/* Where RomM's firmware goes: bios_dir, or a folder in it the core reads
|
||||
/* Where RomM's firmware goes in bios_dir: "" or a folder the core reads
|
||||
* ("firmware_subdir": "pcsx2/bios" for RetroArch's PS2 cores). */
|
||||
char firmware_dir[PATH_MAX_LEN];
|
||||
char firmware_subdir[32];
|
||||
char save_exts[128];
|
||||
char state_exts[128];
|
||||
char save_name[128];
|
||||
@@ -75,7 +91,11 @@ typedef struct {
|
||||
* "emulator" names, default the platform's emulator). An emulator that
|
||||
* switches cores in its own settings ("state_core_setting": {"file",
|
||||
* "key", "values": {"0": "pcsx_rearmed", ...}}) takes only the current
|
||||
* one's. state_format is "retroarch" (RetroArch's container, compressed
|
||||
* one's. A value that lets the emulator choose ("auto": "0") is read
|
||||
* from the line its log names the core on ("log": file, "log_line":
|
||||
* "host: emulator ", "log_names": {"rearmed": "pcsx_rearmed"} by a part
|
||||
* of the name); "values" has the auto value's default for when the log
|
||||
* says nothing. state_format is "retroarch" (RetroArch's container, compressed
|
||||
* or not) or "raw" (the core's bare state, PSXS5); state_slot0 is the
|
||||
* extension of the first slot (".state", or ".state0"). */
|
||||
char state_cores[256];
|
||||
@@ -84,6 +104,10 @@ typedef struct {
|
||||
char core_file[PATH_MAX_LEN];
|
||||
char core_key[32];
|
||||
char core_values[256]; /* "0=pcsx_rearmed,2=mednafen_psx_hw" */
|
||||
char core_auto[16];
|
||||
char core_log[PATH_MAX_LEN];
|
||||
char core_log_line[64];
|
||||
char core_log_names[256]; /* "rearmed=pcsx_rearmed,beetle=mednafen_psx_hw" */
|
||||
/* Saves named differently by some of those cores ("save_name_by_core":
|
||||
* {"mednafen_psx_hw": "{rom_stem}_1{ext}"}): both names are the game's,
|
||||
* downloads take the current core's. "core=template;core=template". */
|
||||
@@ -95,9 +119,40 @@ typedef struct {
|
||||
int extra;
|
||||
int own_slot;
|
||||
char save_format[32];
|
||||
/* An extra that reads its games through its own app folder ("mount_games":
|
||||
* true) or, out of its sandbox, through the real /data ("mount_games":
|
||||
* "data", by its catalog title id),
|
||||
* checked on the console for that emulator, plays the main one's through
|
||||
* a mount in its sandbox (plat_mounts_set) instead of copies:
|
||||
* rom_dir is then the main emulator's folder, own_rom_dir its own, and
|
||||
* mount_title and mount_into say where the mount goes. mount_note says
|
||||
* why one that could have a mount has copies instead. */
|
||||
int mount_games;
|
||||
int mount_blocked; /* games of its own in its folder, the only thing in the way (library.c moves RomM's to the main one) */
|
||||
char own_rom_dir[PATH_MAX_LEN];
|
||||
char mount_title[16];
|
||||
char mount_into[256];
|
||||
char mount_note[200];
|
||||
/* Memory cards every game shares ("memcards": "{root}/Mcd001.ps2,..."),
|
||||
* backed up to RomM's memory cards (memcard.h). */
|
||||
char memcards[512];
|
||||
/* A front end's list of its games' ids ("game_ids": "{root}/data/game-ids.txt",
|
||||
* "<path>\t<id>" lines; PSFlyCast's), read before the disc: {game_id}. */
|
||||
char game_ids[PATH_MAX_LEN];
|
||||
/* Which disc header {game_id} is read from when no list has it
|
||||
* ("disc_id": "dreamcast": IP.BIN's product number). */
|
||||
char disc_id[16];
|
||||
/* Its memory cards ("cards"): the default and the modes it can keep
|
||||
* ("per_game", "shared"), where the user switches, how to tell which is
|
||||
* on, and which is (cards_mode, "" when the system has no cards). */
|
||||
char cards_default[12], cards_modes[24], cards_mode[12];
|
||||
char cards_change[200];
|
||||
char cards_option[64], cards_file[PATH_MAX_LEN], cards_key[64];
|
||||
char cards_per_game[96], cards_shared[64];
|
||||
/* The system's one card method (profiles_expand), and whether this
|
||||
* emulator keeps it: if not, none of its cards sync. */
|
||||
char cards_method[12];
|
||||
int cards_active;
|
||||
int sync_saves;
|
||||
int sync_states; /* 0: the user turned states off for this emulator */
|
||||
int sync_off; /* 1: the user turned save sync off for this emulator */
|
||||
@@ -106,7 +161,6 @@ typedef struct {
|
||||
* by_core = <dir>/<core_name>/. Both sort flags nest content then core. */
|
||||
int in_content[2], by_content[2], by_core[2];
|
||||
char core_name[64]; /* core library name, e.g. "mGBA" */
|
||||
char playlist_dir[PATH_MAX_LEN]; /* RetroArch's, "" for <root>/playlists */
|
||||
char save_layout[8]; /* "" for one file per game, or psp, gci, wii (see bundle.h) */
|
||||
char bundle_dirs[512]; /* candidate folders for bundled saves, comma separated */
|
||||
/* Why saves aren't synced for this platform, shown in the UI. */
|
||||
@@ -132,6 +186,10 @@ typedef struct {
|
||||
|
||||
void profile_rules_for(const profile_map *m, const char *rom_path, rom_rules *out);
|
||||
|
||||
/* A folder setting from the emulator's retroarch.cfg ("playlist_directory"),
|
||||
* as a path; 0 if it has none (or isn't RetroArch). */
|
||||
int profile_ra_dir(const profile_map *m, const char *key, char *out, size_t n);
|
||||
|
||||
/* Built-in default profiles (caller owns the returned array). */
|
||||
cJSON *profiles_default(void);
|
||||
|
||||
@@ -144,6 +202,11 @@ int profiles_excludes(const cJSON *profile, const char *key);
|
||||
* Returns the count; free *out. */
|
||||
int profiles_expand(const cJSON *profiles, profile_map **out);
|
||||
|
||||
/* The map's "firmware" rules (see firmware.h), from its platform entry or
|
||||
* profile, else the built-in catalog's; NULL if none. A custom profile with
|
||||
* no BIOS folder of its own gets {"skip": true}. Free it. */
|
||||
cJSON *profile_firmware_rules(const cJSON *profiles, const profile_map *m);
|
||||
|
||||
/* Does the map handle a RomM platform with this slug / fs_slug? */
|
||||
int profile_map_handles(const profile_map *m, const char *slug, const char *fs_slug);
|
||||
|
||||
|
||||
@@ -20,10 +20,16 @@
|
||||
#define RA_ROM_DIR "{root}/content/{platform}"
|
||||
|
||||
static pthread_mutex_t g_lock = PTHREAD_MUTEX_INITIALIZER;
|
||||
static void rekey(const char *from, const char *to);
|
||||
static time_t g_checked;
|
||||
static cJSON *g_pending;
|
||||
|
||||
int ra2_is_v2(const char *root) {
|
||||
/* Which folders PS5 RetroArch at root keeps games in, by the major version
|
||||
* its webui/version.json gives: 0 before 2.0 (content/<our system name>), 2
|
||||
* for 2.x (the libretro database's: content/Sony - PlayStation), 3 for 3.0 and
|
||||
* newer (ES-DE's: content/psx, content/gc), which renames 2.x's on its first
|
||||
* start. */
|
||||
int ra2_layout(const char *root) {
|
||||
char p[PATH_MAX_LEN];
|
||||
path_join(p, sizeof p, root, "webui/version.json");
|
||||
char *txt = read_file(p, NULL);
|
||||
@@ -33,9 +39,11 @@ int ra2_is_v2(const char *root) {
|
||||
int major = 0;
|
||||
sscanf(release + (*release == 'v' || *release == 'V'), "%d", &major);
|
||||
cJSON_Delete(j);
|
||||
return major >= 2;
|
||||
return major >= 3 ? 3 : major == 2 ? 2 : 0;
|
||||
}
|
||||
|
||||
int ra2_is_v2(const char *root) { return ra2_layout(root) >= 2; }
|
||||
|
||||
/* 2.0 plays PS2 with PS5SX2, not LRPS2: two shared memory cards (memcard.c
|
||||
* backs them up), none per game. */
|
||||
static int to_ps5sx2(cJSON *p, const char *root) {
|
||||
@@ -55,28 +63,43 @@ static int to_ps5sx2(cJSON *p, const char *root) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
int ra2_update_profile(cJSON *p) {
|
||||
int ra2_update_profile(cJSON *p, cJSON *moves) {
|
||||
const char *root = jget_str(p, "root", "");
|
||||
/* A RetroArch profile, its games in content/<system>: the preset's. */
|
||||
if (!jget_str(p, "retroarch_cfg", NULL) || strcmp(jget_str(p, "rom_dir", ""), RA_ROM_DIR) != 0 || !ra2_is_v2(root))
|
||||
return 0;
|
||||
const int layout = jget_str(p, "retroarch_cfg", NULL) && !strcmp(jget_str(p, "rom_dir", ""), RA_ROM_DIR) ? ra2_layout(root) : 0;
|
||||
if (!layout) return 0;
|
||||
int changed = 0;
|
||||
cJSON *e;
|
||||
cJSON_ArrayForEach(e, cJSON_GetObjectItemCaseSensitive(p, "platforms")) {
|
||||
const char *db = detect_db_name(e);
|
||||
char now[PATH_MAX_LEN], old[PATH_MAX_LEN], tpl[PATH_MAX_LEN];
|
||||
if (!db) continue;
|
||||
snprintf(now, sizeof now, "%s/content/%s", root, db);
|
||||
const char *db = detect_db_name(e), *esde = detect_esde_name(e);
|
||||
const char *name = layout == 3 ? esde : db;
|
||||
char now[PATH_MAX_LEN], old[PATH_MAX_LEN], tpl[PATH_MAX_LEN], tpl_db[PATH_MAX_LEN], tpl_esde[PATH_MAX_LEN];
|
||||
if (!name) continue;
|
||||
snprintf(now, sizeof now, "%s/content/%s", root, name);
|
||||
if (!dir_exists(now)) continue; /* a system RetroArch has no folder for */
|
||||
/* Unless the user picked a folder other than the old one. */
|
||||
/* Unless the user picked a folder of their own. */
|
||||
snprintf(old, sizeof old, "%s/content/%s", root, jget_str(e, "dir", profiles_system_key(e)));
|
||||
snprintf(tpl, sizeof tpl, "{root}/content/%s", name);
|
||||
snprintf(tpl_db, sizeof tpl_db, "{root}/content/%s", db ? db : "");
|
||||
snprintf(tpl_esde, sizeof tpl_esde, "{root}/content/%s", esde ? esde : "");
|
||||
const char *set = jget_str(e, "rom_dir", NULL);
|
||||
if (set && strcmp(set, old) != 0 && strcmp(set, RA_ROM_DIR) != 0) continue;
|
||||
snprintf(tpl, sizeof tpl, "{root}/content/%s", db);
|
||||
if (set && !strcmp(set, tpl)) continue; /* already */
|
||||
if (set && strcmp(set, old) && strcmp(set, RA_ROM_DIR) && strcmp(set, tpl_db) && strcmp(set, tpl_esde)) continue;
|
||||
/* 3.0 renamed 2.x's folder itself: our records of the games in it follow. */
|
||||
if (moves && set && !strcmp(set, tpl_db) && layout == 3) {
|
||||
char was[PATH_MAX_LEN];
|
||||
snprintf(was, sizeof was, "%s/content/%s", root, db);
|
||||
if (!dir_exists(was)) {
|
||||
cJSON *m = cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(m, "from", was);
|
||||
cJSON_AddStringToObject(m, "to", now);
|
||||
cJSON_AddItemToArray(moves, m);
|
||||
}
|
||||
}
|
||||
jset_str(e, "rom_dir", tpl);
|
||||
changed++;
|
||||
}
|
||||
if (changed) LOGI("RetroArch 2.0 at %s: %d system%s get their new folders", root, changed, changed == 1 ? "" : "s");
|
||||
if (changed) LOGI("RetroArch %d.x at %s: %d system%s get their new folders", layout, root, changed, changed == 1 ? "" : "s");
|
||||
return changed + to_ps5sx2(p, root);
|
||||
}
|
||||
|
||||
@@ -125,13 +148,22 @@ void ra2_check(int now) {
|
||||
g_checked = time(NULL);
|
||||
config_lock();
|
||||
int changed = 0;
|
||||
cJSON *p;
|
||||
cJSON_ArrayForEach(p, g_cfg.profiles) changed += ra2_update_profile(p);
|
||||
cJSON *p, *moves = cJSON_CreateArray();
|
||||
cJSON_ArrayForEach(p, g_cfg.profiles) changed += ra2_update_profile(p, moves);
|
||||
if (changed && g_cfg.profiles_custom) config_save();
|
||||
profile_map *maps = NULL;
|
||||
int n = profiles_expand(g_cfg.profiles, &maps);
|
||||
const int ready = g_cfg.setup_complete;
|
||||
config_unlock();
|
||||
const cJSON *mv;
|
||||
cJSON_ArrayForEach(mv, moves) {
|
||||
state_lock();
|
||||
rekey(jget_str(mv, "from", ""), jget_str(mv, "to", ""));
|
||||
state_save();
|
||||
state_unlock();
|
||||
LOGI("RetroArch moved %s to %s: the games' records follow", jget_str(mv, "from", ""), jget_str(mv, "to", ""));
|
||||
}
|
||||
cJSON_Delete(moves);
|
||||
|
||||
cJSON_Delete(g_pending);
|
||||
g_pending = NULL;
|
||||
@@ -141,21 +173,40 @@ void ra2_check(int now) {
|
||||
const profile_map *m = &maps[i];
|
||||
char content[PATH_MAX_LEN], from[PATH_MAX_LEN];
|
||||
snprintf(content, sizeof content, "%s/content/", m->root);
|
||||
if (strncmp(m->rom_dir, content, strlen(content)) != 0 || !ra2_is_v2(m->root)) continue;
|
||||
snprintf(from, sizeof from, "%s%s", content, m->platform_dir);
|
||||
int seen = 0;
|
||||
const cJSON *f;
|
||||
cJSON_ArrayForEach(f, folders) seen |= !strcmp(jget_str(f, "from", ""), from);
|
||||
const int games = !seen && strcmp(from, m->rom_dir) != 0 ? games_to_move(from, m->rom_dir, m) : 0;
|
||||
if (!games) continue;
|
||||
cJSON *o = cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(o, "from", from);
|
||||
cJSON_AddStringToObject(o, "to", m->rom_dir);
|
||||
cJSON_AddStringToObject(o, "root", m->root);
|
||||
cJSON_AddStringToObject(o, "playlists", m->playlist_dir);
|
||||
cJSON_AddNumberToObject(o, "games", games);
|
||||
cJSON_AddItemToArray(folders, o);
|
||||
total += games;
|
||||
const int layout = strncmp(m->rom_dir, content, strlen(content)) == 0 ? ra2_layout(m->root) : 0;
|
||||
if (!layout) continue;
|
||||
/* The games still in an older layout's folder: ours (content/<system>),
|
||||
* and with 3.0, 2.x's (content/<libretro database>) when it left one. */
|
||||
char sys[64];
|
||||
str_copy(sys, sizeof sys, m->romm_slugs);
|
||||
sys[strcspn(sys, ",")] = 0;
|
||||
cJSON *probe = cJSON_CreateObject();
|
||||
cJSON *slugs = cJSON_AddArrayToObject(probe, "romm");
|
||||
cJSON_AddItemToArray(slugs, cJSON_CreateString(sys));
|
||||
const char *db = layout == 3 ? detect_db_name(probe) : NULL;
|
||||
char olds[2][PATH_MAX_LEN];
|
||||
int no = 0;
|
||||
snprintf(olds[no++], PATH_MAX_LEN, "%s%s", content, m->platform_dir);
|
||||
if (db) snprintf(olds[no++], PATH_MAX_LEN, "%s%s", content, db);
|
||||
cJSON_Delete(probe);
|
||||
for (int k = 0; k < no; k++) {
|
||||
str_copy(from, sizeof from, olds[k]);
|
||||
int seen = 0;
|
||||
const cJSON *f;
|
||||
cJSON_ArrayForEach(f, folders) seen |= !strcmp(jget_str(f, "from", ""), from);
|
||||
const int games = !seen && strcmp(from, m->rom_dir) != 0 ? games_to_move(from, m->rom_dir, m) : 0;
|
||||
if (!games) continue;
|
||||
cJSON *o = cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(o, "from", from);
|
||||
cJSON_AddStringToObject(o, "to", m->rom_dir);
|
||||
cJSON_AddStringToObject(o, "root", m->root);
|
||||
char playlists[PATH_MAX_LEN] = "";
|
||||
profile_ra_dir(m, "playlist_directory", playlists, sizeof playlists);
|
||||
cJSON_AddStringToObject(o, "playlists", playlists);
|
||||
cJSON_AddNumberToObject(o, "games", games);
|
||||
cJSON_AddItemToArray(folders, o);
|
||||
total += games;
|
||||
}
|
||||
}
|
||||
if (total) {
|
||||
g_pending = cJSON_CreateObject();
|
||||
@@ -246,7 +297,7 @@ int ra2_move_games(int *moved, char *err, int en) {
|
||||
state_unlock();
|
||||
sync_resume();
|
||||
cJSON_Delete(pending);
|
||||
LOGI("moved %d game%s to RetroArch 2.0's folders", *moved, *moved == 1 ? "" : "s");
|
||||
LOGI("moved %d game%s to RetroArch's new folders", *moved, *moved == 1 ? "" : "s");
|
||||
ra2_check(1);
|
||||
if (*moved) sync_request("games moved");
|
||||
if (failed) snprintf(err, (size_t)en, "%d couldn't be moved", failed);
|
||||
|
||||
@@ -1,20 +1,24 @@
|
||||
/* RetroArch 2.0 on the PS5 keeps each system's games in a folder named after
|
||||
* its libretro database (content/Sony - PlayStation 2, content/Nintendo -
|
||||
* Game Boy Advance), where 1.x had content/ps2 and content/gba. It makes
|
||||
* those folders itself. Each system with one gets it (unless the user picked
|
||||
* a folder), and the games already in the old folders move there once the
|
||||
* user says so. */
|
||||
/* PS5 RetroArch's game folders change with its versions: content/<our system
|
||||
* name> before 2.0, the libretro database's in 2.x (content/Sony - PlayStation
|
||||
* 2), ES-DE's from 3.0 (content/ps2, content/gc), which renames 2.x's folders
|
||||
* on its first start. RetroArch makes its folders itself. Each system with
|
||||
* one gets it (unless the user picked a folder), our records follow a folder
|
||||
* RetroArch renamed, and games left in an older folder move once the user
|
||||
* says so. */
|
||||
#ifndef ROMM_RA2_H
|
||||
#define ROMM_RA2_H
|
||||
|
||||
#include "cJSON.h"
|
||||
|
||||
/* RetroArch at root's folder layout: 0 (before 2.0), 2 or 3 (see above). */
|
||||
int ra2_layout(const char *root);
|
||||
/* RetroArch at root is 2.0 or newer, as its webui/version.json says. */
|
||||
int ra2_is_v2(const char *root);
|
||||
|
||||
/* Points a RetroArch 2.0 profile's systems at their new folders, unless the
|
||||
* user picked others. How many changed. */
|
||||
int ra2_update_profile(cJSON *profile);
|
||||
/* Points a RetroArch profile's systems at its version's folders, unless the
|
||||
* user picked others. How many changed. moves (or NULL) gets {"from", "to"}
|
||||
* for folders RetroArch renamed itself, whose records should follow. */
|
||||
int ra2_update_profile(cJSON *profile, cJSON *moves);
|
||||
|
||||
/* Looks at the profiles and the old folders again: at most once a minute,
|
||||
* unless now. Takes the config lock. */
|
||||
|
||||
+130
-10
@@ -1,8 +1,10 @@
|
||||
#include "romm.h"
|
||||
|
||||
#include <pthread.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#include "config.h"
|
||||
#include "platform.h"
|
||||
@@ -19,21 +21,32 @@ static void current_auth(char *base, size_t bn, char *auth, size_t an) {
|
||||
config_unlock();
|
||||
}
|
||||
|
||||
cJSON *romm_call_raw(const char *base, const char *auth, const char *method, const char *path,
|
||||
const cJSON *body, long *status) {
|
||||
/* What a game list carries that nothing here reads, and that makes it heavy:
|
||||
* RetroAchievements' lists alone are most of a page (1.1 of 1.3 MB for 16 PS1
|
||||
* games), and the PS4 payload can't hold that. Dropped as the page arrives. */
|
||||
static const char *const LIST_DROP[] = {
|
||||
"merged_ra_metadata", "ra_metadata", "ss_metadata", "igdb_metadata",
|
||||
"hasheous_metadata", "launchbox_metadata", "moby_metadata", "flashpoint_metadata",
|
||||
"gamelist_metadata", "merged_screenshots", "url_screenshots", "url_manual",
|
||||
NULL};
|
||||
|
||||
static cJSON *call(const char *base, const char *auth, const char *method, const char *path, const cJSON *body,
|
||||
const char *const *drop, long *status) {
|
||||
char url[2048];
|
||||
snprintf(url, sizeof url, "%s%s", base, path);
|
||||
char *payload = body ? cJSON_PrintUnformatted(body) : NULL;
|
||||
http_resp r;
|
||||
int rc = http_request(method, url, auth, payload ? "application/json" : NULL, payload,
|
||||
payload ? strlen(payload) : 0, &r);
|
||||
int rc = drop ? http_get_lean(url, auth, drop, &r)
|
||||
: http_request(method, url, auth, payload ? "application/json" : NULL, payload,
|
||||
payload ? strlen(payload) : 0, &r);
|
||||
free(payload);
|
||||
if (status) *status = rc == 0 ? r.status : 0;
|
||||
cJSON *j = (rc == 0 && r.body) ? cJSON_Parse(r.body) : NULL;
|
||||
/* A big reply can't always be parsed where memory is tight (the PS4 payload
|
||||
* shares GoldHEN's process); romm_list pages lists to stay small. */
|
||||
if (rc == 0 && r.status < 300 && r.len && !j)
|
||||
LOGW("%s %s -> %ld, but the %zu-byte reply couldn't be read (out of memory?)", method, path, r.status, r.len);
|
||||
LOGW("%s %s -> %ld, but the %zu-byte reply couldn't be read (out of memory? largest free block %d MB)", method,
|
||||
path, r.status, r.len, mem_largest_mb());
|
||||
if (rc == 0 && r.status >= 400) {
|
||||
char d[256];
|
||||
romm_error_detail(j, d, sizeof d);
|
||||
@@ -44,16 +57,72 @@ cJSON *romm_call_raw(const char *base, const char *auth, const char *method, con
|
||||
return j;
|
||||
}
|
||||
|
||||
cJSON *romm_call_raw(const char *base, const char *auth, const char *method, const char *path,
|
||||
const cJSON *body, long *status) {
|
||||
return call(base, auth, method, path, body, NULL, status);
|
||||
}
|
||||
|
||||
int romm_no_answer(char *msg, int n) {
|
||||
char base[512];
|
||||
config_lock();
|
||||
str_copy(base, sizeof base, g_cfg.server_url);
|
||||
config_unlock();
|
||||
if (!base[0] || !http_down(base)) return 0;
|
||||
const char *host = strstr(base, "://");
|
||||
host = host ? host + 3 : base;
|
||||
snprintf(msg, (size_t)n, "RomM at %.*s isn't answering", (int)strcspn(host, "/"), host);
|
||||
return 1;
|
||||
}
|
||||
|
||||
static pthread_mutex_t g_check_lock = PTHREAD_MUTEX_INITIALIZER;
|
||||
static int g_checking;
|
||||
|
||||
static void *check_back(void *arg) {
|
||||
(void)arg;
|
||||
long st = 0;
|
||||
cJSON_Delete(romm_call("GET", "/api/heartbeat", NULL, &st));
|
||||
pthread_mutex_lock(&g_check_lock);
|
||||
g_checking = 0;
|
||||
pthread_mutex_unlock(&g_check_lock);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
void romm_check_back(void) {
|
||||
pthread_mutex_lock(&g_check_lock);
|
||||
const int start = !g_checking;
|
||||
g_checking = 1;
|
||||
pthread_mutex_unlock(&g_check_lock);
|
||||
if (start && thread_start(check_back, NULL) != 0) {
|
||||
pthread_mutex_lock(&g_check_lock);
|
||||
g_checking = 0;
|
||||
pthread_mutex_unlock(&g_check_lock);
|
||||
}
|
||||
}
|
||||
|
||||
int romm_list(const char *path, int offset, int limit, romm_list_cb cb, void *ctx, long *status) {
|
||||
int seen = 0;
|
||||
int seen = 0, page_size = strcmp(plat_info()->name, "ps4") ? 60 : 16;
|
||||
char why[160];
|
||||
if (status) *status = 0;
|
||||
while (seen < limit) {
|
||||
char url[2048];
|
||||
int want = limit - seen < ROMM_LIST_PAGE ? limit - seen : ROMM_LIST_PAGE;
|
||||
int want = limit - seen < page_size ? limit - seen : page_size;
|
||||
snprintf(url, sizeof url, "%s%soffset=%d&limit=%d", path, strchr(path, '?') ? "&" : "?", offset + seen, want);
|
||||
long st = 0;
|
||||
cJSON *page = romm_call("GET", url, NULL, &st);
|
||||
char base[512], auth[300];
|
||||
current_auth(base, sizeof base, auth, sizeof auth);
|
||||
cJSON *page = base[0] ? call(base, auth, "GET", url, NULL, LIST_DROP, &st) : NULL;
|
||||
if (status) *status = st;
|
||||
/* A page too big to take in (games with a lot of metadata, where memory
|
||||
* is tight): the same games again, fewer at a time. Not when RomM
|
||||
* didn't answer at all, or memory is so short that a few games at a
|
||||
* time don't fit either: fewer games won't change that. */
|
||||
if ((st == 0 || (st == 200 && !page)) && want > 1 && !(st == 0 && romm_no_answer(why, sizeof why)) &&
|
||||
!(want <= 4 && http_out_of_memory())) {
|
||||
cJSON_Delete(page);
|
||||
page_size = want / 2;
|
||||
LOGI("%s: trying %d games at a time", path, page_size);
|
||||
continue;
|
||||
}
|
||||
if (st != 200 || !page) {
|
||||
cJSON_Delete(page);
|
||||
return -1;
|
||||
@@ -182,6 +251,34 @@ int romm_pair_exchange(const char *base, const char *code, char *token, int tn)
|
||||
return rc;
|
||||
}
|
||||
|
||||
void romm_update_device_name(void) {
|
||||
char name[64], id[64], path[128];
|
||||
config_lock();
|
||||
int same = !strcmp(g_cfg.device_name, g_cfg.romm_device_name);
|
||||
str_copy(name, sizeof name, g_cfg.device_name);
|
||||
str_copy(id, sizeof id, g_cfg.device_id);
|
||||
config_unlock();
|
||||
if (same || !name[0] || !id[0]) return;
|
||||
char *q = http_escape(id);
|
||||
snprintf(path, sizeof path, "/api/devices/%s", q);
|
||||
free(q);
|
||||
cJSON *body = cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(body, "name", name);
|
||||
long st = 0;
|
||||
cJSON_Delete(romm_call("PUT", path, body, &st));
|
||||
cJSON_Delete(body);
|
||||
/* No such device or no renaming on this RomM: nothing to retry. */
|
||||
if (st != 200 && st != 404 && st != 405) {
|
||||
if (st) LOGW("could not rename the device on RomM (HTTP %ld)", st);
|
||||
return;
|
||||
}
|
||||
if (st == 200) LOGI("device is called \"%s\" on RomM now", name);
|
||||
config_lock();
|
||||
str_copy(g_cfg.romm_device_name, sizeof g_cfg.romm_device_name, name);
|
||||
config_save();
|
||||
config_unlock();
|
||||
}
|
||||
|
||||
int romm_register_device(const char *base, const char *token, char *device_id, int dn) {
|
||||
char auth[300], ip[64] = "";
|
||||
snprintf(auth, sizeof auth, "Bearer %s", token);
|
||||
@@ -213,12 +310,35 @@ int romm_register_device(const char *base, const char *token, char *device_id, i
|
||||
return rc;
|
||||
}
|
||||
|
||||
/* A transfer cut off halfway (RomM restarted, paused, the Wi-Fi dropped)
|
||||
* picks up where it stopped, for as long as each go gets further. One that
|
||||
* gets nothing more ends it: RomM is away, and the download waits for it. */
|
||||
#define DOWNLOAD_TRIES 5
|
||||
|
||||
int romm_download(const char *path, const char *dest, http_progress_fn cb, void *ud,
|
||||
http_resp *resp) {
|
||||
char base[512], auth[300], url[2048];
|
||||
char base[512], auth[300], url[2048], part[PATH_MAX_LEN];
|
||||
current_auth(base, sizeof base, auth, sizeof auth);
|
||||
snprintf(url, sizeof url, "%s%s", base, path);
|
||||
return http_download(url, auth, dest, 1, cb, ud, resp);
|
||||
snprintf(part, sizeof part, "%s.part", dest);
|
||||
int rc = -1;
|
||||
for (int go = 1; go <= DOWNLOAD_TRIES; go++) {
|
||||
const int64_t before = file_size(part);
|
||||
rc = http_download(url, auth, dest, 1, cb, ud, resp);
|
||||
if (rc != 0 && resp->status == 404)
|
||||
/* RomM still lists it, its disk doesn't have it: renamed or moved since its last scan. */
|
||||
snprintf(resp->error, sizeof resp->error,
|
||||
"RomM lists this game, but %.120s is missing from its server. If it was moved or renamed, "
|
||||
"rescan the platform in RomM",
|
||||
path_basename(dest));
|
||||
if (rc == 0 || resp->status || resp->no_answer || resp->aborted) break;
|
||||
const int64_t after = file_size(part);
|
||||
if (after <= (before > 0 ? before : 0)) break;
|
||||
LOGI("download of %s cut off at %lld bytes (%s): carrying on", path_basename(dest), (long long)after,
|
||||
resp->error);
|
||||
sleep(2);
|
||||
}
|
||||
return rc;
|
||||
}
|
||||
|
||||
cJSON *romm_upload(const char *method, const char *path, const char *field, const char *file,
|
||||
|
||||
+21
-7
@@ -30,19 +30,33 @@ typedef enum { POLL_OK, POLL_PENDING, POLL_SLOW_DOWN, POLL_DENIED, POLL_EXPIRED,
|
||||
/* JSON request to the paired server. Returns the parsed body (free it) or NULL. */
|
||||
cJSON *romm_call(const char *method, const char *path, const cJSON *body, long *status);
|
||||
|
||||
/* GETs a paged RomM list such as /api/roms?..., limit items from offset,
|
||||
* ROMM_LIST_PAGE at a time. RomM's game lists carry each game's full metadata,
|
||||
* so a page of 30 can be too much to parse in the PS4's memory. cb gets each
|
||||
* item with its page (first is set for the first page, and cb is called once
|
||||
* with a NULL item for an empty list); nonzero stops. Returns how many items
|
||||
* were seen, -1 on an error (status holds the HTTP status). */
|
||||
#define ROMM_LIST_PAGE 16
|
||||
/* Tells RomM the console's name when it changed (or was never sent): the
|
||||
* console was renamed, or its name set in the web UI. One request, only then. */
|
||||
void romm_update_device_name(void);
|
||||
|
||||
/* GETs a paged RomM list such as /api/roms?..., limit items from offset, 60
|
||||
* at a time (the library's page), 16 on the PS4. RomM's game lists carry each
|
||||
* game's full metadata, so a page of 30 can be too much to parse in the PS4's
|
||||
* memory; a page that can't be taken in is asked for again, fewer games at a
|
||||
* time. cb gets each item with its page (first is set for the first page, and
|
||||
* cb is called once with a NULL item for an empty list); nonzero stops.
|
||||
* Returns how many items were seen, -1 on an error (status holds the HTTP
|
||||
* status). */
|
||||
typedef int (*romm_list_cb)(const cJSON *item, const cJSON *page, int first, void *ctx);
|
||||
int romm_list(const char *path, int offset, int limit, romm_list_cb cb, void *ctx, long *status);
|
||||
/* Same, with an explicit server and auth, for use before pairing. */
|
||||
cJSON *romm_call_raw(const char *base, const char *auth, const char *method, const char *path,
|
||||
const cJSON *body, long *status);
|
||||
|
||||
/* 1 while the paired server isn't answering at all (see http_down), with what
|
||||
* to tell the user in msg: "RomM at 192.168.1.2:8085 isn't answering". */
|
||||
int romm_no_answer(char *msg, int n);
|
||||
|
||||
/* While it isn't answering, finds out in the background when it does again
|
||||
* (one check at a time, paced as http_down says), so nothing waits for the
|
||||
* next sync to notice. */
|
||||
void romm_check_back(void);
|
||||
|
||||
/* Checks the server and fills in its version. 0 on success. */
|
||||
int romm_heartbeat(const char *base, char *version, int vn);
|
||||
|
||||
|
||||
+118
@@ -2,6 +2,7 @@
|
||||
|
||||
#include <ctype.h>
|
||||
#include <stdint.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <strings.h>
|
||||
@@ -47,6 +48,123 @@ static unsigned char *unrzip(const unsigned char *b, size_t n, size_t *out_n) {
|
||||
return out;
|
||||
}
|
||||
|
||||
int state_unrzip_file(const char *in_path, const char *out_path) {
|
||||
unsigned char head[20], *packed = NULL, *chunk = NULL;
|
||||
size_t packed_cap = 0;
|
||||
uint64_t got = 0, total = 0;
|
||||
int ok = 0;
|
||||
FILE *in = fopen(in_path, "rb"), *out = NULL;
|
||||
if (!in || fread(head, 1, sizeof head, in) != sizeof head || memcmp(head, "#RZIPv\x01#", 8) != 0) goto done;
|
||||
uint32_t chunk_size = le32(head + 8);
|
||||
total = le64(head + 12);
|
||||
if (!chunk_size || chunk_size > 64u << 20 || !total || total > (uint64_t)256 << 20) goto done;
|
||||
if (!(chunk = malloc(chunk_size)) || !(out = fopen(out_path, "wb"))) goto done;
|
||||
while (got < total) {
|
||||
unsigned char lenb[4];
|
||||
if (fread(lenb, 1, 4, in) != 4) goto done;
|
||||
uint32_t len = le32(lenb);
|
||||
if (len > packed_cap) {
|
||||
unsigned char *grown = realloc(packed, len);
|
||||
if (!grown) goto done;
|
||||
packed = grown;
|
||||
packed_cap = len;
|
||||
}
|
||||
uLongf room = chunk_size;
|
||||
if (fread(packed, 1, len, in) != len || uncompress(chunk, &room, packed, len) != Z_OK ||
|
||||
fwrite(chunk, 1, room, out) != room)
|
||||
goto done;
|
||||
got += room;
|
||||
}
|
||||
ok = got == total;
|
||||
done:
|
||||
if (in) fclose(in);
|
||||
if (out && fclose(out) != 0) ok = 0;
|
||||
free(packed);
|
||||
free(chunk);
|
||||
if (!ok && out) remove(out_path);
|
||||
return ok ? 0 : -1;
|
||||
}
|
||||
|
||||
int state_wrap_file(const char *in_path, const char *out_path) {
|
||||
unsigned char buf[64 * 1024], head[16];
|
||||
int ok = 0;
|
||||
FILE *in = fopen(in_path, "rb"), *out = NULL;
|
||||
if (!in || fseek(in, 0, SEEK_END) != 0) goto done;
|
||||
long size = ftell(in);
|
||||
if (size <= 0 || size > (256L << 20) || fseek(in, 0, SEEK_SET) != 0 || !(out = fopen(out_path, "wb"))) goto done;
|
||||
memcpy(head, "RASTATE\x01" "MEM ", 12);
|
||||
put32(head + 12, (uint32_t)size);
|
||||
if (fwrite(head, 1, 16, out) != 16) goto done;
|
||||
long left = size;
|
||||
while (left > 0) {
|
||||
size_t want = left < (long)sizeof buf ? (size_t)left : sizeof buf;
|
||||
if (fread(buf, 1, want, in) != want || fwrite(buf, 1, want, out) != want) goto done;
|
||||
left -= (long)want;
|
||||
}
|
||||
static const unsigned char zeros[8], end[8] = {'E', 'N', 'D', ' ', 0, 0, 0, 0};
|
||||
size_t pad = (8 - (size_t)size % 8) % 8;
|
||||
ok = fwrite(zeros, 1, pad, out) == pad && fwrite(end, 1, 8, out) == 8;
|
||||
done:
|
||||
if (in) fclose(in);
|
||||
if (out && fclose(out) != 0) ok = 0;
|
||||
if (!ok && out) remove(out_path);
|
||||
return ok ? 0 : -1;
|
||||
}
|
||||
|
||||
/* Copies len bytes from in (already positioned) to out. */
|
||||
static int copy_bytes(FILE *in, FILE *out, uint64_t len) {
|
||||
unsigned char buf[64 * 1024];
|
||||
while (len > 0) {
|
||||
size_t want = len < sizeof buf ? (size_t)len : sizeof buf;
|
||||
if (fread(buf, 1, want, in) != want || fwrite(buf, 1, want, out) != want) return -1;
|
||||
len -= want;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
int state_unwrap_file(const char *in_path, const char *out_path) {
|
||||
unsigned char head[20];
|
||||
char unpacked[1100] = "";
|
||||
int ok = 0;
|
||||
FILE *in = fopen(in_path, "rb"), *out = NULL;
|
||||
size_t got = in ? fread(head, 1, sizeof head, in) : 0;
|
||||
if (!in) return -1;
|
||||
if (state_kind_of(head, got) == STATE_RZIP) {
|
||||
/* Unpacked next to the result first, then unwrapped from there. */
|
||||
fclose(in);
|
||||
snprintf(unpacked, sizeof unpacked, "%s.unrzip", out_path);
|
||||
if (state_unrzip_file(in_path, unpacked) != 0) return -1;
|
||||
in = fopen(unpacked, "rb");
|
||||
got = in ? fread(head, 1, sizeof head, in) : 0;
|
||||
if (!in) goto done;
|
||||
}
|
||||
if (!(out = fopen(out_path, "wb"))) goto done;
|
||||
if (state_kind_of(head, got) == STATE_RASTATE) {
|
||||
long off = 8; /* blocks of a tag, a u32 size and the data padded to 8 */
|
||||
for (;;) {
|
||||
unsigned char block[8];
|
||||
if (fseek(in, off, SEEK_SET) != 0 || fread(block, 1, 8, in) != 8 || !memcmp(block, "END ", 4)) goto done;
|
||||
uint32_t size = le32(block + 4);
|
||||
if (!memcmp(block, "MEM ", 4)) {
|
||||
ok = copy_bytes(in, out, size) == 0;
|
||||
break;
|
||||
}
|
||||
off += 8 + (long)(((size_t)size + 7) & ~(size_t)7);
|
||||
}
|
||||
} else { /* already bare: as it is */
|
||||
unsigned char buf[64 * 1024];
|
||||
size_t n;
|
||||
ok = fseek(in, 0, SEEK_SET) == 0;
|
||||
while (ok && (n = fread(buf, 1, sizeof buf, in)) > 0) ok = fwrite(buf, 1, n, out) == n;
|
||||
}
|
||||
done:
|
||||
if (in) fclose(in);
|
||||
if (out && fclose(out) != 0) ok = 0;
|
||||
if (unpacked[0]) remove(unpacked);
|
||||
if (!ok) remove(out_path);
|
||||
return ok ? 0 : -1;
|
||||
}
|
||||
|
||||
/* The "MEM " block of a RASTATE, pointed into b. */
|
||||
static const unsigned char *rastate_mem(const unsigned char *b, size_t n, size_t *mem_n) {
|
||||
size_t off = 8;
|
||||
|
||||
@@ -26,6 +26,17 @@ unsigned char *state_to_raw(const unsigned char *b, size_t n, size_t *out_n);
|
||||
* A bare state is wrapped, a compressed one unpacked. Malloc'd, or NULL. */
|
||||
unsigned char *state_to_rastate(const unsigned char *b, size_t n, size_t *out_n);
|
||||
|
||||
/* Unpacks a compressed (RZIP) state file into out_path, chunk by chunk, so a
|
||||
* big state never has to fit in memory whole (the PS4 payload has little).
|
||||
* 0 when done; -1 if in_path isn't a whole RZIP state or out_path can't be
|
||||
* written (nothing is left at out_path then). */
|
||||
int state_unrzip_file(const char *in_path, const char *out_path);
|
||||
/* Wraps a bare core state file in RetroArch's container ("MEM " then "END "),
|
||||
* streamed the same way. 0 when done, -1 otherwise (nothing left behind). */
|
||||
int state_wrap_file(const char *in_path, const char *out_path);
|
||||
/* The core's own state out of a state file, whatever its container
|
||||
* (state_to_raw, streamed). 0 when done, -1 otherwise (nothing left behind). */
|
||||
int state_unwrap_file(const char *in_path, const char *out_path);
|
||||
/* The libretro core a bare (or wrapped) state comes from, by its signature:
|
||||
* "pcsx_rearmed", "mednafen_psx_hw", "swanstation", or NULL. */
|
||||
const char *state_core_of(const unsigned char *b, size_t n);
|
||||
|
||||
+191
-55
@@ -19,6 +19,7 @@
|
||||
#include "profiles.h"
|
||||
#include "romm.h"
|
||||
#include "events.h"
|
||||
#include "firmware.h"
|
||||
#include "state.h"
|
||||
#include "statefmt.h"
|
||||
#include "util.h"
|
||||
@@ -38,9 +39,12 @@ typedef struct {
|
||||
char serial[24]; /* a PS1 disc's, "SCUS-94194" */
|
||||
char fnv64[17]; /* FNV-1a 64 of the game file */
|
||||
char safe_stem[256]; /* stems[0], non-alphanumerics as "_" */
|
||||
char game_id[24]; /* a disc's, "GAFE01" (DolphinPS4 names states by it), or a title's */
|
||||
rom_rules rules;
|
||||
} local_rom;
|
||||
|
||||
static int rom_game_id(const local_rom *r, char *out, size_t n);
|
||||
|
||||
typedef struct {
|
||||
char path[PATH_MAX_LEN];
|
||||
int rom_id;
|
||||
@@ -52,7 +56,7 @@ typedef struct {
|
||||
const profile_map *map;
|
||||
const local_rom *rom;
|
||||
int is_bundle; /* several files synced as one zip, see bundle.h */
|
||||
char game_id[16];
|
||||
char game_id[24];
|
||||
char upload_name[256];
|
||||
/* GameCube saves in a GCI folder keep the form RomM has them in (gc_form):
|
||||
* "zip" (the folder's files for the game), "gci" (one of them, gc_file)
|
||||
@@ -71,6 +75,7 @@ typedef struct {
|
||||
local_file *states;
|
||||
int nstates, capstates;
|
||||
cJSON *platforms;
|
||||
long server_status; /* of the platform list; 0 when the server never answered */
|
||||
char slot[32], device_id[64];
|
||||
int keep_backups;
|
||||
int server_versions;
|
||||
@@ -170,9 +175,9 @@ static int map_uses(const profile_map *m, const char *var) {
|
||||
}
|
||||
|
||||
/* The template variables a game's file names can use. */
|
||||
static void name_vars(const local_rom *r, const char *ext, const char *vars[13]) {
|
||||
static void name_vars(const local_rom *r, const char *ext, const char *vars[15]) {
|
||||
const char *v[] = {"rom_stem", r->stems[0], "ext", ext ? ext : "", "rom_md5", r->md5, "serial", r->serial,
|
||||
"rom_fnv64", r->fnv64, "rom_stem_safe", r->safe_stem, NULL};
|
||||
"rom_fnv64", r->fnv64, "rom_stem_safe", r->safe_stem, "game_id", r->game_id, NULL};
|
||||
memcpy(vars, v, sizeof v);
|
||||
}
|
||||
|
||||
@@ -208,7 +213,7 @@ static void add_derived_stems(local_rom *r, cJSON *e) {
|
||||
if (need_fnv) jset_str(e, "fnv64", r->fnv64);
|
||||
}
|
||||
}
|
||||
const char *vars[13];
|
||||
const char *vars[15];
|
||||
name_vars(r, "", vars);
|
||||
for (int pass = 0; pass < 2; pass++) {
|
||||
char list[256], *save = NULL;
|
||||
@@ -405,10 +410,12 @@ static void scan_rom_dir(scan_ctx *c, const profile_map *m) {
|
||||
int is_dir = dir_exists(path);
|
||||
if (!is_dir && !file_exists(path)) continue;
|
||||
|
||||
/* Several platforms can share one folder. */
|
||||
/* Several platforms of one emulator can share a folder. Other
|
||||
* emulators playing the same folder (an extra with a mount, or one
|
||||
* set to the main one's games) each have the game, for their saves. */
|
||||
int dup = 0;
|
||||
for (int i = 0; i < c->nroms; i++)
|
||||
if (!strcmp(c->roms[i].path, path)) dup = 1;
|
||||
if (!strcmp(c->roms[i].path, path) && !strcmp(c->roms[i].map->profile_id, m->profile_id)) dup = 1;
|
||||
if (dup) continue;
|
||||
|
||||
local_rom r;
|
||||
@@ -531,6 +538,7 @@ static void scan_rom_dir(scan_ctx *c, const profile_map *m) {
|
||||
if (sd) closedir(sd);
|
||||
}
|
||||
r.rom_id = rom_id;
|
||||
if (map_uses(m, "{game_id}") && rom_game_id(&r, r.game_id, sizeof r.game_id) != 0) r.game_id[0] = 0;
|
||||
if (m->save_stems[0] || m->state_stems[0] || map_uses(m, "{rom_stem_safe}")) {
|
||||
state_lock();
|
||||
add_derived_stems(&r, state_entry("roms", path));
|
||||
@@ -550,14 +558,12 @@ static void scan_rom_dir(scan_ctx *c, const profile_map *m) {
|
||||
|
||||
/* The folder the emulator reads this game's saves (or states) from. Returns 0
|
||||
* when saves are sorted by core and the core name isn't known. */
|
||||
static int rom_game_id(const local_rom *r, char *out, size_t n);
|
||||
|
||||
static int expected_dir(const local_rom *r, int states, char *out, size_t n) {
|
||||
const rom_rules *ru = &r->rules;
|
||||
/* A folder per game, by its id (Porpoise's states/GAFE01). */
|
||||
const char *at = strstr(states ? ru->state_dir : ru->save_dir, "{game_id}");
|
||||
if (at) {
|
||||
char id[16];
|
||||
char id[24];
|
||||
if (rom_game_id(r, id, sizeof id) != 0) return 0;
|
||||
const char *dir = states ? ru->state_dir : ru->save_dir;
|
||||
snprintf(out, n, "%.*s%s%s", (int)(at - dir), dir, id, at + 9);
|
||||
@@ -829,6 +835,7 @@ static int scan_prepare(scan_ctx *c) {
|
||||
|
||||
long st = 0;
|
||||
c->platforms = romm_call("GET", "/api/platforms", NULL, &st);
|
||||
c->server_status = st;
|
||||
if (st != 200 || !cJSON_IsArray(c->platforms)) {
|
||||
LOGE("could not list platforms (HTTP %ld)", st);
|
||||
return -1;
|
||||
@@ -924,6 +931,8 @@ static void record_save(const local_file *f, int save_id, const char *hash) {
|
||||
|
||||
static const local_file *find_save(const scan_ctx *c, int rom_id, const char *slot);
|
||||
|
||||
#define Z32 "00000000000000000000000000000000" /* Azahar's SD card and console ids */
|
||||
|
||||
/* Candidate folders for a game's bundled saves. */
|
||||
static int bundle_bases(const local_rom *r, char bases[][PATH_MAX_LEN], int max) {
|
||||
static const char *defaults[][3] = {
|
||||
@@ -933,6 +942,9 @@ static int bundle_bases(const local_rom *r, char bases[][PATH_MAX_LEN], int max)
|
||||
{"psp", "{save_path}/PSP/SAVEDATA,{save_root}/PPSSPP/PSP/SAVEDATA,{bios}/PPSSPP/PSP/SAVEDATA", NULL},
|
||||
{"gci", "{save_root}/dolphin-emu/User/GC,{save_path}/User/GC,{save_path}/dolphin-emu/User/GC,{bios}/dolphin-emu/User/GC", NULL},
|
||||
{"wii", "{save_root}/dolphin-emu/User/Wii,{save_path}/User/Wii,{save_path}/dolphin-emu/User/Wii,{bios}/dolphin-emu/User/Wii", NULL},
|
||||
/* RetroArch's Azahar keeps its user folder in savefiles/Azahar, or
|
||||
* in system/Azahar without a save folder. */
|
||||
{"3ds", "{save_root}/Azahar/sdmc/Nintendo 3DS/" Z32 "/" Z32 "/title,{bios}/Azahar/sdmc/Nintendo 3DS/" Z32 "/" Z32 "/title", NULL},
|
||||
};
|
||||
const char *tpl = r->map->bundle_dirs[0] ? r->map->bundle_dirs : NULL;
|
||||
for (size_t i = 0; !tpl && i < sizeof defaults / sizeof defaults[0]; i++)
|
||||
@@ -999,6 +1011,32 @@ static int rom_game_id(const local_rom *r, char *out, size_t n) {
|
||||
state_unlock();
|
||||
if (out[0]) return 0;
|
||||
|
||||
/* A front end that lists its games' ids (PSFlyCast), then the disc
|
||||
* ("disc_id": "dreamcast"). RomM has no title id for these, so it isn't asked. */
|
||||
if (r->map->game_ids[0] || !strcmp(r->map->disc_id, "dreamcast")) {
|
||||
char disc[PATH_MAX_LEN] = "";
|
||||
int ok = r->map->game_ids[0] && gameid_from_list(r->map->game_ids, r->map->root, r->path, out, n) == 0;
|
||||
if (!ok && dir_exists(r->path)) {
|
||||
DIR *d = opendir(r->path);
|
||||
struct dirent *de;
|
||||
while (d && (de = readdir(d)) && !disc[0])
|
||||
if (ext_in_list(de->d_name, ".gdi,.chd,.cue,.iso")) path_join(disc, sizeof disc, r->path, de->d_name);
|
||||
if (d) closedir(d);
|
||||
} else if (!ok) {
|
||||
str_copy(disc, sizeof disc, r->path);
|
||||
}
|
||||
if (!ok && (!disc[0] || gameid_dreamcast(disc, out, n) != 0)) {
|
||||
out[0] = 0;
|
||||
return -1;
|
||||
}
|
||||
state_lock();
|
||||
e = state_entry("roms", r->path);
|
||||
if (e) jset_str(e, "game_id", out);
|
||||
state_unlock();
|
||||
LOGI("game id for %s is %s", path_basename(r->path), out);
|
||||
return 0;
|
||||
}
|
||||
|
||||
char disc[PATH_MAX_LEN];
|
||||
str_copy(disc, sizeof disc, r->path);
|
||||
if (dir_exists(r->path)) { /* multi-file game: use its first disc image */
|
||||
@@ -1006,10 +1044,22 @@ static int rom_game_id(const local_rom *r, char *out, size_t n) {
|
||||
struct dirent *de;
|
||||
disc[0] = 0;
|
||||
while (d && (de = readdir(d)) && !disc[0])
|
||||
if (ext_in_list(de->d_name, ".iso,.cso,.rvz,.wia,.gcm,.ciso,.wbfs")) path_join(disc, sizeof disc, r->path, de->d_name);
|
||||
if (ext_in_list(de->d_name, ".iso,.cso,.rvz,.wia,.gcm,.ciso,.wbfs,.cci,.3ds,.cxi,.cia")) path_join(disc, sizeof disc, r->path, de->d_name);
|
||||
if (d) closedir(d);
|
||||
}
|
||||
int ok = disc[0] && (!strcmp(r->map->save_layout, "psp") ? gameid_psp(disc, out, n) : gameid_dolphin(disc, out, n)) == 0;
|
||||
const char *layout = r->map->save_layout;
|
||||
const int title = !strcmp(layout, "3ds") || !strcmp(layout, "wiiu");
|
||||
int ok = 0;
|
||||
if (title) {
|
||||
/* The front end's list first (a .wux can't be read without its disc
|
||||
* key), then the game file's header. */
|
||||
char lib[PATH_MAX_LEN];
|
||||
path_join(lib, sizeof lib, r->map->root, "library.json");
|
||||
ok = gameid_from_library(lib, layout, r->path, out, n) == 0 ||
|
||||
(!strcmp(layout, "3ds") && disc[0] && gameid_3ds(disc, out, n) == 0);
|
||||
} else {
|
||||
ok = disc[0] && (!strcmp(layout, "psp") ? gameid_psp(disc, out, n) : gameid_dolphin(disc, out, n)) == 0;
|
||||
}
|
||||
if (!ok) {
|
||||
char api[64];
|
||||
long st = 0;
|
||||
@@ -1019,7 +1069,7 @@ static int rom_game_id(const local_rom *r, char *out, size_t n) {
|
||||
size_t o = 0;
|
||||
for (; *tid && o + 1 < n; tid++) if (isalnum((unsigned char)*tid)) out[o++] = (char)toupper((unsigned char)*tid);
|
||||
out[o] = 0;
|
||||
ok = o >= 4;
|
||||
ok = title ? o == 16 : o >= 4;
|
||||
cJSON_Delete(rom);
|
||||
}
|
||||
if (!ok && !strcmp(r->map->save_layout, "psp")) {
|
||||
@@ -1249,6 +1299,10 @@ static void fill_bundle_file(local_file *f, const scan_ctx *c, const local_rom *
|
||||
bundle_hash(b, f->hash);
|
||||
if (!strcmp(r->map->save_layout, "psp")) {
|
||||
snprintf(f->upload_name, sizeof f->upload_name, "%s.zip", b->game_id);
|
||||
} else if (!strcmp(r->map->save_layout, "3ds") || !strcmp(r->map->save_layout, "wiiu")) {
|
||||
/* As RomM's save units name them: the title id's low half. */
|
||||
snprintf(f->upload_name, sizeof f->upload_name, "%s.zip", strlen(b->game_id) == 16 ? b->game_id + 8 : b->game_id);
|
||||
for (char *p = f->upload_name; *p; p++) *p = (char)tolower((unsigned char)*p);
|
||||
} else {
|
||||
char stem[200];
|
||||
path_stem(stem, sizeof stem, r->path);
|
||||
@@ -1280,7 +1334,7 @@ static void scan_bundles(scan_ctx *c) {
|
||||
for (int i = 0; i < c->nroms; i++) {
|
||||
const local_rom *r = &c->roms[i];
|
||||
if (!r->rules.sync_saves || !bundle_supported(r->map->save_layout)) continue;
|
||||
char id[16];
|
||||
char id[24];
|
||||
bundle b;
|
||||
if (rom_game_id(r, id, sizeof id) != 0 || bundle_for_rom(r, id, &b) != 0) continue;
|
||||
state_lock();
|
||||
@@ -1350,7 +1404,7 @@ static int gc_place(const local_rom *r, bundle *b, const char *file) {
|
||||
}
|
||||
free(data);
|
||||
if (rc > 0) {
|
||||
char id[16];
|
||||
char id[24];
|
||||
str_copy(id, sizeof id, b->game_id);
|
||||
bundle_free(b);
|
||||
if (bundle_for_rom(r, id, b) != 0) rc = -1;
|
||||
@@ -1362,7 +1416,7 @@ static int gc_place(const local_rom *r, bundle *b, const char *file) {
|
||||
* record set, the result counts as synced; a manual restore leaves it to upload. */
|
||||
static int do_download_bundle(const scan_ctx *c, int save_id, const local_rom *r, const char *slot, int session_id,
|
||||
const char *server_hash, int record) {
|
||||
char id[16], api[256], zip[PATH_MAX_LEN], tmpdir[PATH_MAX_LEN];
|
||||
char id[24], api[256], zip[PATH_MAX_LEN], tmpdir[PATH_MAX_LEN];
|
||||
bundle b;
|
||||
if (rom_game_id(r, id, sizeof id) != 0 || bundle_for_rom(r, id, &b) != 0) {
|
||||
LOGE("no game id for %s, cannot place its save", path_basename(r->path));
|
||||
@@ -1516,7 +1570,7 @@ static void download_target(const local_rom *r, const char *server_name, const c
|
||||
if (comma) *comma = 0;
|
||||
str_copy(ext, sizeof ext, first[0] ? first : path_ext(server_name));
|
||||
}
|
||||
const char *vars[13];
|
||||
const char *vars[15];
|
||||
name_vars(r, ext, vars);
|
||||
char name[512], dir[PATH_MAX_LEN], tmpl[128];
|
||||
save_name_now(r->map, tmpl, sizeof tmpl);
|
||||
@@ -1598,8 +1652,36 @@ static const local_rom *find_rom(const scan_ctx *c, int rom_id, const char *emul
|
||||
return any;
|
||||
}
|
||||
|
||||
/* The core the emulator's log says it started last (the last "log_line"
|
||||
* in it), by "log_names"; 0 when it says none of them. */
|
||||
static int logged_core(const profile_map *m, char *out, size_t n) {
|
||||
if (!m->core_log[0] || !m->core_log_line[0]) return 0;
|
||||
char *txt = read_file(m->core_log, NULL), name[64] = "";
|
||||
for (char *l = txt ? strstr(txt, m->core_log_line) : NULL; l; l = strstr(l + 1, m->core_log_line)) {
|
||||
size_t k = 0;
|
||||
for (const char *p = l + strlen(m->core_log_line); *p && *p != '\n' && *p != '\r' && k + 1 < sizeof name; p++)
|
||||
name[k++] = (char)tolower((unsigned char)*p);
|
||||
name[k] = 0;
|
||||
}
|
||||
free(txt);
|
||||
char list[256], *save = NULL;
|
||||
str_copy(list, sizeof list, m->core_log_names);
|
||||
for (char *t = strtok_r(list, ",", &save); t && name[0]; t = strtok_r(NULL, ",", &save)) {
|
||||
char *eq = strchr(t, '=');
|
||||
if (!eq) continue;
|
||||
*eq = 0;
|
||||
for (char *c = t; *c; c++) *c = (char)tolower((unsigned char)*c);
|
||||
if (strstr(name, t)) {
|
||||
str_copy(out, n, eq + 1);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* The core an emulator runs now, from its own settings file
|
||||
* ("state_core_setting"); the first listed value when the file says nothing. */
|
||||
* ("state_core_setting"); the first listed value when the file says nothing.
|
||||
* Left to the emulator ("auto", or nothing set), its log says which. */
|
||||
static void active_core(const profile_map *m, char *out, size_t n) {
|
||||
out[0] = 0;
|
||||
char value[32] = "";
|
||||
@@ -1616,6 +1698,7 @@ static void active_core(const profile_map *m, char *out, size_t n) {
|
||||
if (l) l++;
|
||||
}
|
||||
free(txt);
|
||||
if (m->core_auto[0] && (!value[0] || !strcmp(value, m->core_auto)) && logged_core(m, out, n)) return;
|
||||
char list[256];
|
||||
str_copy(list, sizeof list, m->core_values);
|
||||
char *save = NULL;
|
||||
@@ -1812,7 +1895,7 @@ static int restore_missing_bundles(scan_ctx *c, int only_rom) {
|
||||
if (!r->rules.sync_saves || !bundle_supported(r->map->save_layout) || (only_rom && r->rom_id != only_rom)) continue;
|
||||
int done_before = 0; /* another emulator's copy of the game */
|
||||
for (int k = 0; k < i; k++) done_before |= c->roms[k].rom_id == r->rom_id && c->roms[k].rules.sync_saves && bundle_supported(c->roms[k].map->save_layout);
|
||||
char id[16];
|
||||
char id[24];
|
||||
bundle b = {0};
|
||||
if (done_before || rom_game_id(r, id, sizeof id) != 0 || bundle_for_rom(r, id, &b) != 0) continue;
|
||||
const int empty = b.n == 0;
|
||||
@@ -1910,22 +1993,24 @@ static void state_upload_name(const char *path, char *out, size_t n) {
|
||||
* container, uncompressed. *core becomes the core a bare state comes from
|
||||
* when its signature says (PSXS5 runs several). */
|
||||
static int state_for_romm(const char *path, char *tmp, size_t n, const char **core) {
|
||||
size_t len = 0, out_n = 0;
|
||||
unsigned char *data = (unsigned char *)read_file(path, &len);
|
||||
if (!data) return -1;
|
||||
const char *sig = state_core_of(data, len);
|
||||
if (sig && core) *core = sig;
|
||||
unsigned char *out = state_to_rastate(data, len, &out_n);
|
||||
free(data);
|
||||
if (!out) {
|
||||
LOGW("can't read the state %s", path);
|
||||
return -1;
|
||||
}
|
||||
static int seq;
|
||||
snprintf(tmp, n, "%s/tmp/state-up-%d.bin", plat_data_dir(), __sync_add_and_fetch(&seq, 1));
|
||||
int rc = write_file_atomic(tmp, out, out_n);
|
||||
free(out);
|
||||
return rc;
|
||||
/* File to file, never whole in memory: states run to tens of MB (a
|
||||
* GameCube one), more than the PS4's payload can hold. */
|
||||
unsigned char head[64] = {0};
|
||||
FILE *f = fopen(path, "rb");
|
||||
size_t got = f ? fread(head, 1, sizeof head, f) : 0;
|
||||
if (f) fclose(f);
|
||||
state_kind kind = state_kind_of(head, got);
|
||||
if (kind == STATE_RAW) {
|
||||
const char *sig = state_core_of(head, got); /* a core's signature opens its state */
|
||||
if (sig && core) *core = sig;
|
||||
}
|
||||
int rc = kind == STATE_RZIP ? state_unrzip_file(path, tmp)
|
||||
: kind == STATE_RASTATE ? copy_file(path, tmp)
|
||||
: state_wrap_file(path, tmp);
|
||||
if (rc) LOGW("can't read the state %s", path);
|
||||
return rc ? -1 : 0;
|
||||
}
|
||||
|
||||
static int sync_states_up(scan_ctx *c, int only_rom) {
|
||||
@@ -2011,7 +2096,7 @@ static void state_local_name(const local_rom *r, const char *server_name, char *
|
||||
return;
|
||||
}
|
||||
}
|
||||
const char *ext = path_ext(server_name), *vars[13];
|
||||
const char *ext = path_ext(server_name), *vars[15];
|
||||
if (!strcmp(ext, ".state")) ext = r->map->state_slot0;
|
||||
name_vars(r, ext, vars);
|
||||
str_template(out, n, r->map->state_name, vars);
|
||||
@@ -2062,17 +2147,15 @@ static int fetch_state(const scan_ctx *c, const cJSON *s, const char *path, cons
|
||||
return -1;
|
||||
}
|
||||
if (!strcmp(r->map->state_format, "raw")) {
|
||||
size_t len = 0, raw_n = 0;
|
||||
unsigned char *data = (unsigned char *)read_file(tmp, &len);
|
||||
unsigned char *raw = data ? state_to_raw(data, len, &raw_n) : NULL;
|
||||
free(data);
|
||||
if (!raw || write_file_atomic(tmp, raw, raw_n) != 0) {
|
||||
/* File to file: a state can be tens of MB, more than the PS4 holds. */
|
||||
char raw[PATH_MAX_LEN + 8];
|
||||
snprintf(raw, sizeof raw, "%s.raw", tmp);
|
||||
if (state_unwrap_file(tmp, raw) != 0 || rename(raw, tmp) != 0) {
|
||||
LOGW("state %d: can't unpack it for %s", id, path);
|
||||
free(raw);
|
||||
unlink(raw);
|
||||
unlink(tmp);
|
||||
return -1;
|
||||
}
|
||||
free(raw);
|
||||
}
|
||||
backup_file(c, path, rom_id);
|
||||
if (move_file(tmp, path) != 0) return -1;
|
||||
@@ -2248,7 +2331,8 @@ static int sync_states_down(scan_ctx *c, int only_rom) {
|
||||
}
|
||||
|
||||
typedef struct {
|
||||
int uploaded, downloaded, conflicts, skipped, failed, states_up, states_down, noop, deferred, cards;
|
||||
int uploaded, downloaded, conflicts, skipped, failed, states_up, states_down, noop, deferred, cards, cards_down;
|
||||
int unreachable; /* failed because the server never answered */
|
||||
char error[256];
|
||||
} sync_result;
|
||||
|
||||
@@ -2271,7 +2355,7 @@ static int share_saves(scan_ctx *c) {
|
||||
first = 0;
|
||||
if (!first) continue;
|
||||
char paths[8][PATH_MAX_LEN];
|
||||
int n = 0, newest = -1;
|
||||
int n = 0, newest = -1, newest_blank = 0;
|
||||
time_t newest_t = 0;
|
||||
for (int k = i; k < c->nroms && n < 8; k++) {
|
||||
const local_rom *r = &c->roms[k];
|
||||
@@ -2280,7 +2364,10 @@ static int share_saves(scan_ctx *c) {
|
||||
continue;
|
||||
download_target(r, "", c->slot, c, paths[n], sizeof paths[n]);
|
||||
time_t t = file_mtime(paths[n]);
|
||||
if (t > newest_t) newest_t = t, newest = n;
|
||||
/* A blank card (an emulator made a fresh one) never beats one with saves. */
|
||||
const int blank = psx_card_saves(r->map, paths[n]) == 0;
|
||||
if (t > 0 && (newest < 0 || (newest_blank && !blank) || (blank == newest_blank && t > newest_t)))
|
||||
newest_t = t, newest = n, newest_blank = blank;
|
||||
n++;
|
||||
}
|
||||
if (n < 2 || newest < 0) continue;
|
||||
@@ -2313,7 +2400,7 @@ static int share_bundles(scan_ctx *c) {
|
||||
if (c->roms[k].rom_id == a->rom_id && !strcmp(c->roms[k].map->save_layout, "gci") &&
|
||||
!c->roms[k].map->own_slot && c->roms[k].rules.sync_saves)
|
||||
first = 0;
|
||||
char id[16];
|
||||
char id[24];
|
||||
if (!first || rom_game_id(a, id, sizeof id) != 0) continue;
|
||||
bundle bs[4];
|
||||
int n = 0, newest = -1;
|
||||
@@ -2404,10 +2491,13 @@ static void run_sync(const char *reason, int only_rom, sync_result *res) {
|
||||
scan_ctx c;
|
||||
set_phase("scanning local files");
|
||||
if (scan_prepare(&c) != 0) {
|
||||
snprintf(res->error, sizeof res->error, "cannot reach RomM server");
|
||||
snprintf(res->error, sizeof res->error, "%s",
|
||||
c.server_status == 0 && http_out_of_memory() ? "out of memory" : "cannot reach RomM server");
|
||||
res->unreachable = c.server_status == 0;
|
||||
scan_free(&c);
|
||||
return;
|
||||
}
|
||||
romm_update_device_name();
|
||||
share_saves(&c);
|
||||
scan_saves(&c);
|
||||
LOGI("sync (%s): %d local roms, %d saves, %d states", reason, c.nroms, c.nsaves, c.nstates);
|
||||
@@ -2437,6 +2527,7 @@ static void run_sync(const char *reason, int only_rom, sync_result *res) {
|
||||
char d[200];
|
||||
romm_error_detail(neg, d, sizeof d);
|
||||
snprintf(res->error, sizeof res->error, "negotiate failed (HTTP %ld) %s", st, d);
|
||||
res->unreachable = st == 0;
|
||||
cJSON_Delete(neg);
|
||||
scan_free(&c);
|
||||
return;
|
||||
@@ -2615,10 +2706,18 @@ static void run_sync(const char *reason, int only_rom, sync_result *res) {
|
||||
res->states_up = sync_states_up(&c, only_rom);
|
||||
}
|
||||
|
||||
if (!only_rom) res->cards = memcard_backup();
|
||||
if (!only_rom) res->cards = memcard_sync(upload_only, &res->cards_down);
|
||||
/* A save that just came down reaches the other emulators that share it. */
|
||||
share_saves(&c);
|
||||
|
||||
/* BIOS files last: a first sync can bring hundreds of MB of them (Switch
|
||||
* firmware), and saves shouldn't wait on that. Not while a game runs: its
|
||||
* emulator may be reading the folder. */
|
||||
if (!only_rom && !upload_only) {
|
||||
set_phase("BIOS files");
|
||||
firmware_sync(c.maps, c.nmaps, c.platforms, 0, NULL);
|
||||
}
|
||||
|
||||
state_lock();
|
||||
state_save();
|
||||
state_unlock();
|
||||
@@ -2650,6 +2749,7 @@ static void run_and_record(const char *reason, int only_rom, sync_result *r) {
|
||||
cJSON_AddNumberToObject(h, "states_downloaded", r->states_down);
|
||||
cJSON_AddNumberToObject(h, "deferred", r->deferred);
|
||||
cJSON_AddNumberToObject(h, "cards_uploaded", r->cards);
|
||||
cJSON_AddNumberToObject(h, "cards_downloaded", r->cards_down);
|
||||
if (r->error[0]) cJSON_AddStringToObject(h, "error", r->error);
|
||||
state_lock();
|
||||
state_history_add(h);
|
||||
@@ -2666,6 +2766,24 @@ static int notify_on(void) {
|
||||
return notify;
|
||||
}
|
||||
|
||||
/* The console can lose the server for a few minutes while the rest
|
||||
* of the LAN reaches it fine, and the sync a game launch asked for was then
|
||||
* gone until the next periodic one. A sync the server never answered is
|
||||
* tried again after 1, 2 and 4 minutes, quietly; only giving up gets a toast.
|
||||
* The retry only uploads while a game runs, like any sync but the launch one. */
|
||||
#define RETRY_FIRST_SEC 60
|
||||
#define MAX_RETRIES 3
|
||||
|
||||
static volatile time_t g_retry_at;
|
||||
static int g_retries;
|
||||
|
||||
int sync_retry_due(void) {
|
||||
time_t at = g_retry_at;
|
||||
if (!at || time(NULL) < at) return 0;
|
||||
g_retry_at = 0;
|
||||
return 1;
|
||||
}
|
||||
|
||||
static void *worker(void *arg) {
|
||||
(void)arg;
|
||||
for (;;) {
|
||||
@@ -2696,14 +2814,25 @@ static void *worker(void *arg) {
|
||||
const unsigned mark = events_seq();
|
||||
run_and_record(reason, 0, &r);
|
||||
|
||||
int retry_in = 0;
|
||||
if (r.error[0] && r.unreachable && g_retries < MAX_RETRIES) {
|
||||
retry_in = RETRY_FIRST_SEC << g_retries++;
|
||||
g_retry_at = time(NULL) + retry_in;
|
||||
} else {
|
||||
g_retries = 0;
|
||||
g_retry_at = 0;
|
||||
}
|
||||
|
||||
int notify = notify_on();
|
||||
if (r.error[0]) {
|
||||
if (r.error[0] && retry_in) {
|
||||
LOGE("sync failed: %s; trying again in %ds", r.error, retry_in);
|
||||
} else if (r.error[0]) {
|
||||
LOGE("sync failed: %s", r.error);
|
||||
if (notify) plat_notify("RommPS: couldn't sync. %s", r.error);
|
||||
} else {
|
||||
/* Issue #10 - Added notification and log file for memory card operations. */
|
||||
LOGI("sync done: %d up, %d down, %d conflicts, %d failed, %d states, %d cards", r.uploaded,
|
||||
r.downloaded, r.conflicts, r.failed, r.states_up, r.cards);
|
||||
LOGI("sync done: %d up, %d down, %d conflicts, %d failed, %d states, %d cards up, %d cards down; largest free block %d MB",
|
||||
r.uploaded, r.downloaded, r.conflicts, r.failed, r.states_up, r.cards, r.cards_down, mem_largest_mb());
|
||||
char text[200], games[400];
|
||||
if (notify && sync_summary(&r, text, sizeof text)) {
|
||||
if (events_games(mark, 3, games, sizeof games)) plat_notify("RommPS: %s\n%s", text, games);
|
||||
@@ -2720,7 +2849,7 @@ static int sync_summary(const sync_result *r, char *out, size_t n) {
|
||||
struct { int count; const char *one, *many; } parts[] = {
|
||||
{r->uploaded, "save up", "saves up"}, {r->downloaded, "save down", "saves down"},
|
||||
{r->states_up, "state up", "states up"}, {r->states_down, "state down", "states down"},
|
||||
{r->cards, "memory card up", "memory cards up"},
|
||||
{r->cards, "memory card up", "memory cards up"}, {r->cards_down, "memory card down", "memory cards down"},
|
||||
{r->conflicts, "conflict to resolve", "conflicts to resolve"}, {r->failed, "failed", "failed"}};
|
||||
size_t o = 0;
|
||||
out[0] = 0;
|
||||
@@ -2739,7 +2868,12 @@ static void plural(char *out, size_t n, int count, const char *what) {
|
||||
snprintf(out, n, "%d %s%s", count, what, count == 1 ? "" : "s");
|
||||
}
|
||||
|
||||
int sync_game(int rom_id, const char *name, sync_game_result *out) {
|
||||
void download_notice(char *out, size_t n, const char *name, const char *system) {
|
||||
if (system && system[0]) snprintf(out, n, "%s was downloaded for %s", name, system);
|
||||
else snprintf(out, n, "%s was downloaded", name);
|
||||
}
|
||||
|
||||
int sync_game(int rom_id, const char *name, const char *system, sync_game_result *out) {
|
||||
memset(out, 0, sizeof *out);
|
||||
config_lock();
|
||||
int ready = g_cfg.setup_complete && g_cfg.save_sync;
|
||||
@@ -2755,17 +2889,19 @@ int sync_game(int rom_id, const char *name, sync_game_result *out) {
|
||||
LOGI("saves for %s: %d saves, %d states, %d conflicts%s", name, out->saves, out->states, out->conflicts,
|
||||
out->failed ? ", some failed" : "");
|
||||
if (!notify_on()) return 0;
|
||||
char head[400];
|
||||
download_notice(head, sizeof head, name, system);
|
||||
if (out->failed) {
|
||||
plat_notify("RommPS: %s downloaded. Its saves couldn't sync", name);
|
||||
plat_notify("RommPS: %s, but its saves couldn't sync", head);
|
||||
} else if (out->conflicts) {
|
||||
plat_notify("RommPS: %s downloaded. A save conflict needs you", name);
|
||||
plat_notify("RommPS: %s. A save conflict needs you", head);
|
||||
} else if (out->saves || out->states) {
|
||||
char saves[32] = "", states[32] = "";
|
||||
if (out->saves) plural(saves, sizeof saves, out->saves, "save");
|
||||
if (out->states) plural(states, sizeof states, out->states, "state");
|
||||
plat_notify("RommPS: %s downloaded with %s%s%s", name, saves, out->saves && out->states ? " and " : "", states);
|
||||
plat_notify("RommPS: %s, with %s%s%s", head, saves, out->saves && out->states ? " and " : "", states);
|
||||
} else {
|
||||
plat_notify("RommPS: %s downloaded", name);
|
||||
plat_notify("RommPS: %s", head);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
+5
-1
@@ -15,6 +15,8 @@ int sync_is_running(void);
|
||||
void sync_pause(void);
|
||||
void sync_resume(void);
|
||||
time_t sync_last_run(void);
|
||||
/* 1, once, when a sync the server never answered is due to be tried again. */
|
||||
int sync_retry_due(void);
|
||||
cJSON *sync_status_json(void); /* caller frees */
|
||||
|
||||
/* Resolves a conflict. keep is "local" or "server". */
|
||||
@@ -36,7 +38,9 @@ int sync_game_running(void);
|
||||
typedef struct {
|
||||
int saves, states, conflicts, failed;
|
||||
} sync_game_result;
|
||||
int sync_game(int rom_id, const char *name, sync_game_result *out);
|
||||
int sync_game(int rom_id, const char *name, const char *system, sync_game_result *out);
|
||||
/* "Metal Gear Solid was downloaded for PlayStation", for the notification. */
|
||||
void download_notice(char *out, size_t n, const char *name, const char *system);
|
||||
|
||||
/* What a sync would do, without transferring anything. */
|
||||
cJSON *sync_preview(char *err, int en);
|
||||
|
||||
+94
@@ -15,6 +15,12 @@
|
||||
|
||||
#include "md5.h"
|
||||
|
||||
#ifdef ROMM_NETLOG
|
||||
#include <arpa/inet.h>
|
||||
#include <netinet/in.h>
|
||||
#include <sys/socket.h>
|
||||
#endif
|
||||
|
||||
|
||||
#define LOG_RING_LINES 200
|
||||
#define LOG_LINE_LEN 320
|
||||
@@ -36,6 +42,63 @@ int thread_start(void *(*fn)(void *), void *arg) {
|
||||
return rc;
|
||||
}
|
||||
|
||||
#ifdef ROMM_NETLOG
|
||||
/* Test builds only (make ... NETLOG=1). Every log line also goes out as a UDP
|
||||
* broadcast, so a console whose files nobody can reach still tells
|
||||
* tools/netlog.py on the same network what it's doing. UDP because a log that
|
||||
* needs a connection is no help on the console that can't keep one. For
|
||||
* networks that drop broadcasts, an IP address in netlog-host next to the log
|
||||
* gets every line too. */
|
||||
static pthread_mutex_t g_netlog_lock = PTHREAD_MUTEX_INITIALIZER;
|
||||
static int g_netlog_fd = -1, g_netlog_host_read;
|
||||
static struct sockaddr_in g_netlog_to[2];
|
||||
static int g_netlog_n;
|
||||
|
||||
static void netlog_add(in_addr_t addr) {
|
||||
struct sockaddr_in *to = &g_netlog_to[g_netlog_n++];
|
||||
memset(to, 0, sizeof *to);
|
||||
to->sin_family = AF_INET;
|
||||
to->sin_port = htons(ROMM_NETLOG_PORT);
|
||||
to->sin_addr.s_addr = addr;
|
||||
}
|
||||
|
||||
/* netlog-host is read once the log has a folder; until then, broadcast only. */
|
||||
static void netlog_read_host(void) {
|
||||
char path[PATH_MAX_LEN], host[64] = "";
|
||||
const char *slash = strrchr(g_log_path, '/');
|
||||
if (!slash) return;
|
||||
g_netlog_host_read = 1;
|
||||
snprintf(path, sizeof path, "%.*s/netlog-host", (int)(slash - g_log_path), g_log_path);
|
||||
FILE *f = fopen(path, "r");
|
||||
if (!f) return;
|
||||
if (fgets(host, sizeof host, f)) host[strcspn(host, " \r\n")] = 0;
|
||||
fclose(f);
|
||||
struct in_addr a;
|
||||
if (host[0] && inet_pton(AF_INET, host, &a) == 1 && g_netlog_n < 2) netlog_add(a.s_addr);
|
||||
}
|
||||
#endif
|
||||
|
||||
void netlog_send(const char *line) {
|
||||
#ifdef ROMM_NETLOG
|
||||
char pkt[LOG_LINE_LEN + 16];
|
||||
int len = snprintf(pkt, sizeof pkt, "[romm-sync] %s\n", line);
|
||||
if (len >= (int)sizeof pkt) len = (int)sizeof pkt - 1;
|
||||
pthread_mutex_lock(&g_netlog_lock);
|
||||
if (g_netlog_fd < 0) {
|
||||
int on = 1;
|
||||
g_netlog_fd = socket(AF_INET, SOCK_DGRAM, 0);
|
||||
if (g_netlog_fd >= 0) setsockopt(g_netlog_fd, SOL_SOCKET, SO_BROADCAST, &on, sizeof on);
|
||||
netlog_add(htonl(INADDR_BROADCAST));
|
||||
}
|
||||
if (!g_netlog_host_read && g_log_path[0]) netlog_read_host();
|
||||
for (int i = 0; g_netlog_fd >= 0 && i < g_netlog_n; i++)
|
||||
sendto(g_netlog_fd, pkt, (size_t)len, 0, (struct sockaddr *)&g_netlog_to[i], sizeof g_netlog_to[i]);
|
||||
pthread_mutex_unlock(&g_netlog_lock);
|
||||
#else
|
||||
(void)line;
|
||||
#endif
|
||||
}
|
||||
|
||||
void log_init(const char *file_path) {
|
||||
str_copy(g_log_path, sizeof g_log_path, file_path);
|
||||
/* Keep one old log. */
|
||||
@@ -74,6 +137,7 @@ void log_msg(int level, const char *fmt, ...) {
|
||||
fclose(f);
|
||||
}
|
||||
}
|
||||
netlog_send(line);
|
||||
pthread_mutex_unlock(&g_log_lock);
|
||||
}
|
||||
|
||||
@@ -356,6 +420,21 @@ void str_copy(char *dst, size_t n, const char *src) {
|
||||
dst[l] = 0;
|
||||
}
|
||||
|
||||
void str_copy_utf8(char *dst, size_t n, const char *src) {
|
||||
if (!n) return;
|
||||
if (!src) src = "";
|
||||
size_t l = strlen(src);
|
||||
if (l >= n) {
|
||||
l = n - 1;
|
||||
/* src[l] continuing a character that starts before it: cut before that */
|
||||
size_t k = l;
|
||||
while (k > 0 && l - k < 3 && ((unsigned char)src[k] & 0xC0) == 0x80) k--;
|
||||
if (k < l && ((unsigned char)src[k] & 0xC0) == 0xC0) l = k;
|
||||
}
|
||||
memcpy(dst, src, l);
|
||||
dst[l] = 0;
|
||||
}
|
||||
|
||||
void str_template(char *out, size_t n, const char *tpl, const char *const *vars) {
|
||||
size_t o = 0;
|
||||
for (const char *p = tpl; *p && o + 1 < n;) {
|
||||
@@ -528,3 +607,18 @@ void random_uuid(char out[37]) {
|
||||
b[0], b[1], b[2], b[3], b[4], b[5], b[6], b[7], b[8], b[9], b[10], b[11], b[12],
|
||||
b[13], b[14], b[15]);
|
||||
}
|
||||
|
||||
int mem_largest_mb(void) {
|
||||
int lo = 0, hi = 1024;
|
||||
while (lo < hi) {
|
||||
const int mid = (lo + hi + 1) / 2;
|
||||
void *p = malloc((size_t)mid << 20);
|
||||
if (p) {
|
||||
free(p);
|
||||
lo = mid;
|
||||
} else {
|
||||
hi = mid - 1;
|
||||
}
|
||||
}
|
||||
return lo;
|
||||
}
|
||||
+14
-1
@@ -8,7 +8,7 @@
|
||||
|
||||
#define APP_NAME "romm-sync"
|
||||
#ifndef APP_VERSION
|
||||
#define APP_VERSION "1.2.1"
|
||||
#define APP_VERSION "1.3.0"
|
||||
#endif
|
||||
#ifndef APP_BUILD /* git describe of the build, from the Makefile */
|
||||
#define APP_BUILD "unknown"
|
||||
@@ -27,8 +27,18 @@ void log_init(const char *file_path);
|
||||
void log_msg(int level, const char *fmt, ...) __attribute__((format(printf, 2, 3)));
|
||||
/* Recent log lines, newline separated. Free the result. */
|
||||
char *log_recent(void);
|
||||
/* Sends one line to tools/netlog.py in NETLOG=1 builds; a no-op otherwise.
|
||||
* log_msg already does; this is for what runs before the log is open. */
|
||||
void netlog_send(const char *line);
|
||||
#ifndef ROMM_NETLOG_PORT
|
||||
#define ROMM_NETLOG_PORT 8781 /* UDP; the web UI's port plus one */
|
||||
#endif
|
||||
|
||||
#define LOGD(...) log_msg(LOG_DEBUG, __VA_ARGS__)
|
||||
|
||||
/* The largest block malloc hands out, in MB up to 1024: for the logs, where
|
||||
* memory is tight (the PS4 payload lives in a system process's budget). */
|
||||
int mem_largest_mb(void);
|
||||
#define LOGI(...) log_msg(LOG_INFO, __VA_ARGS__)
|
||||
#define LOGW(...) log_msg(LOG_WARN, __VA_ARGS__)
|
||||
#define LOGE(...) log_msg(LOG_ERROR, __VA_ARGS__)
|
||||
@@ -69,6 +79,9 @@ int str_ends_with_ci(const char *s, const char *suffix);
|
||||
/* Is the file's extension in a list like ".srm,.sav,.state*"? */
|
||||
int ext_in_list(const char *file_name, const char *list);
|
||||
void str_copy(char *dst, size_t n, const char *src);
|
||||
/* str_copy for names people read (and RomM keeps): a cut never splits a
|
||||
* UTF-8 character. Not for bytes cut at a known length: those may not be UTF-8. */
|
||||
void str_copy_utf8(char *dst, size_t n, const char *src);
|
||||
/* Fills {key} placeholders. vars holds key, value pairs and ends with NULL. */
|
||||
void str_template(char *out, size_t n, const char *tpl, const char *const *vars);
|
||||
|
||||
|
||||
Loaded 100 of 117 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user