mirror of
https://github.com/s0liton/ps5-romm.git
synced 2026-10-28 23:02:57 +01:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ceebe293aa | ||
|
|
03998081fa | ||
|
|
d2c8abc8e1 | ||
|
|
fe9276a617 | ||
|
|
ae29a28855 | ||
|
|
109f471900 | ||
|
|
e45be32af8 | ||
|
|
2e8e0d9c89 | ||
|
|
129b587558 | ||
|
|
59d8e31d43 | ||
|
|
569382b240 | ||
|
|
3f602735b8 | ||
|
|
bb8b71b21d | ||
|
|
1d2462eb0d | ||
|
|
5421f7b09d | ||
|
|
78d0c98a38 | ||
|
|
ac725f3458 | ||
|
|
b2bd33a9c4 | ||
|
|
d8a595e50b | ||
|
|
024e610cfc | ||
|
|
68275e8740 | ||
|
|
43c8d94c15 | ||
|
|
cbfc79ba1e | ||
|
|
659005ce58 | ||
|
|
a1446f4706 | ||
|
|
b5d3e954f8 | ||
|
|
9066fb38ac | ||
|
|
46aa1710d2 | ||
|
|
bf479d045b | ||
|
|
7a5a07a13f | ||
|
|
7b426b8273 |
No files matched your search
@@ -0,0 +1 @@
|
||||
* @s0liton
|
||||
@@ -14,3 +14,4 @@ jobs:
|
||||
- run: sudo apt-get update && sudo apt-get install -y libcurl4-openssl-dev zlib1g-dev
|
||||
- run: make host tools
|
||||
- run: tests/e2e.sh
|
||||
- run: tests/autostart.sh
|
||||
@@ -19,11 +19,11 @@
|
||||
CHDR_SRCS := $(filter-out %microflac.cpp,$(wildcard third_party/libchdr/src/*.c)) \
|
||||
third_party/libchdr/deps/lzma/src/LzmaDec.c third_party/libchdr/deps/zstd/zstddeclib.c
|
||||
SRCS := src/main.c src/util.c src/http.c src/romm.c src/config.c src/state.c \
|
||||
src/profiles.c src/sync.c src/watch.c src/detect.c src/autostart.c src/memcard.c src/zip.c src/bundle.c src/gameid.c src/covers.c src/library.c src/pair.c src/web.c src/update.c \
|
||||
src/profiles.c src/sync.c src/watch.c src/detect.c src/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 \
|
||||
third_party/cJSON.c third_party/md5.c third_party/monocypher.c third_party/monocypher-ed25519.c \
|
||||
$(CHDR_SRCS)
|
||||
HDRS := $(wildcard src/*.h) third_party/cJSON.h third_party/md5.h third_party/monocypher.h third_party/monocypher-ed25519.h
|
||||
UI_HEADER := build/gen/ui_index.h build/gen/icon_png.h
|
||||
UI_HEADER := build/gen/ui_index.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
|
||||
@@ -37,7 +37,7 @@ COMMON_CFLAGS += -DAPP_BUILD='"$(BUILD_ID)"'
|
||||
endif
|
||||
COMMON_CFLAGS += $(EXTRA_CFLAGS)
|
||||
|
||||
.PHONY: all host ps5 ps4 tools deploy install install-ps4 run clean
|
||||
.PHONY: unit all host ps5 ps4 tools deploy install install-ps4 run clean
|
||||
|
||||
all: host
|
||||
|
||||
@@ -50,10 +50,6 @@ build/gen/inter-font.css: third_party/inter-latin.woff2 tools/font_css.py
|
||||
@mkdir -p $(dir $@)
|
||||
python3 tools/font_css.py $< $@ Inter
|
||||
|
||||
build/gen/icon_png.h: assets/icon0.png tools/embed.py
|
||||
@mkdir -p $(dir $@)
|
||||
python3 tools/embed.py $< $@ ICON_PNG
|
||||
|
||||
# host build
|
||||
HOST_CC ?= cc
|
||||
HOST_BIN := build/host/romm-sync
|
||||
@@ -65,6 +61,14 @@ $(HOST_BIN): $(SRCS) platform/host/platform.c $(HDRS) $(UI_HEADER)
|
||||
@mkdir -p $(dir $@)
|
||||
$(HOST_CC) $(COMMON_CFLAGS) -DROMM_UPDATE_TEST_KEY -o $@ $(SRCS) platform/host/platform.c -lcurl -lz -lpthread
|
||||
|
||||
# Unit tests for the parts with file formats of their own.
|
||||
unit: build/host/gcmem_test
|
||||
build/host/gcmem_test
|
||||
|
||||
build/host/gcmem_test: tests/gcmem_test.c src/gcmem.c src/gcmem.h
|
||||
@mkdir -p $(dir $@)
|
||||
$(HOST_CC) -std=gnu11 -g -Wall -Wextra -fsanitize=address,undefined -Isrc -o $@ tests/gcmem_test.c src/gcmem.c
|
||||
|
||||
tools: build/host/release-sign
|
||||
|
||||
build/host/release-sign: tools/release-sign.c src/update_keys.h third_party/monocypher.c third_party/monocypher-ed25519.c
|
||||
|
||||
@@ -4,42 +4,61 @@
|
||||
|
||||
A [RomM](https://romm.app) companion client for jailbroken PS5 consoles (etaHEN or onionHEN, with kstuff) and PS4 consoles (GoldHEN). Through my insatiable need to mod everything electronic I encounter, I had my 12.70 PS5 jailbroken the minute that P2JB was out. I maintain a RomM server to centralize all my _legal_ ROMs, saves, and savestates, which all my devices sync with, and the PS5 needs similar treatment.
|
||||
|
||||
- **Background save sync**
|
||||
- Monitors save file changes in the background, and uploads them as soon as they've changed. Periodic syncs also ensure that your PS5 always has the latest saves and savestates.
|
||||
- **Save and state sync**
|
||||
- Monitors save file changes in the background, and uploads them as soon as they've changed. Periodic syncs also ensure that your PS4/5 always has the latest saves and save states.
|
||||
- A game's saves come down the moment it finishes downloading, so it's ready to play.
|
||||
- Save states only come down to an emulator that can load them (see [Save states](#save-states)).
|
||||
- Optional: turn save sync off in setup or Settings to use RomM just for games.
|
||||
- **See what synced**
|
||||
- An activity log and every save and state kept in sync, by platform, in the app and the Web UI. Settle conflicts and restore older versions from either.
|
||||
- Settings > Diagnostic bundle saves the logs for a bug report, with your server, names and addresses left out.
|
||||
- **Interactive library**
|
||||
- Browse your RomM library from RommPS, the native app (or some other device), download games and BIOS files into the right emulator folders, and resolve save conflicts.
|
||||
- Browse your RomM library from the RommPS app (or some other device via the Web UI), download games and BIOS files into the right emulator folders, and resolve save conflicts.
|
||||
- **Pluggable emulator profiles and multiple emulators per platform**
|
||||
- Fully customizable sync destinations, but includes frequently updated preset profiles for existing homebrew emulators.
|
||||
- Can sync games and saves and states for multiple emulators per platform (PS2 on RetroArch and PS5SX2 as an example) using hardlinks.
|
||||
- **Beautiful and Responsive UI**
|
||||
- Based on mihawk-99's Vulkan homebrew apps and BlackBearReloaded's UI kit, we get a nice, responsive feel. So worth it, it looks 1000x better.
|
||||
- **Easter Egg**
|
||||
- Check in the Settings, scroll the settings list, see if you find the X.
|
||||
- Based on mihawk-99's Vulkan homebrew apps and BlackBearReloaded's UI kit, we get a nice, responsive feel. So worth it, it looks 1000x better. 15+ different themes to pick from.
|
||||
- **Background Service**
|
||||
- All operations are handled by a backend payload, which means syncing, downloading, and updating keep working in the background, so you can keep **gaming**.
|
||||
- **RomM API Compliant**
|
||||
- RommPS is meant to strictly stick to RomM's API and method of syncing saves and states, ensuring we don't break anything and support RomM's latest features.
|
||||
- **Secure and Human-Coded**
|
||||
- Each release is signed. The payload checks the signature before it updates itself, and ProsperoStore checks every download against the release's checksum. This app was primarily coded by a **human**, and will be frequently updated and maintained. AI was primarily used for explaining concepts, creating documentation, and converting data. Ya know, the tedious stuff.
|
||||
- **Easter Eggs**
|
||||
- Check in the Settings, scroll the settings list, see if you find the X. This **hint** won't be here forever.
|
||||
|
||||
## 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.
|
||||
|
||||
Technically, this RomM client will work with all emulators, but I have included profiles for the emulators that exist for the console as of Oct 2026.
|
||||
### 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.
|
||||
|
||||
### Supported out of the box
|
||||
| Emulator | Systems | Game Downloads | Saves | Comments |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| [RetroArch](https://github.com/mihawk-99/PS5_RetroArch) | NES, Famicom Disk System, SNES, Game Boy, Game Boy Color, Game Boy Advance, Genesis/Mega Drive, Master System, Game Gear, Sega CD, SG-1000, arcade (FinalBurn Neo), PS2 (LRPS2), PSP (PPSSPP), GameCube and Wii (Dolphin) | Full Support | Full Support | - |
|
||||
| [PSXS5](https://github.com/SynoPiia/PSXS5) | PS1 | Full Support | One memory card per game, and states | States work with RomM's web player (PCSX-ReARMed or Beetle, whichever PSXS5 is set to) |
|
||||
| [SwanStationPS5](https://github.com/darkxex/SwanStationPS5) | PS1 | Full Support | One memory card per game, and states | Plays PSXS5's games, keeps its own saves in `/data/SwanStationPS5`. Its states only load in SwanStationPS5 |
|
||||
| [PS5SX2](https://github.com/Swordpdf/PS5SX2) | PS2 | Full Support | Its two shared memory cards, backed up to RomM (5.3 or newer) | - |
|
||||
| [PS5N64](https://github.com/Globaleliteee/PS5N64) | N64 | Full Support | Full Support | - |
|
||||
| [PS5NES](https://github.com/Globaleliteee/PS5NES) | NES | Untested | Untested | Preset uses `/data/PS5NES/games` and `/data/PS5NES/saves`. GH page gone |
|
||||
| [Porpoise](https://github.com/elripalda/Porpoise-Dolphin-Emulator-for-PS5) | Wii/GameCube | Full Support | N/A | Couldn't exit sandbox w/o crash. [Issue](https://github.com/elripalda/Porpoise-Dolphin-Emulator-for-PS5/issues/18) reported. Downloading games should work if the emulator is actually working for you.|
|
||||
| [ProsperoEden](https://github.com/blackbearreloaded/ProsperoEden) | Switch | Manual Support | Researching | - |
|
||||
| [PS5X360](https://github.com/BrinooTk/PS5X360) | Xbox 360 | Manual Support | Researching | - |
|
||||
|
||||
Setup finds these on its own. Anything else can still sync by picking its folders during setup.
|
||||
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.
|
||||
|
||||
**PS5**
|
||||
### 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.
|
||||
|
||||
| Emulator | Systems | Saves |
|
||||
| --- | --- | --- |
|
||||
| RetroArch (mihawk-99's port) | NES, Famicom Disk System, SNES, Game Boy, Game Boy Color, Game Boy Advance, Genesis/Mega Drive, Master System, Game Gear, Sega CD, SG-1000, arcade (FinalBurn Neo), PS2 (LRPS2), PSP (PPSSPP), GameCube and Wii (Dolphin) | Saves and states |
|
||||
| PSXS5 / SwanStationPS5 | PS1 | One memory card per game, and states |
|
||||
| PS5SX2 | PS2 | Its two shared memory cards, backed up to RomM (5.3 or newer) |
|
||||
| PS5N64 | N64 | Saves and states |
|
||||
**Simply put**:
|
||||
- Emulator core must be the same.
|
||||
- Different schema versions of save states are sometimes incompatible with each other.
|
||||
|
||||
PS5NES and Porpoise don't have presets yet, because neither works properly on my console right now:
|
||||
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.
|
||||
|
||||
- **PS5NES** v7 looks for its games in PS5SX2's folder instead of its own.
|
||||
- **Porpoise** crashes the moment it leaves the app sandbox, with both OnionHEN and Lapy freeing it, so it fails to launch. Issue reported to the dev. In the meantime, you can sync files into the sandbox, but you won't have access to /data.
|
||||
|
||||
|
||||
**PS4** (experimental)
|
||||
### PS4 (experimental) ###
|
||||
|
||||
| Emulator | Systems |
|
||||
| --- | --- |
|
||||
@@ -48,6 +67,14 @@ PS5NES and Porpoise don't have presets yet, because neither works properly on my
|
||||
|
||||
Only RetroArch has been tested on the PS4 so far.
|
||||
|
||||
### 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:
|
||||
- **RetroArch:** states from the same core, from RomM's web player or another RetroArch.
|
||||
- **PSXS5:** PCSX-ReARMed or Beetle states, whichever core PSXS5 is set to. They're converted to and from the web player's format.
|
||||
- **SwanStationPS5, PS5N64:** only their own states.
|
||||
|
||||
A state from elsewhere never replaces one of yours: it goes into the next free slot.
|
||||
|
||||
## PS4/PS5 Games
|
||||
|
||||
If you are interested in downloading PS4 and PS5 games from your RomM server, RomM has direct FPKGi support, simply add it as a library. This sync client does NOT handle PS4/PS5 save syncing at this time, but is being researched for potential future inclusion.
|
||||
@@ -59,9 +86,16 @@ If you are interested in downloading PS4 and PS5 games from your RomM server, Ro
|
||||
- RetroArch installed
|
||||
- Whichever other emulators you want
|
||||
|
||||
## Web UI
|
||||
- RommPS exposes a separate Web UI at http://your-ps5-ip:8780 which allows you to access most of the features present in the web app, but can make things like sync profiled editing, and adding multiple downloads of games easier from your computer instead.
|
||||
|
||||
## Install
|
||||
|
||||
Install **RommPS** from ProsperoStore and open it. That's it: RommPS carries the RomM Sync payload and starts it for you, then walks you through setup. Its last step makes RomM Sync start with every jailbreak, so you don't have to open the app for syncing to work.
|
||||
### Install via ProsperoStore
|
||||
|
||||

|
||||
|
||||
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.
|
||||
|
||||
@@ -74,11 +108,13 @@ If you'd rather run just the payload, without the app:
|
||||
```
|
||||
3. Set it up at `http://<ps5-ip>:8780` from another device.
|
||||
|
||||
To start RomM Sync with the console, let your HEN start it: the last setup step does this for you. Both HENs start `payloads/romm-sync.elf` when `romm-sync.elf.auto_start` sits next to it, in `/data/etaHEN/payloads` or `/data/OnionHEN/payloads` (onionHEN's Toolbox has a toggle for it under Payloads). RomM Sync works out which HEN is running. If you build it yourself, `make install PS5_HOST=<ps5-ip>` copies it there over FTP (add `PS5_FTP=ftp://<ps5-ip>:1337 PS5_HEN_DIR=/data/OnionHEN` for onionHEN's FTP plugin).
|
||||
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 experimental and there may be gremlins for now.
|
||||
> **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.
|
||||
|
||||
Needs GoldHEN 2.4b18.5 or newer, whose payload menu runs ELF payloads.
|
||||
|
||||
@@ -93,12 +129,9 @@ Don't send the payload to GoldHEN's network loader on port 9090: GoldHEN 2.4 cra
|
||||
|
||||
## Updates
|
||||
|
||||
There are two pieces to keep updated, and they update separately:
|
||||
On the PS5, just update RommPS: from ProsperoStore (or other homebrew storefronts eventually), or by copying the new zip's folder over the old one. The background service comes with it, and RommPS swaps it in the next time you open it, once nothing is syncing or downloading. An older version never replaces a newer one.
|
||||
|
||||
- **RomM Sync (the payload)** checks GitHub once a day (you can turn that off) and tells you when there's a new version. Settings can install it for you, but nothing installs on its own. Each payload release is signed, and RomM Sync only installs a file whose signature matches the key built into it, so nobody can slip a modified payload in through the updater.
|
||||
- **RommPS (the app)** updates through ProsperoStore, like any other homebrew in its catalog. If the payload ever falls behind what the app needs, RommPS tells you to update it.
|
||||
|
||||
You can always update either one by hand from the releases page.
|
||||
On the PS4, the payload updates itself from Settings.
|
||||
|
||||
## Setup
|
||||
|
||||
@@ -115,10 +148,12 @@ The setup walks you through five steps:
|
||||
## 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. It isn't merged with other devices.
|
||||
- **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!
|
||||
|
||||
My opinion? One card per game is better since 8MB memory cards fill up quick.
|
||||
@@ -127,7 +162,7 @@ My opinion? One card per game is better since 8MB memory cards fill up quick.
|
||||
|
||||
I've included direct support for most of the emulators that are out right now, except for the ones that won't even launch... you can sync games, states, and saves anywhere you want, but the presets should do it for most people.
|
||||
|
||||
Settings has a JSON editor for emulator profiles, and a table showing which folders are used for each platform. A profile lists an emulator's folders and the RomM platforms it plays:
|
||||
Settings in the web UI has a JSON editor for emulator profiles, and a table showing which folders are used for each platform. A profile lists an emulator's folders and the RomM platforms it plays:
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -153,6 +188,7 @@ tools/ps5-build.sh # PS5 build in Docker, output in build/ps5/
|
||||
tools/ps4-build.sh # PS4 payload in Docker, output in build/ps4/
|
||||
tools/ps4-build.sh ps4-pkg # plus the PS4 home screen app (platform/ps4/app)
|
||||
tests/e2e.sh # end-to-end tests against a mock RomM server
|
||||
tests/autostart.sh # autostart, version checks and HEN folders
|
||||
```
|
||||
|
||||
Both consoles build from the same code in `src/`. What differs lives in `platform/<console>/`: `platform.c` (the `src/platform.h` interface), the make rules and the Docker SDK image. `platform/console/` has the parts the PS4 and PS5 share, and `platform/host/` is the development build.
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 1.5 MiB After Width: | Height: | Size: 1.6 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 8.9 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 794 KiB |
@@ -0,0 +1,49 @@
|
||||
# Release checklist
|
||||
|
||||
Run all of this on a real console before tagging. Tests in CI (`tests/e2e.sh`, `tests/autostart.sh`) cover the logic, not the console.
|
||||
|
||||
## Before
|
||||
|
||||
- `kMinPayload` in `rommps_app.hpp`, `contentVersion` in RommPS's `param.json` and `APP_VERSION` in `src/util.h` all match the tag.
|
||||
- `docs/releases/<tag>.md` is written. ProsperoStore shows it as the release notes.
|
||||
- If this release changes who owns something on the console (the home screen icon, starting the payload, the autostart files, updates, the data folder), the old one gets removed or migrated in this same release.
|
||||
|
||||
## Upgrades
|
||||
|
||||
For each one, start from the old version actually installed and set up, then update. Check afterwards:
|
||||
|
||||
- one icon on the home screen (RommPS), no "RomM Sync" tile
|
||||
- one RomM Sync running, the new version (`/api/status`), lock file says the same
|
||||
- every HEN folder that exists has the new `romm-sync.elf` and `.auto_start` (`/api/autostart` lists the copies and their versions)
|
||||
- a pldmgr copy, if there was one, is the new version
|
||||
- no `.bak` files left in the payload folders
|
||||
- a sync runs and finishes without errors
|
||||
- no RomM Sync notifications while RommPS is installed
|
||||
|
||||
| From | How | Also check |
|
||||
| --- | --- | --- |
|
||||
| Nothing | ProsperoStore install, open RommPS | setup turns autostart on by default |
|
||||
| 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, old copy in pldmgr's autoload | update | the pldmgr copy is updated, its autoload list untouched |
|
||||
|
||||
## Boot and rest mode
|
||||
|
||||
- Reboot, jailbreak with onionHEN only, don't open RommPS: RomM Sync starts by itself, one copy.
|
||||
- Same with etaHEN.
|
||||
- Rest mode, then wake: same process keeps running (uptime carries on), web UI answers.
|
||||
- Open RommPS right after a jailbreak: it waits for RomM Sync instead of starting a second copy.
|
||||
|
||||
## Never downgrade
|
||||
|
||||
- Send an older `romm-sync.elf` to port 9021 while a newer one runs: it exits, the newer one keeps running.
|
||||
- Upload an older payload in the web UI's autostart: the newer copies on disk stay.
|
||||
|
||||
## After
|
||||
|
||||
- The release workflow published the payloads, then `tools/release-app.sh <tag> --upload` attached `PPSA76677.zip`.
|
||||
- The catalog bot's pull request for the new version shows up the next day.
|
||||
@@ -0,0 +1,20 @@
|
||||
Update RommPS and open it once. It takes care of the rest.
|
||||
|
||||
### Fixed
|
||||
|
||||
- The extra "RomM Sync" icon is gone.
|
||||
- RomM Sync now starts with onionHEN too.
|
||||
- Old copies of RomM Sync no longer start or replace newer ones.
|
||||
- RommPS shows the right time for the last sync.
|
||||
- Saving settings in the web UI no longer undoes changes made in RommPS.
|
||||
- The library picks up emulator changes right away, without restarting RommPS.
|
||||
|
||||
### Added
|
||||
|
||||
- RomM Sync starts with the console by default.
|
||||
- New home screen art.
|
||||
|
||||
### Changed
|
||||
|
||||
- RomM Sync updates come with RommPS.
|
||||
- No more RomM Sync notifications while RommPS is installed, except sync results.
|
||||
@@ -0,0 +1,25 @@
|
||||
Update RommPS and open it once. It updates everything else.
|
||||
|
||||
### New
|
||||
|
||||
- Saves come down as soon as a game finishes downloading, so it's ready to play.
|
||||
- Save states only come down to an emulator that can load them.
|
||||
- Save sync is optional. Use RomM just for games, or pick per platform: saves and states, saves only, or off.
|
||||
- Home has Conflicts, Activity (what synced and why) and Tracked saves. Each game has its own saves view, with restore.
|
||||
- Settings > Emulators is organized by platform: its emulator, folders, save sync and settings in one place.
|
||||
- GameCube saves keep the form RomM has them in: a zip, a single .gci, or a whole memory card. Other saves on a card are left alone.
|
||||
- Porpoise is supported, including its save states. SwanStationPS5 and PS5NES are offered too.
|
||||
- Settings > Diagnostic bundle, for bug reports and troubleshooting.
|
||||
- Hold Options to close RommPS gracefully, avoiding any weird behaviors that you often see when hard closing homebrew apps.
|
||||
|
||||
### Fixed
|
||||
|
||||
- A save or state deleted from the console comes back from RomM.
|
||||
- Older saves from before RomM's save slots now reach the console.
|
||||
- A blank PS1 memory card from RomM never replaces one with saves on it.
|
||||
- Games in folders download with their file names.
|
||||
- Notifications say what moved and for which games, and every download says when it's done.
|
||||
|
||||
### Good to know
|
||||
|
||||
- RomM's web player and the PS5 emulators don't always use the same core. A state only comes down where it can load.
|
||||
+120
-5
@@ -7,6 +7,7 @@
|
||||
|
||||
#include <arpa/inet.h>
|
||||
#include <errno.h>
|
||||
#include <fcntl.h>
|
||||
#include <ifaddrs.h>
|
||||
#include <netinet/in.h>
|
||||
#include <signal.h>
|
||||
@@ -15,8 +16,10 @@
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <time.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#include <sys/file.h>
|
||||
#include <sys/socket.h>
|
||||
#include <sys/stat.h>
|
||||
#include <sys/sysctl.h>
|
||||
@@ -94,13 +97,10 @@ static pid_t find_pid(const char *name) {
|
||||
return c.found;
|
||||
}
|
||||
|
||||
void console_single_instance(void) {
|
||||
/* Kills every other process called CONSOLE_PROC_NAME, giving up after 10 s. */
|
||||
static void kill_by_name(void) {
|
||||
pid_t pid;
|
||||
int tries = 0;
|
||||
|
||||
/* Name the main thread so find_pid() of the next instance sees us. */
|
||||
syscall(SYS_thr_set_name, -1, CONSOLE_PROC_NAME);
|
||||
|
||||
while ((pid = find_pid(CONSOLE_PROC_NAME)) > 0) {
|
||||
if (kill(pid, SIGKILL) && errno != ESRCH) break;
|
||||
sleep(1);
|
||||
@@ -108,6 +108,121 @@ void console_single_instance(void) {
|
||||
}
|
||||
}
|
||||
|
||||
struct pid_ctx { pid_t pid; };
|
||||
|
||||
static int has_pid_cb(pid_t pid, const char *tdname, void *vctx) {
|
||||
struct pid_ctx *c = vctx;
|
||||
return pid == c->pid && !strcmp(tdname, CONSOLE_PROC_NAME);
|
||||
}
|
||||
|
||||
/* "1.2.3" into numbers; anything after the patch is ignored. */
|
||||
static int parse_version(const char *s, int v[3]) {
|
||||
v[0] = v[1] = v[2] = 0;
|
||||
if (*s == 'v') s++;
|
||||
return sscanf(s, "%d.%d.%d", &v[0], &v[1], &v[2]) >= 2 ? 0 : -1;
|
||||
}
|
||||
|
||||
/* Whether this copy should take over from one running `have`. A version that
|
||||
* doesn't parse (a hand-made build) is sent on purpose, so it takes over. */
|
||||
static int takes_over(const char *mine, const char *have) {
|
||||
int x[3], y[3];
|
||||
if (parse_version(mine, x) || parse_version(have, y)) return 1;
|
||||
for (int i = 0; i < 3; i++)
|
||||
if (x[i] != y[i]) return x[i] > y[i];
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* Held for as long as this copy runs; the kernel lets go when the process
|
||||
* ends, however it ends. The file says which version holds it and its pid. */
|
||||
static int g_lock_fd = -1;
|
||||
|
||||
/* One line in the log, written before the log is set up: a copy that quits
|
||||
* here would otherwise leave no trace. */
|
||||
static void note(const char *fmt, ...) {
|
||||
char line[256], when[32];
|
||||
time_t now = time(NULL);
|
||||
struct tm tm;
|
||||
localtime_r(&now, &tm);
|
||||
strftime(when, sizeof when, "%Y-%m-%d %H:%M:%S", &tm);
|
||||
int n = snprintf(line, sizeof line, "%s [INFO] ", when);
|
||||
va_list ap;
|
||||
va_start(ap, fmt);
|
||||
n += vsnprintf(line + n, sizeof line - (size_t)n - 1, fmt, ap);
|
||||
va_end(ap);
|
||||
if (n > (int)sizeof line - 2) n = (int)sizeof line - 2;
|
||||
line[n++] = '\n';
|
||||
int fd = open(CONSOLE_DATA_DIR "/romm-sync.log", O_WRONLY | O_APPEND | O_CREAT, 0666);
|
||||
if (fd < 0) return;
|
||||
write(fd, line, (size_t)n);
|
||||
close(fd);
|
||||
}
|
||||
|
||||
static int lock_wait(int fd, int seconds) {
|
||||
for (int i = 0; i <= seconds * 4; i++) {
|
||||
if (!flock(fd, LOCK_EX | LOCK_NB)) return 0;
|
||||
usleep(250 * 1000);
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
int console_single_instance(const char *version) {
|
||||
console_mkdir_p(CONSOLE_DATA_DIR);
|
||||
int fd = open(CONSOLE_LOCK_FILE, O_RDWR | O_CREAT, 0644);
|
||||
if (fd < 0) {
|
||||
/* No lock to be had: run, as versions before the lock did. */
|
||||
kill_by_name();
|
||||
syscall(SYS_thr_set_name, -1, CONSOLE_PROC_NAME);
|
||||
return 0;
|
||||
}
|
||||
if (flock(fd, LOCK_EX | LOCK_NB)) {
|
||||
char have[32] = "";
|
||||
pid_t holder = 0;
|
||||
int alive = 0;
|
||||
/* A copy that took the lock a moment ago may not have said who it is
|
||||
* yet; until it does, the file still names the copy before it. */
|
||||
for (int tries = 0; tries < 8 && !alive; tries++) {
|
||||
char buf[64] = "";
|
||||
ssize_t n = pread(fd, buf, sizeof buf - 1, 0);
|
||||
if (n > 0) buf[n] = 0;
|
||||
have[0] = 0;
|
||||
holder = 0;
|
||||
sscanf(buf, "%31s %d", have, &holder);
|
||||
struct pid_ctx c = {holder};
|
||||
alive = holder > 0 && console_for_each_proc(has_pid_cb, &c) == 1;
|
||||
if (!alive) usleep(250 * 1000);
|
||||
}
|
||||
/* Held, but by no copy of ours: RommPS checking whether one runs.
|
||||
* It lets go at once. */
|
||||
if (!alive && !lock_wait(fd, 3)) goto locked;
|
||||
if (!takes_over(version, have)) {
|
||||
note("romm-sync %s not started: %s is already running (pid %d)", version, have, (int)holder);
|
||||
close(fd);
|
||||
return 1;
|
||||
}
|
||||
/* Older: stop it, but only if that pid is still RomM Sync. */
|
||||
note("romm-sync %s replacing %s (pid %d)", version, have, (int)holder);
|
||||
struct pid_ctx c = {holder};
|
||||
if (holder > 0 && console_for_each_proc(has_pid_cb, &c) == 1) kill(holder, SIGKILL);
|
||||
if (lock_wait(fd, 10)) {
|
||||
note("romm-sync %s not started: %s didn't stop", version, have);
|
||||
close(fd);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
locked:;
|
||||
/* Say who holds the lock first, for any copy starting right now. */
|
||||
char line[64];
|
||||
int len = snprintf(line, sizeof line, "%s %d\n", version, (int)getpid());
|
||||
if (!ftruncate(fd, 0) && len > 0) pwrite(fd, line, (size_t)len, 0);
|
||||
fchmod(fd, 0644);
|
||||
g_lock_fd = fd;
|
||||
syscall(SYS_thr_set_name, -1, CONSOLE_PROC_NAME);
|
||||
/* Copies from before the lock don't hold it: find them by name. A copy
|
||||
* still deciding hasn't named itself yet, so it's safe from this. */
|
||||
kill_by_name();
|
||||
return 0;
|
||||
}
|
||||
|
||||
int console_send_elf(int port, const void *elf, size_t len) {
|
||||
struct sockaddr_in sa;
|
||||
memset(&sa, 0, sizeof sa);
|
||||
|
||||
@@ -11,7 +11,8 @@
|
||||
#define CONSOLE_PROC_NAME "romm-sync.elf"
|
||||
#define CONSOLE_DATA_DIR "/data/romm-sync"
|
||||
#define CONSOLE_CA_BUNDLE CONSOLE_DATA_DIR "/cacert.pem"
|
||||
#define CONSOLE_ICON CONSOLE_DATA_DIR "/icon0.png"
|
||||
/* Held by the running copy; holds "<version> <pid>". RommPS reads it too. */
|
||||
#define CONSOLE_LOCK_FILE CONSOLE_DATA_DIR "/romm-sync.lock"
|
||||
|
||||
int console_mkdir_p(const char *path);
|
||||
|
||||
@@ -20,8 +21,11 @@ int console_mkdir_p(const char *path);
|
||||
typedef int (*console_proc_cb)(pid_t pid, const char *tdname, void *ctx);
|
||||
int console_for_each_proc(console_proc_cb cb, void *ctx);
|
||||
|
||||
/* Names the main thread CONSOLE_PROC_NAME and kills any older copy. */
|
||||
void console_single_instance(void);
|
||||
/* Makes this the only copy running. A copy holding the lock keeps running if
|
||||
* it's the same version or newer, and 1 is returned: this copy should quit.
|
||||
* Otherwise it's stopped, as is any copy from before the lock, and this one
|
||||
* takes the lock and the name CONSOLE_PROC_NAME. */
|
||||
int console_single_instance(const char *version);
|
||||
|
||||
/* Sends an ELF to a loader listening on 127.0.0.1:port. -1 with errno set. */
|
||||
int console_send_elf(int port, const void *elf, size_t len);
|
||||
|
||||
@@ -20,7 +20,7 @@ static char g_data[PATH_MAX_LEN];
|
||||
static const char *const HOMEBREW_DIRS[] = {NULL}; /* ROMM_SYNC_HOMEBREW */
|
||||
static const char *const EMULATOR_DIRS[] = {NULL};
|
||||
|
||||
static const plat_info_t INFO = {
|
||||
static plat_info_t INFO = {
|
||||
.name = "host",
|
||||
.console = "PlayStation 5",
|
||||
.client = "romm-sync-ps5",
|
||||
@@ -35,12 +35,15 @@ static const plat_info_t INFO = {
|
||||
.retroarch_root = "/data/homebrew/PPSA99169",
|
||||
.mednafen_root = "/data/homebrew/Mednafen",
|
||||
.can_relaunch = 1,
|
||||
.self_update = 1, /* ROMM_SYNC_SELF_UPDATE=0 tests the PS5's way */
|
||||
};
|
||||
|
||||
const plat_info_t *plat_info(void) { return &INFO; }
|
||||
|
||||
int plat_init(void) {
|
||||
signal(SIGPIPE, SIG_IGN);
|
||||
const char *su = getenv("ROMM_SYNC_SELF_UPDATE");
|
||||
if (su && *su) INFO.self_update = atoi(su);
|
||||
const char *d = getenv("ROMM_SYNC_DATA");
|
||||
str_copy(g_data, sizeof g_data, d && *d ? d : "./host-data");
|
||||
mkdir_p(g_data);
|
||||
@@ -74,9 +77,16 @@ const char *plat_ca_bundle(void) {
|
||||
return c && *c ? c : NULL;
|
||||
}
|
||||
|
||||
int plat_install_tile(int port) {
|
||||
(void)port;
|
||||
return -1;
|
||||
void plat_remove_old_tile(void) {}
|
||||
|
||||
int plat_app_installed(void) {
|
||||
const char *e = getenv("ROMM_SYNC_APP_INSTALLED");
|
||||
return e && *e == '1';
|
||||
}
|
||||
|
||||
void plat_app_version(char *out, size_t n) {
|
||||
const char *e = getenv("ROMM_SYNC_APP_VERSION");
|
||||
str_copy(out, n, e ? e : "");
|
||||
}
|
||||
|
||||
int plat_launch_elf(const void *elf, size_t len) {
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
#include "console.h"
|
||||
#include "emulators_json.h" /* generated from platform/ps4/emulators.json */
|
||||
|
||||
#define LOCK_FILE CONSOLE_DATA_DIR "/romm-sync.lock"
|
||||
#define LOCK_FILE CONSOLE_LOCK_FILE
|
||||
|
||||
|
||||
/* libkernel: layout from sdk/samples/ps/main.c (title_id is wider than the PS5's) */
|
||||
@@ -67,10 +67,9 @@ static const plat_info_t INFO = {
|
||||
.retroarch_root = "/data/retroarch",
|
||||
.mednafen_root = "/data/mednafen",
|
||||
.emulator_catalog = EMULATORS_JSON,
|
||||
/* PS4 tiles need an installed package; there's no URL shortcut. */
|
||||
.has_tile = 0,
|
||||
/* GoldHEN runs the payload on a thread of its own process. */
|
||||
.detach = 1,
|
||||
.self_update = 1,
|
||||
};
|
||||
|
||||
const plat_info_t *plat_info(void) { return &INFO; }
|
||||
@@ -148,9 +147,12 @@ void plat_describe(char *buf, size_t n) {
|
||||
console_describe(buf, n, host);
|
||||
}
|
||||
|
||||
int plat_install_tile(int port) {
|
||||
(void)port;
|
||||
return -1;
|
||||
void plat_remove_old_tile(void) {}
|
||||
|
||||
int plat_app_installed(void) { return 0; }
|
||||
|
||||
void plat_app_version(char *out, size_t n) {
|
||||
if (n) out[0] = 0;
|
||||
}
|
||||
|
||||
/* Not used (can_relaunch is 0): GoldHEN's network loader (9090) crashes on
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
#include "avatar_png.h"
|
||||
#include "core/tween.hpp"
|
||||
#include "qrcodegen.h"
|
||||
#include "rommps_api.hpp"
|
||||
#include "stb_image.h" // implemented by base/VulkanglTFModel.cpp
|
||||
|
||||
#include <algorithm>
|
||||
@@ -238,7 +239,7 @@ void CodecCall::go(int step, hui::ui::Feedback &feedback)
|
||||
{
|
||||
// The payload's page hosting YouTube's player, filling the browser
|
||||
// (an embed opened on its own fails with YouTube's error 153).
|
||||
const std::string page = std::string("http://127.0.0.1:8780/video?v=") + s.video;
|
||||
const std::string page = "http://127.0.0.1:" + std::to_string(payload_port()) + "/video?v=" + s.video;
|
||||
opened_ = open_in_browser(page.c_str());
|
||||
feedback.play(Cue::launch);
|
||||
}
|
||||
|
||||
@@ -421,7 +421,9 @@ void LibraryPage::draw_platforms(hui::gfx::DrawList &list, const hui::ui::Fonts
|
||||
{
|
||||
const float in = tween::stagger(age, 1, 0.08f, 0.6f);
|
||||
list.push_opacity(in);
|
||||
ui::text(list, fonts.semibold, "On this console", kMargin, 190, 24, section_ == 0 ? look.text : look.muted);
|
||||
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});
|
||||
for (int i = 0; i < static_cast<int>(recent.size()); ++i)
|
||||
{
|
||||
@@ -737,6 +739,8 @@ void LibraryPage::draw_grid(hui::gfx::DrawList &list, const hui::ui::Fonts &font
|
||||
const Rect bar{kMargin, 336, 420, 8};
|
||||
list.rounded_rect(bar, 4, look.muted.with_alpha(0.25f));
|
||||
list.rounded_rect({bar.x, bar.y, std::max(8.0f, bar.w * d->progress()), bar.h}, 4, accent);
|
||||
if (d->syncing())
|
||||
ui::text(list, fonts.semibold, "Syncing saves", bar.x + bar.w + 20, 344, 20, look.muted);
|
||||
}
|
||||
else
|
||||
{
|
||||
|
||||
@@ -8,7 +8,9 @@
|
||||
#include "stb_image.h" // implemented by base/VulkanglTFModel.cpp
|
||||
|
||||
#include <arpa/inet.h>
|
||||
#include <fcntl.h>
|
||||
#include <netinet/in.h>
|
||||
#include <sys/file.h>
|
||||
#include <sys/socket.h>
|
||||
#include <unistd.h>
|
||||
|
||||
@@ -31,6 +33,7 @@ namespace
|
||||
constexpr float kStatusEvery = 3.0f;
|
||||
constexpr float kDownloadsEvery = 1.0f; // while one is running
|
||||
constexpr float kDownloadsIdleEvery = 5.0f; // otherwise
|
||||
constexpr int kCoverRetries = 3; // a failed cover is tried again after 15, 30, 60 s
|
||||
constexpr int kDecodesPerFrame = 2; // covers turned into textures each frame
|
||||
constexpr std::size_t kCoverTextures = 360; // covers kept as textures (about 130 MB); the
|
||||
// least recently drawn go, and come back from
|
||||
@@ -39,6 +42,19 @@ constexpr std::size_t kListsKept = 24; // game lists kept at once: every tile on
|
||||
|
||||
void *g_renderer = nullptr;
|
||||
|
||||
// -1, 0 or 1 as a is older, the same as or newer than b ("1.2.3"); one that
|
||||
// isn't a version ("dev", "") counts as 0.0.0.
|
||||
int version_cmp(const std::string &a, const std::string &b)
|
||||
{
|
||||
int x[3] = {}, y[3] = {};
|
||||
std::sscanf(a.c_str(), "%d.%d.%d", &x[0], &x[1], &x[2]);
|
||||
std::sscanf(b.c_str(), "%d.%d.%d", &y[0], &y[1], &y[2]);
|
||||
for (int i = 0; i < 3; ++i)
|
||||
if (x[i] != y[i])
|
||||
return x[i] < y[i] ? -1 : 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
std::string url_escape(const std::string &s)
|
||||
{
|
||||
static const char hex[] = "0123456789ABCDEF";
|
||||
@@ -155,13 +171,18 @@ Palette palette_of(const unsigned char *rgba, int w, int h)
|
||||
|
||||
} // namespace
|
||||
|
||||
std::string plain_text(const char *text)
|
||||
{
|
||||
return plain(text ? text : "");
|
||||
}
|
||||
|
||||
App &app()
|
||||
{
|
||||
static App instance;
|
||||
return instance;
|
||||
}
|
||||
|
||||
App::App() : api_("127.0.0.1", 8780)
|
||||
App::App() : api_("127.0.0.1", payload_port())
|
||||
{
|
||||
refresh_status();
|
||||
}
|
||||
@@ -176,6 +197,7 @@ void App::tick(float dt, hui::gfx::Renderer *renderer)
|
||||
{
|
||||
clock_ += dt;
|
||||
api_.poll();
|
||||
slow_api_.poll();
|
||||
for (Api &a : covers_api_)
|
||||
a.poll();
|
||||
for (Api &a : pages_api_)
|
||||
@@ -193,6 +215,8 @@ void App::tick(float dt, hui::gfx::Renderer *renderer)
|
||||
if (covers_in_flight_ >= kCoversInFlight)
|
||||
break;
|
||||
Cover &c = covers_[path];
|
||||
if (c.failed && c.retry_at > 0 && clock_ >= c.retry_at)
|
||||
c.requested = c.failed = false, c.retry_at = 0; // try again
|
||||
if (c.requested)
|
||||
continue;
|
||||
c.requested = true;
|
||||
@@ -203,7 +227,14 @@ void App::tick(float dt, hui::gfx::Renderer *renderer)
|
||||
if (r.ok() && !r.body.empty())
|
||||
to_decode_.push_back({path, r.body});
|
||||
else
|
||||
covers_[path].failed = true;
|
||||
{
|
||||
Cover &c = covers_[path];
|
||||
c.failed = true;
|
||||
// RomM has no cover: that stays. Anything else (the service
|
||||
// busy, a network hiccup): again in a little while.
|
||||
const bool again = r.status != 404 && c.retries < kCoverRetries;
|
||||
c.retry_at = again ? clock_ + 15.0 * (1 << c.retries++) : 0;
|
||||
}
|
||||
});
|
||||
}
|
||||
wanted_.clear();
|
||||
@@ -233,7 +264,7 @@ void App::tick(float dt, hui::gfx::Renderer *renderer)
|
||||
else if (start_state_ == 3)
|
||||
{
|
||||
start_state_ = 0;
|
||||
say("Couldn't start RomM Sync: " + start_error_);
|
||||
say("Couldn't start RommPS's background service: " + start_error_);
|
||||
}
|
||||
|
||||
// A few covers a frame become textures; the rest wait their turn, so a
|
||||
@@ -301,7 +332,7 @@ void App::start_payload()
|
||||
const std::string &elf = bundled_payload();
|
||||
if (elf.size() < 4 || elf.compare(0, 4, "\x7f" "ELF") != 0)
|
||||
{
|
||||
start_error_ = "this RommPS doesn't carry the RomM Sync payload";
|
||||
start_error_ = "this RommPS doesn't carry its background service";
|
||||
start_state_ = 3;
|
||||
return;
|
||||
}
|
||||
@@ -331,6 +362,37 @@ void App::start_payload()
|
||||
}).detach();
|
||||
}
|
||||
|
||||
void App::repair_autostart()
|
||||
{
|
||||
api_.get("/api/autostart", [this](const Response &r) {
|
||||
std::unique_ptr<cJSON, void (*)(cJSON *)> j(r.json(), cJSON_Delete);
|
||||
if (!r.ok() || !j || !flag(j.get(), "supported"))
|
||||
return;
|
||||
// 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;
|
||||
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());
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
void App::install_autostart(Api::Callback done)
|
||||
{
|
||||
api_.post("/api/autostart/upload", bundled_payload(), [this, done](const Response &r) {
|
||||
@@ -357,6 +419,20 @@ bool App::payload_too_old() const
|
||||
return false;
|
||||
}
|
||||
|
||||
// RomM Sync holds a lock on this file for as long as it runs (1.1.1 on); a
|
||||
// copy can be running and not answering yet, after a start or a wake.
|
||||
int App::payload_lock()
|
||||
{
|
||||
const int fd = ::open("/data/romm-sync/romm-sync.lock", O_RDONLY);
|
||||
if (fd < 0)
|
||||
return errno == ENOENT ? 0 : -1;
|
||||
int held = 0;
|
||||
if (::flock(fd, LOCK_SH | LOCK_NB) != 0)
|
||||
held = errno == EWOULDBLOCK ? 1 : -1;
|
||||
::close(fd);
|
||||
return held;
|
||||
}
|
||||
|
||||
void App::refresh_status()
|
||||
{
|
||||
status_timer_ = 0;
|
||||
@@ -365,12 +441,22 @@ void App::refresh_status()
|
||||
status_busy_ = false;
|
||||
status_.known = true;
|
||||
status_.reachable = r.status != 0;
|
||||
// Not running: RommPS starts the payload it carries, once by itself
|
||||
// (Cross on the screen tries again).
|
||||
// Not answering: RommPS starts the payload it carries, once by itself
|
||||
// (Cross on the screen tries again), but only when none is running.
|
||||
// One that's running is left to come up; with no way to tell, it
|
||||
// waits a few tries first.
|
||||
unanswered_ = status_.reachable ? 0 : unanswered_ + 1;
|
||||
payload_waiting_ = false;
|
||||
if (!status_.reachable && !tried_start_)
|
||||
{
|
||||
tried_start_ = true;
|
||||
start_payload();
|
||||
const int lock = payload_lock();
|
||||
if (lock == 1)
|
||||
payload_waiting_ = true;
|
||||
else if (lock == 0 || unanswered_ >= 3)
|
||||
{
|
||||
tried_start_ = true;
|
||||
start_payload();
|
||||
}
|
||||
}
|
||||
if (!r.ok())
|
||||
{
|
||||
@@ -382,20 +468,21 @@ void App::refresh_status()
|
||||
return;
|
||||
status_.error.clear();
|
||||
status_.version = str(j.get(), "version");
|
||||
// An older payload is replaced by the one RommPS carries, once it's
|
||||
// idle: its autostart copy first, if it has one, then the running one.
|
||||
if (payload_too_old() && !updated_payload_ && !flag(cJSON_GetObjectItemCaseSensitive(j.get(), "sync"), "running") &&
|
||||
active_downloads_ == 0)
|
||||
// An older payload is replaced by the one RommPS carries once it's
|
||||
// idle: the new one takes over from it and then, being this
|
||||
// RommPS's own, updates the copies on disk (below). A newer one is
|
||||
// left alone.
|
||||
const bool idle = !flag(cJSON_GetObjectItemCaseSensitive(j.get(), "sync"), "running") && active_downloads_ == 0;
|
||||
if (payload_too_old() && !updated_payload_ && idle)
|
||||
{
|
||||
updated_payload_ = true;
|
||||
say("Updating RomM Sync to " + std::string(kMinPayload));
|
||||
api_.get("/api/autostart", [this](const Response &a) {
|
||||
std::unique_ptr<cJSON, void (*)(cJSON *)> as(a.json(), cJSON_Delete);
|
||||
if (a.ok() && flag(as.get(), "installed"))
|
||||
install_autostart([this](const Response &) { start_payload(); });
|
||||
else
|
||||
start_payload();
|
||||
});
|
||||
say("Updating RommPS's background service to " + std::string(kMinPayload));
|
||||
start_payload();
|
||||
}
|
||||
else if (!repaired_ && version_cmp(status_.version, kMinPayload) == 0)
|
||||
{
|
||||
repaired_ = true;
|
||||
repair_autostart();
|
||||
}
|
||||
status_.ip = str(j.get(), "ip");
|
||||
status_.port = static_cast<int>(num(j.get(), "web_port", 8780));
|
||||
@@ -640,7 +727,7 @@ const Details &App::details(int rom_id)
|
||||
|
||||
void App::refresh_installed()
|
||||
{
|
||||
api_.get("/api/installed?limit=30", [this](const Response &r) {
|
||||
api_.get("/api/installed?limit=1000", [this](const Response &r) { // every one: the shelf's count is theirs
|
||||
if (!r.ok())
|
||||
return;
|
||||
std::unique_ptr<cJSON, void (*)(cJSON *)> j(r.json(), cJSON_Delete);
|
||||
@@ -654,6 +741,7 @@ void App::refresh_installed()
|
||||
game.name = plain(str(g, "name"));
|
||||
game.fs_name = str(g, "fs_name");
|
||||
game.cover = str(g, "cover");
|
||||
game.size = num(g, "size");
|
||||
game.installed = true;
|
||||
list.push_back(std::move(game));
|
||||
}
|
||||
@@ -699,6 +787,10 @@ void App::refresh_downloads()
|
||||
dl.done = num(d, "done");
|
||||
dl.total = num(d, "total");
|
||||
dl.speed = num(d, "speed");
|
||||
dl.saves = static_cast<int>(num(d, "saves"));
|
||||
dl.states = static_cast<int>(num(d, "states"));
|
||||
dl.save_conflicts = static_cast<int>(num(d, "save_conflicts"));
|
||||
dl.saves_failed = flag(d, "saves_failed");
|
||||
active += dl.active();
|
||||
// A game that just finished shows as downloaded on its shelf.
|
||||
for (const Download &old : downloads_)
|
||||
@@ -717,6 +809,21 @@ void App::refresh_downloads()
|
||||
});
|
||||
}
|
||||
|
||||
std::string Download::saves_note() const
|
||||
{
|
||||
if (saves_failed)
|
||||
return "Saves couldn't sync";
|
||||
if (save_conflicts)
|
||||
return "Save conflict to resolve";
|
||||
auto count = [](int n, const char *what) { return std::to_string(n) + " " + what + (n == 1 ? "" : "s"); };
|
||||
std::string got;
|
||||
if (saves)
|
||||
got = count(saves, "save");
|
||||
if (states)
|
||||
got += (got.empty() ? "" : " and ") + count(states, "state");
|
||||
return got.empty() ? "" : got + " synced";
|
||||
}
|
||||
|
||||
const Download *App::download_for(int rom_id) const
|
||||
{
|
||||
const Download *found = nullptr;
|
||||
@@ -762,6 +869,23 @@ void App::cancel_download(int job_id)
|
||||
});
|
||||
}
|
||||
|
||||
void App::remove_game(int rom_id)
|
||||
{
|
||||
char body[64];
|
||||
std::snprintf(body, sizeof body, "{\"rom_id\":%d}", rom_id);
|
||||
api_.post("/api/delete", body, [this](const Response &r) {
|
||||
if (!r.ok())
|
||||
{
|
||||
say("Couldn't remove the game: " + r.message());
|
||||
return;
|
||||
}
|
||||
say("Game removed. Its saves are kept.");
|
||||
lists_.clear();
|
||||
refresh_installed();
|
||||
refresh_downloads();
|
||||
});
|
||||
}
|
||||
|
||||
void App::sync_now()
|
||||
{
|
||||
api_.post("/api/sync", "{}", [this](const Response &r) {
|
||||
@@ -786,6 +910,7 @@ void App::refresh_config()
|
||||
c.sync_on_game_exit = flag(j.get(), "sync_on_game_exit");
|
||||
c.sync_on_game_start = flag(j.get(), "sync_on_game_start");
|
||||
c.sync_on_change = flag(j.get(), "sync_on_change");
|
||||
c.save_sync = !cJSON_IsFalse(cJSON_GetObjectItemCaseSensitive(j.get(), "save_sync")); // older payloads: on
|
||||
c.notify = flag(j.get(), "notify");
|
||||
c.update_check = flag(j.get(), "update_check");
|
||||
c.states = str(j.get(), "states", "off");
|
||||
@@ -831,6 +956,9 @@ void App::refresh_update()
|
||||
u.latest = str(j.get(), "latest");
|
||||
u.available = flag(j.get(), "available");
|
||||
u.autostart = flag(j.get(), "autostart");
|
||||
// Payloads before 1.1.1 don't say: they update themselves.
|
||||
const cJSON *su = cJSON_GetObjectItemCaseSensitive(j.get(), "self_update");
|
||||
u.self_update = !cJSON_IsBool(su) || cJSON_IsTrue(su);
|
||||
u.done = num(j.get(), "done");
|
||||
u.total = num(j.get(), "total");
|
||||
update_ = u;
|
||||
@@ -839,9 +967,10 @@ void App::refresh_update()
|
||||
if (u.state == "error")
|
||||
say("Update: " + (u.error.empty() ? u.message : u.error));
|
||||
else if (u.available)
|
||||
say("RomM Sync " + u.latest + " is available");
|
||||
say(u.self_update ? "RommPS " + u.latest + " is available"
|
||||
: "RommPS " + u.latest + " is available in ProsperoStore");
|
||||
else if (u.state == "idle")
|
||||
say("RomM Sync is up to date");
|
||||
say("RommPS is up to date");
|
||||
else if (!u.message.empty())
|
||||
say(u.message);
|
||||
}
|
||||
|
||||
@@ -42,6 +42,8 @@ struct Cover
|
||||
bool requested = false;
|
||||
bool failed = false;
|
||||
double used = 0; // when it was last drawn
|
||||
double retry_at = 0; // a fetch that failed (not a missing cover) is tried again then
|
||||
int retries = 0; // tries again so far this launch (kCoverRetries at most)
|
||||
};
|
||||
|
||||
struct Game
|
||||
@@ -136,11 +138,18 @@ struct Download
|
||||
int id = 0, rom_id = 0;
|
||||
std::string name, platform, status, error;
|
||||
double done = 0, total = 0, speed = 0;
|
||||
// What the sync after the download brought.
|
||||
int saves = 0, states = 0, save_conflicts = 0;
|
||||
bool saves_failed = false;
|
||||
float progress() const { return total > 0 ? static_cast<float>(done / total) : 0.0f; }
|
||||
// The payload's states: queued, downloading, cancelling, then done, error or cancelled.
|
||||
// The payload's states: queued, downloading, cancelling, syncing (the
|
||||
// game's saves, once it's downloaded), then done, error or cancelled.
|
||||
bool running() const { return status == "downloading"; }
|
||||
bool active() const { return status == "queued" || running() || status == "cancelling"; }
|
||||
bool syncing() const { return status == "syncing"; }
|
||||
bool active() const { return status == "queued" || running() || status == "cancelling" || syncing(); }
|
||||
bool cancellable() const { return active() && !syncing(); }
|
||||
bool finished() const { return !active(); }
|
||||
std::string saves_note() const;
|
||||
};
|
||||
|
||||
struct Status
|
||||
@@ -173,6 +182,7 @@ struct Config
|
||||
std::string server_url;
|
||||
bool tls_verify = true;
|
||||
int sync_interval_min = 0;
|
||||
bool save_sync = true; // false: RomM only as a game source
|
||||
bool sync_on_game_exit = true, sync_on_game_start = false, sync_on_change = false;
|
||||
bool notify = true, update_check = true;
|
||||
std::string states = "off"; // off, upload, sync
|
||||
@@ -188,6 +198,8 @@ struct Update
|
||||
bool known = false;
|
||||
std::string state, message, error, current, latest;
|
||||
bool available = false, autostart = false;
|
||||
// false: RomM Sync doesn't update itself; a new RommPS brings it (PS5).
|
||||
bool self_update = true;
|
||||
double done = 0, total = 0;
|
||||
bool busy() const { return state == "checking" || state == "downloading" || state == "installing" || state == "restarting"; }
|
||||
};
|
||||
@@ -204,11 +216,14 @@ class App
|
||||
const Status &status() const { return status_; }
|
||||
// RommPS carries the RomM Sync payload of its own release
|
||||
// (assets/rommps/romm-sync.elf, version kMinPayload): it starts it when
|
||||
// it isn't running, and puts it in place of an older one.
|
||||
static constexpr const char *kMinPayload = "1.1.0";
|
||||
// none is running, puts it in place of an older one (never of a newer
|
||||
// one), and keeps the copies the console starts it from up to date.
|
||||
static constexpr const char *kMinPayload = "1.2.0";
|
||||
bool payload_too_old() const;
|
||||
void start_payload(); // sends the payload to the HEN's loader
|
||||
bool payload_starting() const { return start_state_ == 1; }
|
||||
// Sending it, or RomM Sync is running and not answering yet (starting up,
|
||||
// or waking from rest mode): either way it'll answer in a moment.
|
||||
bool payload_starting() const { return start_state_ == 1 || payload_waiting_; }
|
||||
const std::string &payload_error() const { return start_error_; }
|
||||
// The payload into the HEN's autostart folder, so it starts with the console.
|
||||
void install_autostart(Api::Callback done);
|
||||
@@ -228,11 +243,19 @@ class App
|
||||
// A game's details; asks for them the first time.
|
||||
const Details &details(int rom_id);
|
||||
void refresh_installed();
|
||||
// Asks for the platforms again: which emulator plays each, and its
|
||||
// folder, change when emulators are set up here or in the web UI.
|
||||
void reload_platforms()
|
||||
{
|
||||
if (!platforms_busy_)
|
||||
refresh_platforms();
|
||||
}
|
||||
// A cover's texture and palette; asks for it the first time.
|
||||
const Cover &cover(const std::string &path);
|
||||
|
||||
void download(int rom_id, const std::string &name);
|
||||
void cancel_download(int job_id);
|
||||
void remove_game(int rom_id);
|
||||
void clear_finished_downloads();
|
||||
void sync_now();
|
||||
|
||||
@@ -255,8 +278,12 @@ class App
|
||||
// Asks for the status now rather than at the next tick.
|
||||
void refresh_now() { refresh_status(); }
|
||||
|
||||
// Any other call to the payload, for the setup's steps.
|
||||
void get(const std::string &path, Api::Callback done) { api_.get(path, std::move(done)); }
|
||||
// Any other call to the payload, for the setup's steps. A long one (the
|
||||
// sync preview) goes on a worker of its own, so the status never waits.
|
||||
void get(const std::string &path, Api::Callback done, int timeout_ms = 15000)
|
||||
{
|
||||
(timeout_ms > 15000 ? slow_api_ : api_).get(path, std::move(done), timeout_ms);
|
||||
}
|
||||
void post(const std::string &path, const std::string &body, Api::Callback done)
|
||||
{
|
||||
api_.post(path, body, std::move(done));
|
||||
@@ -269,17 +296,25 @@ class App
|
||||
|
||||
private:
|
||||
void refresh_status();
|
||||
// Whether a RomM Sync is running, from the lock it holds while it runs:
|
||||
// 1 yes, 0 no, -1 can't tell.
|
||||
static int payload_lock();
|
||||
// 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).
|
||||
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();
|
||||
|
||||
Api api_;
|
||||
Api slow_api_; // calls that may take minutes
|
||||
// Covers on workers of their own, so lists never wait behind them; asked
|
||||
// for only while they're on screen (wanted_), newest first.
|
||||
static constexpr int kCoverWorkers = 3;
|
||||
static constexpr int kCoversInFlight = 6;
|
||||
Api covers_api_[kCoverWorkers]; // each to the payload on 127.0.0.1:8780
|
||||
Api covers_api_[kCoverWorkers]; // each to the payload on 127.0.0.1
|
||||
std::vector<std::string> wanted_; // drawn since the last tick, in draw order
|
||||
// Pages of games, on workers of their own too; asked for at the tick if
|
||||
// the screen still wants them.
|
||||
@@ -318,7 +353,9 @@ class App
|
||||
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;
|
||||
bool tried_start_ = false, updated_payload_ = false, repaired_ = false;
|
||||
bool payload_waiting_ = false;
|
||||
int unanswered_ = 0; // status requests in a row RomM Sync didn't answer
|
||||
int active_downloads_ = 0;
|
||||
std::string toast_;
|
||||
float toast_age_ = 0;
|
||||
@@ -327,6 +364,10 @@ 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").
|
||||
std::string plain_text(const char *text);
|
||||
|
||||
} // namespace rommps
|
||||
|
||||
// The program hands the kit's renderer over (rommps.cpp is compiled in a
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstdio>
|
||||
#include <cctype>
|
||||
|
||||
namespace rommps
|
||||
{
|
||||
@@ -35,13 +36,28 @@ RowEvent RowList::handle(const std::vector<Row> &rows, const hui::InputFrame &in
|
||||
if (count == 0)
|
||||
return event;
|
||||
focus_ = std::clamp(focus_, 0, count - 1);
|
||||
// A section's name can't hold the focus: the first row after it does
|
||||
// (or, at the end of the list, the last one before it).
|
||||
if (rows[static_cast<std::size_t>(focus_)].kind == RowKind::header)
|
||||
{
|
||||
int at = focus_;
|
||||
while (at < count && rows[static_cast<std::size_t>(at)].kind == RowKind::header)
|
||||
++at;
|
||||
if (at == count)
|
||||
for (at = focus_; at > 0 && rows[static_cast<std::size_t>(at)].kind == RowKind::header;)
|
||||
--at;
|
||||
focus_ = at;
|
||||
}
|
||||
const Row &row = rows[static_cast<std::size_t>(focus_)];
|
||||
switch (input.nav)
|
||||
{
|
||||
case Direction::up:
|
||||
case Direction::down:
|
||||
{
|
||||
const int next = focus_ + (input.nav == Direction::down ? 1 : -1);
|
||||
const int step = input.nav == Direction::down ? 1 : -1;
|
||||
int next = focus_ + step;
|
||||
while (next >= 0 && next < count && rows[static_cast<std::size_t>(next)].kind == RowKind::header)
|
||||
next += step;
|
||||
if (next >= 0 && next < count)
|
||||
{
|
||||
focus_ = next;
|
||||
@@ -118,6 +134,14 @@ void RowList::draw(hui::gfx::DrawList &list, const hui::ui::Fonts &fonts, const
|
||||
const float y = bounds.y + static_cast<float>(i) * kRowHeight - scroll_.value;
|
||||
if (y + kRowHeight < bounds.y || y > bounds.y + bounds.h)
|
||||
continue;
|
||||
if (row.kind == RowKind::header)
|
||||
{
|
||||
std::string caps = row.label;
|
||||
for (char &ch : caps)
|
||||
ch = static_cast<char>(std::toupper(static_cast<unsigned char>(ch)));
|
||||
ui::text(list, fonts.semibold, caps, bounds.x + 32, y + kRowHeight - 20, 18, look.muted);
|
||||
continue;
|
||||
}
|
||||
const bool focused = i == focus_;
|
||||
const float dim = row.enabled ? 1.0f : 0.45f;
|
||||
const Color ink = focused ? look.on_highlight : row.danger ? look.danger : look.text;
|
||||
@@ -155,10 +179,27 @@ void RowList::draw(hui::gfx::DrawList &list, const hui::ui::Fonts &fonts, const
|
||||
}
|
||||
used = w + chevrons + (row.kind == RowKind::choice ? 50.0f : 24.0f);
|
||||
}
|
||||
const float room = std::max(200.0f, bounds.w - 64 - used);
|
||||
ui::text(list, fonts.semibold, fonts.semibold.font->fit(row.label, 26, room), x, label_y, 26, ink.with_alpha(dim));
|
||||
// A chevron for a row that opens: right when closed, down when open.
|
||||
const float indent = row.expander ? 30.0f : 0.0f;
|
||||
if (row.expander)
|
||||
{
|
||||
const float cx = x + 8, cy = label_y - 9;
|
||||
const Color c = ink.with_alpha(dim);
|
||||
if (row.expander == 1)
|
||||
{
|
||||
list.line(cx - 3, cy - 8, cx + 4, cy, 3.0f, c);
|
||||
list.line(cx + 4, cy, cx - 3, cy + 8, 3.0f, c);
|
||||
}
|
||||
else
|
||||
{
|
||||
list.line(cx - 8, cy - 3, cx, cy + 4, 3.0f, c);
|
||||
list.line(cx, cy + 4, cx + 8, cy - 3, 3.0f, c);
|
||||
}
|
||||
}
|
||||
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));
|
||||
if (two_lines)
|
||||
ui::text(list, fonts.regular, fonts.regular.font->fit(row.detail, 19, room), x, label_y + 30, 19,
|
||||
ui::text(list, fonts.regular, fonts.regular.font->fit(row.detail, 19, room), x + indent, label_y + 30, 19,
|
||||
soft.with_alpha(dim));
|
||||
}
|
||||
list.pop_clip();
|
||||
|
||||
@@ -25,6 +25,7 @@ enum class RowKind
|
||||
choice, // left and right step through its options
|
||||
action, // Cross does it
|
||||
info, // only shows something
|
||||
header // a section's name
|
||||
};
|
||||
|
||||
struct Row
|
||||
@@ -36,6 +37,7 @@ struct Row
|
||||
bool on = false; // a toggle's state
|
||||
bool enabled = true;
|
||||
bool danger = false; // an action that can't be undone
|
||||
int expander = 0; // 1: closed, 2: open: a chevron before the label (Cross opens what's under it)
|
||||
};
|
||||
|
||||
struct RowEvent
|
||||
@@ -73,7 +75,7 @@ struct Named
|
||||
};
|
||||
inline constexpr Named kPolicies[] = {
|
||||
{"ask", "Ask me"}, {"newest", "Keep the newest"}, {"local", "Keep this console's"}, {"server", "Keep RomM's"}};
|
||||
inline constexpr Named kStates[] = {{"off", "Off"}, {"upload", "Upload only"}, {"sync", "Upload and download"}};
|
||||
inline constexpr Named kStates[] = {{"off", "Off"}, {"upload", "Back up to RomM"}, {"sync", "Back up and restore"}};
|
||||
inline constexpr int kIntervals[] = {0, 15, 30, 60, 120, 360};
|
||||
|
||||
const char *label_of(std::span<const Named> options, const std::string &id);
|
||||
|
||||
@@ -8,7 +8,8 @@
|
||||
// what the pad asks for.
|
||||
//
|
||||
// - L1 / R1 move between Home, Library and Downloads.
|
||||
// - Home: the sync at a glance; Cross syncs now.
|
||||
// - 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
|
||||
@@ -29,12 +30,14 @@
|
||||
#include "library_page.hpp"
|
||||
#include "settings_page.hpp"
|
||||
#include "setup_page.hpp"
|
||||
#include "sync_views.hpp"
|
||||
#include "ui/glyphs.hpp"
|
||||
#include "ui/motion.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <cstdio>
|
||||
#include <ctime>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
@@ -64,7 +67,7 @@ enum Tab
|
||||
constexpr const char *kTabNames[kTabs] = {"Home", "Library", "Downloads", "Settings"};
|
||||
|
||||
constexpr const char *kTechniques[] = {
|
||||
"The RomM Sync payload's state, read through its local API on a worker thread",
|
||||
"The background service's state, read through its local API on a worker thread",
|
||||
"Covers fetched once, decoded a few per frame, their colours driving the backdrop",
|
||||
"Spring-driven shelves, card growth and a gliding focus ring (from Aurora Shelf)",
|
||||
"Frosted details sheet over the blurred screen",
|
||||
@@ -126,7 +129,7 @@ class RommPS final : public app::Concept
|
||||
const bool calling = settings_.call().active();
|
||||
if (calling)
|
||||
settings_.call().update(input, dt, feedback);
|
||||
if (ready && !sheet_open_ && !calling && !setup_.editing())
|
||||
if (ready && !sheet_open_ && !calling && !setup_.editing() && !view_.active())
|
||||
{
|
||||
if (input.is_pressed(Action::page_prev) || input.is_pressed(Action::page_next))
|
||||
{
|
||||
@@ -136,7 +139,10 @@ class RommPS final : public app::Concept
|
||||
if (tab_ == kSettings)
|
||||
settings_.enter(app_);
|
||||
if (tab_ == kLibrary)
|
||||
{
|
||||
library_.enter(app_);
|
||||
app_.reload_platforms(); // emulators may have changed meanwhile
|
||||
}
|
||||
}
|
||||
}
|
||||
// Settings' Emulators opens that setup step on its own.
|
||||
@@ -160,6 +166,8 @@ class RommPS final : public app::Concept
|
||||
feedback.play(audio::Cue::focus);
|
||||
}
|
||||
}
|
||||
else if (view_.active())
|
||||
view_.update(app_, input, dt, feedback);
|
||||
else if (tab_ == kHome)
|
||||
update_home(input, feedback);
|
||||
else if (tab_ == kLibrary)
|
||||
@@ -224,6 +232,8 @@ class RommPS final : public app::Concept
|
||||
setup_.draw(list, context_.fonts, L, app_, clock_);
|
||||
else if (!ready)
|
||||
draw_not_ready(list);
|
||||
else if (view_.active())
|
||||
view_.draw(list, context_.fonts, L, app_, clock_);
|
||||
else if (tab_ == kHome)
|
||||
draw_home(list);
|
||||
else if (tab_ == kLibrary)
|
||||
@@ -302,9 +312,27 @@ class RommPS final : public app::Concept
|
||||
nudge_.trigger();
|
||||
}
|
||||
|
||||
// Home's buttons: Sync now, then the lists beside it.
|
||||
static constexpr int kHomeButtons = 4;
|
||||
|
||||
void update_home(const InputFrame &input, app::Feedback &feedback)
|
||||
{
|
||||
if (input.is_pressed(Action::confirm))
|
||||
if (!app_.config().save_sync)
|
||||
return; // nothing to sync: Settings turns it back on
|
||||
if (input.nav == Direction::left || input.nav == Direction::right)
|
||||
{
|
||||
const int next = home_focus_ + (input.nav == Direction::right ? 1 : -1);
|
||||
if (next >= 0 && next < kHomeButtons)
|
||||
{
|
||||
home_focus_ = next;
|
||||
feedback.play(audio::Cue::focus);
|
||||
}
|
||||
else if (!input.nav_repeat)
|
||||
refuse(feedback, input.nav == Direction::right ? 1.0f : -1.0f);
|
||||
}
|
||||
if (!input.is_pressed(Action::confirm))
|
||||
return;
|
||||
if (home_focus_ == 0)
|
||||
{
|
||||
if (app_.status().syncing)
|
||||
{
|
||||
@@ -314,7 +342,12 @@ class RommPS final : public app::Concept
|
||||
app_.sync_now();
|
||||
feedback.play(audio::Cue::launch);
|
||||
feedback.rumble(0.5f, 0.12f);
|
||||
return;
|
||||
}
|
||||
static const rommps::SyncView::Kind kinds[] = {rommps::SyncView::kConflicts, rommps::SyncView::kActivity,
|
||||
rommps::SyncView::kTracked};
|
||||
view_.open(app_, kinds[home_focus_ - 1]);
|
||||
feedback.play(audio::Cue::select);
|
||||
}
|
||||
|
||||
void open_sheet()
|
||||
@@ -333,7 +366,7 @@ class RommPS final : public app::Concept
|
||||
{
|
||||
std::string label;
|
||||
bool enabled;
|
||||
int kind; // 0 download, 1 cancel, 2 close
|
||||
int kind; // 0 download, 1 cancel, 3 remove, 4 saves and states (Circle closes the sheet)
|
||||
};
|
||||
|
||||
std::vector<SheetAction> sheet_actions() const
|
||||
@@ -342,15 +375,19 @@ 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;
|
||||
if (d && d->active())
|
||||
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)
|
||||
actions.push_back({"Downloaded", false, 0});
|
||||
{
|
||||
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
|
||||
actions.push_back({"Download", true, 0});
|
||||
actions.push_back({"Close", true, 2});
|
||||
return actions;
|
||||
}
|
||||
|
||||
@@ -362,7 +399,7 @@ class RommPS final : public app::Concept
|
||||
bool hold_to_cancel(const InputFrame &input, float dt, const rommps::Download *d, app::Feedback &feedback)
|
||||
{
|
||||
cancel_seen_ = true;
|
||||
if (!d || !d->active() || !input.is_held(Action::confirm))
|
||||
if (!d || !d->cancellable() || !input.is_held(Action::confirm))
|
||||
{
|
||||
cancel_hold_ = 0;
|
||||
cancel_job_ = 0;
|
||||
@@ -394,6 +431,35 @@ class RommPS final : public app::Concept
|
||||
return job_id && job_id == cancel_job_ && cancel_hold_ > 0 ? std::min(1.0f, cancel_hold_ / kCancelHold) : 0.0f;
|
||||
}
|
||||
|
||||
bool hold_to_remove(const InputFrame &input, float dt, app::Feedback &feedback)
|
||||
{
|
||||
if (!input.is_held(Action::confirm))
|
||||
{
|
||||
remove_hold_ = -1;
|
||||
return false;
|
||||
}
|
||||
|
||||
if (input.is_pressed(Action::confirm))
|
||||
{
|
||||
remove_hold_ = 0; // every hold begins with a press
|
||||
feedback.play(audio::Cue::tick, 1.0f, 0.0f, 0.5f);
|
||||
}
|
||||
|
||||
if (remove_hold_ < 0)
|
||||
return false; // was held from before or is already done
|
||||
|
||||
remove_hold_ += dt;
|
||||
|
||||
if (remove_hold_ < kCancelHold)
|
||||
return false;
|
||||
|
||||
app_.remove_game(sheet_game_.id);
|
||||
feedback.play(audio::Cue::back);
|
||||
feedback.rumble(0.8f, 0.2f);
|
||||
remove_hold_ = -1;
|
||||
return true;
|
||||
}
|
||||
|
||||
void update_sheet(const InputFrame &input, float dt, app::Feedback &feedback)
|
||||
{
|
||||
const std::vector<SheetAction> actions = sheet_actions();
|
||||
@@ -408,11 +474,18 @@ class RommPS final : public app::Concept
|
||||
}
|
||||
}
|
||||
action_ = std::clamp(action_, 0, count - 1);
|
||||
if (actions[static_cast<std::size_t>(action_)].kind != 3)
|
||||
remove_hold_ = -1;
|
||||
if (actions[static_cast<std::size_t>(action_)].kind == 1)
|
||||
{
|
||||
if (hold_to_cancel(input, dt, app_.download_for(sheet_game_.id), feedback))
|
||||
sheet_open_ = false;
|
||||
}
|
||||
else if (actions[static_cast<std::size_t>(action_)].kind == 3)
|
||||
{
|
||||
if (hold_to_remove(input, dt, feedback))
|
||||
sheet_open_ = false;
|
||||
}
|
||||
else if (input.is_pressed(Action::confirm))
|
||||
{
|
||||
const SheetAction &a = actions[static_cast<std::size_t>(action_)];
|
||||
@@ -426,6 +499,12 @@ class RommPS final : public app::Concept
|
||||
feedback.rumble(0.7f, 0.18f);
|
||||
sheet_open_ = false;
|
||||
}
|
||||
else if (a.kind == 4 && g)
|
||||
{
|
||||
feedback.play(audio::Cue::select);
|
||||
sheet_open_ = false;
|
||||
view_.open(app_, rommps::SyncView::kGame, g->id, g->name);
|
||||
}
|
||||
else
|
||||
{
|
||||
feedback.play(audio::Cue::modal_close);
|
||||
@@ -470,7 +549,7 @@ class RommPS final : public app::Concept
|
||||
if (count > 0 && download_row_ < static_cast<int>(app_.downloads().size()))
|
||||
{
|
||||
const rommps::Download &d = app_.downloads()[static_cast<std::size_t>(download_row_)];
|
||||
if (d.active())
|
||||
if (d.cancellable())
|
||||
hold_to_cancel(input, dt, &d, feedback);
|
||||
else if (input.is_pressed(Action::confirm))
|
||||
refuse(feedback, 0.0f);
|
||||
@@ -505,8 +584,8 @@ class RommPS final : public app::Concept
|
||||
const rommps::Status &s = app_.status();
|
||||
const bool old_payload = s.reachable && app_.payload_too_old();
|
||||
std::string where = !s.known ? "Connecting"
|
||||
: !s.reachable ? "RomM Sync isn't running"
|
||||
: old_payload ? "RomM Sync " + s.version + " is too old: update it in Settings"
|
||||
: !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,
|
||||
@@ -534,12 +613,12 @@ class RommPS final : public app::Concept
|
||||
const float x = panel.x + 72;
|
||||
const bool starting = app_.payload_starting() || (s.known && !s.reachable && app_.payload_error().empty());
|
||||
ui::text(list, fonts.display,
|
||||
!s.known ? "Connecting to RomM Sync" : starting ? "Starting RomM Sync" : "RomM Sync isn't running", x,
|
||||
!s.known ? "Connecting to the background service" : starting ? "Starting the background service" : "The background service isn't running", x,
|
||||
panel.y + 120, 56, L.panel_text);
|
||||
ui::paragraph(list, fonts.regular,
|
||||
starting ? "RomM Sync does the syncing in the background, and RommPS starts it for you. "
|
||||
starting ? "RommPS syncs through a background service, and starts it for you. "
|
||||
"This takes a few seconds."
|
||||
: "RommPS couldn't start RomM Sync. Press Cross to try again, or load romm-sync.elf "
|
||||
: "RommPS couldn't start its background service. Press Cross to try again, or load romm-sync.elf "
|
||||
"with your HEN's payload loader.",
|
||||
x, panel.y + 190, 28, 1060, 42, L.panel_muted, 4);
|
||||
if (!starting && !app_.payload_error().empty())
|
||||
@@ -560,6 +639,13 @@ class RommPS final : public app::Concept
|
||||
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);
|
||||
ui::text(list, fonts.regular, "RomM is only for games. Turn it on in Settings.", x, 360, 28, L.muted);
|
||||
list.pop_opacity();
|
||||
return;
|
||||
}
|
||||
// The headline only says synced when the last sync went through.
|
||||
std::string headline = s.syncing ? "Syncing"
|
||||
: !s.sync_count ? "Not synced yet"
|
||||
@@ -569,17 +655,38 @@ class RommPS final : public app::Concept
|
||||
: "Your saves are synced";
|
||||
ui::text(list, fonts.display, headline, x - 4, 304, 80, L.text);
|
||||
std::string line = s.syncing ? (s.phase.empty() ? "Working" : s.phase)
|
||||
: s.sync_count ? "Last sync " + ago(s.uptime - s.last_run) + " \xC2\xB7 " + s.last_result
|
||||
: s.conflicts > 0 ? std::string("Open Conflicts to choose which copy to keep")
|
||||
: s.sync_count ? "Last sync " + ago(std::difftime(std::time(nullptr), s.last_run)) +
|
||||
" \xC2\xB7 " + s.last_result
|
||||
: "No sync yet";
|
||||
ui::text(list, fonts.regular, context_.fonts.regular.font->fit(line, 28, 1500), x, 360, 28, L.muted);
|
||||
|
||||
// Sync now
|
||||
const Rect button{x, 410, 260, 68};
|
||||
list.glow(button, 34, 18, accent().with_alpha(0.35f));
|
||||
list.rounded_rect(button, 34, s.syncing ? L.button.with_alpha(0.4f) : L.button);
|
||||
ui::draw_button(list, fonts, L.covers ? ui::GlyphStyle::light() : ui::GlyphStyle::dark(), ui::Button::cross,
|
||||
button.x + 24, button.cy(), 34);
|
||||
ui::text(list, fonts.semibold, "Sync now", button.x + 76, button.cy() + 10, 28, L.on_button);
|
||||
// Sync now, and the lists beside it; the focused one is lit.
|
||||
std::string labels[kHomeButtons] = {"Sync now", "Conflicts", "Activity", "Tracked saves"};
|
||||
if (s.conflicts > 0)
|
||||
labels[1] += " (" + std::to_string(s.conflicts) + ")";
|
||||
float bx = x;
|
||||
for (int i = 0; i < kHomeButtons; ++i)
|
||||
{
|
||||
const bool focused = i == home_focus_;
|
||||
const float w = fonts.semibold.font->measure(labels[i], 28) + (focused ? 104.0f : 64.0f);
|
||||
const Rect button{bx, 410, w, 68};
|
||||
if (focused)
|
||||
{
|
||||
list.glow(button, 34, 18, accent().with_alpha(0.35f));
|
||||
list.rounded_rect(button, 34, i == 0 && s.syncing ? L.button.with_alpha(0.4f) : L.button);
|
||||
ui::draw_button(list, fonts, L.covers ? ui::GlyphStyle::light() : ui::GlyphStyle::dark(), ui::Button::cross,
|
||||
button.x + 24, button.cy(), 34);
|
||||
ui::text(list, fonts.semibold, labels[i], button.x + 76, button.cy() + 10, 28, L.on_button);
|
||||
}
|
||||
else
|
||||
{
|
||||
draw_panel(list, button, 34);
|
||||
ui::text(list, fonts.semibold, labels[i], button.x + 32, button.cy() + 10, 28,
|
||||
i == 1 && s.conflicts ? L.warning : L.panel_text);
|
||||
}
|
||||
bx += w + 20;
|
||||
}
|
||||
|
||||
// Tiles
|
||||
char value[64];
|
||||
@@ -594,7 +701,7 @@ class RommPS final : public app::Concept
|
||||
tiles[1] = {"CONFLICTS", value};
|
||||
std::snprintf(value, sizeof value, "%d up \xC2\xB7 %d down", s.last_up, s.last_down);
|
||||
tiles[2] = {"LAST SYNC", s.sync_count ? value : "-"};
|
||||
tiles[3] = {"ROMM SYNC", "v" + s.version};
|
||||
tiles[3] = {"ROMMPS", "v" + s.version};
|
||||
for (int i = 0; i < 4; ++i)
|
||||
{
|
||||
const float appear = tween::stagger(tab_age_, 2 + i, 0.07f, 0.5f);
|
||||
@@ -607,8 +714,6 @@ class RommPS final : public app::Concept
|
||||
46, i == 1 && s.conflicts ? L.warning : L.panel_text);
|
||||
list.pop_opacity();
|
||||
}
|
||||
ui::text(list, fonts.regular, context_.fonts.regular.font->fit(s.server, 22, 1700), kMargin, 850, 22,
|
||||
L.muted.with_alpha(0.7f * L.muted.a));
|
||||
list.pop_opacity();
|
||||
}
|
||||
|
||||
@@ -654,7 +759,7 @@ class RommPS final : public app::Concept
|
||||
}
|
||||
if (downloads.empty())
|
||||
{
|
||||
ui::text(list, fonts.regular, "Nothing downloading. Pick a game in Library to download it.", kMargin, 310, 28,
|
||||
ui::text(list, fonts.regular, "Nothing downloading.", kMargin, 310, 28,
|
||||
L.muted);
|
||||
list.pop_opacity();
|
||||
return;
|
||||
@@ -679,6 +784,10 @@ class RommPS final : public app::Concept
|
||||
}
|
||||
else if (d.status == "error")
|
||||
meta += d.error;
|
||||
else if (d.syncing())
|
||||
meta += "Syncing saves";
|
||||
else if (d.status == "done" && !d.saves_note().empty())
|
||||
meta += "Downloaded \xC2\xB7 " + d.saves_note();
|
||||
else
|
||||
{
|
||||
static const char *const kStates[][2] = {{"queued", "Waiting"},
|
||||
@@ -806,6 +915,8 @@ class RommPS final : public app::Concept
|
||||
if (const rommps::Download *d = app_.download_for(g->id))
|
||||
if (const float hold = cancel_progress(d->id); hold > 0)
|
||||
list.rounded_rect({ax, y, std::max(72.0f, 460 * hold), 72}, 36, L.danger.with_alpha(0.85f));
|
||||
if (a.kind == 3 && focused && remove_hold_ > 0)
|
||||
list.rounded_rect({ax, y, std::max(72.0f, 460 * std::min(1.0f, remove_hold_ / kCancelHold)), 72}, 36, L.danger.with_alpha(0.85f));
|
||||
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));
|
||||
}
|
||||
@@ -848,12 +959,21 @@ class RommPS final : public app::Concept
|
||||
ui::draw_hints(list, fonts, style, hints, 2, 1824, true);
|
||||
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);
|
||||
return;
|
||||
}
|
||||
list.push_opacity(tween::stagger(age_, 8, 0.08f, 0.5f) * (1.0f - sheet_.value));
|
||||
const ui::Hint pages{ui::Button::l1, "Pages", ui::Button::r1};
|
||||
const ui::Hint close{ui::Button::options, "Hold to close"}; // rommps.cpp's HoldToLeave
|
||||
if (tab_ == kHome)
|
||||
{
|
||||
const ui::Hint hints[] = {pages, {ui::Button::cross, "Sync now"}};
|
||||
ui::draw_hints(list, fonts, style, hints, 2, 1824, true);
|
||||
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);
|
||||
}
|
||||
else if (tab_ == kLibrary)
|
||||
{
|
||||
@@ -866,7 +986,7 @@ class RommPS final : public app::Concept
|
||||
// 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_)].active();
|
||||
downloads[static_cast<std::size_t>(download_row_)].cancellable();
|
||||
const bool any_finished = std::any_of(downloads.begin(), downloads.end(),
|
||||
[](const rommps::Download &d) { return d.finished(); });
|
||||
std::vector<ui::Hint> hints{pages};
|
||||
@@ -878,8 +998,8 @@ class RommPS final : public app::Concept
|
||||
}
|
||||
else
|
||||
{
|
||||
const ui::Hint hints[] = {pages, {ui::Button::cross, settings_.cross_hint(app_)}};
|
||||
ui::draw_hints(list, fonts, style, hints, 2, 1824, true);
|
||||
const ui::Hint hints[] = {close, pages, {ui::Button::cross, settings_.cross_hint(app_)}};
|
||||
ui::draw_hints(list, fonts, style, hints, 3, 1824, true);
|
||||
}
|
||||
list.pop_opacity();
|
||||
}
|
||||
@@ -887,6 +1007,8 @@ class RommPS final : public app::Concept
|
||||
app::Context &context_;
|
||||
App &app_;
|
||||
int tab_ = kLibrary;
|
||||
int home_focus_ = 0; // Home's button with the focus
|
||||
rommps::SyncView view_; // a list opened from Home or a game's sheet
|
||||
float tab_age_ = 0.0f;
|
||||
tween::Spring tab_position_;
|
||||
float age_ = 0.0f;
|
||||
@@ -901,6 +1023,7 @@ class RommPS final : public app::Concept
|
||||
tween::Spring action_position_;
|
||||
int download_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_;
|
||||
|
||||
@@ -3,10 +3,12 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#include "settings_page.hpp"
|
||||
|
||||
#include "cJSON.h"
|
||||
#include "core/tween.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstdio>
|
||||
#include <memory>
|
||||
|
||||
namespace rommps
|
||||
{
|
||||
@@ -48,23 +50,25 @@ void SettingsPage::enter(App &app)
|
||||
on_credits_ = false;
|
||||
}
|
||||
|
||||
std::vector<Row> SettingsPage::rows(const App &app) const
|
||||
std::vector<Row> SettingsPage::rows(const App &app, std::vector<int> *ids) const
|
||||
{
|
||||
const Config &c = app.config();
|
||||
const Update &u = app.update();
|
||||
std::vector<Row> r(kCount);
|
||||
const Look &l = look(current_look());
|
||||
r[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[kInterval] = {RowKind::choice, "Sync every", interval_label(c.sync_interval_min), "Besides the syncs below"};
|
||||
r[kOnExit] = {RowKind::toggle, "Sync when a game closes", "", "Uploads what the game saved", c.sync_on_game_exit};
|
||||
r[kOnStart] = {RowKind::toggle, "Sync when a game starts", "", "Fetches newer saves before you play",
|
||||
r[kInterval] = {RowKind::choice, "Sync every", interval_label(c.sync_interval_min), ""};
|
||||
r[kOnExit] = {RowKind::toggle, "Sync when a game closes", "", "", c.sync_on_game_exit};
|
||||
r[kOnStart] = {RowKind::toggle, "Sync when a game starts", "", "",
|
||||
c.sync_on_game_start};
|
||||
r[kOnChange] = {RowKind::toggle, "Upload saves as they change", "", "While you play", c.sync_on_change};
|
||||
r[kStates] = {RowKind::choice, "Save states", label_of(rommps::kStates, c.states), "Besides the saves themselves"};
|
||||
r[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),
|
||||
"On this console and on RomM since the last sync"};
|
||||
""};
|
||||
r[kConcurrency] = {RowKind::choice, "Downloads at once", std::to_string(c.download_concurrency), ""};
|
||||
r[kNotify] = {RowKind::toggle, "Notifications", "", "On the console when a sync ends or fails", c.notify};
|
||||
r[kNotify] = {RowKind::toggle, "Notifications", "", "", c.notify};
|
||||
r[kUpdateCheck] = {RowKind::toggle, "Look for updates every day", "", "", c.update_check};
|
||||
{
|
||||
const Status &s = app.status();
|
||||
@@ -82,14 +86,25 @@ std::vector<Row> SettingsPage::rows(const App &app) const
|
||||
else if (s.covers_total > 0)
|
||||
std::snprintf(detail, sizeof detail, "All %d covers are on the console", s.covers_total);
|
||||
else
|
||||
std::snprintf(detail, sizeof detail, "Faster browsing: every game's cover, kept up to date");
|
||||
std::snprintf(detail, sizeof detail, "%s", "");
|
||||
r[kCovers] = {RowKind::toggle, "Keep all covers on the console", "", detail, c.cover_cache};
|
||||
}
|
||||
|
||||
Row &update = r[kUpdate];
|
||||
update.kind = RowKind::action;
|
||||
update.label = "RomM Sync " + (u.current.empty() ? app.status().version : u.current);
|
||||
if (u.busy())
|
||||
update.label = "RommPS " + (u.current.empty() ? app.status().version : u.current);
|
||||
if (!u.self_update)
|
||||
{
|
||||
// The app brings its background service with it: the store updates both.
|
||||
update.label = std::string("RommPS ") + App::kMinPayload;
|
||||
update.value = u.state == "checking" ? "Checking" : "Check for updates";
|
||||
update.enabled = u.state != "checking";
|
||||
update.detail = u.available ? "RommPS " + u.latest + " is out: update it in ProsperoStore"
|
||||
: u.state == "error" ? (u.error.empty() ? u.message : u.error)
|
||||
: u.latest.empty() ? "Updates come from ProsperoStore"
|
||||
: "Up to date";
|
||||
}
|
||||
else if (u.busy())
|
||||
{
|
||||
update.value = u.message.empty() ? "Working" : u.message;
|
||||
if (u.total > 0)
|
||||
@@ -103,7 +118,7 @@ std::vector<Row> SettingsPage::rows(const App &app) const
|
||||
else if (u.available)
|
||||
{
|
||||
update.value = "Install " + u.latest;
|
||||
update.detail = "RomM Sync restarts with the new version";
|
||||
update.detail = "RommPS restarts with the new version";
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -114,16 +129,56 @@ std::vector<Row> SettingsPage::rows(const App &app) const
|
||||
: "Up to date";
|
||||
}
|
||||
|
||||
r[kEmulators] = {RowKind::action, "Emulators", "", "Which emulators RomM Sync uses, and for which systems"};
|
||||
r[kSetup] = {RowKind::action, "Set up again", "", "Pick your emulators again. Background syncs wait until it's done"};
|
||||
r[kEmulators] = {RowKind::action, "Emulators", "", ""};
|
||||
r[kSetup] = {RowKind::action, "Set up again", "", ""};
|
||||
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"};
|
||||
r[kDiagnostics].enabled = diag_ != 1;
|
||||
if (armed_ == kSetup || armed_ == kForget)
|
||||
r[static_cast<std::size_t>(armed_)].value = "Press again to confirm";
|
||||
if (!c.known)
|
||||
for (int i = kInterval; i <= kCovers; ++i)
|
||||
r[static_cast<std::size_t>(i)].enabled = false;
|
||||
return r;
|
||||
|
||||
// In sections.
|
||||
static const struct
|
||||
{
|
||||
const char *name;
|
||||
int ids[7];
|
||||
} kSections[] = {
|
||||
{"Sync", {kSaveSync, kInterval, kOnExit, kOnStart, kOnChange, kPolicy, kStates}},
|
||||
{"Downloads", {kConcurrency, kCovers, -1}},
|
||||
{"Look and notifications", {kLook, kNotify, -1}},
|
||||
{"Emulators", {kEmulators, kSetup, -1}},
|
||||
{"Updates", {kUpdate, kUpdateCheck, -1}},
|
||||
{"RomM", {kForget, -1}},
|
||||
{"Help", {kDiagnostics, -1}},
|
||||
};
|
||||
std::vector<Row> out;
|
||||
if (ids)
|
||||
ids->clear();
|
||||
for (const auto §ion : kSections)
|
||||
{
|
||||
out.push_back({RowKind::header, section.name});
|
||||
if (ids)
|
||||
ids->push_back(kHeader);
|
||||
for (int id : section.ids)
|
||||
{
|
||||
if (id < 0)
|
||||
break;
|
||||
// Save sync off: just the switch to turn it back on.
|
||||
if (!c.save_sync && id >= kInterval && id <= kPolicy)
|
||||
continue;
|
||||
out.push_back(r[static_cast<std::size_t>(id)]);
|
||||
if (ids)
|
||||
ids->push_back(id);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const char *SettingsPage::cross_hint(const App &app) const
|
||||
@@ -131,7 +186,7 @@ const char *SettingsPage::cross_hint(const App &app) const
|
||||
if (on_credits_)
|
||||
return "Answer";
|
||||
const std::vector<Row> r = rows(app);
|
||||
const Row &row = r[static_cast<std::size_t>(std::clamp(list_.focus(), 0, kCount - 1))];
|
||||
const Row &row = r[static_cast<std::size_t>(std::clamp(list_.focus(), 0, static_cast<int>(r.size()) - 1))];
|
||||
switch (row.kind)
|
||||
{
|
||||
case RowKind::toggle:
|
||||
@@ -146,12 +201,14 @@ const char *SettingsPage::cross_hint(const App &app) const
|
||||
void SettingsPage::update(App &app, const hui::InputFrame &input, float dt, hui::ui::Feedback &feedback)
|
||||
{
|
||||
avatar_texture(static_cast<hui::gfx::Renderer *>(rommps_renderer()));
|
||||
const std::vector<Row> r = rows(app);
|
||||
std::vector<int> ids;
|
||||
const std::vector<Row> r = rows(app, &ids);
|
||||
const int count = static_cast<int>(r.size());
|
||||
armed_age_ += dt;
|
||||
// Below the last row: the author's portrait, and the call behind it.
|
||||
if (on_credits_)
|
||||
{
|
||||
list_.update(dt, kCount, kRows.h);
|
||||
list_.update(dt, count, kRows.h);
|
||||
if (input.nav == hui::Direction::up || input.is_pressed(hui::Action::back))
|
||||
{
|
||||
on_credits_ = false;
|
||||
@@ -166,21 +223,26 @@ void SettingsPage::update(App &app, const hui::InputFrame &input, float dt, hui:
|
||||
list_.refuse(feedback);
|
||||
return;
|
||||
}
|
||||
if (input.nav == hui::Direction::down && !input.nav_repeat && list_.focus() == kCount - 1)
|
||||
if (input.nav == hui::Direction::down && !input.nav_repeat && list_.focus() == count - 1)
|
||||
{
|
||||
on_credits_ = true;
|
||||
feedback.play(hui::audio::Cue::focus);
|
||||
return;
|
||||
}
|
||||
if (armed_ >= 0 && (armed_age_ > kArmedFor || list_.focus() != armed_))
|
||||
const int focused_id = ids[static_cast<std::size_t>(std::clamp(list_.focus(), 0, count - 1))];
|
||||
if (armed_ >= 0 && (armed_age_ > kArmedFor || focused_id != armed_))
|
||||
armed_ = -1;
|
||||
const RowEvent e = list_.handle(r, input, feedback);
|
||||
list_.update(dt, kCount, kRows.h);
|
||||
list_.update(dt, count, kRows.h);
|
||||
if (e.row < 0)
|
||||
return;
|
||||
const int id = ids[static_cast<std::size_t>(e.row)];
|
||||
const Config &c = app.config();
|
||||
switch (e.row)
|
||||
switch (id)
|
||||
{
|
||||
case kSaveSync:
|
||||
app.set_config(flag_json("save_sync", !c.save_sync), [](Config &k) { k.save_sync = !k.save_sync; });
|
||||
break;
|
||||
case kLook:
|
||||
set_look((current_look() + e.step + look_count()) % look_count());
|
||||
break;
|
||||
@@ -230,7 +292,7 @@ void SettingsPage::update(App &app, const hui::InputFrame &input, float dt, hui:
|
||||
app.set_config(flag_json("cover_cache", !c.cover_cache), [](Config &k) { k.cover_cache = !k.cover_cache; });
|
||||
break;
|
||||
case kUpdate:
|
||||
if (app.update().available)
|
||||
if (app.update().available && app.update().self_update)
|
||||
app.install_update();
|
||||
else
|
||||
app.check_update();
|
||||
@@ -238,16 +300,27 @@ void SettingsPage::update(App &app, const hui::InputFrame &input, float dt, hui:
|
||||
case kEmulators:
|
||||
emulators_asked_ = true;
|
||||
break;
|
||||
case kDiagnostics:
|
||||
diag_ = 1;
|
||||
app.post("/api/diagnostics", "{}", [this](const Response &r) {
|
||||
std::unique_ptr<cJSON, void (*)(cJSON *)> j(r.ok() ? r.json() : nullptr, cJSON_Delete);
|
||||
const cJSON *usb = cJSON_GetObjectItemCaseSensitive(j.get(), "usb"),
|
||||
*path = cJSON_GetObjectItemCaseSensitive(j.get(), "path");
|
||||
const cJSON *at = cJSON_IsString(usb) ? usb : path;
|
||||
diag_ = cJSON_IsString(at) ? 2 : 3;
|
||||
diag_where_ = cJSON_IsString(at) ? at->valuestring : "";
|
||||
});
|
||||
break;
|
||||
case kSetup:
|
||||
case kForget:
|
||||
if (armed_ != e.row)
|
||||
if (armed_ != id)
|
||||
{
|
||||
armed_ = e.row;
|
||||
armed_ = id;
|
||||
armed_age_ = 0.0f;
|
||||
break;
|
||||
}
|
||||
armed_ = -1;
|
||||
if (e.row == kSetup)
|
||||
if (id == kSetup)
|
||||
app.setup_restart();
|
||||
else
|
||||
app.pair_forget();
|
||||
@@ -286,7 +359,7 @@ void SettingsPage::draw(hui::gfx::DrawList &list, const hui::ui::Fonts &fonts, c
|
||||
std::snprintf(web, sizeof web, "http://%s:%d", s.ip.c_str(), s.port);
|
||||
item("ALSO ON YOUR PHONE OR COMPUTER", s.ip.empty() ? "" : web);
|
||||
const Update &u = app.update();
|
||||
item("STARTS WITH THE CONSOLE", !u.known ? "" : u.autostart ? "Yes" : "No, load romm-sync.elf after each boot");
|
||||
item("STARTS WITH THE CONSOLE", !u.known ? "" : u.autostart ? "Yes" : "No: turn it on in Set up again");
|
||||
list.line(panel.x + 36, panel.y + panel.h - 196, panel.x + panel.w - 36, panel.y + panel.h - 196, 1.5f, look.outline);
|
||||
draw_credits(list, fonts, look, {x, panel.y + panel.h - 168, panel.w - 72, 136}, on_credits_, clock);
|
||||
list.pop_opacity();
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
// RommPS: the Settings tab. The look, when and what RomM Sync syncs, its
|
||||
// updates, the setup again and unpairing; an About panel beside them.
|
||||
// RommPS: the Settings tab, in sections: when and what it syncs, downloads,
|
||||
// the look and notifications, emulators, updates and the RomM connection; an
|
||||
// About panel beside them.
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
|
||||
#include "credits.hpp"
|
||||
#include "rommps_app.hpp"
|
||||
#include "rows.hpp"
|
||||
@@ -34,6 +37,7 @@ class SettingsPage
|
||||
private:
|
||||
enum Id
|
||||
{
|
||||
kSaveSync,
|
||||
kLook,
|
||||
kInterval,
|
||||
kOnExit,
|
||||
@@ -49,14 +53,20 @@ class SettingsPage
|
||||
kEmulators,
|
||||
kSetup,
|
||||
kForget,
|
||||
kCount
|
||||
kDiagnostics,
|
||||
kCount,
|
||||
kHeader = -1 // a section's name
|
||||
};
|
||||
std::vector<Row> rows(const App &app) const;
|
||||
// The rows as shown, sections included; ids[i] is row i's Id.
|
||||
std::vector<Row> rows(const App &app, std::vector<int> *ids = nullptr) const;
|
||||
|
||||
RowList list_;
|
||||
bool on_credits_ = false;
|
||||
bool emulators_asked_ = false; // the focus is on the author's portrait, below the rows
|
||||
CodecCall call_;
|
||||
// The diagnostic bundle: 0 not asked, 1 being made, 2 saved (where_), 3 failed.
|
||||
int diag_ = 0;
|
||||
std::string diag_where_;
|
||||
int armed_ = -1; // a row that does something lasting, waiting for its second press
|
||||
float armed_age_ = 0.0f;
|
||||
};
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -2,8 +2,9 @@
|
||||
//
|
||||
// 1. the RomM server's address, typed on the kit's on-screen keyboard;
|
||||
// 2. the code to approve in RomM (its device pairing), until it's approved;
|
||||
// 3. the emulators found on the console, and which one plays a system
|
||||
// several of them can;
|
||||
// 3. the emulators found on the console, then each system they play: which
|
||||
// one plays it when several can, and its folders (the ones found, or
|
||||
// folders the user picks);
|
||||
// 4. what the first sync would do, and a word about backups;
|
||||
// 5. when to sync, then the first sync.
|
||||
//
|
||||
@@ -17,6 +18,7 @@
|
||||
#include "rows.hpp"
|
||||
#include "ui/components/keyboard.hpp"
|
||||
|
||||
#include <array>
|
||||
#include <map>
|
||||
#include <set>
|
||||
#include <string>
|
||||
@@ -72,9 +74,14 @@ class SetupPage
|
||||
std::vector<std::string> cores;
|
||||
std::vector<System> systems;
|
||||
bool on = true;
|
||||
std::string sync = "all"; // its save sync: "all", "saves" (no states) or "off"
|
||||
bool ready = true; // false: found, no preset yet (set up by its folders, like a system with no emulator)
|
||||
bool fresh = false; // found since the last setup
|
||||
std::string note;
|
||||
// Its folders for each system when left as they are (detection's
|
||||
// "dirs"): games, saves, states; and which it doesn't let you change.
|
||||
std::map<std::string, std::array<std::string, 3>> dirs;
|
||||
std::map<std::string, int> games; // games already in its folder, by system
|
||||
std::set<std::string> fixed; // "rom_dir", "save_dir", "state_dir"
|
||||
};
|
||||
// What a row of the emulator step does.
|
||||
struct RowRef
|
||||
@@ -83,11 +90,15 @@ class SetupPage
|
||||
{
|
||||
kNone,
|
||||
kEmulator,
|
||||
kEmuSync, // an emulator's save sync (unused since save sync is per platform)
|
||||
kSysSync, // a platform's save sync
|
||||
kMain,
|
||||
kAlso,
|
||||
kPs2,
|
||||
kOwn,
|
||||
kOwnFolder,
|
||||
kFolders, // a system's folders in an emulator: as found, or picked
|
||||
kSysFolder, // one of them (emulator: 0 games, 1 saves, 2 states)
|
||||
kContinue
|
||||
} kind = kNone;
|
||||
int emulator = -1; // or, for kOwnFolder, which folder (0 games, 1 saves, 2 states)
|
||||
@@ -119,19 +130,31 @@ class SetupPage
|
||||
void load_emulators(App &app);
|
||||
void load_preview(App &app);
|
||||
void load_autostart(App &app);
|
||||
std::vector<Conflict> conflicts() const;
|
||||
// The root of the emulator that keeps a system: the one picked, else RetroArch, else the first.
|
||||
std::vector<Conflict> conflicts() const; // systems several emulators on play
|
||||
std::vector<Conflict> systems() const; // every system an emulator on plays, by name
|
||||
// The root of the emulator that keeps a system: the one picked ("" when
|
||||
// it's off), else RetroArch, else the first.
|
||||
std::string chosen(const Conflict &conflict) const;
|
||||
// Rows above the emulators' (a message instead of them).
|
||||
int lead() const { return error_.empty() && !emulators_.empty() ? 0 : 1; }
|
||||
// Whether an emulator plays a system, as the main one or an extra.
|
||||
bool used(const Emulator &e) const;
|
||||
// Rows above the emulators': their heading, or a message instead of them.
|
||||
int lead() const { return 1; }
|
||||
std::vector<Row> emulator_rows(const App &app, std::vector<RowRef> *refs = nullptr) const;
|
||||
std::vector<Row> preview_rows() const;
|
||||
std::vector<Row> finish_rows() const;
|
||||
// Settings' Emulators: what Save sends, whether it differs from what was
|
||||
// saved, and saving it.
|
||||
std::string save_body() const;
|
||||
bool dirty() const;
|
||||
void save_emulators(App &app, hui::ui::Feedback &feedback);
|
||||
// Leaving with changes not saved: Save, Discard or Keep editing.
|
||||
std::vector<Row> leave_rows() const;
|
||||
// ids: what each row is (the steps' rows depend on save_sync_).
|
||||
std::vector<Row> preview_rows(std::vector<int> *ids = nullptr) const;
|
||||
std::vector<Row> finish_rows(std::vector<int> *ids = nullptr) const;
|
||||
bool has_ps2() const;
|
||||
// The server's systems no ticked emulator plays, in the server's order.
|
||||
std::vector<std::string> uncovered() const;
|
||||
// The folder picker (/api/fs), for one of a system's folders.
|
||||
void open_picker(App &app, const std::string &key, int which);
|
||||
void open_picker(App &app, const std::string &key, int which, const std::string &root = "");
|
||||
void browse(App &app, const std::string &path);
|
||||
void update_picker(App &app, const hui::InputFrame &input, float dt, hui::ui::Feedback &feedback);
|
||||
void draw_picker(hui::gfx::DrawList &list, const hui::ui::Fonts &fonts, const Look &look, float clock) const;
|
||||
@@ -159,15 +182,26 @@ class SetupPage
|
||||
// 3
|
||||
std::vector<Emulator> emulators_;
|
||||
bool emulators_loaded_ = false;
|
||||
std::map<std::string, std::string> choices_; // system key -> the root of its main emulator
|
||||
float retry_in_ = 0.0f; // after an error: looking again (the service may have been restarting)
|
||||
std::map<std::string, std::string> choices_; // system key -> the root of its main emulator ("": off)
|
||||
std::map<std::string, std::string> sync_; // system key -> its save sync: "all", "saves" or "off"
|
||||
std::string expanded_; // the system whose details show
|
||||
int focus_expander_ = 0; // the focused row's chevron (for the hint): 0, 1 closed, 2 open
|
||||
std::map<std::string, std::set<std::string>> also_; // system key -> roots of its extra emulators
|
||||
// Folders picked for a system in an emulator, instead of the ones found:
|
||||
// system key -> emulator root -> games, saves, states ("" keeps that one).
|
||||
std::map<std::string, std::map<std::string, std::array<std::string, 3>>> custom_dirs_;
|
||||
bool editing_ = false;
|
||||
std::string saved_; // save_body() + ps2_cards_ as last saved (Settings' Emulators)
|
||||
bool leaving_ = false; // asking whether to save before going back
|
||||
RowList leave_list_;
|
||||
std::string ps2_cards_ = "per_game";
|
||||
std::map<std::string, Own> own_; // by system key (first slug)
|
||||
std::vector<std::string> own_order_; // the server's order
|
||||
std::vector<std::string> manual_; // own_ keys of emulators with no preset, in the order found
|
||||
bool picking_ = false;
|
||||
std::string pick_key_;
|
||||
std::string pick_root_; // set: the folder is for custom_dirs_[pick_key_][pick_root_], not own_
|
||||
int pick_which_ = 0;
|
||||
std::string fs_path_, fs_parent_;
|
||||
std::vector<std::string> fs_folders_;
|
||||
@@ -178,11 +212,13 @@ class SetupPage
|
||||
// 4
|
||||
Preview preview_;
|
||||
bool backed_up_ = false;
|
||||
bool save_sync_ = true; // false: RomM only as a game source, the sync steps skipped
|
||||
|
||||
// 5
|
||||
Config finish_;
|
||||
bool finish_known_ = false;
|
||||
bool autostart_supported_ = false, autostart_installed_ = false, autostart_on_ = false;
|
||||
bool autostart_defaulted_ = false; // turned on for a first setup already
|
||||
};
|
||||
|
||||
} // namespace rommps
|
||||
@@ -0,0 +1,493 @@
|
||||
// RommPS: lists about the sync. See sync_views.hpp.
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#include "sync_views.hpp"
|
||||
|
||||
#include "cJSON.h"
|
||||
#include "core/tween.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstdio>
|
||||
#include <ctime>
|
||||
#include <memory>
|
||||
|
||||
namespace rommps
|
||||
{
|
||||
|
||||
namespace
|
||||
{
|
||||
|
||||
namespace ui = hui::ui;
|
||||
using hui::Action;
|
||||
using hui::audio::Cue;
|
||||
using hui::gfx::Rect;
|
||||
using Json = std::unique_ptr<cJSON, void (*)(cJSON *)>;
|
||||
|
||||
constexpr float kMargin = 96.0f;
|
||||
constexpr float kArmedFor = 4.0f;
|
||||
const Rect kRows{kMargin - 32, 300, 1792, 650};
|
||||
|
||||
const char *str(const cJSON *o, const char *key, const char *fallback = "")
|
||||
{
|
||||
const cJSON *v = cJSON_GetObjectItemCaseSensitive(o, key);
|
||||
return cJSON_IsString(v) ? v->valuestring : fallback;
|
||||
}
|
||||
|
||||
double num(const cJSON *o, const char *key, double fallback = 0)
|
||||
{
|
||||
const cJSON *v = cJSON_GetObjectItemCaseSensitive(o, key);
|
||||
return cJSON_IsNumber(v) ? v->valuedouble : fallback;
|
||||
}
|
||||
|
||||
std::string json_str(const std::string &s)
|
||||
{
|
||||
std::string out = "\"";
|
||||
for (char c : s)
|
||||
{
|
||||
if (c == '"' || c == '\\')
|
||||
out += '\\';
|
||||
out += c;
|
||||
}
|
||||
return out + "\"";
|
||||
}
|
||||
|
||||
std::string ago(double t)
|
||||
{
|
||||
if (t <= 0)
|
||||
return "never";
|
||||
const double s = std::difftime(std::time(nullptr), static_cast<std::time_t>(t));
|
||||
char text[32];
|
||||
if (s < 60)
|
||||
return "just now";
|
||||
if (s < 3600)
|
||||
std::snprintf(text, sizeof text, "%d min ago", static_cast<int>(s / 60));
|
||||
else if (s < 86400)
|
||||
std::snprintf(text, sizeof text, "%d h ago", static_cast<int>(s / 3600));
|
||||
else
|
||||
std::snprintf(text, sizeof text, s < 2 * 86400 ? "%d day ago" : "%d days ago", static_cast<int>(s / 86400));
|
||||
return text;
|
||||
}
|
||||
|
||||
// "2026-10-14T06:53:37+00:00" as "2026-10-14 06:53 UTC".
|
||||
std::string when(const char *iso)
|
||||
{
|
||||
const std::string s = iso ? iso : "";
|
||||
if (s.size() < 16)
|
||||
return s;
|
||||
return s.substr(0, 10) + " " + s.substr(11, 5) + " UTC";
|
||||
}
|
||||
|
||||
std::string local_time(double t)
|
||||
{
|
||||
if (t <= 0)
|
||||
return "never";
|
||||
const std::time_t tt = static_cast<std::time_t>(t);
|
||||
char text[32];
|
||||
std::strftime(text, sizeof text, "%Y-%m-%d %H:%M", std::localtime(&tt));
|
||||
return text;
|
||||
}
|
||||
|
||||
std::string base_name(const std::string &path)
|
||||
{
|
||||
const std::string p = path.substr(0, path.find('#')); // a save folder synced as a zip
|
||||
const std::size_t slash = p.find_last_of('/');
|
||||
return slash == std::string::npos ? p : p.substr(slash + 1);
|
||||
}
|
||||
|
||||
std::string size_text(double bytes)
|
||||
{
|
||||
char text[32];
|
||||
if (bytes >= 1024 * 1024)
|
||||
std::snprintf(text, sizeof text, "%.1f MB", bytes / (1024 * 1024));
|
||||
else
|
||||
std::snprintf(text, sizeof text, "%.0f KB", bytes / 1024);
|
||||
return text;
|
||||
}
|
||||
|
||||
// "uploaded" -> "Uploaded"
|
||||
std::string capital(std::string s)
|
||||
{
|
||||
if (!s.empty() && s[0] >= 'a' && s[0] <= 'z')
|
||||
s[0] = static_cast<char>(s[0] - 'a' + 'A');
|
||||
return s;
|
||||
}
|
||||
|
||||
const char *status_label(const std::string &status)
|
||||
{
|
||||
return status == "synced" ? "In sync"
|
||||
: status == "changed" ? "Changed here"
|
||||
: status == "conflict" ? "Conflict"
|
||||
: status == "missing" ? "Missing"
|
||||
: status == "no_game" ? "Game not here"
|
||||
: status.c_str();
|
||||
}
|
||||
|
||||
// A platform's name: RomM's, else the folder's own.
|
||||
std::string platform_name(const App &app, const std::string &dir)
|
||||
{
|
||||
for (const Platform &p : app.platforms())
|
||||
if (p.slug == dir)
|
||||
return p.name;
|
||||
std::string up = dir;
|
||||
for (char &c : up)
|
||||
if (c >= 'a' && c <= 'z')
|
||||
c = static_cast<char>(c - 'a' + 'A');
|
||||
return up.empty() ? std::string("Other") : up;
|
||||
}
|
||||
|
||||
// One log entry as a row: the game, its result ("Downloaded") on the right,
|
||||
// then "Save · RetroArch (mGBA) · 3 min ago".
|
||||
Row event_row(const cJSON *e, bool with_game)
|
||||
{
|
||||
const std::string kind = str(e, "kind"), action = str(e, "action");
|
||||
const std::string what = kind == "card" ? std::string("Memory card") : capital(kind);
|
||||
std::string label = what;
|
||||
if (with_game && kind != "card")
|
||||
{
|
||||
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();
|
||||
auto more = [&detail](const std::string &part) {
|
||||
if (!part.empty())
|
||||
detail += (detail.empty() ? "" : " \xC2\xB7 ") + part;
|
||||
};
|
||||
more(str(e, "detail"));
|
||||
if (action != "skipped")
|
||||
more(plain_text(str(e, "emulator")));
|
||||
more(ago(num(e, "t")));
|
||||
Row r{RowKind::info, label, capital(action), detail};
|
||||
r.danger = action == "failed" || action == "conflict";
|
||||
return r;
|
||||
}
|
||||
|
||||
// One tracked file as a row: the game (or the kind, inside a game's view),
|
||||
// its status on the right, then "Save · synced 3 min ago".
|
||||
Row file_row(const cJSON *f, const std::string &game)
|
||||
{
|
||||
const std::string kind = str(f, "kind");
|
||||
std::string detail = (game.empty() ? std::string() : capital(kind) + " \xC2\xB7 ") + "synced " + ago(num(f, "synced_at"));
|
||||
if (kind == "state" && !cJSON_IsTrue(cJSON_GetObjectItemCaseSensitive(f, "from_here")) && num(f, "server_id") > 0)
|
||||
detail += " \xC2\xB7 from another device";
|
||||
Row r{RowKind::info, game.empty() ? capital(kind) : game, status_label(str(f, "status")), detail};
|
||||
r.danger = std::string(str(f, "status")) == "missing";
|
||||
return r;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
void SyncView::open(App &app, Kind kind, int rom_id, const std::string &name)
|
||||
{
|
||||
active_ = true;
|
||||
kind_ = kind;
|
||||
rom_id_ = rom_id;
|
||||
name_ = name;
|
||||
choosing_ = -1;
|
||||
armed_ = -1;
|
||||
age_ = 0.0f;
|
||||
busy_ = false;
|
||||
list_.set_focus(0);
|
||||
load(app);
|
||||
}
|
||||
|
||||
void SyncView::load(App &app)
|
||||
{
|
||||
const int gen = ++generation_;
|
||||
items_.clear();
|
||||
conflicts_.clear();
|
||||
switch (kind_)
|
||||
{
|
||||
case kConflicts:
|
||||
title_ = "Conflicts";
|
||||
subtitle_ = "The copy you don't keep is backed up.";
|
||||
break;
|
||||
case kActivity:
|
||||
title_ = "Activity";
|
||||
subtitle_ = "Newest first";
|
||||
break;
|
||||
case kTracked:
|
||||
title_ = "Tracked saves";
|
||||
subtitle_ = "";
|
||||
break;
|
||||
case kGame:
|
||||
title_ = plain_text(name_.c_str());
|
||||
subtitle_ = "Cross twice restores a version from RomM";
|
||||
break;
|
||||
}
|
||||
add({RowKind::info, "Loading", "", ""});
|
||||
waiting_ = 1;
|
||||
|
||||
if (kind_ == kConflicts)
|
||||
{
|
||||
app.get("/api/status", [this, gen](const Response &r) {
|
||||
if (gen != generation_)
|
||||
return;
|
||||
Json j(r.json(), cJSON_Delete);
|
||||
const cJSON *c;
|
||||
cJSON_ArrayForEach(c, cJSON_GetObjectItemCaseSensitive(cJSON_GetObjectItemCaseSensitive(j.get(), "sync"), "conflicts"))
|
||||
{
|
||||
Conflict k;
|
||||
k.path = str(c, "path");
|
||||
k.game = plain_text(*str(c, "rom_name") ? str(c, "rom_name") : base_name(k.path).c_str());
|
||||
k.local = local_time(num(c, "local_mtime"));
|
||||
k.server = when(str(c, "server_updated_at"));
|
||||
conflicts_.push_back(std::move(k));
|
||||
}
|
||||
if (!r.ok())
|
||||
conflicts_.clear();
|
||||
show_conflicts();
|
||||
if (!r.ok())
|
||||
{
|
||||
items_.clear();
|
||||
add({RowKind::info, "Couldn't load them", "", r.message()});
|
||||
}
|
||||
});
|
||||
}
|
||||
else if (kind_ == kActivity)
|
||||
{
|
||||
app.get("/api/events?limit=200", [this, gen](const Response &r) {
|
||||
if (gen != generation_)
|
||||
return;
|
||||
items_.clear();
|
||||
Json j(r.json(), cJSON_Delete);
|
||||
const bool ok = r.ok() && cJSON_IsArray(j.get());
|
||||
const cJSON *e;
|
||||
if (ok)
|
||||
cJSON_ArrayForEach(e, j.get()) add(event_row(e, true));
|
||||
if (items_.empty())
|
||||
add({RowKind::info, ok ? "Nothing yet" : "Couldn't load the log", "",
|
||||
ok ? "" : r.ok() ? "RommPS's background service is out of date" : r.message()});
|
||||
});
|
||||
}
|
||||
else if (kind_ == kTracked)
|
||||
{
|
||||
app.get("/api/tracked", [this, gen, &app](const Response &r) {
|
||||
if (gen != generation_)
|
||||
return;
|
||||
items_.clear();
|
||||
Json j(r.json(), cJSON_Delete);
|
||||
const bool ok = r.ok() && cJSON_IsArray(cJSON_GetObjectItemCaseSensitive(j.get(), "platforms"));
|
||||
struct Plat
|
||||
{
|
||||
std::string name;
|
||||
const cJSON *games;
|
||||
};
|
||||
std::vector<Plat> plats;
|
||||
const cJSON *p;
|
||||
cJSON_ArrayForEach(p, cJSON_GetObjectItemCaseSensitive(j.get(), "platforms"))
|
||||
plats.push_back({platform_name(app, str(p, "platform")), cJSON_GetObjectItemCaseSensitive(p, "games")});
|
||||
std::sort(plats.begin(), plats.end(), [](const Plat &a, const Plat &b) { return a.name < b.name; });
|
||||
for (const Plat &pl : plats)
|
||||
{
|
||||
add({RowKind::header, pl.name});
|
||||
const cJSON *g, *f;
|
||||
cJSON_ArrayForEach(g, pl.games)
|
||||
{
|
||||
const std::string game = plain_text(str(g, "name"));
|
||||
cJSON_ArrayForEach(f, cJSON_GetObjectItemCaseSensitive(g, "files")) add(file_row(f, game));
|
||||
}
|
||||
}
|
||||
if (items_.empty())
|
||||
add({RowKind::info, ok ? "Nothing tracked yet" : "Couldn't load them", "",
|
||||
ok ? "" : r.ok() ? "RommPS's background service is out of date" : r.message()});
|
||||
char sub[96];
|
||||
std::snprintf(sub, sizeof sub, "%d saves, %d states", static_cast<int>(num(j.get(), "saves")),
|
||||
static_cast<int>(num(j.get(), "states")));
|
||||
if (ok)
|
||||
subtitle_ = sub;
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
// A game: what's here, RomM's versions and the game's log, shown together.
|
||||
waiting_ = 3;
|
||||
game_here_.clear();
|
||||
game_history_.clear();
|
||||
game_log_.clear();
|
||||
const std::string id = std::to_string(rom_id_);
|
||||
auto done = [this, gen](std::string *slot, const Response &r) {
|
||||
if (gen != generation_)
|
||||
return;
|
||||
*slot = r.ok() ? r.body : std::string("!") + r.message();
|
||||
if (--waiting_ > 0)
|
||||
return;
|
||||
items_.clear();
|
||||
Json here(cJSON_Parse(game_here_.c_str()), cJSON_Delete), history(cJSON_Parse(game_history_.c_str()), cJSON_Delete),
|
||||
log(cJSON_Parse(game_log_.c_str()), cJSON_Delete);
|
||||
add({RowKind::header, "On this console"});
|
||||
const std::size_t before = items_.size();
|
||||
const cJSON *p, *g, *f;
|
||||
cJSON_ArrayForEach(p, cJSON_GetObjectItemCaseSensitive(here.get(), "platforms"))
|
||||
cJSON_ArrayForEach(g, cJSON_GetObjectItemCaseSensitive(p, "games"))
|
||||
cJSON_ArrayForEach(f, cJSON_GetObjectItemCaseSensitive(g, "files")) add(file_row(f, ""));
|
||||
if (items_.size() == before)
|
||||
add({RowKind::info, "Nothing here yet", "", ""});
|
||||
static const char *const kinds[2][2] = {{"saves", "Saves on RomM"}, {"states", "Save states on RomM"}};
|
||||
for (int k = 0; k < 2; ++k)
|
||||
{
|
||||
add({RowKind::header, kinds[k][1]});
|
||||
const cJSON *list = cJSON_GetObjectItemCaseSensitive(history.get(), kinds[k][0]);
|
||||
std::vector<const cJSON *> versions;
|
||||
const cJSON *v;
|
||||
cJSON_ArrayForEach(v, list) versions.push_back(v);
|
||||
std::sort(versions.begin(), versions.end(), [](const cJSON *a, const cJSON *b) {
|
||||
return std::string(str(a, "updated_at")) > std::string(str(b, "updated_at"));
|
||||
});
|
||||
for (const cJSON *x : versions)
|
||||
{
|
||||
std::string from = *str(x, "device") ? str(x, "device") : str(x, "emulator");
|
||||
std::string detail = plain_text(str(x, "file_name")) + " \xC2\xB7 " + size_text(num(x, "size"));
|
||||
if (!from.empty())
|
||||
detail += " \xC2\xB7 from " + plain_text(from.c_str());
|
||||
add({RowKind::action, when(str(x, "updated_at")), "Restore", detail}, k ? kRestoreState : kRestoreSave,
|
||||
static_cast<int>(num(x, "id")));
|
||||
}
|
||||
if (versions.empty())
|
||||
add({RowKind::info, game_history_[0] == '!' ? "Couldn't ask RomM" : "Nothing on RomM yet", "",
|
||||
game_history_[0] == '!' ? game_history_.substr(1) : ""});
|
||||
}
|
||||
add({RowKind::header, "Recent"});
|
||||
const std::size_t log_at = items_.size();
|
||||
const cJSON *e;
|
||||
int n = 0;
|
||||
cJSON_ArrayForEach(e, log.get()) if (n++ < 30) add(event_row(e, false));
|
||||
if (items_.size() == log_at)
|
||||
add({RowKind::info, "Nothing synced yet", "", ""});
|
||||
};
|
||||
app.get("/api/tracked?rom_id=" + id, [done, this](const Response &r) { done(&game_here_, r); });
|
||||
app.get("/api/history?rom_id=" + id, [done, this](const Response &r) { done(&game_history_, r); });
|
||||
app.get("/api/events?limit=30&rom_id=" + id, [done, this](const Response &r) { done(&game_log_, r); });
|
||||
}
|
||||
}
|
||||
|
||||
void SyncView::show_conflicts()
|
||||
{
|
||||
items_.clear();
|
||||
choosing_ = -1;
|
||||
for (std::size_t i = 0; i < conflicts_.size(); ++i)
|
||||
{
|
||||
const Conflict &c = conflicts_[i];
|
||||
add({RowKind::action, c.game, "Choose", "This console: " + c.local + " \xC2\xB7 RomM: " + c.server}, kPickConflict,
|
||||
static_cast<int>(i));
|
||||
}
|
||||
if (items_.empty())
|
||||
add({RowKind::info, "No conflicts", "", ""});
|
||||
}
|
||||
|
||||
std::vector<Row> SyncView::rows() const
|
||||
{
|
||||
std::vector<Row> out;
|
||||
out.reserve(items_.size());
|
||||
for (const Item &it : items_)
|
||||
out.push_back(it.row);
|
||||
if (armed_ >= 0 && armed_ < static_cast<int>(out.size()))
|
||||
out[static_cast<std::size_t>(armed_)].value = "Press again to restore";
|
||||
return out;
|
||||
}
|
||||
|
||||
void SyncView::update(App &app, const hui::InputFrame &input, float dt, hui::ui::Feedback &feedback)
|
||||
{
|
||||
age_ += dt;
|
||||
armed_age_ += dt;
|
||||
if (input.is_pressed(Action::back))
|
||||
{
|
||||
feedback.play(Cue::back);
|
||||
if (choosing_ >= 0)
|
||||
show_conflicts();
|
||||
else
|
||||
active_ = false;
|
||||
return;
|
||||
}
|
||||
const std::vector<Row> r = rows();
|
||||
if (armed_ >= 0 && (armed_age_ > kArmedFor || list_.focus() != armed_))
|
||||
armed_ = -1;
|
||||
const RowEvent e = list_.handle(r, input, feedback);
|
||||
list_.update(dt, static_cast<int>(r.size()), kRows.h);
|
||||
if (e.row < 0 || !e.activated || busy_)
|
||||
return;
|
||||
const Item it = items_[static_cast<std::size_t>(e.row)];
|
||||
switch (it.action)
|
||||
{
|
||||
case kPickConflict:
|
||||
{
|
||||
const Conflict &c = conflicts_[static_cast<std::size_t>(it.arg)];
|
||||
items_.clear();
|
||||
choosing_ = it.arg;
|
||||
add({RowKind::info, c.game, "", "Which copy should stay?"});
|
||||
add({RowKind::action, "Keep this console's", "", "Changed here " + c.local + ". RomM's copy is replaced."}, kKeepLocal);
|
||||
add({RowKind::action, "Keep RomM's", "", "Changed on RomM " + c.server + ". This console's copy is backed up."},
|
||||
kKeepServer);
|
||||
add({RowKind::action, "Not now"}, kCancel);
|
||||
list_.set_focus(1);
|
||||
break;
|
||||
}
|
||||
case kKeepLocal:
|
||||
case kKeepServer:
|
||||
{
|
||||
const Conflict c = conflicts_[static_cast<std::size_t>(choosing_)];
|
||||
const bool local = it.action == kKeepLocal;
|
||||
busy_ = true;
|
||||
app.post("/api/conflicts/resolve",
|
||||
"{\"path\":" + json_str(c.path) + ",\"keep\":\"" + (local ? "local" : "server") + "\"}",
|
||||
[this, &app, local](const Response &r) {
|
||||
busy_ = false;
|
||||
app.say(r.ok() ? (local ? "Kept this console's save" : "Kept RomM's save") : "Couldn't settle it: " + r.message());
|
||||
app.refresh_now();
|
||||
load(app);
|
||||
});
|
||||
break;
|
||||
}
|
||||
case kCancel:
|
||||
show_conflicts();
|
||||
list_.set_focus(0);
|
||||
break;
|
||||
case kRestoreSave:
|
||||
case kRestoreState:
|
||||
if (armed_ != e.row)
|
||||
{
|
||||
armed_ = e.row;
|
||||
armed_age_ = 0.0f;
|
||||
break;
|
||||
}
|
||||
armed_ = -1;
|
||||
busy_ = true;
|
||||
app.post("/api/restore",
|
||||
std::string("{\"kind\":\"") + (it.action == kRestoreSave ? "save" : "state") + "\",\"id\":" +
|
||||
std::to_string(it.arg) + "}",
|
||||
[this, &app](const Response &r) {
|
||||
busy_ = false;
|
||||
app.say(r.ok() ? "Restored. The current file was backed up" : "Couldn't restore it: " + r.message());
|
||||
if (r.ok())
|
||||
load(app);
|
||||
});
|
||||
break;
|
||||
default:
|
||||
list_.refuse(feedback);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
void SyncView::draw(hui::gfx::DrawList &list, const hui::ui::Fonts &fonts, const Look &look, const App &app,
|
||||
float clock) const
|
||||
{
|
||||
(void)app;
|
||||
const float in = hui::tween::stagger(age_, 1, 0.08f, 0.6f);
|
||||
list.push_opacity(in);
|
||||
ui::text(list, fonts.display, fonts.display.font->fit(title_, 64, 1700), kMargin, 220, 64, look.text);
|
||||
ui::text(list, fonts.regular, fonts.regular.font->fit(subtitle_, 24, 1700), kMargin, 268, 24, look.muted);
|
||||
list_.draw(list, fonts, look, rows(), {kRows.x, kRows.y + 24 * (1.0f - in), kRows.w, kRows.h}, clock);
|
||||
list.pop_opacity();
|
||||
}
|
||||
|
||||
std::vector<hui::ui::Hint> SyncView::hints() const
|
||||
{
|
||||
std::vector<hui::ui::Hint> h;
|
||||
const int f = list_.focus();
|
||||
const bool can = f >= 0 && f < static_cast<int>(items_.size()) && items_[static_cast<std::size_t>(f)].action != kNothing;
|
||||
if (can)
|
||||
h.push_back({hui::ui::Button::cross, choosing_ >= 0 ? "Choose" : kind_ == kGame ? "Restore" : "Choose"});
|
||||
h.push_back({hui::ui::Button::circle, "Back"});
|
||||
return h;
|
||||
}
|
||||
|
||||
} // namespace rommps
|
||||
@@ -0,0 +1,93 @@
|
||||
// RommPS: lists about the sync, opened from Home and from a game's sheet.
|
||||
//
|
||||
// - Conflicts: saves changed here and on RomM since the last sync. Cross on
|
||||
// one asks which copy to keep; the other is backed up.
|
||||
// - Activity: the sync log (the payload's /api/events), newest first.
|
||||
// - Tracked saves: the saves and states kept in sync, by platform, each
|
||||
// with its status (/api/tracked).
|
||||
// - A game's saves and states: what's on this console, the versions RomM
|
||||
// keeps (Cross twice restores one) and the game's log.
|
||||
//
|
||||
// Each is a list of rows (rows.hpp) filled from the payload when it opens;
|
||||
// Circle closes it.
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#pragma once
|
||||
|
||||
#include "rommps_app.hpp"
|
||||
#include "rows.hpp"
|
||||
#include "ui/glyphs.hpp"
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace rommps
|
||||
{
|
||||
|
||||
class SyncView
|
||||
{
|
||||
public:
|
||||
enum Kind
|
||||
{
|
||||
kConflicts,
|
||||
kActivity,
|
||||
kTracked,
|
||||
kGame
|
||||
};
|
||||
|
||||
void open(App &app, Kind kind, int rom_id = 0, const std::string &name = "");
|
||||
void close() { active_ = false; }
|
||||
bool active() const { return active_; }
|
||||
Kind kind() const { return kind_; }
|
||||
|
||||
void update(App &app, const hui::InputFrame &input, float dt, hui::ui::Feedback &feedback);
|
||||
void draw(hui::gfx::DrawList &list, const hui::ui::Fonts &fonts, const Look &look, const App &app, float clock) const;
|
||||
std::vector<hui::ui::Hint> hints() const;
|
||||
|
||||
private:
|
||||
// What Cross does on a row.
|
||||
enum Do
|
||||
{
|
||||
kNothing,
|
||||
kPickConflict, // opens the choice for conflicts_[arg]
|
||||
kKeepLocal,
|
||||
kKeepServer,
|
||||
kCancel,
|
||||
kRestoreSave, // arg: RomM's save id
|
||||
kRestoreState // arg: RomM's state id
|
||||
};
|
||||
struct Item
|
||||
{
|
||||
Row row;
|
||||
Do action = kNothing;
|
||||
int arg = 0;
|
||||
};
|
||||
struct Conflict
|
||||
{
|
||||
std::string path, game, local, server;
|
||||
};
|
||||
|
||||
void load(App &app);
|
||||
void show_conflicts();
|
||||
void add(Row row, Do action = kNothing, int arg = 0) { items_.push_back({std::move(row), action, arg}); }
|
||||
std::vector<Row> rows() const;
|
||||
|
||||
bool active_ = false;
|
||||
Kind kind_ = kActivity;
|
||||
int rom_id_ = 0;
|
||||
std::string name_;
|
||||
std::string title_, subtitle_;
|
||||
std::vector<Item> items_;
|
||||
std::vector<Conflict> conflicts_;
|
||||
int choosing_ = -1; // the conflict whose choice is showing, or -1
|
||||
int armed_ = -1; // a restore row waiting for its second press
|
||||
float armed_age_ = 0.0f, age_ = 0.0f;
|
||||
int generation_ = 0; // answers for an older open() are dropped
|
||||
int waiting_ = 0; // requests still out
|
||||
bool busy_ = false; // a resolve or restore in flight
|
||||
// A game's view gathers three answers before it shows.
|
||||
std::string game_here_, game_history_, game_log_;
|
||||
RowList list_;
|
||||
};
|
||||
|
||||
} // namespace rommps
|
||||
@@ -10,7 +10,7 @@
|
||||
"conceptId": "76677",
|
||||
"contentBadgeType": 1,
|
||||
"contentId": "UP9000-PPSA76677_00-ROMMPS0000000000",
|
||||
"contentVersion": "01.001.000",
|
||||
"contentVersion": "01.002.000",
|
||||
"downloadDataSize": 256,
|
||||
"gameIntent": {
|
||||
"permittedIntents": [
|
||||
|
||||
@@ -132,6 +132,12 @@ hui::InputFrame Kit::scripted(const char *program, uint32_t frame) const
|
||||
input.pressed |= hui::action_bit(hui::Action::page_next);
|
||||
} else if (press.action == "l1") {
|
||||
input.pressed |= hui::action_bit(hui::Action::page_prev);
|
||||
} else if (press.action == "circle") {
|
||||
input.pressed |= hui::action_bit(hui::Action::back);
|
||||
} else if (press.action == "triangle") {
|
||||
input.pressed |= hui::action_bit(hui::Action::north);
|
||||
} else if (press.action == "square") {
|
||||
input.pressed |= hui::action_bit(hui::Action::west);
|
||||
}
|
||||
input.held |= input.pressed;
|
||||
}
|
||||
|
||||
@@ -9,8 +9,10 @@
|
||||
#include <sys/time.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#include <atomic>
|
||||
#include <cerrno>
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
|
||||
#include "cJSON.h"
|
||||
@@ -20,7 +22,7 @@ namespace rommps {
|
||||
cJSON *Response::json() const { return body.empty() ? nullptr : cJSON_ParseWithLength(body.data(), body.size()); }
|
||||
|
||||
std::string Response::message() const {
|
||||
if (status == 0) return error.empty() ? "RomM Sync isn't answering" : error;
|
||||
if (status == 0) return error.empty() ? "RommPS's background service isn't answering" : error;
|
||||
std::string out;
|
||||
if (cJSON *j = json()) {
|
||||
const cJSON *e = cJSON_GetObjectItemCaseSensitive(j, "error");
|
||||
@@ -41,6 +43,21 @@ static bool send_all(int fd, const char *p, size_t n) {
|
||||
return true;
|
||||
}
|
||||
|
||||
namespace {
|
||||
// The request on the wire, so closing the app needn't wait for its answer.
|
||||
std::atomic<int> g_open_fd{-1};
|
||||
|
||||
struct OpenSocket {
|
||||
int fd;
|
||||
explicit OpenSocket(int f) : fd(f) { g_open_fd = f; }
|
||||
~OpenSocket() {
|
||||
int expected = fd;
|
||||
g_open_fd.compare_exchange_strong(expected, -1);
|
||||
::close(fd);
|
||||
}
|
||||
};
|
||||
} // namespace
|
||||
|
||||
Response http_request(const std::string &host, int port, const std::string &method, const std::string &path,
|
||||
const std::string &body, int timeout_ms) {
|
||||
Response r;
|
||||
@@ -49,6 +66,7 @@ Response http_request(const std::string &host, int port, const std::string &meth
|
||||
r.error = std::string("socket: ") + std::strerror(errno);
|
||||
return r;
|
||||
}
|
||||
const OpenSocket open(fd);
|
||||
timeval tv{timeout_ms / 1000, (timeout_ms % 1000) * 1000};
|
||||
::setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof tv);
|
||||
::setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, &tv, sizeof tv);
|
||||
@@ -56,8 +74,7 @@ Response http_request(const std::string &host, int port, const std::string &meth
|
||||
sa.sin_family = AF_INET;
|
||||
sa.sin_port = htons((uint16_t)port);
|
||||
if (::inet_pton(AF_INET, host.c_str(), &sa.sin_addr) != 1 || ::connect(fd, (sockaddr *)&sa, sizeof sa) != 0) {
|
||||
r.error = "RomM Sync isn't running (" + std::string(std::strerror(errno)) + ")";
|
||||
::close(fd);
|
||||
r.error = "RommPS's background service isn't running (" + std::string(std::strerror(errno)) + ")";
|
||||
return r;
|
||||
}
|
||||
char head[1024];
|
||||
@@ -67,7 +84,6 @@ Response http_request(const std::string &host, int port, const std::string &meth
|
||||
method.c_str(), path.c_str(), host.c_str(), body.size());
|
||||
if (hn <= 0 || hn >= (int)sizeof head || !send_all(fd, head, (size_t)hn) || !send_all(fd, body.data(), body.size())) {
|
||||
r.error = "couldn't send the request";
|
||||
::close(fd);
|
||||
return r;
|
||||
}
|
||||
std::string in;
|
||||
@@ -76,18 +92,16 @@ Response http_request(const std::string &host, int port, const std::string &meth
|
||||
ssize_t n = ::recv(fd, buf, sizeof buf, 0);
|
||||
if (n < 0 && errno == EINTR) continue;
|
||||
if (n < 0) {
|
||||
r.error = errno == EAGAIN || errno == EWOULDBLOCK ? "RomM Sync took too long to answer" : std::strerror(errno);
|
||||
::close(fd);
|
||||
r.error = errno == EAGAIN || errno == EWOULDBLOCK ? "RommPS's background service took too long to answer" : std::strerror(errno);
|
||||
return r;
|
||||
}
|
||||
if (n == 0) break;
|
||||
in.append(buf, (size_t)n);
|
||||
}
|
||||
::close(fd);
|
||||
size_t split = in.find("\r\n\r\n");
|
||||
int status = 0;
|
||||
if (split == std::string::npos || std::sscanf(in.c_str(), "HTTP/%*d.%*d %d", &status) != 1) {
|
||||
r.error = "RomM Sync sent a reply the app can't read";
|
||||
r.error = "RommPS's background service sent a reply the app can't read";
|
||||
return r;
|
||||
}
|
||||
r.status = status;
|
||||
@@ -95,6 +109,29 @@ Response http_request(const std::string &host, int port, const std::string &meth
|
||||
return r;
|
||||
}
|
||||
|
||||
int payload_port()
|
||||
{
|
||||
static const int port = [] {
|
||||
int p = 8780;
|
||||
if (FILE *f = std::fopen("/data/romm-sync/config.json", "rb"))
|
||||
{
|
||||
char buf[8192];
|
||||
const size_t n = std::fread(buf, 1, sizeof buf - 1, f);
|
||||
std::fclose(f);
|
||||
buf[n] = 0;
|
||||
if (const char *k = std::strstr(buf, "\"web_port\""))
|
||||
if (const char *colon = std::strchr(k, ':'))
|
||||
{
|
||||
const long v = std::strtol(colon + 1, nullptr, 10);
|
||||
if (v > 0 && v < 65536)
|
||||
p = static_cast<int>(v);
|
||||
}
|
||||
}
|
||||
return p;
|
||||
}();
|
||||
return port;
|
||||
}
|
||||
|
||||
Api::Api(std::string host, int port) : host_(std::move(host)), port_(port), thread_(&Api::worker, this) {}
|
||||
|
||||
Api::~Api() {
|
||||
@@ -103,13 +140,16 @@ Api::~Api() {
|
||||
stopping_ = true;
|
||||
}
|
||||
wake_.notify_all();
|
||||
// A request still waiting for its answer ends now.
|
||||
const int fd = g_open_fd.exchange(-1);
|
||||
if (fd >= 0) ::shutdown(fd, SHUT_RDWR);
|
||||
thread_.join();
|
||||
}
|
||||
|
||||
void Api::get(const std::string &path, Callback done) {
|
||||
void Api::get(const std::string &path, Callback done, int timeout_ms) {
|
||||
{
|
||||
std::lock_guard<std::mutex> g(lock_);
|
||||
queue_.push_back(Job{"GET", path, "", std::move(done), {}});
|
||||
queue_.push_back(Job{"GET", path, "", std::move(done), {}, timeout_ms});
|
||||
}
|
||||
wake_.notify_one();
|
||||
}
|
||||
@@ -148,7 +188,7 @@ void Api::worker() {
|
||||
queue_.pop_front();
|
||||
in_flight_ = 1;
|
||||
}
|
||||
job.result = http_request(host_, port_, job.method, job.path, job.body);
|
||||
job.result = http_request(host_, port_, job.method, job.path, job.body, job.timeout_ms);
|
||||
std::lock_guard<std::mutex> g(lock_);
|
||||
in_flight_ = 0;
|
||||
finished_.push_back(std::move(job));
|
||||
|
||||
@@ -37,16 +37,20 @@ struct Response {
|
||||
Response http_request(const std::string &host, int port, const std::string &method, const std::string &path,
|
||||
const std::string &body = "", int timeout_ms = 15000);
|
||||
|
||||
// The port RomM Sync's API is on: web_port in its config.json, 8780 by default.
|
||||
int payload_port();
|
||||
|
||||
class Api {
|
||||
public:
|
||||
using Callback = std::function<void(const Response &)>;
|
||||
|
||||
explicit Api(std::string host = "127.0.0.1", int port = 8780);
|
||||
explicit Api(std::string host = "127.0.0.1", int port = payload_port());
|
||||
~Api();
|
||||
Api(const Api &) = delete;
|
||||
Api &operator=(const Api &) = delete;
|
||||
|
||||
void get(const std::string &path, Callback done);
|
||||
// timeout_ms: how long to wait for the answer (most come in well under a second).
|
||||
void get(const std::string &path, Callback done, int timeout_ms = 15000);
|
||||
void post(const std::string &path, const std::string &json_body, Callback done);
|
||||
// Runs the callbacks of finished requests. Call once per frame.
|
||||
void poll();
|
||||
@@ -58,6 +62,7 @@ class Api {
|
||||
std::string method, path, body;
|
||||
Callback done;
|
||||
Response result;
|
||||
int timeout_ms = 15000;
|
||||
};
|
||||
void worker();
|
||||
|
||||
|
||||
@@ -4,14 +4,14 @@
|
||||
make-title-art.py OUT.png
|
||||
|
||||
A 3840x2160 picture for sce_sys/pic0.dds and pic1.dds (tools/title-art.sh
|
||||
turns it into BC7): RommPS's Acrylic aurora, a shelf of abstract cover cards
|
||||
easing into the distance, and the RommPS wordmark in Montserrat (OFL,
|
||||
third_party/Montserrat-Medium.ttf). No game's art: every shape is drawn here.
|
||||
turns it into BC7): deep space with soft nebulae, a spiral galaxy swirling on
|
||||
the right, a field of stars, and the RommPS wordmark in Montserrat (OFL,
|
||||
third_party/Montserrat-Medium.ttf), its "PS" lit in the app's cyan and violet.
|
||||
Every shape is drawn here.
|
||||
|
||||
SPDX-License-Identifier: GPL-3.0-or-later
|
||||
"""
|
||||
import math
|
||||
import random
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
@@ -21,111 +21,177 @@ from PIL import Image, ImageDraw, ImageFilter, ImageFont
|
||||
W, H = 3840, 2160
|
||||
REPO = Path(__file__).resolve().parents[4]
|
||||
FONT = REPO / "third_party/Montserrat-Medium.ttf"
|
||||
CYAN, VIOLET, BLUE = 0x76d6ff, 0xc04bd6, 0x3a5bd9
|
||||
|
||||
|
||||
def hex_rgb(value):
|
||||
return np.array([(value >> 16) & 255, (value >> 8) & 255, value & 255], dtype=np.float32)
|
||||
|
||||
|
||||
def aurora():
|
||||
"""The app's Acrylic backdrop: deep navy, with blue and violet light."""
|
||||
def blur(field, radius):
|
||||
"""A float field blurred, through Pillow (values kept in 0..1)."""
|
||||
img = Image.fromarray(np.clip(field * 255, 0, 255).astype(np.uint8), "L")
|
||||
return np.asarray(img.filter(ImageFilter.GaussianBlur(radius)), dtype=np.float32) / 255
|
||||
|
||||
|
||||
def noise(rng, cells, radius):
|
||||
"""Smooth clouds: random cells scaled up and softened."""
|
||||
small = Image.fromarray((rng.random((cells[1], cells[0])) * 255).astype(np.uint8), "L")
|
||||
big = small.resize((W, H), Image.BICUBIC).filter(ImageFilter.GaussianBlur(radius))
|
||||
return np.asarray(big, dtype=np.float32) / 255
|
||||
|
||||
|
||||
def space(rng):
|
||||
"""Deep navy, with nebulae in the app's blue, violet and cyan."""
|
||||
y, x = np.mgrid[0:H, 0:W].astype(np.float32)
|
||||
u, v = x / W, y / H
|
||||
base = hex_rgb(0x070a1c) * (1 - v[..., None]) + hex_rgb(0x0b1026) * v[..., None]
|
||||
img = base.copy()
|
||||
# Soft lights: centre, radius, colour, strength.
|
||||
for cx, cy, r, colour, k in [
|
||||
(0.78, 0.18, 0.55, 0x3a5bd9, 0.85),
|
||||
(0.95, 0.70, 0.45, 0xc04bd6, 0.55),
|
||||
(0.30, 0.95, 0.60, 0x1b1f4a, 0.90),
|
||||
(0.55, 0.45, 0.35, 0x76d6ff, 0.18),
|
||||
]:
|
||||
d = np.sqrt(((u - cx) * (W / H)) ** 2 + (v - cy) ** 2) / r
|
||||
fall = np.exp(-d * d * 2.2)[..., None] * k
|
||||
img = img * (1 - fall) + hex_rgb(colour) * fall
|
||||
# Aurora ribbons: sine bands across the upper half.
|
||||
for phase, colour, k in [(0.0, 0x76d6ff, 0.10), (1.7, 0xc04bd6, 0.08)]:
|
||||
band = 0.30 + 0.06 * np.sin(u * 5.0 + phase) + 0.03 * np.sin(u * 13.0 + phase * 2)
|
||||
d = (v - band) / 0.05
|
||||
fall = (np.exp(-d * d) * (0.4 + 0.6 * u))[..., None] * k
|
||||
img = img + hex_rgb(colour) * fall
|
||||
# A whisper of grain, so the gradients never band.
|
||||
rng = np.random.default_rng(7)
|
||||
img += rng.normal(0, 1.2, img.shape).astype(np.float32)
|
||||
return Image.fromarray(np.clip(img, 0, 255).astype(np.uint8), "RGB").convert("RGBA")
|
||||
img = hex_rgb(0x03050d) * (1 - v[..., None]) + hex_rgb(0x090c20) * v[..., None]
|
||||
clouds = 0.55 * noise(rng, (8, 5), 260) + 0.30 * noise(rng, (16, 9), 140) + 0.15 * noise(rng, (32, 18), 70)
|
||||
clouds = np.clip((clouds - 0.40) * 2.6, 0, 1) ** 1.8
|
||||
# Brighter towards the galaxy, quiet behind the wordmark.
|
||||
near = np.exp(-(((u - 0.70) * W / H) ** 2 + (v - 0.42) ** 2) / 0.35)
|
||||
for colour, k, shift in [(BLUE, 0.55, 0.0), (VIOLET, 0.40, 0.25), (CYAN, 0.18, 0.5)]:
|
||||
tint = np.roll(clouds, int(shift * W / 3), axis=1) * (0.25 + 0.75 * near)
|
||||
img = img + hex_rgb(colour) * (tint * k)[..., None]
|
||||
return img
|
||||
|
||||
|
||||
def cards(canvas):
|
||||
"""A shelf of cover-shaped cards on the right, receding and fading."""
|
||||
rnd = random.Random(42)
|
||||
hues = [0x3a5bd9, 0xc04bd6, 0x76d6ff, 0x4ade80, 0xfbbf24, 0xf87171, 0x8b5cf6, 0x22d3ee]
|
||||
glow = Image.new("RGBA", (W, H), (0, 0, 0, 0))
|
||||
def galaxy(rng, img):
|
||||
"""A two-armed spiral seen at a slant, its arms trailing into the swirl."""
|
||||
cx, cy, tilt, squash = 0.68 * W, 0.43 * H, math.radians(-22), 0.52
|
||||
stars = np.zeros((H, W), dtype=np.float32)
|
||||
colour = np.zeros((H, W, 3), dtype=np.float32)
|
||||
|
||||
def place(r, theta, bright, rgb):
|
||||
# The disc: a circle squashed into an ellipse, then tilted.
|
||||
dx, dy = r * np.cos(theta), r * np.sin(theta) * squash
|
||||
x = cx + dx * math.cos(tilt) - dy * math.sin(tilt)
|
||||
y = cy + dx * math.sin(tilt) + dy * math.cos(tilt)
|
||||
keep = (x >= 0) & (x < W) & (y >= 0) & (y < H)
|
||||
xi, yi = x[keep].astype(int), y[keep].astype(int)
|
||||
np.add.at(stars, (yi, xi), bright[keep])
|
||||
np.add.at(colour, (yi, xi), rgb[None, :] * bright[keep, None])
|
||||
|
||||
n = 160000
|
||||
for arm in range(2):
|
||||
t = rng.random(n) ** 0.8 * 4.2 * math.pi # how far round the arm
|
||||
r = 95 * np.exp(0.185 * t) # a logarithmic spiral
|
||||
spread = 0.07 + 0.05 * t / (4.2 * math.pi)
|
||||
theta = t + arm * math.pi + rng.normal(0, spread, n) * (1 + t / 8)
|
||||
r = r * (1 + rng.normal(0, 0.10, n))
|
||||
bright = (0.15 + rng.random(n) ** 3) * (1.3 - 0.8 * t / (4.2 * math.pi))
|
||||
place(r, theta, bright, hex_rgb(0xcfdcff))
|
||||
# The outer arms take the app's colours.
|
||||
far = t > 2.2 * math.pi
|
||||
place(r[far] * 1.01, theta[far] + 0.02, bright[far] * 0.6, hex_rgb(CYAN if arm else VIOLET))
|
||||
# The bulge: a dense, warm core.
|
||||
m = 40000
|
||||
r = np.abs(rng.normal(0, 150, m))
|
||||
place(r, rng.random(m) * 2 * math.pi, rng.random(m) ** 2 * 0.5, hex_rgb(0xffe6c4))
|
||||
|
||||
norm = np.maximum(stars, 1e-6)[..., None]
|
||||
tint = colour / norm
|
||||
sharp = np.clip(stars, 0, 1)
|
||||
haze = blur(np.clip(stars * 0.08, 0, 1), 40) * 6 + blur(np.clip(stars * 0.2, 0, 1), 10) * 2.5
|
||||
glow_tint = np.stack([blur(np.clip(tint[..., c] / 255 * np.clip(stars, 0, 1), 0, 1), 30) for c in range(3)], -1)
|
||||
glow_tint = glow_tint / np.maximum(blur(sharp, 30), 1e-3)[..., None] * 255
|
||||
img = img + glow_tint * np.clip(haze, 0, 1.5)[..., None] * 0.55
|
||||
img = img + tint * sharp[..., None] * 0.9
|
||||
# The core's light, falling off over the whole disc.
|
||||
y, x = np.mgrid[0:H, 0:W].astype(np.float32)
|
||||
dx, dy = x - cx, y - cy
|
||||
rx = dx * math.cos(-tilt) - dy * math.sin(-tilt)
|
||||
ry = (dx * math.sin(-tilt) + dy * math.cos(-tilt)) / squash
|
||||
d = np.sqrt(rx * rx + ry * ry)
|
||||
img = img + hex_rgb(0xfff1dc) * (np.exp(-d / 45) * 0.75 + np.exp(-d / 220) * 0.28)[..., None]
|
||||
img = img + hex_rgb(BLUE) * (np.exp(-d / 1100) * 0.22)[..., None]
|
||||
return img
|
||||
|
||||
|
||||
def starfield(rng, img):
|
||||
"""Stars across the sky; a few bright ones with a soft cross."""
|
||||
n = 14000
|
||||
x, y = rng.integers(0, W, n), rng.integers(0, H, n)
|
||||
bright = 0.12 + 0.88 * rng.random(n) ** 5
|
||||
field = np.zeros((H, W), dtype=np.float32)
|
||||
np.add.at(field, (y, x), bright * 1.4)
|
||||
img = img + 255 * np.clip(field, 0, 1)[..., None] + 255 * blur(np.clip(field * 0.5, 0, 1), 2)[..., None] * 2
|
||||
layer = Image.new("RGBA", (W, H), (0, 0, 0, 0))
|
||||
gd, ld = ImageDraw.Draw(glow), ImageDraw.Draw(layer)
|
||||
base_y = 1380
|
||||
for i in range(9):
|
||||
t = i / 8
|
||||
scale = 1.0 - 0.55 * t # nearer cards first, from the left
|
||||
w, h = 430 * scale, 573 * scale
|
||||
x = 1880 + 520 * i * (1.0 - 0.32 * t)
|
||||
y = base_y - h + 40 * t
|
||||
alpha = int(255 * (0.92 - 0.6 * t))
|
||||
top, bottom = hex_rgb(hues[i % len(hues)]), hex_rgb(hues[(i + 3) % len(hues)]) * 0.45
|
||||
# The card: a vertical gradient, rounded, with a light edge.
|
||||
card = Image.new("RGBA", (int(w), int(h)))
|
||||
grad = np.linspace(0, 1, int(h), dtype=np.float32)[:, None, None]
|
||||
rgb = top * (1 - grad) + bottom * grad
|
||||
rgb = np.broadcast_to(rgb, (int(h), int(w), 3))
|
||||
a = np.full((int(h), int(w), 1), alpha, dtype=np.float32)
|
||||
card = Image.fromarray(np.concatenate([rgb, a], axis=2).astype(np.uint8), "RGBA")
|
||||
mask = Image.new("L", card.size, 0)
|
||||
ImageDraw.Draw(mask).rounded_rectangle([0, 0, card.size[0] - 1, card.size[1] - 1], radius=int(34 * scale),
|
||||
fill=alpha)
|
||||
layer.paste(card, (int(x), int(y)), mask)
|
||||
ld.rounded_rectangle([x, y, x + w, y + h], radius=int(34 * scale), outline=(255, 255, 255, int(alpha * 0.35)),
|
||||
width=max(2, int(4 * scale)))
|
||||
gd.rounded_rectangle([x - 30, y - 30, x + w + 30, y + h + 30], radius=int(60 * scale),
|
||||
fill=tuple(int(c) for c in top) + (int(alpha * 0.45),))
|
||||
# The focus ring on the first card, as the app draws it.
|
||||
if i == 0:
|
||||
ld.rounded_rectangle([x - 16, y - 16, x + w + 16, y + h + 16], radius=int(46 * scale),
|
||||
outline=(255, 255, 255, 240), width=10)
|
||||
# A reflection fading into the floor.
|
||||
refl = card.transpose(Image.FLIP_TOP_BOTTOM).crop((0, 0, card.size[0], int(h * 0.35)))
|
||||
fade = np.linspace(0.22, 0, refl.size[1], dtype=np.float32)[:, None] * alpha
|
||||
rmask = Image.fromarray(np.broadcast_to(fade, (refl.size[1], refl.size[0])).astype(np.uint8), "L")
|
||||
layer.paste(refl, (int(x), int(y + h + 18)), rmask)
|
||||
glow = glow.filter(ImageFilter.GaussianBlur(70))
|
||||
canvas.alpha_composite(glow)
|
||||
canvas.alpha_composite(layer)
|
||||
# The shelf's far end dissolves into the backdrop.
|
||||
fade = Image.new("RGBA", (W, H), (0, 0, 0, 0))
|
||||
ramp = np.clip((np.arange(W, dtype=np.float32) - 3000) / 840, 0, 1) ** 1.5
|
||||
alpha = np.broadcast_to((ramp * 200)[None, :], (H, W)).astype(np.uint8)
|
||||
fade.putalpha(Image.fromarray(alpha, "L"))
|
||||
tint = Image.new("RGBA", (W, H), (11, 16, 38, 255))
|
||||
tint.putalpha(Image.fromarray(alpha, "L"))
|
||||
canvas.alpha_composite(tint)
|
||||
draw = ImageDraw.Draw(layer)
|
||||
for _ in range(26):
|
||||
sx, sy, s = rng.integers(0, W), rng.integers(0, H), rng.uniform(10, 34)
|
||||
rgb = tuple(int(c) for c in [hex_rgb(0xffffff), hex_rgb(CYAN), hex_rgb(0xffe6c4)][rng.integers(0, 3)])
|
||||
draw.line([(sx - s, sy), (sx + s, sy)], fill=rgb + (150,), width=2)
|
||||
draw.line([(sx, sy - s), (sx, sy + s)], fill=rgb + (150,), width=2)
|
||||
draw.ellipse([sx - 4, sy - 4, sx + 4, sy + 4], fill=rgb + (255,))
|
||||
glow = layer.filter(ImageFilter.GaussianBlur(5))
|
||||
out = Image.fromarray(np.clip(img, 0, 255).astype(np.uint8), "RGB").convert("RGBA")
|
||||
out.alpha_composite(glow)
|
||||
out.alpha_composite(layer)
|
||||
return out
|
||||
|
||||
|
||||
def swirl(rng, canvas):
|
||||
"""Faint star trails arcing round the galaxy, as on a long exposure."""
|
||||
cx, cy = 0.68 * W, 0.43 * H
|
||||
layer = Image.new("RGBA", (W, H), (0, 0, 0, 0))
|
||||
draw = ImageDraw.Draw(layer)
|
||||
for _ in range(420):
|
||||
r = rng.uniform(600, 2600)
|
||||
start = rng.uniform(0, 360)
|
||||
length = rng.uniform(4, 14) * (1100 / r) # degrees: inner trails sweep further
|
||||
rgb = [hex_rgb(0xffffff), hex_rgb(CYAN), hex_rgb(VIOLET), hex_rgb(0xcfdcff)][rng.integers(0, 4)]
|
||||
alpha = int(rng.uniform(25, 90) * min(1, 900 / r + 0.3))
|
||||
box = [cx - r, cy - r * 0.62, cx + r, cy + r * 0.62]
|
||||
draw.arc(box, start, start + length, fill=tuple(int(c) for c in rgb) + (alpha,), width=2)
|
||||
canvas.alpha_composite(layer.filter(ImageFilter.GaussianBlur(1.2)))
|
||||
|
||||
|
||||
def shade(canvas):
|
||||
"""Darker towards the lower left, so the wordmark reads."""
|
||||
y, x = np.mgrid[0:H, 0:W].astype(np.float32)
|
||||
d = np.sqrt(((x / W - 0.0) * 1.2) ** 2 + ((y / H - 1.0) * 1.0) ** 2)
|
||||
a = (np.clip(1 - d / 0.75, 0, 1) ** 1.5 * 150).astype(np.uint8)
|
||||
dark = Image.new("RGBA", (W, H), (2, 3, 10, 255))
|
||||
dark.putalpha(Image.fromarray(a, "L"))
|
||||
canvas.alpha_composite(dark)
|
||||
|
||||
|
||||
def wordmark(canvas):
|
||||
draw = ImageDraw.Draw(canvas)
|
||||
big = ImageFont.truetype(str(FONT), 300)
|
||||
small = ImageFont.truetype(str(FONT), 76)
|
||||
x, y = 250, 1560
|
||||
# A soft shadow, then the name.
|
||||
""""Romm" in white, "PS" filled cyan to violet, with a glow of its own."""
|
||||
font = ImageFont.truetype(str(FONT), 300)
|
||||
x, y = 250, 1700
|
||||
romm_w = font.getlength("Romm")
|
||||
shadow = Image.new("RGBA", (W, H), (0, 0, 0, 0))
|
||||
ImageDraw.Draw(shadow).text((x, y + 18), "RommPS", font=big, fill=(0, 0, 0, 170), anchor="ls")
|
||||
ImageDraw.Draw(shadow).text((x, y + 18), "RommPS", font=font, fill=(0, 0, 0, 180), anchor="ls")
|
||||
canvas.alpha_composite(shadow.filter(ImageFilter.GaussianBlur(26)))
|
||||
draw.text((x, y), "RommPS", font=big, fill=(255, 255, 255, 255), anchor="ls")
|
||||
draw.rounded_rectangle([x + 8, y + 54, x + 8 + 280, y + 66], radius=6, fill=(118, 214, 255, 255))
|
||||
draw.text((x + 8, y + 190), "Your RomM library and save sync", font=small, fill=(255, 255, 255, 190), anchor="ls")
|
||||
ImageDraw.Draw(canvas).text((x, y), "Romm", font=font, fill=(255, 255, 255, 255), anchor="ls")
|
||||
|
||||
# The letters' shape, filled with a diagonal gradient.
|
||||
mask = Image.new("L", (W, H), 0)
|
||||
ImageDraw.Draw(mask).text((x + romm_w, y), "PS", font=font, fill=255, anchor="ls")
|
||||
yy, xx = np.mgrid[0:H, 0:W].astype(np.float32)
|
||||
left, width = x + romm_w, font.getlength("PS")
|
||||
t = np.clip(((xx - left) / width) * 0.75 + ((y - yy) / 220) * 0.25, 0, 1)[..., None]
|
||||
fill = hex_rgb(CYAN) * (1 - t) + hex_rgb(VIOLET) * t
|
||||
gradient = Image.fromarray(fill.astype(np.uint8), "RGB").convert("RGBA")
|
||||
glow = Image.new("RGBA", (W, H), (0, 0, 0, 0))
|
||||
glow.paste(gradient, (0, 0), mask.filter(ImageFilter.MaxFilter(9)))
|
||||
canvas.alpha_composite(glow.filter(ImageFilter.GaussianBlur(34)))
|
||||
letters = Image.new("RGBA", (W, H), (0, 0, 0, 0))
|
||||
letters.paste(gradient, (0, 0), mask)
|
||||
canvas.alpha_composite(letters)
|
||||
|
||||
|
||||
def main():
|
||||
out = Path(sys.argv[1])
|
||||
canvas = aurora()
|
||||
cards(canvas)
|
||||
rng = np.random.default_rng(11)
|
||||
img = space(rng)
|
||||
img = galaxy(rng, img)
|
||||
img += rng.normal(0, 1.2, img.shape).astype(np.float32) # grain, so gradients never band
|
||||
canvas = starfield(rng, img)
|
||||
swirl(rng, canvas)
|
||||
shade(canvas)
|
||||
wordmark(canvas)
|
||||
canvas.convert("RGB").convert("RGBA").save(out)
|
||||
print(f"title art: {out} ({W}x{H})")
|
||||
|
||||
+33
-12
@@ -1,14 +1,32 @@
|
||||
[
|
||||
{
|
||||
"id": "psxs5", "name": "PSXS5 / SwanStationPS5",
|
||||
"title_ids": ["PPSA97510", "PPSA98510"],
|
||||
"detect": ["/data/PSXS5", "/data/homebrew/PPSA97510", "/data/homebrew/PPSA98510"],
|
||||
"note": "Both apps share /data/PSXS5. One memory card per game, named after the disc.",
|
||||
"id": "psxs5", "name": "PSXS5",
|
||||
"title_ids": ["PPSA97510"],
|
||||
"detect": ["/data/PSXS5", "/data/homebrew/PPSA97510"],
|
||||
"note": "One memory card per game, named after the 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": "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"}]
|
||||
}
|
||||
},
|
||||
@@ -30,17 +48,17 @@
|
||||
"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_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/homebrew/PPSA99204"],
|
||||
"ready": false,
|
||||
"note": "PS5NES v7 looks for its games in PS5SX2's folder, so there's no preset for it yet.",
|
||||
"detect": ["/data/PS5NES", "/data/homebrew/PPSA99204"],
|
||||
"note": "Untested: v7 may look for games in PS5SX2's folder.",
|
||||
"profile": {
|
||||
"root": "/data/PS5NES", "rom_dir": "{root}/games", "save_dir": "{root}/saves", "state_dir": "{root}/saves",
|
||||
"save_exts": ".sav", "save_name": "{rom_fnv64}{ext}", "save_stems": "{rom_fnv64}",
|
||||
@@ -51,11 +69,14 @@
|
||||
"id": "porpoise", "name": "Porpoise",
|
||||
"title_ids": ["PPSA99764"],
|
||||
"detect": ["/data/porpoise", "/data/homebrew/PPSA99764"],
|
||||
"ready": false,
|
||||
"note": "The current build crashes when it leaves the app sandbox, so there's no preset for it yet.",
|
||||
"note": "Dolphin, with its user folder in {root}/saves/User.",
|
||||
"profile": {
|
||||
"root": "/data/porpoise", "rom_dir": "{root}/games", "save_dir": "{root}/saves",
|
||||
"platforms": [{"romm": ["ngc", "gamecube"], "dir": "gamecube"}, {"romm": ["wii"], "dir": "wii"}]
|
||||
"root": "/data/porpoise", "rom_dir": "{root}/games", "save_dir": "{root}/saves/User",
|
||||
"state_dir": "{root}/states/{game_id}", "state_exts": ".state", "state_format": "raw",
|
||||
"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"}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
+57
-103
@@ -1,9 +1,9 @@
|
||||
/* platform.h for the PS5 (etaHEN, ps5-payload-dev SDK). The parts shared with
|
||||
* the PS4 are in platform/console/console.c.
|
||||
*
|
||||
* Adapted from John Törnblom's GPLv3 ftpsrv and websrv: the home screen tile
|
||||
* (ftpsrv install-ps5.c) and the foreground app (websrv). Links against
|
||||
* SceSystemService and SceAppInstUtil. */
|
||||
* Adapted from John Törnblom's GPLv3 ftpsrv and websrv: removing the old home
|
||||
* screen tile (ftpsrv install-ps5.c) and the foreground app (websrv). Links
|
||||
* against SceSystemService and SceAppInstUtil. */
|
||||
#include "platform.h"
|
||||
#include "emulators_json.h" /* generated from platform/ps5/emulators.json */
|
||||
|
||||
@@ -20,11 +20,12 @@
|
||||
#include <ps5/kernel.h>
|
||||
|
||||
#include "console.h"
|
||||
#include "util.h" /* APP_VERSION */
|
||||
|
||||
#define ROMM_TITLE_ID "ROMM00001"
|
||||
#define ROMM_TITLE_NAME "RomM Sync"
|
||||
#define ROMM_APP_DIR "/user/app/" ROMM_TITLE_ID
|
||||
#define ROMM_APPMETA "/user/appmeta/" ROMM_TITLE_ID "/param.json"
|
||||
/* The "RomM Sync" tile versions before 1.1.1 installed, opening the web UI,
|
||||
* and RommPS, which took its place. */
|
||||
#define OLD_TILE_ID "ROMM00001"
|
||||
#define ROMMPS_ID "PPSA76677"
|
||||
|
||||
/* AuthID used by ftpsrv/websrv (ShellCore-like privileges). */
|
||||
#define ROMM_AUTHID 0x4801000000000013L
|
||||
@@ -46,7 +47,6 @@ int sceSystemServiceGetAppTitleId(int app_id, char *title_id);
|
||||
|
||||
/* libSceAppInstUtil: ftpsrv install-ps5.c */
|
||||
int sceAppInstUtilInitialize(void);
|
||||
int sceAppInstUtilAppInstallAll(void *);
|
||||
int sceAppInstUtilAppUnInstall(const char *);
|
||||
|
||||
|
||||
@@ -60,10 +60,12 @@ static const char *const EMULATOR_DIRS[] = {NULL};
|
||||
* next to it. onionHEN (github.com/aydencharles/onionHEN) keeps its runtime
|
||||
* state in /system_tmp/onionhen, which is gone after a reboot, so that tells
|
||||
* the one running now; they refuse to run together. Both leave the ELF loader
|
||||
* on 9021 to us. An update replaces RomM Sync in the other HEN's folder too,
|
||||
* so switching HENs never brings back an old copy. */
|
||||
* on 9021 to us. Autostart is set up in both HENs' folders, so RomM Sync
|
||||
* starts with either, and switching HENs never brings back an old copy. */
|
||||
static const char *const ETAHEN_ALSO[] = {"/data/OnionHEN/payloads", NULL};
|
||||
static const char *const ONIONHEN_ALSO[] = {"/data/etaHEN/payloads", NULL};
|
||||
/* pldmgr's autoload starts the copies it keeps here. */
|
||||
static const char *const PAYLOAD_COPIES[] = {"/data/pldmgr/payloads/romm-sync/romm-sync.elf", NULL};
|
||||
|
||||
static plat_info_t INFO = {
|
||||
.name = "ps5",
|
||||
@@ -76,14 +78,15 @@ static plat_info_t INFO = {
|
||||
.autostart_dir = "/data/etaHEN/payloads",
|
||||
.autostart_flag = "romm-sync.elf.auto_start",
|
||||
.autostart_also = ETAHEN_ALSO,
|
||||
.payload_copies = PAYLOAD_COPIES,
|
||||
.loader_dir = "/data/etaHEN",
|
||||
.homebrew_dirs = HOMEBREW_DIRS,
|
||||
.emulator_dirs = EMULATOR_DIRS,
|
||||
.emulator_catalog = EMULATORS_JSON,
|
||||
.retroarch_root = "/data/homebrew/PPSA99169",
|
||||
.mednafen_root = "/data/homebrew/Mednafen",
|
||||
.has_tile = 1,
|
||||
.can_relaunch = 1,
|
||||
.self_update = 0,
|
||||
};
|
||||
|
||||
static int is_dir(const char *path) {
|
||||
@@ -110,12 +113,12 @@ const plat_info_t *plat_info(void) {
|
||||
|
||||
int plat_init(void) {
|
||||
signal(SIGPIPE, SIG_IGN);
|
||||
console_single_instance();
|
||||
|
||||
/* Elevated AuthID as ftpsrv/websrv do; needed for /user/app writes and
|
||||
* AppInstUtil. Non-fatal: kernel_* returns -1 without kernel R/W. */
|
||||
/* Elevated AuthID as ftpsrv/websrv do; needed for AppInstUtil.
|
||||
* Non-fatal: kernel_* returns -1 without kernel R/W. */
|
||||
kernel_set_ucred_authid(getpid(), ROMM_AUTHID);
|
||||
|
||||
if (console_single_instance(APP_VERSION)) return 1;
|
||||
if (console_mkdir_p(CONSOLE_DATA_DIR)) return -1;
|
||||
return 0;
|
||||
}
|
||||
@@ -159,56 +162,6 @@ int plat_running_title(char *title_id, size_t n) {
|
||||
}
|
||||
|
||||
|
||||
static int write_file(const char *path, const void *data, size_t size) {
|
||||
FILE *f = fopen(path, "wb");
|
||||
if (!f) return -1;
|
||||
if (size && fwrite(data, size, 1, f) != 1) {
|
||||
fclose(f);
|
||||
return -1;
|
||||
}
|
||||
return fclose(f) ? -1 : 0;
|
||||
}
|
||||
|
||||
/* Returns malloc'd contents (NUL-terminated) or NULL. */
|
||||
static char *read_file(const char *path, size_t *size) {
|
||||
FILE *f = fopen(path, "rb");
|
||||
char *buf = NULL;
|
||||
long len;
|
||||
if (!f) return NULL;
|
||||
if (fseek(f, 0, SEEK_END) || (len = ftell(f)) < 0 || len > 16 * 1024 * 1024 ||
|
||||
fseek(f, 0, SEEK_SET) || !(buf = malloc((size_t)len + 1)) ||
|
||||
(len && fread(buf, (size_t)len, 1, f) != 1)) {
|
||||
free(buf);
|
||||
fclose(f);
|
||||
return NULL;
|
||||
}
|
||||
fclose(f);
|
||||
buf[len] = 0;
|
||||
if (size) *size = (size_t)len;
|
||||
return buf;
|
||||
}
|
||||
|
||||
static int copy_file(const char *src, const char *dst) {
|
||||
size_t size;
|
||||
char *data = read_file(src, &size);
|
||||
int r;
|
||||
if (!data) return -1;
|
||||
r = write_file(dst, data, size);
|
||||
free(data);
|
||||
return r;
|
||||
}
|
||||
|
||||
static int install_title_dir(const char *title_id, const char *dir) {
|
||||
int (*install_dir)(const char *, const char *, void *) = NULL;
|
||||
const char *nid = "Wudg3Xe3heE"; /* sceAppInstUtilAppInstallTitleDir */
|
||||
uint32_t handle;
|
||||
|
||||
if (!kernel_dynlib_handle(-1, "libSceAppInstUtil.sprx", &handle))
|
||||
install_dir = (void *)kernel_dynlib_resolve(-1, handle, nid);
|
||||
if (install_dir) return install_dir(title_id, dir, NULL);
|
||||
return sceAppInstUtilAppInstallAll(NULL);
|
||||
}
|
||||
|
||||
void plat_describe(char *buf, size_t n) {
|
||||
app_info_t info;
|
||||
char host[16] = "";
|
||||
@@ -217,49 +170,50 @@ void plat_describe(char *buf, size_t n) {
|
||||
console_describe(buf, n, host);
|
||||
}
|
||||
|
||||
int plat_install_tile(int port) {
|
||||
char param[512];
|
||||
struct stat st;
|
||||
char *old;
|
||||
int installed, have_icon, err;
|
||||
static int on_home_screen(const char *title_id) {
|
||||
char app[64], meta[64];
|
||||
snprintf(app, sizeof app, "/user/app/%s", title_id);
|
||||
snprintf(meta, sizeof meta, "/user/appmeta/%s", title_id);
|
||||
return dir_exists(app) || dir_exists(meta);
|
||||
}
|
||||
|
||||
if (port <= 0 || port > 65535) return -1;
|
||||
int plat_app_installed(void) { return on_home_screen(ROMMPS_ID); }
|
||||
|
||||
snprintf(param, sizeof param,
|
||||
"{\n"
|
||||
" \"titleId\": \"" ROMM_TITLE_ID "\",\n"
|
||||
" \"deeplinkUri\": \"http://127.0.0.1:%d/\",\n"
|
||||
" \"localizedParameters\": {\n"
|
||||
" \"defaultLanguage\": \"en-US\",\n"
|
||||
" \"en-US\": {\n"
|
||||
" \"titleName\": \"" ROMM_TITLE_NAME "\"\n"
|
||||
" }\n"
|
||||
" }\n"
|
||||
"}\n",
|
||||
port);
|
||||
|
||||
installed = !stat(ROMM_APPMETA, &st);
|
||||
have_icon = !stat(CONSOLE_ICON, &st);
|
||||
|
||||
/* Already installed with identical param.json (same port) -> nothing to do. */
|
||||
if (installed && (old = read_file(ROMM_APP_DIR "/sce_sys/param.json", NULL))) {
|
||||
int same = !strcmp(old, param);
|
||||
free(old);
|
||||
if (same && (!have_icon || !stat(ROMM_APP_DIR "/sce_sys/icon0.png", &st)))
|
||||
return 0;
|
||||
/* From its param.json: contentVersion "01.001.000" is 1.1.0. The app's own
|
||||
* folder first (mount.lnk names it when ShadowMount mounts it from
|
||||
* /data/homebrew): copying a new version over it leaves the home screen's
|
||||
* copies in /user/app and /user/appmeta at the old one. */
|
||||
void plat_app_version(char *out, size_t n) {
|
||||
char params[3][PATH_MAX_LEN] = {"", "/user/app/" ROMMPS_ID "/sce_sys/param.json", "/user/appmeta/" ROMMPS_ID "/param.json"};
|
||||
char *link = read_file("/user/app/" ROMMPS_ID "/mount.lnk", NULL);
|
||||
if (link) {
|
||||
link[strcspn(link, "\r\n")] = 0;
|
||||
if (link[0] == '/') snprintf(params[0], sizeof params[0], "%s/sce_sys/param.json", link);
|
||||
free(link);
|
||||
}
|
||||
out[0] = 0;
|
||||
for (size_t i = 0; i < 3 && !out[0]; i++) {
|
||||
if (!params[i][0]) continue;
|
||||
char *json = read_file(params[i], NULL);
|
||||
const char *k = json ? strstr(json, "\"contentVersion\"") : NULL;
|
||||
const char *q = k ? strchr(k + 16, '"') : NULL;
|
||||
int v[3];
|
||||
if (q && sscanf(q + 1, "%d.%d.%d", &v[0], &v[1], &v[2]) == 3) snprintf(out, n, "%d.%d.%d", v[0], v[1], v[2]);
|
||||
free(json);
|
||||
}
|
||||
}
|
||||
|
||||
if ((err = sceAppInstUtilInitialize())) return -1;
|
||||
|
||||
/* Re-install (e.g. port changed): uninstall may remove /user/app/<id>. */
|
||||
if (installed) sceAppInstUtilAppUnInstall(ROMM_TITLE_ID);
|
||||
|
||||
if (console_mkdir_p(ROMM_APP_DIR "/sce_sys")) return -1;
|
||||
if (write_file(ROMM_APP_DIR "/sce_sys/param.json", param, strlen(param))) return -1;
|
||||
if (have_icon) copy_file(CONSOLE_ICON, ROMM_APP_DIR "/sce_sys/icon0.png");
|
||||
|
||||
if ((err = install_title_dir(ROMM_TITLE_ID, "/user/app/"))) return -1;
|
||||
return 0;
|
||||
void plat_remove_old_tile(void) {
|
||||
if (!on_home_screen(OLD_TILE_ID)) return;
|
||||
/* Without RommPS the tile is the only way in from the home screen. */
|
||||
if (!on_home_screen(ROMMPS_ID)) {
|
||||
LOGI("kept the old RomM Sync tile: RommPS isn't installed");
|
||||
return;
|
||||
}
|
||||
int err = sceAppInstUtilInitialize();
|
||||
if (!err) err = sceAppInstUtilAppUnInstall(OLD_TILE_ID);
|
||||
if (err) LOGW("could not remove the old RomM Sync tile (0x%x)", (unsigned)err);
|
||||
else LOGI("removed the old RomM Sync tile: RommPS is the app now");
|
||||
}
|
||||
|
||||
/* etaHEN's loader runs whatever arrives on port 9021, loopback included
|
||||
|
||||
+281
-94
@@ -9,123 +9,310 @@
|
||||
#include "platform.h"
|
||||
#include "util.h"
|
||||
|
||||
/* The folder the loader starts payloads from (plat_info), or NULL if the
|
||||
* loader can't start payloads at boot. */
|
||||
static const char *autostart_dir(void) {
|
||||
const char *e = getenv("ROMM_SYNC_AUTOSTART");
|
||||
return e && *e ? e : plat_info()->autostart_dir;
|
||||
#define MAX_PLACES 8
|
||||
|
||||
/* A loader's payload folder: the payload in it and the file that turns it on
|
||||
* ("" when the payload's presence is enough). */
|
||||
typedef struct {
|
||||
char elf[PATH_MAX_LEN];
|
||||
char flag[PATH_MAX_LEN];
|
||||
int present; /* the loader is installed */
|
||||
} place;
|
||||
|
||||
/* "a,b,c" into list, at most max entries. */
|
||||
static int split(const char *s, char list[][PATH_MAX_LEN], int max) {
|
||||
int n = 0;
|
||||
while (s && *s && n < max) {
|
||||
const char *comma = strchr(s, ',');
|
||||
size_t len = comma ? (size_t)(comma - s) : strlen(s);
|
||||
if (len && len < PATH_MAX_LEN) {
|
||||
memcpy(list[n], s, len);
|
||||
list[n++][len] = 0;
|
||||
}
|
||||
s = comma ? comma + 1 : NULL;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
/* The loader counts as installed when plat_info's loader_dir exists, or else the
|
||||
* folder above its payload folder: /data/etaHEN for /data/etaHEN/payloads. */
|
||||
static int loader_found(void) {
|
||||
static int parent_exists(const char *path) {
|
||||
char parent[PATH_MAX_LEN];
|
||||
if (!autostart_dir()) return 0;
|
||||
if (plat_info()->loader_dir && !getenv("ROMM_SYNC_AUTOSTART")) return dir_exists(plat_info()->loader_dir);
|
||||
str_copy(parent, sizeof parent, autostart_dir());
|
||||
str_copy(parent, sizeof parent, path);
|
||||
char *slash = strrchr(parent, '/');
|
||||
if (slash && slash != parent) *slash = 0;
|
||||
return dir_exists(parent);
|
||||
}
|
||||
|
||||
/* Same mode "make install" leaves over FTP. */
|
||||
static int write_payload(const char *elf, const void *data, size_t len) {
|
||||
if (write_file_atomic(elf, data, len) != 0) return -1;
|
||||
chmod(elf, 0777);
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* flag is "" when the payload's presence is enough. Both are "" without autostart. */
|
||||
static void paths(char *elf, char *flag, size_t n) {
|
||||
/* Every loader payload folder, the running loader's first. ROMM_SYNC_AUTOSTART
|
||||
* (comma separated, running loader first) replaces them in tests. 0 if the
|
||||
* loader can't start payloads at boot. */
|
||||
static int places(place *p, int max) {
|
||||
const plat_info_t *pi = plat_info();
|
||||
elf[0] = flag[0] = 0;
|
||||
if (!autostart_dir()) return;
|
||||
snprintf(elf, n, "%s/%s", autostart_dir(), pi->payload);
|
||||
if (pi->autostart_flag) snprintf(flag, n, "%s/%s", autostart_dir(), pi->autostart_flag);
|
||||
char dirs[MAX_PLACES][PATH_MAX_LEN];
|
||||
int n = 0;
|
||||
const char *env = getenv("ROMM_SYNC_AUTOSTART");
|
||||
if (env && *env) {
|
||||
n = split(env, dirs, MAX_PLACES);
|
||||
} else if (pi->autostart_dir) {
|
||||
str_copy(dirs[n++], PATH_MAX_LEN, pi->autostart_dir);
|
||||
for (int i = 0; pi->autostart_also && pi->autostart_also[i] && n < MAX_PLACES; i++)
|
||||
str_copy(dirs[n++], PATH_MAX_LEN, pi->autostart_also[i]);
|
||||
}
|
||||
if (n > max) n = max;
|
||||
for (int i = 0; i < n; i++) {
|
||||
snprintf(p[i].elf, sizeof p[i].elf, "%s/%s", dirs[i], pi->payload);
|
||||
p[i].flag[0] = 0;
|
||||
if (pi->autostart_flag) snprintf(p[i].flag, sizeof p[i].flag, "%s/%s", dirs[i], pi->autostart_flag);
|
||||
/* A loader counts as installed when its folder (/data/etaHEN for
|
||||
* /data/etaHEN/payloads) exists; plat_info's loader_dir for the
|
||||
* running one. */
|
||||
p[i].present = (i == 0 && pi->loader_dir && !(env && *env)) ? dir_exists(pi->loader_dir) : parent_exists(dirs[i]);
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
cJSON *autostart_status(void) {
|
||||
char elf[PATH_MAX_LEN], flag[PATH_MAX_LEN];
|
||||
paths(elf, flag, sizeof elf);
|
||||
cJSON *j = cJSON_CreateObject();
|
||||
cJSON_AddBoolToObject(j, "supported", autostart_dir() != NULL);
|
||||
cJSON_AddStringToObject(j, "loader", plat_info()->loader);
|
||||
cJSON_AddStringToObject(j, "payload", plat_info()->payload);
|
||||
if (plat_info()->autostart_hint) cJSON_AddStringToObject(j, "hint", plat_info()->autostart_hint);
|
||||
cJSON_AddBoolToObject(j, "loader_found", loader_found());
|
||||
cJSON_AddBoolToObject(j, "installed", elf[0] && file_exists(elf));
|
||||
cJSON_AddBoolToObject(j, "enabled", elf[0] && file_exists(elf) && (!flag[0] || file_exists(flag)));
|
||||
cJSON_AddStringToObject(j, "path", elf);
|
||||
return j;
|
||||
/* Copies other tools keep, that exist. */
|
||||
static int copies(char list[][PATH_MAX_LEN], int max) {
|
||||
const char *env = getenv("ROMM_SYNC_PAYLOAD_COPIES");
|
||||
int n = 0;
|
||||
if (env) {
|
||||
n = split(env, list, max);
|
||||
} else {
|
||||
const char *const *c = plat_info()->payload_copies;
|
||||
for (int i = 0; c && c[i] && n < max; i++) str_copy(list[n++], PATH_MAX_LEN, c[i]);
|
||||
}
|
||||
int kept = 0;
|
||||
for (int i = 0; i < n; i++)
|
||||
if (file_exists(list[i])) memmove(list[kept++], list[i], PATH_MAX_LEN);
|
||||
return kept;
|
||||
}
|
||||
|
||||
int autostart_replace(const void *data, size_t len, char *err, int en) {
|
||||
char elf[PATH_MAX_LEN], flag[PATH_MAX_LEN], bak[PATH_MAX_LEN + 8];
|
||||
paths(elf, flag, sizeof elf);
|
||||
if (!elf[0] || !file_exists(elf)) return 0;
|
||||
snprintf(bak, sizeof bak, "%s.bak", elf);
|
||||
if (copy_file(elf, bak) != 0) {
|
||||
snprintf(err, (size_t)en, "cannot back up %s", elf);
|
||||
static int enabled_at(const place *p) { return file_exists(p->elf) && (!p->flag[0] || file_exists(p->flag)); }
|
||||
|
||||
/* The version of the payload at path, "" if none or too old to say. Read
|
||||
* every time: two builds can have the same size and date to the second. */
|
||||
static void file_version(const char *path, char *out, size_t n) {
|
||||
size_t len;
|
||||
char *data = read_file(path, &len);
|
||||
out[0] = 0;
|
||||
if (data) payload_version(data, len, out, n);
|
||||
free(data);
|
||||
}
|
||||
|
||||
/* Writes the payload to path unless a newer one is already there. Same mode
|
||||
* "make install" leaves over FTP. 1 if written, 0 if kept, -1 on error. */
|
||||
static int put(const char *path, const void *data, size_t len, const char *version) {
|
||||
char have[32];
|
||||
if (file_exists(path)) {
|
||||
file_version(path, have, sizeof have);
|
||||
if (version_cmp(have, version) > 0) {
|
||||
LOGI("kept %s: it's %s, newer than %s", path, have, version[0] ? version : "this payload");
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
if (write_file_atomic(path, data, len) != 0) {
|
||||
LOGW("could not write %s", path);
|
||||
return -1;
|
||||
}
|
||||
if (write_payload(elf, data, len) != 0) {
|
||||
snprintf(err, (size_t)en, "cannot write %s", elf);
|
||||
return -1;
|
||||
}
|
||||
LOGI("replaced %s (%zu bytes), previous version kept as %s", elf, len, bak);
|
||||
/* A copy left in another loader's folder (from before switching loaders)
|
||||
* would start the old version if that loader came back. */
|
||||
const char *const *also = plat_info()->autostart_also;
|
||||
for (int i = 0; also && also[i] && !getenv("ROMM_SYNC_AUTOSTART"); i++) {
|
||||
char other[PATH_MAX_LEN];
|
||||
snprintf(other, sizeof other, "%s/%s", also[i], plat_info()->payload);
|
||||
if (!file_exists(other)) continue;
|
||||
if (write_payload(other, data, len) == 0) LOGI("also replaced %s", other);
|
||||
else LOGW("could not replace %s", other);
|
||||
}
|
||||
chmod(path, 0777);
|
||||
return 1;
|
||||
}
|
||||
|
||||
int autostart_set(int enable, const void *data, size_t len, char *err, int en) {
|
||||
char elf[PATH_MAX_LEN], flag[PATH_MAX_LEN];
|
||||
paths(elf, flag, sizeof elf);
|
||||
if (!autostart_dir()) {
|
||||
snprintf(err, (size_t)en, "%s can't start payloads with the console", plat_info()->loader);
|
||||
return -1;
|
||||
static void remove_bak(const place *p) {
|
||||
char bak[PATH_MAX_LEN + 8];
|
||||
snprintf(bak, sizeof bak, "%s.bak", p->elf);
|
||||
if (file_exists(bak) && unlink(bak) == 0) LOGI("removed %s", bak);
|
||||
}
|
||||
|
||||
cJSON *autostart_status(void) {
|
||||
place p[MAX_PLACES];
|
||||
int n = places(p, MAX_PLACES);
|
||||
char version[32] = "";
|
||||
if (n && file_exists(p[0].elf)) file_version(p[0].elf, version, sizeof version);
|
||||
cJSON *j = cJSON_CreateObject();
|
||||
cJSON_AddBoolToObject(j, "supported", n > 0);
|
||||
cJSON_AddStringToObject(j, "loader", plat_info()->loader);
|
||||
cJSON_AddStringToObject(j, "payload", plat_info()->payload);
|
||||
if (plat_info()->autostart_hint) cJSON_AddStringToObject(j, "hint", plat_info()->autostart_hint);
|
||||
cJSON_AddBoolToObject(j, "loader_found", n && p[0].present);
|
||||
cJSON_AddBoolToObject(j, "installed", n && file_exists(p[0].elf));
|
||||
cJSON_AddBoolToObject(j, "enabled", n && enabled_at(&p[0]));
|
||||
cJSON_AddStringToObject(j, "path", n ? p[0].elf : "");
|
||||
cJSON_AddStringToObject(j, "version", version);
|
||||
cJSON *list = cJSON_AddArrayToObject(j, "copies");
|
||||
char extra[MAX_PLACES][PATH_MAX_LEN];
|
||||
int ne = copies(extra, MAX_PLACES);
|
||||
for (int i = 0; i < n + ne; i++) {
|
||||
const char *path = i < n ? p[i].elf : extra[i - n];
|
||||
if (!file_exists(path)) continue;
|
||||
char v[32];
|
||||
file_version(path, v, sizeof v);
|
||||
cJSON *o = cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(o, "path", path);
|
||||
cJSON_AddStringToObject(o, "version", v);
|
||||
cJSON_AddBoolToObject(o, "enabled", i < n ? enabled_at(&p[i]) : 1);
|
||||
cJSON_AddItemToArray(list, o);
|
||||
}
|
||||
if (!loader_found()) {
|
||||
snprintf(err, (size_t)en, "%s folder not found (%s)", plat_info()->loader, autostart_dir());
|
||||
return -1;
|
||||
return j;
|
||||
}
|
||||
|
||||
/* Writes the payload to every installed loader's folder (or, with existing_only,
|
||||
* only over copies already there) and over other tools' copies. The number
|
||||
* written or kept, -1 if one couldn't be written. */
|
||||
static int put_everywhere(const void *data, size_t len, int existing_only) {
|
||||
place p[MAX_PLACES];
|
||||
int n = places(p, MAX_PLACES), done = 0, failed = 0;
|
||||
char version[32];
|
||||
payload_version(data, len, version, sizeof version);
|
||||
for (int i = 0; i < n; i++) {
|
||||
if (!p[i].present || (existing_only && !file_exists(p[i].elf))) continue;
|
||||
int r = put(p[i].elf, data, len, version);
|
||||
if (r < 0) failed = 1;
|
||||
else done++;
|
||||
if (r > 0) LOGI("installed %s %s (%zu bytes)", version[0] ? version : "payload", p[i].elf, len);
|
||||
remove_bak(&p[i]);
|
||||
}
|
||||
if (data && len) {
|
||||
/* Only accept ELF files. */
|
||||
if (len < 4 || memcmp(data, "\x7f" "ELF", 4) != 0) {
|
||||
snprintf(err, (size_t)en, "that file is not an ELF payload");
|
||||
return -1;
|
||||
}
|
||||
if (write_payload(elf, data, len) != 0) {
|
||||
snprintf(err, (size_t)en, "cannot write %s", elf);
|
||||
return -1;
|
||||
}
|
||||
LOGI("installed payload to %s (%zu bytes)", elf, len);
|
||||
char extra[MAX_PLACES][PATH_MAX_LEN];
|
||||
int ne = copies(extra, MAX_PLACES);
|
||||
for (int i = 0; i < ne; i++) {
|
||||
int r = put(extra[i], data, len, version);
|
||||
if (r < 0) failed = 1;
|
||||
else done++;
|
||||
if (r > 0) LOGI("updated %s to %s", extra[i], version[0] ? version : "this payload");
|
||||
}
|
||||
if (enable) {
|
||||
if (!file_exists(elf)) {
|
||||
snprintf(err, (size_t)en, "upload %s first", plat_info()->payload);
|
||||
return failed ? -1 : done;
|
||||
}
|
||||
|
||||
/* Turns autostart on in every installed loader's folder that has the payload. */
|
||||
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 (write_file_atomic(p[i].flag, "", 0) != 0) {
|
||||
snprintf(err, (size_t)en, "cannot write %s", p[i].flag);
|
||||
return -1;
|
||||
}
|
||||
if (flag[0] && write_file_atomic(flag, "", 0) != 0) {
|
||||
snprintf(err, (size_t)en, "cannot write %s", flag);
|
||||
return -1;
|
||||
}
|
||||
LOGI("autostart enabled");
|
||||
} else if (flag[0]) {
|
||||
unlink(flag);
|
||||
LOGI("autostart disabled");
|
||||
} else {
|
||||
/* Nothing but the payload itself turns autostart on, so remove it. */
|
||||
unlink(elf);
|
||||
LOGI("autostart disabled, removed %s", elf);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int check_elf(const void *data, size_t len, char *err, int en) {
|
||||
if (len < 4 || memcmp(data, "\x7f" "ELF", 4) != 0) {
|
||||
snprintf(err, (size_t)en, "that file is not an ELF payload");
|
||||
return -1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
int autostart_set(int enable, const void *data, size_t len, char *err, int en) {
|
||||
place p[MAX_PLACES];
|
||||
int n = places(p, MAX_PLACES);
|
||||
if (!n) {
|
||||
snprintf(err, (size_t)en, "%s can't start payloads with the console", plat_info()->loader);
|
||||
return -1;
|
||||
}
|
||||
if (!p[0].present) {
|
||||
snprintf(err, (size_t)en, "%s folder not found (%s)", plat_info()->loader, p[0].elf);
|
||||
return -1;
|
||||
}
|
||||
if (data && len) {
|
||||
if (check_elf(data, len, err, en)) return -1;
|
||||
if (put_everywhere(data, len, 0) < 0) {
|
||||
snprintf(err, (size_t)en, "cannot write %s", p[0].elf);
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
if (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);
|
||||
return -1;
|
||||
}
|
||||
return on ? enable_all(err, en) : 0;
|
||||
}
|
||||
|
||||
int autostart_replace(const void *data, size_t len, char *err, int en) {
|
||||
int r = put_everywhere(data, len, 1);
|
||||
if (r < 0) {
|
||||
snprintf(err, (size_t)en, "cannot write the payload to %s's folder", plat_info()->loader);
|
||||
return -1;
|
||||
}
|
||||
return r > 0;
|
||||
}
|
||||
|
||||
void autostart_repair(void) {
|
||||
place p[MAX_PLACES];
|
||||
int n = places(p, MAX_PLACES);
|
||||
char extra[MAX_PLACES][PATH_MAX_LEN];
|
||||
int ne = copies(extra, MAX_PLACES);
|
||||
|
||||
/* The newest copy on the console, and whether autostart is on anywhere. */
|
||||
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];
|
||||
file_version(path, v, sizeof v);
|
||||
if (!best[0] || version_cmp(v, best_v) > 0) {
|
||||
str_copy(best, sizeof best, path);
|
||||
str_copy(best_v, sizeof best_v, v);
|
||||
}
|
||||
}
|
||||
if (!best[0]) return;
|
||||
|
||||
for (int i = 0; i < n + ne; i++) {
|
||||
const char *path = i < n ? p[i].elf : extra[i - n];
|
||||
if (i < n && !p[i].present) continue;
|
||||
int have = file_exists(path);
|
||||
/* Missing copies are only added to loaders, and only while it's on. */
|
||||
if (!have && !(i < n && on)) continue;
|
||||
if (have) {
|
||||
char v[32];
|
||||
file_version(path, v, sizeof v);
|
||||
if (version_cmp(v, best_v) >= 0) continue;
|
||||
}
|
||||
if (copy_file(best, path) == 0) {
|
||||
chmod(path, 0777);
|
||||
LOGI("%s %s with %s from %s", have ? "updated" : "set up", path, best_v[0] ? best_v : "the payload", best);
|
||||
} else {
|
||||
LOGW("could not copy %s to %s", best, path);
|
||||
}
|
||||
}
|
||||
if (on) {
|
||||
char err[256];
|
||||
if (enable_all(err, sizeof err)) LOGW("%s", err);
|
||||
}
|
||||
}
|
||||
+21
-6
@@ -1,5 +1,9 @@
|
||||
/* Starting with the console through the loader (etaHEN on the PS5). Consoles
|
||||
* whose loader can't do that report supported: false. */
|
||||
/* Starting with the console through the loader (etaHEN or onionHEN on the
|
||||
* PS5). Consoles whose loader can't do that report supported: false.
|
||||
*
|
||||
* The payload goes into every installed loader's payload folder, so it starts
|
||||
* whichever loader the console runs, and copies other tools keep (a payload
|
||||
* manager's) are kept up to date. A copy is never replaced by an older one. */
|
||||
#ifndef ROMM_AUTOSTART_H
|
||||
#define ROMM_AUTOSTART_H
|
||||
|
||||
@@ -7,11 +11,22 @@
|
||||
|
||||
#include "cJSON.h"
|
||||
|
||||
cJSON *autostart_status(void); /* {supported, loader, payload, loader_found, installed, enabled, path} */
|
||||
/* Turns autostart on or off, installing the payload first if one is given. */
|
||||
/* {supported, loader, payload, hint, loader_found, installed, enabled, path,
|
||||
* version, copies: [{path, version, enabled}]}. installed, enabled, path and
|
||||
* version are about the running loader's folder. */
|
||||
cJSON *autostart_status(void);
|
||||
/* Turns autostart on 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);
|
||||
/* Replaces an installed payload, keeping the old one as <payload>.bak.
|
||||
* 1 if replaced, 0 if none is installed, -1 on error. */
|
||||
/* 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);
|
||||
/* 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. */
|
||||
void autostart_repair(void);
|
||||
|
||||
#endif
|
||||
+6
-1
@@ -54,6 +54,7 @@ static void set_defaults(void) {
|
||||
g_cfg.sync_on_game_exit = 1;
|
||||
g_cfg.sync_on_game_start = 1;
|
||||
g_cfg.sync_on_change = 1;
|
||||
g_cfg.save_sync = 1;
|
||||
g_cfg.exit_delay_sec = 5;
|
||||
g_cfg.sync_states = 1;
|
||||
g_cfg.notify = 1;
|
||||
@@ -65,7 +66,7 @@ static void set_defaults(void) {
|
||||
g_cfg.conflicts = CONFLICT_ASK;
|
||||
str_copy(g_cfg.slot, sizeof g_cfg.slot, "autosave");
|
||||
str_copy(g_cfg.ps2_cards, sizeof g_cfg.ps2_cards, "per_game");
|
||||
str_copy(g_cfg.ui_theme, sizeof g_cfg.ui_theme, "classic");
|
||||
str_copy(g_cfg.ui_theme, sizeof g_cfg.ui_theme, "acrylic");
|
||||
}
|
||||
|
||||
void config_apply_json(const cJSON *j) {
|
||||
@@ -80,6 +81,7 @@ void config_apply_json(const cJSON *j) {
|
||||
get_int(j, "sync_on_game_exit", &g_cfg.sync_on_game_exit);
|
||||
get_int(j, "sync_on_game_start", &g_cfg.sync_on_game_start);
|
||||
get_int(j, "sync_on_change", &g_cfg.sync_on_change);
|
||||
get_int(j, "save_sync", &g_cfg.save_sync);
|
||||
get_int(j, "exit_delay_sec", &g_cfg.exit_delay_sec);
|
||||
get_int(j, "sync_states", &g_cfg.sync_states); /* older configs: true/false */
|
||||
char states[16] = "";
|
||||
@@ -90,6 +92,7 @@ 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;
|
||||
@@ -172,11 +175,13 @@ cJSON *config_to_json(int include_secrets) {
|
||||
cJSON_AddBoolToObject(j, "sync_on_game_exit", g_cfg.sync_on_game_exit);
|
||||
cJSON_AddBoolToObject(j, "sync_on_game_start", g_cfg.sync_on_game_start);
|
||||
cJSON_AddBoolToObject(j, "sync_on_change", g_cfg.sync_on_change);
|
||||
cJSON_AddBoolToObject(j, "save_sync", g_cfg.save_sync);
|
||||
cJSON_AddNumberToObject(j, "exit_delay_sec", g_cfg.exit_delay_sec);
|
||||
cJSON_AddStringToObject(j, "states", g_cfg.sync_states == 2 ? "sync" : g_cfg.sync_states ? "upload" : "off");
|
||||
cJSON_AddBoolToObject(j, "notify", g_cfg.notify);
|
||||
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);
|
||||
|
||||
@@ -19,6 +19,7 @@ typedef struct {
|
||||
int sync_interval_min; /* periodic sync, 0 = off */
|
||||
int sync_on_game_exit;
|
||||
int sync_on_game_start;
|
||||
int save_sync; /* 0: RomM only as a game source, no save or state sync */
|
||||
int sync_on_change; /* upload saves as soon as they change */
|
||||
int exit_delay_sec;
|
||||
int sync_states; /* 0 off, 1 upload, 2 upload and download */
|
||||
@@ -36,6 +37,7 @@ typedef struct {
|
||||
char ps2_cards[16]; /* "per_game" or "backup" */
|
||||
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;
|
||||
|
||||
|
||||
+34
-2
@@ -262,12 +262,39 @@ 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 int collect_cover(const cJSON *it, const cJSON *page, int first, void *ctx) {
|
||||
(void)page, (void)first;
|
||||
if (!it) return 0;
|
||||
const char *p = jget_str(it, "path_cover_small", "");
|
||||
if (!valid_path(p)) return 0;
|
||||
struct { char **list; int n, cap; } *c = ctx;
|
||||
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);
|
||||
@@ -281,7 +308,9 @@ static int collect_cover(const cJSON *it, const cJSON *page, int first, void *ct
|
||||
/* Every game's cover path, platform by platform. 0 when RomM gave the whole
|
||||
* list; -1 if it couldn't be reached part way (what came is still used). */
|
||||
static int list_all(void) {
|
||||
struct { char **list; int n, cap; } c = {0};
|
||||
struct cover_list c = {0};
|
||||
static char heads[100000]; /* a platform's first covers (its tile) */
|
||||
memset(heads, 0, sizeof heads);
|
||||
long st = 0;
|
||||
cJSON *plats = romm_call("GET", "/api/platforms", NULL, &st), *p;
|
||||
int ok = st == 200 && cJSON_IsArray(plats);
|
||||
@@ -289,9 +318,12 @@ static int list_all(void) {
|
||||
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;
|
||||
}
|
||||
cJSON_Delete(plats);
|
||||
prioritize(&c, heads);
|
||||
pthread_mutex_lock(&g_lock);
|
||||
for (int i = 0; i < g_nall; i++) free(g_all[i]);
|
||||
free(g_all);
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
#include "detect.h"
|
||||
|
||||
#include <dirent.h>
|
||||
#include <pthread.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
@@ -323,6 +324,18 @@ static cJSON *emulator_catalog(void) {
|
||||
return plat_info()->emulator_catalog ? cJSON_Parse(plat_info()->emulator_catalog) : NULL;
|
||||
}
|
||||
|
||||
static cJSON *g_catalog;
|
||||
static void load_catalog(void) { g_catalog = emulator_catalog(); }
|
||||
|
||||
const cJSON *detect_catalog_entry(const char *id) {
|
||||
static pthread_once_t once = PTHREAD_ONCE_INIT;
|
||||
pthread_once(&once, load_catalog);
|
||||
const cJSON *e;
|
||||
cJSON_ArrayForEach(e, g_catalog)
|
||||
if (!strcmp(jget_str(e, "id", ""), id)) return e;
|
||||
return NULL;
|
||||
}
|
||||
|
||||
static void add_standalone(cJSON *out) {
|
||||
cJSON *catalog = emulator_catalog();
|
||||
const cJSON *e, *x;
|
||||
@@ -398,12 +411,79 @@ cJSON *detect_emulators(void) {
|
||||
closedir(d);
|
||||
}
|
||||
add_standalone(out);
|
||||
/* The folders each emulator uses by default, for a given platform. */
|
||||
cJSON *c;
|
||||
cJSON_ArrayForEach(c, out) {
|
||||
if (cJSON_IsFalse(cJSON_GetObjectItemCaseSensitive(c, "ready"))) continue; /* No preset, custom */
|
||||
cJSON *profile = cJSON_GetObjectItemCaseSensitive(c, "profile"), *one = cJSON_CreateArray();
|
||||
cJSON_AddItemReferenceToArray(one, profile);
|
||||
profile_map *maps = NULL;
|
||||
int n = profiles_expand(one, &maps);
|
||||
cJSON *dirs = cJSON_AddObjectToObject(c, "dirs");
|
||||
for (int i = 0; i < n; i++) {
|
||||
char key[64];
|
||||
str_copy(key, sizeof key, maps[i].romm_slugs);
|
||||
char *comma = strchr(key, ',');
|
||||
if (comma) *comma = 0;
|
||||
cJSON *d = cJSON_AddObjectToObject(dirs, key);
|
||||
cJSON_AddStringToObject(d, "rom_dir", maps[i].rom_dir);
|
||||
cJSON_AddStringToObject(d, "save_dir", maps[i].save_dir);
|
||||
cJSON_AddStringToObject(d, "state_dir", maps[i].state_dir);
|
||||
/* Games already there, so setup can pick the emulator they're in. */
|
||||
int games = 0;
|
||||
DIR *g = opendir(maps[i].rom_dir);
|
||||
struct dirent *ge;
|
||||
while (g && (ge = readdir(g)))
|
||||
if (ge->d_name[0] != '.' && !ext_in_list(ge->d_name, maps[i].save_exts) &&
|
||||
!ext_in_list(ge->d_name, maps[i].state_exts) && !ext_in_list(ge->d_name, ".part,.tmp,.txt,.png,.jpg,.xml"))
|
||||
games++;
|
||||
if (g) closedir(g);
|
||||
cJSON_AddNumberToObject(d, "games", games);
|
||||
}
|
||||
free(maps);
|
||||
cJSON_Delete(one);
|
||||
const cJSON *fixed = cJSON_GetObjectItemCaseSensitive(profile, "fixed");
|
||||
cJSON_AddItemToObject(c, "fixed", fixed ? cJSON_Duplicate(fixed, 1) : cJSON_CreateArray());
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/* Keys a newer preset has that a saved profile lacks (a state folder added
|
||||
* to Porpoise's, say) are added to it. Nothing already there changes, so
|
||||
* folders the user picked stay. */
|
||||
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)) {
|
||||
cJSON_AddItemToObject(p, k->string, cJSON_Duplicate(k, 1));
|
||||
added++;
|
||||
}
|
||||
cJSON_ArrayForEach(pe, cJSON_GetObjectItemCaseSensitive(p, "platforms")) {
|
||||
const cJSON *first = cJSON_GetArrayItem(cJSON_GetObjectItemCaseSensitive(pe, "romm"), 0), *ce;
|
||||
if (!cJSON_IsString(first)) continue;
|
||||
cJSON_ArrayForEach(ce, cJSON_GetObjectItemCaseSensitive(preset, "platforms")) {
|
||||
const cJSON *cf = cJSON_GetArrayItem(cJSON_GetObjectItemCaseSensitive(ce, "romm"), 0);
|
||||
if (!cJSON_IsString(cf) || strcmp(cf->valuestring, first->valuestring)) continue;
|
||||
cJSON_ArrayForEach(k, ce)
|
||||
if (!cJSON_GetObjectItemCaseSensitive(pe, k->string)) {
|
||||
cJSON_AddItemToObject(pe, k->string, cJSON_Duplicate(k, 1));
|
||||
added++;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (added) LOGI("the %s profile got %d setting%s from its newer preset", jget_str(p, "name", ""), added, added == 1 ? "" : "s");
|
||||
return added;
|
||||
}
|
||||
|
||||
int detect_refresh_profiles(cJSON *profiles) {
|
||||
int changed = 0;
|
||||
cJSON *found = NULL;
|
||||
cJSON *each;
|
||||
cJSON_ArrayForEach(each, profiles) changed += fill_from_catalog(each) > 0;
|
||||
for (int i = 0; i < cJSON_GetArraySize(profiles); i++) {
|
||||
cJSON *p = cJSON_GetArrayItem(profiles, i);
|
||||
const char *id = jget_str(p, "id", "");
|
||||
@@ -420,6 +500,19 @@ int detect_refresh_profiles(cJSON *profiles) {
|
||||
/* The user's choice of emulator per system survives a refresh. */
|
||||
const cJSON *ex = cJSON_GetObjectItemCaseSensitive(p, "exclude");
|
||||
if (ex) cJSON_AddItemToObject(fresh, "exclude", cJSON_Duplicate(ex, 1));
|
||||
const cJSON *extra = cJSON_GetObjectItemCaseSensitive(p, "extra");
|
||||
if (extra) cJSON_AddItemToObject(fresh, "extra", cJSON_Duplicate(extra, 1));
|
||||
const cJSON *mode = cJSON_GetObjectItemCaseSensitive(p, "sync");
|
||||
if (cJSON_IsString(mode)) cJSON_AddStringToObject(fresh, "sync", mode->valuestring);
|
||||
/* ... and each platform's. */
|
||||
cJSON *fe;
|
||||
cJSON_ArrayForEach(fe, cJSON_GetObjectItemCaseSensitive(fresh, "platforms")) {
|
||||
const cJSON *pe, *m;
|
||||
cJSON_ArrayForEach(pe, cJSON_GetObjectItemCaseSensitive(p, "platforms"))
|
||||
if (!strcmp(profiles_system_key(pe), profiles_system_key(fe)) &&
|
||||
cJSON_IsString(m = cJSON_GetObjectItemCaseSensitive(pe, "sync")))
|
||||
cJSON_AddStringToObject(fe, "sync", m->valuestring);
|
||||
}
|
||||
LOGI("updated the %s profile for %s", jget_str(fresh, "name", ""), jget_str(p, "root", ""));
|
||||
cJSON_ReplaceItemInArray(profiles, i, fresh);
|
||||
changed++;
|
||||
|
||||
@@ -11,4 +11,8 @@ cJSON *detect_emulators(void);
|
||||
* features. Profiles written by hand are left alone. Returns how many changed. */
|
||||
int detect_refresh_profiles(cJSON *profiles);
|
||||
|
||||
/* The built-in emulator catalog entry with this id (emulators.json), or NULL.
|
||||
* Parsed once; don't free. */
|
||||
const cJSON *detect_catalog_entry(const char *id);
|
||||
|
||||
#endif
|
||||
+134
@@ -0,0 +1,134 @@
|
||||
#include "events.h"
|
||||
|
||||
#include <pthread.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <time.h>
|
||||
|
||||
#include "platform.h"
|
||||
#include "state.h"
|
||||
#include "util.h"
|
||||
|
||||
static pthread_mutex_t g_lock = PTHREAD_MUTEX_INITIALIZER;
|
||||
static cJSON *g_events; /* oldest first */
|
||||
static char g_sync[64] = "";
|
||||
static int g_dirty;
|
||||
static unsigned g_seq; /* entries ever added */
|
||||
|
||||
static void path_of(char *out, size_t n) { path_join(out, n, plat_data_dir(), "events.json"); }
|
||||
|
||||
void events_load(void) {
|
||||
char path[PATH_MAX_LEN];
|
||||
path_of(path, sizeof path);
|
||||
char *txt = read_file(path, NULL);
|
||||
cJSON *j = txt ? cJSON_Parse(txt) : NULL;
|
||||
free(txt);
|
||||
pthread_mutex_lock(&g_lock);
|
||||
cJSON_Delete(g_events);
|
||||
g_events = cJSON_IsArray(j) ? j : cJSON_CreateArray();
|
||||
if (!cJSON_IsArray(j)) cJSON_Delete(j);
|
||||
pthread_mutex_unlock(&g_lock);
|
||||
}
|
||||
|
||||
void events_save(void) {
|
||||
pthread_mutex_lock(&g_lock);
|
||||
char *txt = g_dirty && g_events ? cJSON_PrintUnformatted(g_events) : NULL;
|
||||
g_dirty = 0;
|
||||
pthread_mutex_unlock(&g_lock);
|
||||
if (!txt) return;
|
||||
char path[PATH_MAX_LEN];
|
||||
path_of(path, sizeof path);
|
||||
if (write_file_atomic(path, txt, strlen(txt)) != 0) LOGW("could not write %s", path);
|
||||
free(txt);
|
||||
}
|
||||
|
||||
void events_set_sync(const char *reason) {
|
||||
pthread_mutex_lock(&g_lock);
|
||||
str_copy(g_sync, sizeof g_sync, reason ? reason : "");
|
||||
pthread_mutex_unlock(&g_lock);
|
||||
}
|
||||
|
||||
void events_add(const char *kind, const char *action, int rom_id, const char *path, const char *emulator,
|
||||
const char *detail) {
|
||||
cJSON *e = cJSON_CreateObject();
|
||||
cJSON_AddNumberToObject(e, "t", (double)time(NULL));
|
||||
cJSON_AddNumberToObject(e, "rom_id", rom_id);
|
||||
cJSON_AddStringToObject(e, "kind", kind);
|
||||
cJSON_AddStringToObject(e, "action", action);
|
||||
cJSON_AddStringToObject(e, "path", path ? path : "");
|
||||
cJSON_AddStringToObject(e, "emulator", emulator ? emulator : "");
|
||||
if (detail && detail[0]) cJSON_AddStringToObject(e, "detail", detail);
|
||||
pthread_mutex_lock(&g_lock);
|
||||
if (!g_events) g_events = cJSON_CreateArray();
|
||||
cJSON_AddStringToObject(e, "sync", g_sync);
|
||||
cJSON_AddItemToArray(g_events, e);
|
||||
g_seq++;
|
||||
while (cJSON_GetArraySize(g_events) > EVENTS_MAX) cJSON_DeleteItemFromArray(g_events, 0);
|
||||
g_dirty = 1;
|
||||
pthread_mutex_unlock(&g_lock);
|
||||
}
|
||||
|
||||
unsigned events_seq(void) {
|
||||
pthread_mutex_lock(&g_lock);
|
||||
unsigned s = g_seq;
|
||||
pthread_mutex_unlock(&g_lock);
|
||||
return s;
|
||||
}
|
||||
|
||||
int events_games(unsigned since, int max, char *out, size_t n) {
|
||||
int ids[64], nids = 0;
|
||||
pthread_mutex_lock(&g_lock);
|
||||
int size = g_events ? cJSON_GetArraySize(g_events) : 0;
|
||||
unsigned added = g_seq - since;
|
||||
for (int i = added < (unsigned)size ? size - (int)added : 0; i < size && nids < 64; i++) {
|
||||
const cJSON *e = cJSON_GetArrayItem(g_events, i);
|
||||
const char *a = jget_str(e, "action", "");
|
||||
int id = (int)jget_num(e, "rom_id", 0), seen = 0;
|
||||
if (!id || !(!strcmp(a, "uploaded") || !strcmp(a, "downloaded") || !strcmp(a, "conflict") || !strcmp(a, "failed")))
|
||||
continue;
|
||||
for (int k = 0; k < nids; k++) seen |= ids[k] == id;
|
||||
if (!seen) ids[nids++] = id;
|
||||
}
|
||||
pthread_mutex_unlock(&g_lock);
|
||||
size_t o = 0;
|
||||
int named = 0, more = 0;
|
||||
out[0] = 0;
|
||||
state_lock();
|
||||
const cJSON *roms = state_section("roms"), *r;
|
||||
for (int k = 0; k < nids; k++) {
|
||||
const char *name = "";
|
||||
cJSON_ArrayForEach(r, roms) if ((int)jget_num(r, "rom_id", 0) == ids[k] && jget_str(r, "name", "")[0])
|
||||
name = jget_str(r, "name", "");
|
||||
if (!name[0]) continue;
|
||||
if (named == max) { more++; continue; }
|
||||
if (o < n) o += (size_t)snprintf(out + o, n - o, "%s%s", named ? ", " : "", name);
|
||||
named++;
|
||||
}
|
||||
state_unlock();
|
||||
if (more && o < n) snprintf(out + o, n - o, " and %d more", more);
|
||||
return named > 0;
|
||||
}
|
||||
|
||||
cJSON *events_json(int rom_id, int limit) {
|
||||
cJSON *out = cJSON_CreateArray();
|
||||
pthread_mutex_lock(&g_lock);
|
||||
cJSON *copy = g_events ? cJSON_Duplicate(g_events, 1) : cJSON_CreateArray();
|
||||
pthread_mutex_unlock(&g_lock);
|
||||
state_lock();
|
||||
const cJSON *roms = state_section("roms");
|
||||
for (int i = cJSON_GetArraySize(copy) - 1; i >= 0 && (limit <= 0 || cJSON_GetArraySize(out) < limit); i--) {
|
||||
cJSON *e = cJSON_GetArrayItem(copy, i);
|
||||
int id = (int)jget_num(e, "rom_id", 0);
|
||||
if (rom_id && id != rom_id) continue;
|
||||
const cJSON *r, *game = NULL;
|
||||
cJSON_ArrayForEach(r, roms) if ((int)jget_num(r, "rom_id", -1) == id && id) game = r;
|
||||
cJSON *o = cJSON_Duplicate(e, 1);
|
||||
cJSON_AddStringToObject(o, "game", game ? jget_str(game, "name", "") : "");
|
||||
cJSON_AddStringToObject(o, "platform", game ? jget_str(game, "platform_dir", "") : "");
|
||||
cJSON_AddItemToArray(out, o);
|
||||
}
|
||||
state_unlock();
|
||||
cJSON_Delete(copy);
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
/* The sync log: one entry per file a sync moved or left alone on purpose,
|
||||
* newest last, the last EVENTS_MAX kept in events.json.
|
||||
*
|
||||
* {"t": 1791936000, "sync": "game exited", "rom_id": 878,
|
||||
* "kind": "save" | "state" | "card",
|
||||
* "action": "uploaded" | "downloaded" | "conflict" | "resolved" |
|
||||
* "skipped" | "restored" | "backed up" | "failed",
|
||||
* "path": "/data/.../Game.srm", "emulator": "mgba", "detail": "..."}
|
||||
*
|
||||
* The game's name and platform are filled in when the log is read. */
|
||||
#ifndef ROMM_EVENTS_H
|
||||
#define ROMM_EVENTS_H
|
||||
|
||||
#include "cJSON.h"
|
||||
|
||||
#define EVENTS_MAX 500
|
||||
|
||||
void events_load(void);
|
||||
void events_save(void); /* after each sync; cheap when nothing changed */
|
||||
|
||||
/* Names the sync the next entries come from ("startup", "game exited"...). */
|
||||
void events_set_sync(const char *reason);
|
||||
|
||||
void events_add(const char *kind, const char *action, int rom_id, const char *path, const char *emulator,
|
||||
const char *detail);
|
||||
|
||||
/* Counts the entries added so far; with events_games, the games a sync touched. */
|
||||
unsigned events_seq(void);
|
||||
/* The games of the saves and states moved since events_seq() was "since":
|
||||
* "Wind Waker, Mario Kart and 2 more" (at most "max" names). 0 if none. */
|
||||
int events_games(unsigned since, int max, char *out, size_t n);
|
||||
|
||||
/* Newest first; rom_id 0 for every game. Caller frees. */
|
||||
cJSON *events_json(int rom_id, int limit);
|
||||
|
||||
#endif
|
||||
+241
@@ -0,0 +1,241 @@
|
||||
#include "gcmem.h"
|
||||
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
#define FIRST_DATA 5
|
||||
#define NENTRIES 127
|
||||
|
||||
static unsigned be16(const unsigned char *p) { return (unsigned)p[0] << 8 | p[1]; }
|
||||
static void put16(unsigned char *p, unsigned v) {
|
||||
p[0] = (unsigned char)(v >> 8);
|
||||
p[1] = (unsigned char)v;
|
||||
}
|
||||
|
||||
/* Dolphin's two checksums over big-endian words; 0xFFFF counts as 0. */
|
||||
static void checksums(const unsigned char *p, size_t n, unsigned *c1, unsigned *c2) {
|
||||
unsigned a = 0, b = 0;
|
||||
for (size_t i = 0; i + 1 < n; i += 2) {
|
||||
unsigned w = be16(p + i);
|
||||
a = (a + w) & 0xFFFF;
|
||||
b = (b + (w ^ 0xFFFF)) & 0xFFFF;
|
||||
}
|
||||
*c1 = a == 0xFFFF ? 0 : a;
|
||||
*c2 = b == 0xFFFF ? 0 : b;
|
||||
}
|
||||
|
||||
static unsigned char *block(const gc_card *c, int n) { return c->data + (size_t)n * GC_BLOCK; }
|
||||
|
||||
/* Directory: checksums over 0..0x1FFB, at 0x1FFC; update counter at 0x1FFA. */
|
||||
static int dir_ok(const unsigned char *d) {
|
||||
unsigned a, b;
|
||||
checksums(d, 0x1FFC, &a, &b);
|
||||
return a == be16(d + 0x1FFC) && b == be16(d + 0x1FFE);
|
||||
}
|
||||
/* Table: checksums over 0x4..0x1FFF, at 0x0; update counter at 0x4. */
|
||||
static int bat_ok(const unsigned char *t) {
|
||||
unsigned a, b;
|
||||
checksums(t + 4, GC_BLOCK - 4, &a, &b);
|
||||
return a == be16(t) && b == be16(t + 2);
|
||||
}
|
||||
|
||||
/* The current copy: the one that checks out, or the newer of two that do. */
|
||||
static int pick(const unsigned char *x, const unsigned char *y, int ok_x, int ok_y, size_t counter_at, int ix, int iy) {
|
||||
if (ok_x && ok_y) return (short)be16(y + counter_at) > (short)be16(x + counter_at) ? iy : ix;
|
||||
return ok_x ? ix : ok_y ? iy : -1;
|
||||
}
|
||||
|
||||
int gc_card_open(gc_card *c, const unsigned char *data, size_t size) {
|
||||
memset(c, 0, sizeof *c);
|
||||
if (size < (size_t)GC_BLOCK * (FIRST_DATA + 1) || size % GC_BLOCK) return -1;
|
||||
c->data = malloc(size);
|
||||
if (!c->data) return -1;
|
||||
memcpy(c->data, data, size);
|
||||
c->size = size;
|
||||
c->nblocks = (int)(size / GC_BLOCK);
|
||||
if (c->nblocks > 0xFFB + FIRST_DATA) c->nblocks = 0xFFB + FIRST_DATA;
|
||||
c->dir = pick(block(c, 1), block(c, 2), dir_ok(block(c, 1)), dir_ok(block(c, 2)), 0x1FFA, 1, 2);
|
||||
c->bat = pick(block(c, 3), block(c, 4), bat_ok(block(c, 3)), bat_ok(block(c, 4)), 4, 3, 4);
|
||||
if (c->dir < 0 || c->bat < 0) {
|
||||
gc_card_free(c);
|
||||
return -1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
void gc_card_free(gc_card *c) {
|
||||
free(c->data);
|
||||
memset(c, 0, sizeof *c);
|
||||
}
|
||||
|
||||
static unsigned char *entry(const gc_card *c, int i) { return block(c, c->dir) + i * GC_DENTRY; }
|
||||
static int empty(const unsigned char *e) { return e[0] == 0xFF && e[1] == 0xFF && e[2] == 0xFF && e[3] == 0xFF; }
|
||||
static unsigned char *map_at(const gc_card *c, int blk) { return block(c, c->bat) + 0xA + (size_t)(blk - FIRST_DATA) * 2; }
|
||||
static int is_data(const gc_card *c, unsigned blk) { return blk >= FIRST_DATA && blk < (unsigned)c->nblocks; }
|
||||
|
||||
int gc_gci_valid(const unsigned char *g, size_t size) {
|
||||
if (size < GC_DENTRY || (size - GC_DENTRY) % GC_BLOCK || empty(g)) return 0;
|
||||
unsigned count = be16(g + 0x38);
|
||||
return count > 0 && (size_t)count * GC_BLOCK == size - GC_DENTRY;
|
||||
}
|
||||
|
||||
static int same_id(const unsigned char *e, const char *id) { return strlen(id) >= 6 && !memcmp(e, id, 6); }
|
||||
|
||||
int gc_gci_same_save(const unsigned char *a, const unsigned char *b) {
|
||||
return !memcmp(a, b, 6) && !memcmp(a + 8, b + 8, 32);
|
||||
}
|
||||
|
||||
void gc_gci_id(const unsigned char *g, char out[7]) {
|
||||
memcpy(out, g, 6);
|
||||
out[6] = 0;
|
||||
}
|
||||
|
||||
/* Copies an entry's blocks after it, following the table. */
|
||||
static unsigned char *to_gci(const gc_card *c, const unsigned char *e, size_t *size) {
|
||||
unsigned count = be16(e + 0x38), blk = be16(e + 0x36);
|
||||
if (!count || count > (unsigned)c->nblocks) return NULL;
|
||||
unsigned char *g = malloc(GC_DENTRY + (size_t)count * GC_BLOCK);
|
||||
if (!g) return NULL;
|
||||
memcpy(g, e, GC_DENTRY);
|
||||
for (unsigned k = 0; k < count; k++) {
|
||||
if (!is_data(c, blk)) {
|
||||
free(g);
|
||||
return NULL;
|
||||
}
|
||||
memcpy(g + GC_DENTRY + (size_t)k * GC_BLOCK, block(c, (int)blk), GC_BLOCK);
|
||||
blk = be16(map_at(c, (int)blk));
|
||||
}
|
||||
*size = GC_DENTRY + (size_t)count * GC_BLOCK;
|
||||
return g;
|
||||
}
|
||||
|
||||
int gc_card_saves(const gc_card *c, const char *id, gc_save_fn fn, void *ud) {
|
||||
int n = 0;
|
||||
for (int i = 0; i < NENTRIES; i++) {
|
||||
const unsigned char *e = entry(c, i);
|
||||
if (empty(e) || !same_id(e, id)) continue;
|
||||
size_t size = 0;
|
||||
unsigned char *g = to_gci(c, e, &size);
|
||||
if (!g) continue;
|
||||
if (fn) fn(g, size, ud);
|
||||
free(g);
|
||||
n++;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
/* Edits go to a copy of the current directory and table, written over the
|
||||
* older copies when done. */
|
||||
typedef struct {
|
||||
unsigned char dir[GC_BLOCK], bat[GC_BLOCK];
|
||||
} draft;
|
||||
|
||||
static void draft_start(const gc_card *c, draft *d) {
|
||||
memcpy(d->dir, block(c, c->dir), GC_BLOCK);
|
||||
memcpy(d->bat, block(c, c->bat), GC_BLOCK);
|
||||
}
|
||||
|
||||
static void draft_commit(gc_card *c, draft *d) {
|
||||
unsigned a, b;
|
||||
put16(d->dir + 0x1FFA, (be16(d->dir + 0x1FFA) + 1) & 0xFFFF);
|
||||
checksums(d->dir, 0x1FFC, &a, &b);
|
||||
put16(d->dir + 0x1FFC, a);
|
||||
put16(d->dir + 0x1FFE, b);
|
||||
put16(d->bat + 4, (be16(d->bat + 4) + 1) & 0xFFFF);
|
||||
checksums(d->bat + 4, GC_BLOCK - 4, &a, &b);
|
||||
put16(d->bat, a);
|
||||
put16(d->bat + 2, b);
|
||||
c->dir = c->dir == 1 ? 2 : 1;
|
||||
c->bat = c->bat == 3 ? 4 : 3;
|
||||
memcpy(block(c, c->dir), d->dir, GC_BLOCK);
|
||||
memcpy(block(c, c->bat), d->bat, GC_BLOCK);
|
||||
}
|
||||
|
||||
static unsigned char *dmap(const gc_card *c, draft *d, unsigned blk) {
|
||||
(void)c;
|
||||
return d->bat + 0xA + (size_t)(blk - FIRST_DATA) * 2;
|
||||
}
|
||||
|
||||
/* Frees entry i's blocks and empties it. */
|
||||
static void draft_drop(const gc_card *c, draft *d, int i) {
|
||||
unsigned char *e = d->dir + i * GC_DENTRY;
|
||||
unsigned count = be16(e + 0x38), blk = be16(e + 0x36), freed = 0;
|
||||
for (unsigned k = 0; k < count && is_data(c, blk); k++) {
|
||||
unsigned char *m = dmap(c, d, blk);
|
||||
blk = be16(m);
|
||||
put16(m, 0);
|
||||
freed++;
|
||||
}
|
||||
put16(d->bat + 6, be16(d->bat + 6) + freed);
|
||||
memset(e, 0xFF, GC_DENTRY);
|
||||
}
|
||||
|
||||
int gc_card_remove(gc_card *c, const char *id) {
|
||||
draft d;
|
||||
draft_start(c, &d);
|
||||
int n = 0;
|
||||
for (int i = 0; i < NENTRIES; i++) {
|
||||
const unsigned char *e = d.dir + i * GC_DENTRY;
|
||||
if (empty(e) || !same_id(e, id)) continue;
|
||||
draft_drop(c, &d, i);
|
||||
n++;
|
||||
}
|
||||
if (n) draft_commit(c, &d);
|
||||
return n;
|
||||
}
|
||||
|
||||
int gc_card_put(gc_card *c, const unsigned char *g, size_t size) {
|
||||
if (!gc_gci_valid(g, size)) return -1;
|
||||
draft d;
|
||||
draft_start(c, &d);
|
||||
int slot = -1;
|
||||
for (int i = 0; i < NENTRIES; i++) {
|
||||
unsigned char *e = d.dir + i * GC_DENTRY;
|
||||
if (!empty(e) && gc_gci_same_save(e, g)) draft_drop(c, &d, i);
|
||||
if (empty(e) && slot < 0) slot = i;
|
||||
}
|
||||
const unsigned count = be16(g + 0x38);
|
||||
if (slot < 0 || be16(d.bat + 6) < count) return -1;
|
||||
/* Free blocks from just after the last one given out, wrapping round,
|
||||
* as Dolphin hands them out. */
|
||||
unsigned *blocks = malloc(sizeof *blocks * count), found = 0;
|
||||
if (!blocks) return -1;
|
||||
unsigned start = be16(d.bat + 8) + 1;
|
||||
if (start < FIRST_DATA || start >= (unsigned)c->nblocks) start = FIRST_DATA;
|
||||
for (unsigned k = 0; k < (unsigned)c->nblocks - FIRST_DATA && found < count; k++) {
|
||||
unsigned blk = FIRST_DATA + (start - FIRST_DATA + k) % ((unsigned)c->nblocks - FIRST_DATA);
|
||||
if (be16(dmap(c, &d, blk)) == 0) blocks[found++] = blk;
|
||||
}
|
||||
if (found < count) {
|
||||
free(blocks);
|
||||
return -1;
|
||||
}
|
||||
for (unsigned k = 0; k < count; k++) {
|
||||
memcpy(block(c, (int)blocks[k]), g + GC_DENTRY + (size_t)k * GC_BLOCK, GC_BLOCK);
|
||||
put16(dmap(c, &d, blocks[k]), k + 1 < count ? blocks[k + 1] : 0xFFFF);
|
||||
}
|
||||
put16(d.bat + 6, be16(d.bat + 6) - count);
|
||||
put16(d.bat + 8, blocks[count - 1]);
|
||||
unsigned char *e = d.dir + slot * GC_DENTRY;
|
||||
memcpy(e, g, GC_DENTRY);
|
||||
put16(e + 0x36, blocks[0]);
|
||||
free(blocks);
|
||||
draft_commit(c, &d);
|
||||
return 0;
|
||||
}
|
||||
|
||||
void gc_gci_name(const unsigned char *g, char *out, size_t n) {
|
||||
char name[33 * 6 + 1];
|
||||
size_t o = 0;
|
||||
for (int i = 0; i < 32 && g[8 + i]; i++) {
|
||||
unsigned char ch = g[8 + i];
|
||||
/* Dolphin's escaping of characters a file name can't have. */
|
||||
if (ch < 0x20 || strchr("\"*/:<>?\\|", ch))
|
||||
o += (size_t)snprintf(name + o, sizeof name - o, "__%02x__", ch);
|
||||
else
|
||||
name[o++] = (char)ch;
|
||||
}
|
||||
name[o] = 0;
|
||||
snprintf(out, n, "%.2s-%.4s-%s.gci", (const char *)g + 4, (const char *)g, name);
|
||||
}
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
/* GameCube memory cards (Dolphin's .raw) and the .gci files of single saves.
|
||||
*
|
||||
* A card is 8 KiB blocks: 0 the header, 1 and 2 the directory and its
|
||||
* previous copy, 3 and 4 the block allocation table and its previous copy,
|
||||
* then the saves. Each directory entry (64 bytes) names a save: game code,
|
||||
* maker, file name, first block and block count; the table chains its
|
||||
* blocks. A .gci is one entry followed by its blocks.
|
||||
*
|
||||
* Changes are made the way Dolphin makes them: the current directory and
|
||||
* table are edited, their update counters go up by one, their checksums are
|
||||
* set, and they're written over the older copies, which become the backups. */
|
||||
#ifndef ROMM_GCMEM_H
|
||||
#define ROMM_GCMEM_H
|
||||
|
||||
#include <stddef.h>
|
||||
|
||||
#define GC_BLOCK 0x2000
|
||||
#define GC_DENTRY 0x40
|
||||
|
||||
typedef struct {
|
||||
unsigned char *data;
|
||||
size_t size;
|
||||
int nblocks;
|
||||
int dir, bat; /* which copy is current: block 1 or 2, 3 or 4 */
|
||||
} gc_card;
|
||||
|
||||
/* 0 when data (size bytes, copied) is a card whose header, directory and
|
||||
* table check out. */
|
||||
int gc_card_open(gc_card *c, const unsigned char *data, size_t size);
|
||||
void gc_card_free(gc_card *c);
|
||||
|
||||
/* A .gci in memory: 0 when it's one entry and as many blocks as it says. */
|
||||
int gc_gci_valid(const unsigned char *gci, size_t size);
|
||||
|
||||
/* The saves of a game: entries whose game code and maker are id's six
|
||||
* characters ("GZLE01"). Calls fn with each as a .gci; returns how many. */
|
||||
typedef void (*gc_save_fn)(const unsigned char *gci, size_t size, void *ud);
|
||||
int gc_card_saves(const gc_card *c, const char *id, gc_save_fn fn, void *ud);
|
||||
|
||||
/* Takes the game's saves off the card. */
|
||||
int gc_card_remove(gc_card *c, const char *id);
|
||||
|
||||
/* Puts a .gci on the card, over any save with the same game code, maker and
|
||||
* file name. 0, or -1 when it doesn't fit or the directory is full. */
|
||||
int gc_card_put(gc_card *c, const unsigned char *gci, size_t size);
|
||||
|
||||
/* Dolphin's name for a .gci in a GCI folder: "01-GZLE-gczelda2.gci". */
|
||||
void gc_gci_name(const unsigned char *gci, char *out, size_t n);
|
||||
|
||||
/* The game id ("GZLE01") a .gci belongs to. */
|
||||
void gc_gci_id(const unsigned char *gci, char out[7]);
|
||||
|
||||
/* Whether two .gci are the same save (game code, maker and file name). */
|
||||
int gc_gci_same_save(const unsigned char *a, const unsigned char *b);
|
||||
|
||||
#endif
|
||||
+112
-18
@@ -22,7 +22,7 @@
|
||||
#define MAX_JOBS 50
|
||||
|
||||
typedef enum { JOB_ROM, JOB_FIRMWARE } job_type;
|
||||
typedef enum { JOB_QUEUED, JOB_RUNNING, JOB_DONE, JOB_ERROR, JOB_CANCELLED } job_status;
|
||||
typedef enum { JOB_QUEUED, JOB_RUNNING, JOB_DONE, JOB_ERROR, JOB_CANCELLED, JOB_SYNCING } job_status;
|
||||
|
||||
typedef struct job {
|
||||
int id;
|
||||
@@ -36,6 +36,8 @@ typedef struct job {
|
||||
int64_t done, total;
|
||||
int64_t base; /* bytes from files already finished (multi-file games) */
|
||||
double speed; /* bytes/s, smoothed */
|
||||
char note[80]; /* Running job updates this after a download. */
|
||||
sync_game_result saves; /* what the sync after the download brought */
|
||||
double sample_t;
|
||||
int64_t sample_bytes;
|
||||
struct job *next;
|
||||
@@ -253,8 +255,12 @@ static void run_rom_job(job *j) {
|
||||
|
||||
const char *fs_name = jget_str(rom, "fs_name", "");
|
||||
const cJSON *files = cJSON_GetObjectItemCaseSensitive(rom, "files");
|
||||
int multi = cJSON_IsTrue(cJSON_GetObjectItemCaseSensitive(rom, "has_multiple_files")) &&
|
||||
cJSON_GetArraySize(files) > 1;
|
||||
/* fs_name is one of files[] only for a plain single-file rom; otherwise
|
||||
* it's the folder's name (no extension) and the rom is fetched per file. */
|
||||
int multi = cJSON_GetArraySize(files) > 0;
|
||||
const cJSON *f;
|
||||
cJSON_ArrayForEach(f, files)
|
||||
if (strcmp(jget_str(f, "file_name", ""), fs_name) == 0) { multi = 0; break; }
|
||||
if (library_not_game(fs_name)) {
|
||||
job_end(j, JOB_ERROR, "%s isn't a game", fs_name);
|
||||
free(maps);
|
||||
@@ -320,13 +326,36 @@ static void run_rom_job(job *j) {
|
||||
path_join(other, sizeof other, x->rom_dir, fs_name);
|
||||
if (!strcmp(other, dest)) continue;
|
||||
mkdir_p(x->rom_dir);
|
||||
if (link_or_copy(dest, other) == 0) {
|
||||
pthread_mutex_lock(&g_q_lock);
|
||||
snprintf(j->note, sizeof j->note, "Copying to %s", x->profile_name);
|
||||
j->base = j->done = j->total = 0;
|
||||
j->speed = 0;
|
||||
j->sample_t = 0;
|
||||
pthread_mutex_unlock(&g_q_lock);
|
||||
if (link_or_copy_progress(dest, other, progress_cb, j) == 0) {
|
||||
record_rom(j, rom, x, other, multi);
|
||||
LOGI("also for %s: %s", x->profile_name, other);
|
||||
} else {
|
||||
LOGW("could not put %s in %s", j->name, x->rom_dir);
|
||||
}
|
||||
}
|
||||
/* The game's saves land before it shows as ready, so it isn't
|
||||
* started without them. */
|
||||
pthread_mutex_lock(&g_q_lock);
|
||||
j->status = JOB_SYNCING;
|
||||
pthread_mutex_unlock(&g_q_lock);
|
||||
char name[256];
|
||||
str_copy(name, sizeof name, j->name);
|
||||
sync_game_result saves;
|
||||
if (sync_game(j->target_id, name, &saves) != 0) { /* save sync off: just the game */
|
||||
config_lock();
|
||||
const int notify = g_cfg.notify;
|
||||
config_unlock();
|
||||
if (notify) plat_notify("RommPS: %s downloaded", name);
|
||||
}
|
||||
pthread_mutex_lock(&g_q_lock);
|
||||
j->saves = saves;
|
||||
pthread_mutex_unlock(&g_q_lock);
|
||||
job_end(j, JOB_DONE, NULL, NULL);
|
||||
} else {
|
||||
if (cancelled(j) && !(multi && had)) discard_partial(dest, multi);
|
||||
@@ -427,7 +456,8 @@ void library_init(void) {
|
||||
int library_busy(void) {
|
||||
int busy = 0;
|
||||
pthread_mutex_lock(&g_q_lock);
|
||||
for (job *j = g_jobs; j && !busy; j = j->next) busy = j->status == JOB_QUEUED || j->status == JOB_RUNNING;
|
||||
for (job *j = g_jobs; j && !busy; j = j->next)
|
||||
busy = j->status == JOB_QUEUED || j->status == JOB_RUNNING || j->status == JOB_SYNCING;
|
||||
pthread_mutex_unlock(&g_q_lock);
|
||||
return busy;
|
||||
}
|
||||
@@ -457,7 +487,7 @@ static void trim_jobs(void) {
|
||||
static int enqueue(job_type type, int id, const char *name, char *err, int en) {
|
||||
pthread_mutex_lock(&g_q_lock);
|
||||
for (job *p = g_jobs; p; p = p->next) {
|
||||
if (p->type == type && p->target_id == id && (p->status == JOB_QUEUED || p->status == JOB_RUNNING)) {
|
||||
if (p->type == type && p->target_id == id && !finished(p)) {
|
||||
pthread_mutex_unlock(&g_q_lock);
|
||||
snprintf(err, (size_t)en, "already downloading");
|
||||
return -1;
|
||||
@@ -504,6 +534,8 @@ int library_cancel(int id, char *err, int en) {
|
||||
} else if (j->status == JOB_RUNNING) {
|
||||
j->cancel = 1; /* the progress callback aborts the transfer */
|
||||
rc = 0;
|
||||
} else if (j->status == JOB_SYNCING) {
|
||||
snprintf(err, (size_t)en, "the game is downloaded, its saves are syncing");
|
||||
} else {
|
||||
snprintf(err, (size_t)en, "download already finished");
|
||||
}
|
||||
@@ -548,12 +580,26 @@ int library_delete_rom(int rom_id, char *err, int en) {
|
||||
}
|
||||
state_save();
|
||||
state_unlock();
|
||||
if (removed) {
|
||||
/* Can't leave a finished download in the downloads page if we removed that title. */
|
||||
pthread_mutex_lock(&g_q_lock);
|
||||
for (job **pp = &g_jobs; *pp;) {
|
||||
job *dead = *pp;
|
||||
if (dead->type == JOB_ROM && dead->target_id == rom_id && finished(dead)) {
|
||||
*pp = dead->next;
|
||||
free(dead);
|
||||
} else {
|
||||
pp = &dead->next;
|
||||
}
|
||||
}
|
||||
pthread_mutex_unlock(&g_q_lock);
|
||||
}
|
||||
if (!removed) snprintf(err, (size_t)en, "game is not on this console");
|
||||
return removed ? 0 : -1;
|
||||
}
|
||||
|
||||
cJSON *library_downloads(void) {
|
||||
static const char *names[] = {"queued", "downloading", "done", "error", "cancelled"};
|
||||
static const char *names[] = {"queued", "downloading", "done", "error", "cancelled", "syncing"};
|
||||
cJSON *arr = cJSON_CreateArray();
|
||||
pthread_mutex_lock(&g_q_lock);
|
||||
for (job *j = g_jobs; j; j = j->next) {
|
||||
@@ -568,6 +614,13 @@ cJSON *library_downloads(void) {
|
||||
cJSON_AddNumberToObject(o, "done", (double)j->done);
|
||||
cJSON_AddNumberToObject(o, "total", (double)j->total);
|
||||
cJSON_AddNumberToObject(o, "speed", j->status == JOB_RUNNING ? j->speed : 0);
|
||||
cJSON_AddStringToObject(o, "note", j->status == JOB_RUNNING ? j->note : "");
|
||||
if (j->status == JOB_DONE && j->type == JOB_ROM) {
|
||||
cJSON_AddNumberToObject(o, "saves", j->saves.saves);
|
||||
cJSON_AddNumberToObject(o, "states", j->saves.states);
|
||||
cJSON_AddNumberToObject(o, "save_conflicts", j->saves.conflicts);
|
||||
cJSON_AddBoolToObject(o, "saves_failed", j->saves.failed);
|
||||
}
|
||||
cJSON_AddItemToArray(arr, o);
|
||||
}
|
||||
pthread_mutex_unlock(&g_q_lock);
|
||||
@@ -654,6 +707,10 @@ again:;
|
||||
if (comma) *comma = 0;
|
||||
const profile_map *m = profile_find_for_platform(maps, n, first, NULL);
|
||||
if (!m || m == x || m->extra || !strcmp(m->rom_dir, x->rom_dir)) continue;
|
||||
/* A folder several of the main emulator's systems share (Porpoise's
|
||||
* games: GameCube and Wii): only the games matched for this system. */
|
||||
int shared = 0;
|
||||
for (int k = 0; k < n; k++) shared |= &maps[k] != m && !strcmp(maps[k].rom_dir, m->rom_dir);
|
||||
DIR *d = opendir(m->rom_dir);
|
||||
struct dirent *de;
|
||||
int here = 0;
|
||||
@@ -663,6 +720,13 @@ again:;
|
||||
continue;
|
||||
char from[PATH_MAX_LEN], to[PATH_MAX_LEN];
|
||||
path_join(from, sizeof from, m->rom_dir, de->d_name);
|
||||
if (shared) {
|
||||
state_lock();
|
||||
const cJSON *rec = cJSON_GetObjectItemCaseSensitive(state_section("roms"), from);
|
||||
const int ours = rec && jget_num(rec, "rom_id", 0) > 0 && !strcmp(jget_str(rec, "platform_dir", ""), m->platform_dir);
|
||||
state_unlock();
|
||||
if (!ours) continue;
|
||||
}
|
||||
path_join(to, sizeof to, x->rom_dir, de->d_name);
|
||||
if (file_exists(to) || dir_exists(to) || (dir_exists(from) && still_downloading(from))) continue;
|
||||
mkdir_p(x->rom_dir);
|
||||
@@ -679,7 +743,7 @@ again:;
|
||||
str_copy(sys, sizeof sys, x->platform_dir);
|
||||
for (char *c = sys; *c; c++)
|
||||
if (*c >= 'a' && *c <= 'z') *c -= 32;
|
||||
plat_notify("RomM Sync: %d %s game%s now also sync with %s", here, sys, here == 1 ? "" : "s",
|
||||
plat_notify("RommPS: %d %s game%s now also sync with %s", here, sys, here == 1 ? "" : "s",
|
||||
x->profile_name);
|
||||
linked += here;
|
||||
}
|
||||
@@ -871,6 +935,27 @@ static void backfill_installed(void) {
|
||||
}
|
||||
}
|
||||
|
||||
/* A game's size here: its file, or everything in its folder. */
|
||||
static double local_size(const char *path, int depth) {
|
||||
if (!dir_exists(path)) return (double)(file_size(path) > 0 ? file_size(path) : 0);
|
||||
double total = 0;
|
||||
DIR *d = depth < 3 ? opendir(path) : NULL;
|
||||
struct dirent *de;
|
||||
while (d && (de = readdir(d))) {
|
||||
if (de->d_name[0] == '.') continue;
|
||||
char p[PATH_MAX_LEN];
|
||||
path_join(p, sizeof p, path, de->d_name);
|
||||
total += local_size(p, depth + 1);
|
||||
}
|
||||
if (d) closedir(d);
|
||||
return total;
|
||||
}
|
||||
|
||||
static int newest_first(const void *a, const void *b) {
|
||||
double x = jget_num(*(cJSON *const *)a, "played", 0), y = jget_num(*(cJSON *const *)b, "played", 0);
|
||||
return x < y ? 1 : x > y ? -1 : 0;
|
||||
}
|
||||
|
||||
cJSON *library_installed(int limit) {
|
||||
backfill_installed();
|
||||
cJSON *out = cJSON_CreateArray();
|
||||
@@ -896,22 +981,31 @@ cJSON *library_installed(int limit) {
|
||||
cJSON_AddStringToObject(o, "cover", jget_str(e, "cover", ""));
|
||||
cJSON_AddStringToObject(o, "profile", jget_str(e, "profile", ""));
|
||||
cJSON_AddNumberToObject(o, "played", played);
|
||||
cJSON_AddStringToObject(o, "path", e->string); /* sized below, off the lock */
|
||||
cJSON_AddItemToArray(out, o);
|
||||
}
|
||||
state_unlock();
|
||||
/* Newest first (a handful of games: a simple selection sort will do). */
|
||||
int n = cJSON_GetArraySize(out);
|
||||
/* Newest first. */
|
||||
int n = cJSON_GetArraySize(out), k = 0;
|
||||
cJSON **all = malloc(sizeof *all * (size_t)(n ? n : 1)), *o;
|
||||
cJSON *sorted = cJSON_CreateArray();
|
||||
for (int k = 0; k < n && k < limit; k++) {
|
||||
int best = -1;
|
||||
double top = -1;
|
||||
for (int i = 0; i < cJSON_GetArraySize(out); i++) {
|
||||
double t = jget_num(cJSON_GetArrayItem(out, i), "played", 0);
|
||||
if (t > top) top = t, best = i;
|
||||
}
|
||||
cJSON_AddItemToArray(sorted, cJSON_DetachItemFromArray(out, best));
|
||||
if (!all) {
|
||||
cJSON_Delete(out);
|
||||
return sorted;
|
||||
}
|
||||
while ((o = cJSON_DetachItemFromArray(out, 0))) all[k++] = o;
|
||||
cJSON_Delete(out);
|
||||
qsort(all, (size_t)n, sizeof *all, newest_first);
|
||||
for (k = 0; k < n; k++) {
|
||||
if (k < limit) {
|
||||
cJSON_AddNumberToObject(all[k], "size", local_size(jget_str(all[k], "path", ""), 0));
|
||||
cJSON_DeleteItemFromObjectCaseSensitive(all[k], "path");
|
||||
cJSON_AddItemToArray(sorted, all[k]);
|
||||
} else {
|
||||
cJSON_Delete(all[k]);
|
||||
}
|
||||
}
|
||||
free(all);
|
||||
return sorted;
|
||||
}
|
||||
|
||||
|
||||
+38
-16
@@ -9,11 +9,12 @@
|
||||
#include <time.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#include "autostart.h"
|
||||
#include "config.h"
|
||||
#include "events.h"
|
||||
#include "covers.h"
|
||||
#include "detect.h"
|
||||
#include "http.h"
|
||||
#include "icon_png.h" /* generated from assets/icon0.png */
|
||||
#include "library.h"
|
||||
#include "platform.h"
|
||||
#include "romm.h"
|
||||
@@ -40,16 +41,15 @@ static int is_game_title(const char *t) {
|
||||
if (!strncmp(t, "NPXS", 4)) return 0;
|
||||
/* RomM Sync's own apps (RommPS on the PS5, the launcher on the PS4) aren't
|
||||
* games: while one is open, syncs, downloads and covers carry on. */
|
||||
if (!strcmp(t, "PPSA76677") || !strcmp(t, "ROOM00002")) return 0;
|
||||
if (!strcmp(t, "PPSA76677") || !strcmp(t, "ROMM00002")) return 0;
|
||||
if (!strncmp(t, "ROMM0", 5)) return 0;
|
||||
return 1;
|
||||
}
|
||||
|
||||
/* AppInstUtil can take a while, so this gets its own thread. */
|
||||
static void *tile_thread(void *arg) {
|
||||
if (plat_install_tile((int)(long)arg) == 0)
|
||||
LOGI("home screen tile ready");
|
||||
else
|
||||
LOGW("home screen tile not installed (unsupported or failed)");
|
||||
(void)arg;
|
||||
plat_remove_old_tile();
|
||||
return NULL;
|
||||
}
|
||||
|
||||
@@ -97,7 +97,8 @@ static void *app_main(void *arg) {
|
||||
(void)arg;
|
||||
int init = plat_init();
|
||||
if (init > 0) {
|
||||
plat_notify("RomM Sync is already running");
|
||||
/* RommPS and the HEN can both start a copy at boot; that's no news. */
|
||||
if (!plat_app_installed()) plat_notify("RommPS is already running");
|
||||
return NULL;
|
||||
}
|
||||
if (init != 0) fprintf(stderr, "platform init reported a problem\n");
|
||||
@@ -120,12 +121,15 @@ static void *app_main(void *arg) {
|
||||
* breaks HTTPS certificate checks and save timestamps. */
|
||||
if (time(NULL) < 1577836800) { /* 2020-01-01 */
|
||||
LOGW("the console clock isn't set; HTTPS and save times will be wrong");
|
||||
plat_notify("RomM Sync: the console's date and time aren't set. Set them in the console's Settings for syncing to work.");
|
||||
plat_notify("RommPS: the console's date and time aren't set. Set them in the console's Settings for syncing to work.");
|
||||
}
|
||||
|
||||
config_load();
|
||||
if (g_cfg.profiles_custom && detect_refresh_profiles(g_cfg.profiles) > 0) config_save();
|
||||
state_load();
|
||||
events_load();
|
||||
/* 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);
|
||||
|
||||
sync_init();
|
||||
@@ -135,18 +139,30 @@ static void *app_main(void *arg) {
|
||||
int port = g_cfg.web_port;
|
||||
if (web_start(port) != 0) LOGE("web UI unavailable");
|
||||
|
||||
/* Installing the tile takes about 25 s on the console, so it gets its own thread. */
|
||||
/* The old tile's icon, from versions before 1.1.1. */
|
||||
path_join(path, sizeof path, plat_data_dir(), "icon0.png");
|
||||
if (!file_exists(path)) write_file_atomic(path, ICON_PNG, sizeof ICON_PNG - 1);
|
||||
thread_start(tile_thread, (void *)(long)port);
|
||||
if (file_exists(path)) unlink(path);
|
||||
thread_start(tile_thread, NULL);
|
||||
|
||||
char ip[64] = "";
|
||||
plat_local_ip(ip, sizeof ip);
|
||||
LOGI("web UI on http://%s:%d", ip[0] ? ip : "127.0.0.1", port);
|
||||
if (config_is_paired()) {
|
||||
plat_notify("RomM Sync running - http://%s:%d", ip[0] ? ip : "127.0.0.1", port);
|
||||
/* With RommPS installed, it's where the user sees all this. An older
|
||||
* RommPS, though, starts its own older copy now and then (1.1.0 does when
|
||||
* this one is slow to answer), so it's worth updating. */
|
||||
int quiet = plat_app_installed();
|
||||
char app_version[32];
|
||||
plat_app_version(app_version, sizeof app_version);
|
||||
if (quiet && app_version[0] && version_cmp(app_version, APP_VERSION) < 0) {
|
||||
LOGI("RommPS %s is older than RomM Sync %s", app_version, APP_VERSION);
|
||||
plat_notify("RommPS %s is out of date. Update it from ProsperoStore.", app_version);
|
||||
} else if (quiet) {
|
||||
} else if (!plat_info()->self_update) {
|
||||
plat_notify("RommPS is running. Install the RommPS app from ProsperoStore to use it on the console.");
|
||||
} else if (config_is_paired()) {
|
||||
plat_notify("RommPS running - http://%s:%d", ip[0] ? ip : "127.0.0.1", port);
|
||||
} else {
|
||||
plat_notify("RomM Sync: open http://%s:%d to pair with RomM", ip[0] ? ip : "127.0.0.1", port);
|
||||
plat_notify("RommPS: open http://%s:%d to pair with RomM", ip[0] ? ip : "127.0.0.1", port);
|
||||
}
|
||||
|
||||
sleep(5); /* wait for the network after boot */
|
||||
@@ -166,7 +182,7 @@ static void *app_main(void *arg) {
|
||||
|
||||
char current[32] = "";
|
||||
time_t game_start = 0, exit_at = 0, now, last_tick = time(NULL), resume_notified = 0;
|
||||
int title_failures = 0, resume_ticks = 0;
|
||||
int title_failures = 0, resume_ticks = 0, rommps_seen = 0;
|
||||
for (;;) {
|
||||
sleep(MONITOR_INTERVAL_SEC);
|
||||
now = time(NULL);
|
||||
@@ -184,7 +200,7 @@ static void *app_main(void *arg) {
|
||||
if (ip[0] || --resume_ticks == 0) {
|
||||
resume_ticks = 0;
|
||||
resume_notified = now;
|
||||
plat_notify("RomM Sync resumed - http://%s:%d", ip[0] ? ip : "127.0.0.1", port);
|
||||
if (!quiet) plat_notify("RommPS resumed - http://%s:%d", ip[0] ? ip : "127.0.0.1", port);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -197,6 +213,12 @@ static void *app_main(void *arg) {
|
||||
} else if (title_failures < MAX_TITLE_FAILURES && ++title_failures == MAX_TITLE_FAILURES) {
|
||||
LOGW("can't tell which game is running (%d failed reads); relying on periodic sync", title_failures);
|
||||
}
|
||||
/* RommPS installed while this copy runs (someone coming from 1.0.2):
|
||||
* the old tile goes the first time it's opened, not at the next boot. */
|
||||
if (fg > 0 && !strcmp(title, "PPSA76677") && !rommps_seen) {
|
||||
rommps_seen = 1;
|
||||
thread_start(tile_thread, NULL);
|
||||
}
|
||||
if (fg <= 0 || !is_game_title(title)) title[0] = 0;
|
||||
web_set_foreground(title);
|
||||
sync_set_game_running(title[0] != 0);
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
#include "memcard.h"
|
||||
|
||||
#include "events.h"
|
||||
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
@@ -39,6 +41,7 @@ static int cards_list(card *out, int max) {
|
||||
int n = 0;
|
||||
for (int i = 0; i < cnt && n < max; i++) {
|
||||
const profile_map *m = &maps[i];
|
||||
if (m->sync_off) continue;
|
||||
if (!strcmp(mode, "backup") && profile_map_handles(m, "ps2", NULL) && str_ieq(m->core_name, "LRPS2")) {
|
||||
for (int s = 1; s <= 2 && n < max; s++) {
|
||||
card *c = &out[n++];
|
||||
@@ -155,6 +158,7 @@ static int upload_slot(const card *cd, const char *hash) {
|
||||
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);
|
||||
@@ -243,6 +247,8 @@ int memcard_restore(int slot, char *err, int en) {
|
||||
state_save();
|
||||
state_unlock();
|
||||
LOGI("restored memory card %s from RomM", path_basename(path));
|
||||
events_add("card", "restored", 0, path, "", "Older version");
|
||||
events_save();
|
||||
return 0;
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -82,7 +82,7 @@ static void finish_pairing(const char *base, const char *token, const char *devi
|
||||
snprintf(msg, sizeof msg, "Paired as %s", g_cfg.username[0] ? g_cfg.username : "(unknown user)");
|
||||
set_status("ok", msg);
|
||||
LOGI("paired with %s, device %s", base, device_id);
|
||||
plat_notify("RomM Sync paired with %s", base);
|
||||
if (!plat_app_installed()) plat_notify("RommPS paired with %s", base);
|
||||
sync_new_pairing();
|
||||
sync_request("paired");
|
||||
}
|
||||
|
||||
+19
-6
@@ -20,9 +20,14 @@ typedef struct {
|
||||
* NULL if the loader can't. ROMM_SYNC_AUTOSTART overrides it, for tests. */
|
||||
const char *autostart_dir;
|
||||
const char *autostart_flag;
|
||||
/* Other loaders' payload folders (NULL-terminated, may be NULL): an update
|
||||
* replaces a copy of the payload there as well. */
|
||||
/* Other loaders' payload folders (NULL-terminated, may be NULL). Autostart
|
||||
* is set up in every one whose loader is installed, so the payload starts
|
||||
* whichever loader the console runs. */
|
||||
const char *const *autostart_also;
|
||||
/* Copies other tools keep and start (a payload manager's autoload), kept
|
||||
* up to date when they exist but never created or removed. NULL-terminated,
|
||||
* may be NULL. ROMM_SYNC_PAYLOAD_COPIES (comma separated) overrides it. */
|
||||
const char *const *payload_copies;
|
||||
const char *autostart_hint; /* what the user does in the loader's menu, or NULL */
|
||||
const char *loader_dir; /* present when the loader is installed; NULL: autostart_dir's parent */
|
||||
/* Folders to look in for emulators. homebrew_dirs hold one app per subfolder;
|
||||
@@ -34,9 +39,11 @@ typedef struct {
|
||||
const char *mednafen_root;
|
||||
/* Standalone emulators this console has, as a JSON array (detect.c), or NULL. */
|
||||
const char *emulator_catalog;
|
||||
int has_tile; /* plat_install_tile can add a home-screen tile */
|
||||
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
|
||||
* RommPS carries the payload and its updates bring the new one. */
|
||||
int self_update;
|
||||
} plat_info_t;
|
||||
|
||||
const plat_info_t *plat_info(void);
|
||||
@@ -57,9 +64,15 @@ const char *plat_data_dir(void);
|
||||
/* CA bundle for libcurl, or NULL for the default. */
|
||||
const char *plat_ca_bundle(void);
|
||||
|
||||
/* Install a home-screen tile that opens http://127.0.0.1:<port>/.
|
||||
* Returns 0 on success, -1 if unsupported/failed. */
|
||||
int plat_install_tile(int port);
|
||||
/* Removes the home-screen tile versions before 1.1.1 installed, when RommPS
|
||||
* is installed to take its place. */
|
||||
void plat_remove_old_tile(void);
|
||||
|
||||
/* Whether the console has an app that speaks for RomM Sync (RommPS on the
|
||||
* PS5): then it tells the user what's going on, and RomM Sync keeps quiet. */
|
||||
int plat_app_installed(void);
|
||||
/* That app's version ("1.1.0"), or "" if it isn't installed or doesn't say. */
|
||||
void plat_app_version(char *out, size_t n);
|
||||
|
||||
/* Starts a payload through the console's loader on loopback, if can_relaunch.
|
||||
* The new copy
|
||||
|
||||
+67
-2
@@ -4,6 +4,7 @@
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
#include "detect.h"
|
||||
#include "platform.h"
|
||||
|
||||
/* Platforms are matched on RomM's slug or fs_slug. The roots are the PS5's;
|
||||
@@ -89,6 +90,26 @@ 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;
|
||||
if (!cat) return NULL;
|
||||
cJSON_ArrayForEach(ce, cJSON_GetObjectItemCaseSensitive(cat, "platforms")) {
|
||||
if (strcmp(profiles_system_key(ce), profiles_system_key(e)) != 0) continue;
|
||||
if ((v = cJSON_GetObjectItemCaseSensitive(ce, key))) return v;
|
||||
}
|
||||
return cJSON_GetObjectItemCaseSensitive(cat, key);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
static int jbool(const cJSON *a, const cJSON *b, const char *key, int def) {
|
||||
const cJSON *v = b ? cJSON_GetObjectItemCaseSensitive(b, key) : NULL;
|
||||
if (!cJSON_IsBool(v)) v = cJSON_GetObjectItemCaseSensitive(a, key);
|
||||
@@ -192,6 +213,7 @@ static void check_requirement(const profile_map *m, const char *game, const char
|
||||
size_t nn) {
|
||||
*sync = m->base_sync_saves;
|
||||
note[0] = 0;
|
||||
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);
|
||||
@@ -340,9 +362,42 @@ int profiles_expand(const cJSON *profiles, profile_map **out) {
|
||||
str_copy(m->state_stems, sizeof m->state_stems, jstr(p, e, "state_stems", ""));
|
||||
str_copy(m->state_name, sizeof m->state_name, jstr(p, e, "state_name", "{rom_stem}{ext}"));
|
||||
str_copy(m->save_format, sizeof m->save_format, jstr(p, e, "save_format", ""));
|
||||
/* Every Dolphin with a GCI folder keeps GameCube saves the same way. */
|
||||
if (!m->save_format[0] && !strcmp(jstr(p, e, "save_layout", ""), "gci"))
|
||||
str_copy(m->save_format, sizeof m->save_format, "gc-gci");
|
||||
str_copy(m->state_cores, sizeof m->state_cores, state_str(p, e, "state_cores", m->emulator));
|
||||
str_copy(m->state_format, sizeof m->state_format, state_str(p, e, "state_format", "retroarch"));
|
||||
str_copy(m->state_slot0, sizeof m->state_slot0, state_str(p, e, "state_slot0", ".state"));
|
||||
const cJSON *nbc = state_setting(p, e, "save_name_by_core"), *nv;
|
||||
size_t no = 0;
|
||||
cJSON_ArrayForEach(nv, nbc) {
|
||||
if (!cJSON_IsString(nv)) continue;
|
||||
int w = snprintf(m->save_names_by_core + no, sizeof m->save_names_by_core - no, "%s%s=%s", no ? ";" : "",
|
||||
nv->string, nv->valuestring);
|
||||
if (w < 0 || (size_t)w >= sizeof m->save_names_by_core - no) break;
|
||||
no += (size_t)w;
|
||||
}
|
||||
const cJSON *cs = state_setting(p, e, "state_core_setting");
|
||||
if (cJSON_IsObject(cs)) {
|
||||
str_template(m->core_file, sizeof m->core_file, jstr(cs, NULL, "file", ""), vars);
|
||||
str_copy(m->core_key, sizeof m->core_key, jstr(cs, NULL, "key", ""));
|
||||
const cJSON *v;
|
||||
size_t o = 0;
|
||||
cJSON_ArrayForEach(v, cJSON_GetObjectItemCaseSensitive(cs, "values")) {
|
||||
if (!cJSON_IsString(v)) continue;
|
||||
int w = snprintf(m->core_values + o, sizeof m->core_values - o, "%s%s=%s", o ? "," : "", v->string,
|
||||
v->valuestring);
|
||||
if (w < 0 || (size_t)w >= sizeof m->core_values - o) break;
|
||||
o += (size_t)w;
|
||||
}
|
||||
}
|
||||
str_template(m->memcards, sizeof m->memcards, jstr(p, e, "memcards", ""), vars);
|
||||
m->extra = profile_lists(p, "extra", profiles_system_key(e));
|
||||
m->sync_saves = m->base_sync_saves = 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);
|
||||
const cJSON *req = cJSON_GetObjectItemCaseSensitive(e, "sync_requires_option");
|
||||
@@ -354,6 +409,7 @@ int profiles_expand(const cJSON *profiles, profile_map **out) {
|
||||
}
|
||||
/* Platform-wide answer, for the UI; games can override it. */
|
||||
check_requirement(m, NULL, NULL, &m->sync_saves, m->sync_note, sizeof m->sync_note);
|
||||
if (m->sync_off) str_copy(m->sync_note, sizeof m->sync_note, "save sync is off for this platform");
|
||||
cnt++;
|
||||
}
|
||||
}
|
||||
@@ -362,10 +418,19 @@ int profiles_expand(const cJSON *profiles, profile_map **out) {
|
||||
for (int i = 0; i < cnt; i++) {
|
||||
if (!maps[i].extra) continue;
|
||||
maps[i].own_slot = 1;
|
||||
for (int k = 0; k < cnt; k++)
|
||||
if (!maps[k].extra && !strcmp(maps[k].platform_dir, maps[i].platform_dir) && maps[i].save_format[0] &&
|
||||
/* The same system (RomM's first slug: Porpoise calls GameCube's
|
||||
* folder "gamecube", RetroArch "ngc") and the same format. */
|
||||
char sys_i[64];
|
||||
str_copy(sys_i, sizeof sys_i, maps[i].romm_slugs);
|
||||
sys_i[strcspn(sys_i, ",")] = 0;
|
||||
for (int k = 0; k < cnt; k++) {
|
||||
char sys_k[64];
|
||||
str_copy(sys_k, sizeof sys_k, maps[k].romm_slugs);
|
||||
sys_k[strcspn(sys_k, ",")] = 0;
|
||||
if (!maps[k].extra && !strcmp(sys_k, sys_i) && maps[i].save_format[0] &&
|
||||
!strcmp(maps[k].save_format, maps[i].save_format))
|
||||
maps[i].own_slot = 0;
|
||||
}
|
||||
}
|
||||
*out = maps;
|
||||
return cnt;
|
||||
|
||||
+28
-1
@@ -22,6 +22,10 @@
|
||||
* {serial} (a PS1 disc's, "SCUS-94194"), {rom_fnv64} (FNV-1a 64 of the game
|
||||
* file, in hex) and {rom_stem_safe} (the file name, non-alphanumerics as "_").
|
||||
*
|
||||
* "state_cores", "state_format", "state_slot0" and "state_core_setting" say
|
||||
* which save states an emulator can load (see profile_map); profiles saved
|
||||
* before they existed take them from the built-in catalog, by profile id.
|
||||
*
|
||||
* "save_layout" (psp, gci or wii) syncs a game's save folder or files as one zip;
|
||||
* "bundle_dirs" lists where to look, with {save_path}, {save_root} and {bios}.
|
||||
* A platform entry can override any profile key. "sync_requires_option"
|
||||
@@ -32,7 +36,10 @@
|
||||
* the first slugs of detect.c's DB_MAP). When several emulators play one, the
|
||||
* user picks one and the others list it in "exclude": ["psx"], which drops
|
||||
* that platform entry from them, or in "extra": ["psx"], which keeps it as
|
||||
* an extra emulator for the system (see profile_map.extra). */
|
||||
* an extra emulator for the system (see profile_map.extra).
|
||||
*
|
||||
* "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
|
||||
#define ROMM_PROFILES_H
|
||||
|
||||
@@ -60,6 +67,24 @@ typedef struct {
|
||||
char save_stems[256];
|
||||
char state_stems[256];
|
||||
char state_name[128];
|
||||
/* Save states from elsewhere come down only when the emulator can load
|
||||
* them: state_cores lists the cores whose states it takes (RomM
|
||||
* "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
|
||||
* 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];
|
||||
char state_format[16];
|
||||
char state_slot0[16];
|
||||
char core_file[PATH_MAX_LEN];
|
||||
char core_key[32];
|
||||
char core_values[256]; /* "0=pcsx_rearmed,2=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". */
|
||||
char save_names_by_core[256];
|
||||
/* Several emulators for one system: the main one gets downloads first and
|
||||
* the extras ("extra": ["psx"] in their profile) a link to the same file.
|
||||
* Extras whose saves have the main one's format ("save_format": "psx-card")
|
||||
@@ -71,6 +96,8 @@ typedef struct {
|
||||
* backed up to RomM's memory cards (memcard.h). */
|
||||
char memcards[512];
|
||||
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 */
|
||||
/* RetroArch folder rules, [0] for saves and [1] for states:
|
||||
* in_content = next to the game, by_content = <dir>/<game folder>/,
|
||||
* by_core = <dir>/<core_name>/. Both sort flags nest content then core. */
|
||||
|
||||
+155
@@ -0,0 +1,155 @@
|
||||
#include "statefmt.h"
|
||||
|
||||
#include <ctype.h>
|
||||
#include <stdint.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <strings.h>
|
||||
#include <zlib.h>
|
||||
|
||||
#include "util.h"
|
||||
|
||||
static uint32_t le32(const unsigned char *p) { return p[0] | p[1] << 8 | p[2] << 16 | (uint32_t)p[3] << 24; }
|
||||
|
||||
static uint64_t le64(const unsigned char *p) { return le32(p) | (uint64_t)le32(p + 4) << 32; }
|
||||
|
||||
static void put32(unsigned char *p, uint32_t v) {
|
||||
for (int i = 0; i < 4; i++) p[i] = (unsigned char)(v >> (8 * i));
|
||||
}
|
||||
|
||||
state_kind state_kind_of(const unsigned char *b, size_t n) {
|
||||
if (n >= 20 && !memcmp(b, "#RZIPv\x01#", 8)) return STATE_RZIP;
|
||||
if (n >= 16 && !memcmp(b, "RASTATE\x01", 8)) return STATE_RASTATE;
|
||||
return STATE_RAW;
|
||||
}
|
||||
|
||||
/* Unpacks an RZIP stream. Malloc'd, or NULL. */
|
||||
static unsigned char *unrzip(const unsigned char *b, size_t n, size_t *out_n) {
|
||||
uint64_t total = le64(b + 12);
|
||||
if (total == 0 || total > (uint64_t)256 << 20) return NULL; /* states are tens of MB at most */
|
||||
unsigned char *out = malloc((size_t)total);
|
||||
if (!out) return NULL;
|
||||
size_t off = 20, got = 0;
|
||||
while (off + 4 <= n && got < total) {
|
||||
uint32_t len = le32(b + off);
|
||||
off += 4;
|
||||
if (len > n - off) break;
|
||||
uLongf room = (uLongf)(total - got);
|
||||
if (uncompress(out + got, &room, b + off, len) != Z_OK) break;
|
||||
got += room;
|
||||
off += len;
|
||||
}
|
||||
if (got != total) {
|
||||
free(out);
|
||||
return NULL;
|
||||
}
|
||||
*out_n = (size_t)total;
|
||||
return out;
|
||||
}
|
||||
|
||||
/* 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;
|
||||
while (off + 8 <= n) {
|
||||
uint32_t size = le32(b + off + 4);
|
||||
const unsigned char *tag = b + off;
|
||||
off += 8;
|
||||
if (size > n - off) return NULL;
|
||||
if (!memcmp(tag, "MEM ", 4)) {
|
||||
*mem_n = size;
|
||||
return b + off;
|
||||
}
|
||||
if (!memcmp(tag, "END ", 4)) return NULL;
|
||||
off += ((size_t)size + 7) & ~(size_t)7;
|
||||
}
|
||||
return NULL;
|
||||
}
|
||||
|
||||
unsigned char *state_to_raw(const unsigned char *b, size_t n, size_t *out_n) {
|
||||
unsigned char *unpacked = NULL;
|
||||
if (state_kind_of(b, n) == STATE_RZIP) {
|
||||
unpacked = unrzip(b, n, &n);
|
||||
if (!unpacked) return NULL;
|
||||
b = unpacked;
|
||||
}
|
||||
unsigned char *out = NULL;
|
||||
if (state_kind_of(b, n) == STATE_RASTATE) {
|
||||
size_t mem_n;
|
||||
const unsigned char *mem = rastate_mem(b, n, &mem_n);
|
||||
if (mem && (out = malloc(mem_n ? mem_n : 1))) {
|
||||
memcpy(out, mem, mem_n);
|
||||
*out_n = mem_n;
|
||||
}
|
||||
} else if ((out = malloc(n ? n : 1))) { /* already bare */
|
||||
memcpy(out, b, n);
|
||||
*out_n = n;
|
||||
}
|
||||
free(unpacked);
|
||||
return out;
|
||||
}
|
||||
|
||||
unsigned char *state_to_rastate(const unsigned char *b, size_t n, size_t *out_n) {
|
||||
state_kind k = state_kind_of(b, n);
|
||||
if (k == STATE_RZIP) return unrzip(b, n, out_n);
|
||||
if (k == STATE_RASTATE) {
|
||||
unsigned char *out = malloc(n);
|
||||
if (out) {
|
||||
memcpy(out, b, n);
|
||||
*out_n = n;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
size_t pad = (8 - n % 8) % 8, total = 16 + n + pad + 8;
|
||||
unsigned char *out = calloc(1, total);
|
||||
if (!out) return NULL;
|
||||
memcpy(out, "RASTATE\x01" "MEM ", 12);
|
||||
put32(out + 12, (uint32_t)n);
|
||||
memcpy(out + 16, b, n);
|
||||
memcpy(out + 16 + n + pad, "END ", 4); /* size 0 */
|
||||
*out_n = total;
|
||||
return out;
|
||||
}
|
||||
|
||||
const char *state_core_of(const unsigned char *b, size_t n) {
|
||||
size_t mem_n = n;
|
||||
if (state_kind_of(b, n) == STATE_RASTATE) {
|
||||
b = rastate_mem(b, n, &mem_n);
|
||||
if (!b) return NULL;
|
||||
}
|
||||
static const struct {
|
||||
const char *magic, *core;
|
||||
} sigs[] = {
|
||||
{"STv4 PCSXra", "pcsx_rearmed"},
|
||||
{"MDFNSVST", "mednafen_psx_hw"},
|
||||
{"DUCC", "swanstation"},
|
||||
};
|
||||
for (size_t i = 0; i < sizeof sigs / sizeof sigs[0]; i++) {
|
||||
size_t l = strlen(sigs[i].magic);
|
||||
if (mem_n >= l && !memcmp(b, sigs[i].magic, l)) return sigs[i].core;
|
||||
}
|
||||
return NULL;
|
||||
}
|
||||
|
||||
void emulator_key(const char *name, char *out, size_t n) {
|
||||
if (!n) return;
|
||||
static const char *const prefixes[] = {"retroarch-", "retroarch_", "libretro-", "libretro_"};
|
||||
for (size_t i = 0; i < sizeof prefixes / sizeof prefixes[0]; i++) {
|
||||
size_t l = strlen(prefixes[i]);
|
||||
if (!strncasecmp(name, prefixes[i], l)) {
|
||||
name += l;
|
||||
break;
|
||||
}
|
||||
}
|
||||
size_t o = 0;
|
||||
for (; *name && o + 1 < n; name++) {
|
||||
unsigned char ch = (unsigned char)*name;
|
||||
out[o++] = ch == '-' || ch == ' ' ? '_' : (char)tolower(ch);
|
||||
}
|
||||
out[o] = 0;
|
||||
char *lib = strstr(out, "_libretro");
|
||||
if (lib) *lib = 0;
|
||||
/* Beetle PSX is "mednafen_psx_hw" in RetroArch and RomM's web player. */
|
||||
static const char *const aliases[][2] = {{"beetle_psx_hw", "mednafen_psx_hw"}, {"beetle_psx", "mednafen_psx"}};
|
||||
for (size_t i = 0; i < sizeof aliases / sizeof aliases[0]; i++)
|
||||
if (!strcmp(out, aliases[i][0])) str_copy(out, n, aliases[i][1]);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
/* Save state files: the containers emulators keep them in, and the names
|
||||
* RomM clients give the emulator that made them.
|
||||
*
|
||||
* RetroArch (and RomM's web player) wrap a core's state:
|
||||
* "RASTATE\x01", then blocks of a 4-byte tag, a little-endian u32 size and
|
||||
* the data padded to 8 bytes: "MEM " (the core's state) ... "END ".
|
||||
* and RetroArch compresses that by default:
|
||||
* "#RZIPv\x01#", u32 chunk size, u64 total size, then chunks of a u32
|
||||
* length and a zlib stream.
|
||||
* Standalone emulators built on a libretro core (PSXS5) keep the core's
|
||||
* state bare, which is what "MEM " holds. */
|
||||
#ifndef ROMM_STATEFMT_H
|
||||
#define ROMM_STATEFMT_H
|
||||
|
||||
#include <stddef.h>
|
||||
|
||||
typedef enum { STATE_RAW, STATE_RASTATE, STATE_RZIP } state_kind;
|
||||
|
||||
state_kind state_kind_of(const unsigned char *b, size_t n);
|
||||
|
||||
/* The core's own state, out of whatever container it's in. Returns malloc'd
|
||||
* bytes, or NULL when the container is broken. */
|
||||
unsigned char *state_to_raw(const unsigned char *b, size_t n, size_t *out_n);
|
||||
|
||||
/* An uncompressed RetroArch state: what RomM's web player saves and loads.
|
||||
* 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);
|
||||
|
||||
/* 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);
|
||||
|
||||
/* RomM's emulator names, made comparable: lower case, "-" as "_", without
|
||||
* the "retroarch-"/"libretro-" prefixes and "_libretro..." suffixes other
|
||||
* clients add ("mGBA", "retroarch-mgba", "mgba_libretro_ios" -> "mgba"). */
|
||||
void emulator_key(const char *name, char *out, size_t n);
|
||||
|
||||
#endif
|
||||
+1420
-102
File diff suppressed because it is too large.
Load diff
+12
@@ -27,6 +27,14 @@ void sync_add_play_session(time_t start, time_t end, const char *title_id);
|
||||
void sync_set_game_running(int running);
|
||||
int sync_game_running(void);
|
||||
|
||||
/* Syncs one game's saves and states now, e.g. once it's downloaded, and
|
||||
* notifies what came down. Waits for a sync already running. -1 when
|
||||
* syncing isn't set up yet. */
|
||||
typedef struct {
|
||||
int saves, states, conflicts, failed;
|
||||
} sync_game_result;
|
||||
int sync_game(int rom_id, const char *name, sync_game_result *out);
|
||||
|
||||
/* What a sync would do, without transferring anything. */
|
||||
cJSON *sync_preview(char *err, int en);
|
||||
|
||||
@@ -35,6 +43,10 @@ cJSON *sync_history(int rom_id, char *err, int en);
|
||||
/* Restores a save version or a state ("save" or "state") into place. */
|
||||
int sync_restore(const char *kind, int id, char *err, int en);
|
||||
|
||||
/* The saves and states this console keeps in sync, by platform and game, each
|
||||
* with its status (synced, changed, conflict, missing); only_rom 0 for all. */
|
||||
cJSON *sync_tracked(int only_rom);
|
||||
|
||||
/* Save folders the last sync looked in, for the watcher. any_file marks
|
||||
* bundle folders, where every file counts. */
|
||||
int sync_scanned_dirs(char dirs[][1024], int *any_file, int max);
|
||||
|
||||
+13
-5
@@ -1,6 +1,9 @@
|
||||
/* Updates from GitHub releases. Nothing installs by itself: the UI checks,
|
||||
* shows the release notes and installs when the user asks.
|
||||
*
|
||||
* On the PS5 (plat_info()->self_update off) the payload doesn't install
|
||||
* itself: RommPS carries it, so the check only says a new RommPS is out.
|
||||
*
|
||||
* A release carries a payload, manifest and signature for each console, named in
|
||||
* plat_info(): romm-sync.elf, manifest.json and manifest.sig for the PS5,
|
||||
* romm-sync-ps4.elf, manifest-ps4.json and manifest-ps4.sig for the PS4. The
|
||||
@@ -137,7 +140,8 @@ static void *check_thread(void *arg) {
|
||||
LOGI("update check: latest release %s, running %s", g_latest, APP_VERSION);
|
||||
if (notify && available && strcmp(g_notified, g_latest) != 0) {
|
||||
str_copy(g_notified, sizeof g_notified, g_latest);
|
||||
plat_notify("RomM Sync %s is available. Update it from Settings.", g_latest);
|
||||
if (plat_info()->self_update) plat_notify("RommPS %s is available. Update it from Settings.", g_latest);
|
||||
else plat_notify("RommPS %s is available. Update it from ProsperoStore.", g_latest);
|
||||
}
|
||||
}
|
||||
pthread_mutex_unlock(&g_lock);
|
||||
@@ -274,7 +278,7 @@ static void *install_thread(void *arg) {
|
||||
}
|
||||
free(elf);
|
||||
LOGI("update: %s installed, starts with the next jailbreak", want);
|
||||
plat_notify("RomM Sync %s is installed and starts with the next jailbreak", want);
|
||||
plat_notify("RommPS %s is installed and starts with the next jailbreak", want);
|
||||
pthread_mutex_lock(&g_lock);
|
||||
g_state = U_PENDING;
|
||||
str_copy(g_pending, sizeof g_pending, want);
|
||||
@@ -284,7 +288,7 @@ static void *install_thread(void *arg) {
|
||||
}
|
||||
|
||||
set_message(U_RESTARTING, persisted ? "Restarting" : "Restarting (until the next reboot, autostart isn't set up)");
|
||||
plat_notify("Updating RomM Sync to %s", want);
|
||||
plat_notify("Updating RommPS to %s", want);
|
||||
sleep(1); /* lets the UI see the restart coming */
|
||||
if (plat_launch_elf(elf, elf_len) != 0) {
|
||||
snprintf(err, sizeof err, "couldn't start the new version (%s).%s", strerror(errno),
|
||||
@@ -310,7 +314,8 @@ fail:
|
||||
int update_install(char *err, int en) {
|
||||
pthread_mutex_lock(&g_lock);
|
||||
int rc = -1;
|
||||
if (busy(g_state)) snprintf(err, (size_t)en, "an update check or install is already running");
|
||||
if (!plat_info()->self_update) snprintf(err, (size_t)en, "update RommPS from ProsperoStore: it brings the new RomM Sync with it");
|
||||
else if (busy(g_state)) snprintf(err, (size_t)en, "an update check or install is already running");
|
||||
else if (!g_latest[0] || !newer_than(g_latest, APP_VERSION)) snprintf(err, (size_t)en, "no newer version found, check again first");
|
||||
else if (!strcmp(g_pending, g_latest)) snprintf(err, (size_t)en, "%s is already installed and starts with the next jailbreak", g_latest);
|
||||
else if (!g_elf_url[0] || !g_manifest_url[0] || !g_sig_url[0])
|
||||
@@ -337,6 +342,8 @@ cJSON *update_status(void) {
|
||||
cJSON *j = cJSON_CreateObject();
|
||||
cJSON *as = autostart_status();
|
||||
cJSON_AddStringToObject(j, "current", APP_VERSION);
|
||||
/* false: updates come with RommPS, not from here. */
|
||||
cJSON_AddBoolToObject(j, "self_update", plat_info()->self_update);
|
||||
pthread_mutex_lock(&g_lock);
|
||||
cJSON_AddStringToObject(j, "state", STATE_NAMES[g_state]);
|
||||
cJSON_AddStringToObject(j, "message", g_message);
|
||||
@@ -352,7 +359,8 @@ cJSON *update_status(void) {
|
||||
cJSON_AddNumberToObject(j, "done", (double)g_done);
|
||||
cJSON_AddNumberToObject(j, "total", (double)g_total);
|
||||
pthread_mutex_unlock(&g_lock);
|
||||
cJSON_AddBoolToObject(j, "autostart", cJSON_IsTrue(cJSON_GetObjectItemCaseSensitive(as, "installed")));
|
||||
/* On, not just installed: what "starts with the console" means. */
|
||||
cJSON_AddBoolToObject(j, "autostart", cJSON_IsTrue(cJSON_GetObjectItemCaseSensitive(as, "enabled")));
|
||||
cJSON_AddBoolToObject(j, "autostart_supported", cJSON_IsTrue(cJSON_GetObjectItemCaseSensitive(as, "supported")));
|
||||
cJSON_Delete(as);
|
||||
return j;
|
||||
|
||||
+49
-4
@@ -194,8 +194,9 @@ int write_file_atomic(const char *path, const void *data, size_t len) {
|
||||
return rename(tmp, path);
|
||||
}
|
||||
|
||||
int copy_file(const char *src, const char *dst) {
|
||||
int copy_file_progress(const char *src, const char *dst, copy_progress_fn cb, void *ud) {
|
||||
char tmp[PATH_MAX_LEN];
|
||||
int64_t total = file_size(src), done = 0;
|
||||
FILE *in = fopen(src, "rb");
|
||||
if (!in) return -1;
|
||||
char *buf = malloc(64 * 1024);
|
||||
@@ -218,6 +219,11 @@ int copy_file(const char *src, const char *dst) {
|
||||
rc = -1;
|
||||
break;
|
||||
}
|
||||
done += (int64_t)n;
|
||||
if (cb && cb(ud, done, total)) {
|
||||
rc = -1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
fclose(in);
|
||||
free(buf);
|
||||
@@ -229,6 +235,40 @@ int copy_file(const char *src, const char *dst) {
|
||||
return rc;
|
||||
}
|
||||
|
||||
int copy_file(const char *src, const char *dst) {
|
||||
return copy_file_progress(src, dst, NULL, NULL);
|
||||
}
|
||||
|
||||
static void parse_version(const char *s, int v[3]) {
|
||||
v[0] = v[1] = v[2] = 0;
|
||||
if (!s) return;
|
||||
if (*s == 'v') s++;
|
||||
sscanf(s, "%d.%d.%d", &v[0], &v[1], &v[2]);
|
||||
}
|
||||
|
||||
int version_cmp(const char *a, const char *b) {
|
||||
int x[3], y[3];
|
||||
parse_version(a, x);
|
||||
parse_version(b, y);
|
||||
for (int i = 0; i < 3; i++)
|
||||
if (x[i] != y[i]) return x[i] < y[i] ? -1 : 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
void payload_version(const void *elf, size_t len, char *out, size_t n) {
|
||||
static const char tag[] = "ROMM_SYNC_VERSION=";
|
||||
const size_t tl = sizeof tag - 1;
|
||||
const char *p = elf;
|
||||
out[0] = 0;
|
||||
for (size_t i = 0; len >= tl && i <= len - tl; i++) {
|
||||
if (p[i] != 'R' || memcmp(p + i, tag, tl) != 0) continue;
|
||||
size_t j = i + tl, k = 0;
|
||||
while (j < len && p[j] != '\n' && p[j] && k + 1 < n) out[k++] = p[j++];
|
||||
out[k] = 0;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
int move_file(const char *src, const char *dst) {
|
||||
mkdir_parent(dst);
|
||||
if (rename(src, dst) == 0) return 0;
|
||||
@@ -417,7 +457,7 @@ int md5_file_hex(const char *path, char out[33]) {
|
||||
}
|
||||
|
||||
/* A hard link to src at dst (a copy across drives); folders file by file. */
|
||||
int link_or_copy(const char *src, const char *dst) {
|
||||
int link_or_copy_progress(const char *src, const char *dst, copy_progress_fn cb, void *ud) {
|
||||
if (dir_exists(src)) {
|
||||
if (mkdir_p(dst) != 0) return -1;
|
||||
DIR *d = opendir(src);
|
||||
@@ -429,7 +469,7 @@ int link_or_copy(const char *src, const char *dst) {
|
||||
char a[PATH_MAX_LEN], b[PATH_MAX_LEN];
|
||||
path_join(a, sizeof a, src, de->d_name);
|
||||
path_join(b, sizeof b, dst, de->d_name);
|
||||
if (link_or_copy(a, b) != 0) rc = -1;
|
||||
if (link_or_copy_progress(a, b, cb, ud) != 0) rc = -1;
|
||||
}
|
||||
closedir(d);
|
||||
return rc;
|
||||
@@ -439,7 +479,12 @@ int link_or_copy(const char *src, const char *dst) {
|
||||
unlink(dst);
|
||||
}
|
||||
if (link(src, dst) == 0) return 0;
|
||||
return copy_file(src, dst);
|
||||
LOGI("can't link %s to %s (%s), copying instead", src, dst, strerror(errno));
|
||||
return copy_file_progress(src, dst, cb, ud);
|
||||
}
|
||||
|
||||
int link_or_copy(const char *src, const char *dst) {
|
||||
return link_or_copy_progress(src, dst, NULL, NULL);
|
||||
}
|
||||
|
||||
int fnv1a64_file_hex(const char *path, char out[17]) {
|
||||
|
||||
+12
-1
@@ -8,7 +8,7 @@
|
||||
|
||||
#define APP_NAME "romm-sync"
|
||||
#ifndef APP_VERSION
|
||||
#define APP_VERSION "1.1.0"
|
||||
#define APP_VERSION "1.2.0"
|
||||
#endif
|
||||
#ifndef APP_BUILD /* git describe of the build, from the Makefile */
|
||||
#define APP_BUILD "unknown"
|
||||
@@ -43,8 +43,19 @@ int64_t file_mtime_ns(const char *path); /* nanoseconds, 0 if missing */
|
||||
int set_file_mtime(const char *path, time_t t);
|
||||
char *read_file(const char *path, size_t *len); /* malloc'd, NUL-terminated */
|
||||
int write_file_atomic(const char *path, const void *data, size_t len);
|
||||
/* Tells us how far along a copy is; Nonzero return will stop it. */
|
||||
typedef int (*copy_progress_fn)(void *ud, int64_t done, int64_t total);
|
||||
int copy_file(const char *src, const char *dst);
|
||||
int copy_file_progress(const char *src, const char *dst, copy_progress_fn cb, void *ud);
|
||||
|
||||
/* -1, 0 or 1 as a is older, the same as or newer than b ("1.2.3", a leading
|
||||
* "v" and anything after the patch ignored). Unreadable counts as 0.0.0. */
|
||||
int version_cmp(const char *a, const char *b);
|
||||
/* The version a payload ELF says it is (its ROMM_SYNC_VERSION= tag, from
|
||||
* 1.1.0 on), or "" if it has none. */
|
||||
void payload_version(const void *elf, size_t len, char *out, size_t n);
|
||||
int link_or_copy(const char *src, const char *dst); /* hard link, a copy across drives; folders too */
|
||||
int link_or_copy_progress(const char *src, const char *dst, copy_progress_fn cb, void *ud);
|
||||
int move_file(const char *src, const char *dst); /* rename, falls back to copy+unlink */
|
||||
void remove_tree(const char *path); /* rm -rf */
|
||||
const char *path_basename(const char *path);
|
||||
|
||||
+8
-2
@@ -1,6 +1,8 @@
|
||||
/* Polls the save and state folders once a second and asks for a sync when a
|
||||
* changed file has been left alone for SETTLE_SEC. Files that changed before
|
||||
* startup are left to the startup sync. */
|
||||
* startup are left to the startup sync. While a game runs, a file goes up at
|
||||
* most every PLAYING_GAP_SEC: some emulators rewrite the save every few
|
||||
* seconds, and the sync after the game closes sends the last version. */
|
||||
#include "watch.h"
|
||||
|
||||
#include <dirent.h>
|
||||
@@ -21,6 +23,7 @@
|
||||
|
||||
#define POLL_SEC 1
|
||||
#define SETTLE_SEC 2
|
||||
#define PLAYING_GAP_SEC 300
|
||||
#define RELOAD_DIRS_SEC 60
|
||||
#define MAX_DEPTH 3
|
||||
|
||||
@@ -31,6 +34,7 @@ typedef struct {
|
||||
int64_t synced_mtime; /* last version handed to a sync */
|
||||
off_t synced_size;
|
||||
time_t changed_at; /* when an unsynced change was seen, 0 if none */
|
||||
time_t sent_at; /* when it last asked for a sync */
|
||||
int seen;
|
||||
} watched;
|
||||
|
||||
@@ -190,7 +194,7 @@ static void *watch_thread(void *arg) {
|
||||
time_t now = time(NULL);
|
||||
int enabled;
|
||||
config_lock();
|
||||
enabled = g_cfg.sync_on_change && g_cfg.setup_complete;
|
||||
enabled = g_cfg.sync_on_change && g_cfg.setup_complete && g_cfg.save_sync;
|
||||
config_unlock();
|
||||
if (!config_is_paired() || !enabled) {
|
||||
baseline = 1;
|
||||
@@ -218,11 +222,13 @@ static void *watch_thread(void *arg) {
|
||||
for (int i = 0; i < g_nfiles; i++) {
|
||||
watched *w = &g_files[i];
|
||||
if (!w->changed_at || now - w->changed_at < SETTLE_SEC) continue;
|
||||
if (sync_game_running() && w->sent_at && now - w->sent_at < PLAYING_GAP_SEC) continue; /* later */
|
||||
w->changed_at = 0;
|
||||
if (w->mtime == w->synced_mtime && w->size == w->synced_size) continue;
|
||||
w->synced_mtime = w->mtime;
|
||||
w->synced_size = w->size;
|
||||
if (written_by_sync(w)) continue;
|
||||
w->sent_at = now;
|
||||
if (!first) first = w->path;
|
||||
ready++;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
#include <arpa/inet.h>
|
||||
#include <dirent.h>
|
||||
#include <errno.h>
|
||||
#include <fcntl.h>
|
||||
#include <netinet/in.h>
|
||||
#include <pthread.h>
|
||||
#include <stdio.h>
|
||||
@@ -18,6 +19,7 @@
|
||||
#include "config.h"
|
||||
#include "covers.h"
|
||||
#include "detect.h"
|
||||
#include "events.h"
|
||||
#include "http.h"
|
||||
#include "library.h"
|
||||
#include "memcard.h"
|
||||
@@ -29,6 +31,7 @@
|
||||
#include "ui_index.h" /* generated from ui/index.html */
|
||||
#include "update.h"
|
||||
#include "util.h"
|
||||
#include "zip.h"
|
||||
|
||||
#define MAX_HEADER (16 * 1024)
|
||||
#define MAX_BODY (2 * 1024 * 1024)
|
||||
@@ -51,6 +54,8 @@ typedef struct {
|
||||
char query[2048];
|
||||
char *body;
|
||||
size_t body_len;
|
||||
/* For telling our own pages from other sites' (cross_site). */
|
||||
char host[256], origin[256], fetch_site[32];
|
||||
} request;
|
||||
|
||||
|
||||
@@ -158,6 +163,35 @@ static int qint(const request *r, const char *key, int def) {
|
||||
return qparam(r, key, b, sizeof b) && b[0] ? atoi(b) : def;
|
||||
}
|
||||
|
||||
static void autostart_chosen(void) {
|
||||
config_lock();
|
||||
if (!g_cfg.autostart_chosen) {
|
||||
g_cfg.autostart_chosen = 1;
|
||||
config_save();
|
||||
}
|
||||
config_unlock();
|
||||
}
|
||||
|
||||
/* A header's value, from just after "Name:" to the end of its line. */
|
||||
static void header_value(const char *v, char *out, size_t n) {
|
||||
while (*v == ' ' || *v == '\t') v++;
|
||||
size_t len = strcspn(v, "\r\n");
|
||||
while (len && (v[len - 1] == ' ' || v[len - 1] == '\t')) len--;
|
||||
if (len >= n) len = n - 1;
|
||||
memcpy(out, v, len);
|
||||
out[len] = 0;
|
||||
}
|
||||
|
||||
/* Whether another site's page made the browser send this. Browsers say where
|
||||
* a request comes from (Origin, Sec-Fetch-Site); apps such as RommPS and curl
|
||||
* send neither, and our own pages come from this very address. */
|
||||
static int cross_site(const request *r) {
|
||||
if (!strcasecmp(r->fetch_site, "cross-site") || !strcasecmp(r->fetch_site, "same-site")) return 1;
|
||||
if (!r->origin[0]) return 0;
|
||||
const char *o = strstr(r->origin, "://");
|
||||
return !o || strcasecmp(o + 3, r->host) != 0; /* "null" included */
|
||||
}
|
||||
|
||||
static int read_request(int fd, request *r) {
|
||||
char *buf = malloc(MAX_HEADER + 1);
|
||||
if (!buf) return -1;
|
||||
@@ -193,8 +227,11 @@ static int read_request(int fd, request *r) {
|
||||
size_t clen = 0;
|
||||
for (char *h = strstr(buf, "\r\n"); h && h < hdr_end; h = strstr(h + 2, "\r\n")) {
|
||||
if (!strncasecmp(h + 2, "Content-Length:", 15)) clen = (size_t)strtoul(h + 17, NULL, 10);
|
||||
else if (!strncasecmp(h + 2, "Host:", 5)) header_value(h + 7, r->host, sizeof r->host);
|
||||
else if (!strncasecmp(h + 2, "Origin:", 7)) header_value(h + 9, r->origin, sizeof r->origin);
|
||||
else if (!strncasecmp(h + 2, "Sec-Fetch-Site:", 15)) header_value(h + 17, r->fetch_site, sizeof r->fetch_site);
|
||||
}
|
||||
if (clen > (strcmp(r->path, "/api/autostart/upload") == 0 ? MAX_UPLOAD : MAX_BODY)) {
|
||||
if (clen > (!strcmp(r->path, "/api/autostart/upload") || !strcmp(r->path, "/api/autostart/install") ? MAX_UPLOAD : MAX_BODY)) {
|
||||
free(buf);
|
||||
return -1;
|
||||
}
|
||||
@@ -225,12 +262,12 @@ static cJSON *status_json(void) {
|
||||
cJSON_AddStringToObject(j, "platform", plat_name());
|
||||
cJSON_AddStringToObject(j, "console", plat_info()->console);
|
||||
cJSON_AddStringToObject(j, "loader", plat_info()->loader);
|
||||
cJSON_AddBoolToObject(j, "has_tile", plat_info()->has_tile);
|
||||
cJSON_AddStringToObject(j, "ip", ip);
|
||||
cJSON_AddNumberToObject(j, "uptime", (double)(time(NULL) - g_started));
|
||||
config_lock();
|
||||
cJSON_AddBoolToObject(j, "paired", config_is_paired());
|
||||
cJSON_AddBoolToObject(j, "setup_complete", g_cfg.setup_complete);
|
||||
cJSON_AddBoolToObject(j, "save_sync", g_cfg.save_sync);
|
||||
cJSON_AddStringToObject(j, "server_url", g_cfg.server_url);
|
||||
cJSON_AddStringToObject(j, "username", g_cfg.username);
|
||||
cJSON_AddStringToObject(j, "server_version", g_cfg.server_version);
|
||||
@@ -285,6 +322,14 @@ static cJSON *systems_json(const cJSON *profiles) {
|
||||
return out;
|
||||
}
|
||||
|
||||
/* Whether a list in obj ("on": [roots]) names root. */
|
||||
static int lists_root(const cJSON *obj, const char *list, const char *root) {
|
||||
const cJSON *x;
|
||||
cJSON_ArrayForEach(x, cJSON_GetObjectItemCaseSensitive(obj, list))
|
||||
if (cJSON_IsString(x) && !strcmp(x->valuestring, root)) return 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int plays_system(const cJSON *profile, const char *key) {
|
||||
const cJSON *e;
|
||||
cJSON_ArrayForEach(e, cJSON_GetObjectItemCaseSensitive(profile, "platforms"))
|
||||
@@ -372,7 +417,7 @@ static char *diagnostics_text(void) {
|
||||
char line[512], about[256], now_iso[40], path[PATH_MAX_LEN];
|
||||
plat_describe(about, sizeof about);
|
||||
iso8601_utc(time(NULL), now_iso, sizeof now_iso);
|
||||
snprintf(line, sizeof line, "RomM Sync diagnostics\nversion %s, build %s, %s\n%s\nclock %s, up %lds\n",
|
||||
snprintf(line, sizeof line, "RommPS diagnostics\nversion %s, build %s, %s\n%s\nclock %s, up %lds\n",
|
||||
APP_VERSION, APP_BUILD, plat_name(), about, now_iso, (long)(time(NULL) - g_started));
|
||||
tb_str(&b, line);
|
||||
tb_json(&b, "status", status_json());
|
||||
@@ -393,6 +438,286 @@ static char *diagnostics_text(void) {
|
||||
return b.s;
|
||||
}
|
||||
|
||||
/* Diagnostic bundle: a zip with the report, the logs, the sync log, the
|
||||
* settings and emulators found, the emulators' own logs and the kernel log
|
||||
* when it can be read. Nothing in it names the person: the server, user
|
||||
* name, device name and ids, tokens, IP and MAC addresses, emails and
|
||||
* console user ids are replaced before anything is written. */
|
||||
typedef struct { char from[600]; const char *to; } redaction;
|
||||
|
||||
static size_t redactions(redaction *r, size_t max) {
|
||||
size_t n = 0;
|
||||
config_lock();
|
||||
const struct { const char *v; const char *to; } src[] = {
|
||||
{g_cfg.token, "<token>"}, {g_cfg.server_url, "<server>"}, {g_cfg.username, "<user>"},
|
||||
{g_cfg.device_name, "<device>"}, {g_cfg.device_id, "<device-id>"},
|
||||
{g_cfg.client_device_identifier, "<device-id>"}};
|
||||
for (size_t i = 0; i < sizeof src / sizeof src[0] && n < max; i++)
|
||||
if (strlen(src[i].v) >= 3 && strcmp(src[i].v, "PlayStation 5")) {
|
||||
str_copy(r[n].from, sizeof r[n].from, src[i].v);
|
||||
r[n++].to = src[i].to;
|
||||
}
|
||||
/* The server's host alone, as it shows in errors. */
|
||||
const char *h = strstr(g_cfg.server_url, "://");
|
||||
if (h && n < max) {
|
||||
h += 3;
|
||||
size_t l = strcspn(h, ":/");
|
||||
if (l >= 3 && l < sizeof r[n].from) {
|
||||
memcpy(r[n].from, h, l);
|
||||
r[n].from[l] = 0;
|
||||
r[n++].to = "<server>";
|
||||
}
|
||||
}
|
||||
config_unlock();
|
||||
return n;
|
||||
}
|
||||
|
||||
static int is_hex(char c) { return (c >= '0' && c <= '9') || ((c | 32) >= 'a' && (c | 32) <= 'f'); }
|
||||
static int is_digit(char c) { return c >= '0' && c <= '9'; }
|
||||
static int is_alnum(char c) { return is_digit(c) || ((c | 32) >= 'a' && (c | 32) <= 'z'); }
|
||||
static int is_word(char c) { return is_hex(c) || ((c | 32) >= 'a' && (c | 32) <= 'z') || c == '_' || c == '.' || c == '-' || c == '+'; }
|
||||
|
||||
/* Length of a dotted IPv4 address at s, or 0. */
|
||||
static size_t ipv4_at(const char *s) {
|
||||
size_t i = 0;
|
||||
for (int part = 0; part < 4; part++) {
|
||||
size_t d = 0;
|
||||
while (is_digit(s[i + d]) && d < 4) d++;
|
||||
if (!d || d > 3) return 0;
|
||||
i += d;
|
||||
if (part < 3) {
|
||||
if (s[i] != '.') return 0;
|
||||
i++;
|
||||
}
|
||||
}
|
||||
return is_digit(s[i]) || (s[i] == '.' && is_digit(s[i + 1])) ? 0 : i;
|
||||
}
|
||||
|
||||
/* Length of a MAC address (aa:bb:cc:dd:ee:ff or with -) at s, or 0. */
|
||||
static size_t mac_at(const char *s) {
|
||||
for (int i = 0; i < 6; i++) {
|
||||
if (!is_hex(s[i * 3]) || !is_hex(s[i * 3 + 1])) return 0;
|
||||
if (i < 5 && s[i * 3 + 2] != ':' && s[i * 3 + 2] != '-') return 0;
|
||||
}
|
||||
return is_hex(s[17]) ? 0 : 17;
|
||||
}
|
||||
|
||||
/* A copy of txt with everything that could name the person replaced. */
|
||||
static char *redact(const char *txt, const redaction *r, size_t nr) {
|
||||
textbuf b = {0};
|
||||
tb_str(&b, "");
|
||||
for (const char *p = txt; *p;) {
|
||||
size_t m = 0;
|
||||
const char *to = NULL;
|
||||
const int start = p == txt || !is_word(p[-1]);
|
||||
for (size_t i = 0; i < nr && !m; i++) { /* whole words only */
|
||||
size_t l = strlen(r[i].from);
|
||||
if ((p == txt || !is_alnum(p[-1])) && !strncasecmp(p, r[i].from, l) && !is_alnum(p[l])) m = l, to = r[i].to;
|
||||
}
|
||||
if (!m && start && (m = ipv4_at(p)) && (!strncmp(p, "127.", 4) || !strncmp(p, "0.0.0.0", 7))) m = 0;
|
||||
if (m && !to) to = "<ip>";
|
||||
if (!m && start && (m = mac_at(p))) to = "<mac>";
|
||||
if (!m && !strncmp(p, "/user/home/", 11)) { /* a console user's id */
|
||||
size_t l = 0;
|
||||
while (is_hex(p[11 + l])) l++;
|
||||
if (l) m = 11 + l, to = "/user/home/<user>";
|
||||
}
|
||||
static const char *const keys[] = {"onlineid", "online_id", "accountid", "account_id", "npid", "userid", "user_id", "username", "user_name", "email"};
|
||||
for (size_t i = 0; !m && start && i < sizeof keys / sizeof keys[0]; i++) { /* key=value, key: "value" */
|
||||
size_t l = strlen(keys[i]);
|
||||
if (strncasecmp(p, keys[i], l) || is_alnum(p[l])) continue;
|
||||
size_t v = l;
|
||||
while (p[v] == ' ' || p[v] == '=' || p[v] == ':' || p[v] == '"' || p[v] == '\'' || p[v] == '\t') v++;
|
||||
if (v == l || !is_word(p[v])) continue;
|
||||
tb_add(&b, p, v);
|
||||
m = v;
|
||||
while (is_word(p[m])) m++;
|
||||
m -= v;
|
||||
p += v;
|
||||
to = "<id>";
|
||||
}
|
||||
if (!m && *p == '@' && p > txt && is_word(p[-1]) && is_word(p[1])) { /* an email: drop both sides */
|
||||
while (b.len && is_word(b.s[b.len - 1])) b.s[--b.len] = 0;
|
||||
m = 1;
|
||||
while (is_word(p[m])) m++;
|
||||
to = "<email>";
|
||||
}
|
||||
if (m) {
|
||||
tb_str(&b, to);
|
||||
p += m;
|
||||
} else tb_add(&b, p++, 1);
|
||||
}
|
||||
return b.s;
|
||||
}
|
||||
|
||||
/* Writes name into dir with its text redacted; adds it to the members. */
|
||||
static void bundle_add(const char *dir, const char *name, const char *txt, const redaction *r, size_t nr,
|
||||
zip_member *mem, char (*paths)[PATH_MAX_LEN], int *n, int max) {
|
||||
if (!txt || *n >= max) return;
|
||||
char *clean = redact(txt, r, nr);
|
||||
path_join(paths[*n], PATH_MAX_LEN, dir, name);
|
||||
if (clean && !write_file_atomic(paths[*n], clean, strlen(clean))) {
|
||||
mem[*n].path = paths[*n];
|
||||
mem[*n].name = strrchr(paths[*n], '/') + 1;
|
||||
(*n)++;
|
||||
}
|
||||
free(clean);
|
||||
}
|
||||
|
||||
/* The last max bytes of a file, from the start of a line, or NULL. */
|
||||
static char *file_tail(const char *path, size_t max) {
|
||||
size_t len = 0;
|
||||
char *txt = read_file(path, &len);
|
||||
if (!txt || len <= max) return txt;
|
||||
const char *start = txt + len - max, *nl = strchr(start, '\n');
|
||||
if (nl) start = nl + 1;
|
||||
memmove(txt, start, strlen(start) + 1);
|
||||
return txt;
|
||||
}
|
||||
|
||||
/* What the kernel log holds right now, without waiting for more. */
|
||||
static char *kernel_log(void) {
|
||||
int fd = open("/dev/klog", O_RDONLY | O_NONBLOCK);
|
||||
if (fd < 0) return NULL;
|
||||
textbuf b = {0};
|
||||
char buf[4096];
|
||||
time_t until = time(NULL) + 2;
|
||||
while (b.len < 512 * 1024 && time(NULL) <= until) {
|
||||
ssize_t got = read(fd, buf, sizeof buf);
|
||||
if (got <= 0) break;
|
||||
tb_add(&b, buf, (size_t)got);
|
||||
}
|
||||
close(fd);
|
||||
return b.s;
|
||||
}
|
||||
|
||||
/* The emulators' own logs: *.log files in each emulator's folder. */
|
||||
static void emulator_logs(const char *dir, const redaction *r, size_t nr, zip_member *mem,
|
||||
char (*paths)[PATH_MAX_LEN], int *n, int max) {
|
||||
cJSON *found = detect_emulators(), *c;
|
||||
char seen[16][PATH_MAX_LEN];
|
||||
int nseen = 0;
|
||||
cJSON_ArrayForEach(c, found) {
|
||||
char roots[2][PATH_MAX_LEN] = {"", ""};
|
||||
str_copy(roots[0], PATH_MAX_LEN, jget_str(c, "root", ""));
|
||||
const char *tid = jget_str(c, "title_id", "");
|
||||
if (tid[0]) snprintf(roots[1], PATH_MAX_LEN, "/data/homebrew/%s", tid);
|
||||
for (int k = 0; k < 2; k++) {
|
||||
int dup = !roots[k][0];
|
||||
for (int i = 0; i < nseen && !dup; i++) dup = !strcmp(seen[i], roots[k]);
|
||||
if (dup || nseen >= 16) continue;
|
||||
str_copy(seen[nseen++], PATH_MAX_LEN, roots[k]);
|
||||
DIR *d = opendir(roots[k]);
|
||||
struct dirent *de;
|
||||
while (d && (de = readdir(d))) {
|
||||
if (de->d_name[0] == '.' || !str_ends_with_ci(de->d_name, ".log")) continue;
|
||||
char path[PATH_MAX_LEN], name[256];
|
||||
path_join(path, sizeof path, roots[k], de->d_name);
|
||||
if (dir_exists(path)) continue;
|
||||
const char *base = strrchr(roots[k], '/');
|
||||
snprintf(name, sizeof name, "emulators-%s-%s", base ? base + 1 : roots[k], de->d_name);
|
||||
char *txt = file_tail(path, 256 * 1024);
|
||||
bundle_add(dir, name, txt, r, nr, mem, paths, n, max);
|
||||
free(txt);
|
||||
}
|
||||
if (d) closedir(d);
|
||||
}
|
||||
}
|
||||
cJSON_Delete(found);
|
||||
}
|
||||
|
||||
/* Builds the bundle at zip_path. 0 on success. */
|
||||
static int diagnostics_bundle(const char *zip_path) {
|
||||
enum { kMax = 48 };
|
||||
redaction r[8];
|
||||
size_t nr = redactions(r, 8);
|
||||
char dir[PATH_MAX_LEN], path[PATH_MAX_LEN];
|
||||
path_join(dir, sizeof dir, plat_data_dir(), "diagnostics.tmp");
|
||||
remove_tree(dir);
|
||||
mkdir_p(dir);
|
||||
zip_member mem[kMax];
|
||||
char (*paths)[PATH_MAX_LEN] = malloc(kMax * sizeof *paths); /* console threads have small stacks */
|
||||
int n = 0;
|
||||
if (!paths) return -1;
|
||||
|
||||
char *txt = diagnostics_text();
|
||||
bundle_add(dir, "report.txt", txt, r, nr, mem, paths, &n, kMax);
|
||||
free(txt);
|
||||
const char *logs[][2] = {{"romm-sync.log", "romm-sync.log"}, {"romm-sync.log.1", "romm-sync.previous.log"},
|
||||
{"events.json", "sync-log.json"}, {"boot.log", "boot.log"}, {"launcher.log", "launcher.log"}};
|
||||
for (size_t i = 0; i < sizeof logs / sizeof logs[0]; i++) {
|
||||
path_join(path, sizeof path, plat_data_dir(), logs[i][0]);
|
||||
txt = read_file(path, NULL);
|
||||
bundle_add(dir, logs[i][1], txt, r, nr, mem, paths, &n, kMax);
|
||||
free(txt);
|
||||
}
|
||||
cJSON *j = detect_emulators();
|
||||
txt = cJSON_Print(j);
|
||||
cJSON_Delete(j);
|
||||
bundle_add(dir, "emulators-found.json", txt, r, nr, mem, paths, &n, kMax);
|
||||
free(txt);
|
||||
config_lock();
|
||||
txt = cJSON_Print(g_cfg.profiles);
|
||||
config_unlock();
|
||||
bundle_add(dir, "emulator-settings.json", txt, r, nr, mem, paths, &n, kMax);
|
||||
free(txt);
|
||||
emulator_logs(dir, r, nr, mem, paths, &n, kMax);
|
||||
txt = kernel_log();
|
||||
bundle_add(dir, "kernel.log", txt ? txt : "The kernel log couldn't be read (another tool may be reading it).\n", r,
|
||||
nr, mem, paths, &n, kMax);
|
||||
free(txt);
|
||||
|
||||
int rc = zip_write(zip_path, mem, n);
|
||||
remove_tree(dir);
|
||||
free(paths);
|
||||
LOGI("diagnostic bundle %s: %d files%s", zip_path, n, rc ? ", failed" : "");
|
||||
return rc;
|
||||
}
|
||||
|
||||
/* Saves a bundle where the person can get at it: a USB drive when one is in,
|
||||
* and the payload's folder. The newest three are kept there. */
|
||||
static cJSON *diagnostics_save(void) {
|
||||
char stamp[32], name[64], path[PATH_MAX_LEN], dir[PATH_MAX_LEN];
|
||||
time_t now = time(NULL);
|
||||
struct tm tm;
|
||||
localtime_r(&now, &tm);
|
||||
strftime(stamp, sizeof stamp, "%Y%m%d-%H%M%S", &tm);
|
||||
snprintf(name, sizeof name, "rommps-diagnostics-%s.zip", stamp);
|
||||
path_join(dir, sizeof dir, plat_data_dir(), "diagnostics");
|
||||
mkdir_p(dir);
|
||||
path_join(path, sizeof path, dir, name);
|
||||
if (diagnostics_bundle(path)) return NULL;
|
||||
/* Older ones go: names sort by time. */
|
||||
DIR *d = opendir(dir);
|
||||
struct dirent *de;
|
||||
char (*old)[64] = malloc(64 * sizeof *old);
|
||||
int n = 0;
|
||||
while (old && d && (de = readdir(d)) && n < 64)
|
||||
if (!strncmp(de->d_name, "rommps-diagnostics-", 19)) str_copy(old[n++], sizeof old[0], de->d_name);
|
||||
if (d) closedir(d);
|
||||
if (old) {
|
||||
qsort(old, (size_t)n, sizeof old[0], (int (*)(const void *, const void *))strcmp);
|
||||
for (int i = 0; i + 3 < n; i++) {
|
||||
char p[PATH_MAX_LEN];
|
||||
path_join(p, sizeof p, dir, old[i]);
|
||||
unlink(p);
|
||||
}
|
||||
free(old);
|
||||
}
|
||||
cJSON *j = cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(j, "path", path);
|
||||
const char *usb[] = {"/mnt/usb0", "/mnt/usb1", "/mnt/usb2", "/mnt/usb3"};
|
||||
for (size_t i = 0; i < sizeof usb / sizeof usb[0]; i++) {
|
||||
char to[PATH_MAX_LEN];
|
||||
path_join(to, sizeof to, usb[i], name);
|
||||
if (dir_exists(usb[i]) && !copy_file(path, to)) {
|
||||
cJSON_AddStringToObject(j, "usb", to);
|
||||
break;
|
||||
}
|
||||
}
|
||||
return j;
|
||||
}
|
||||
|
||||
/* Covers and platform icons, from the console's cache or RomM (covers.h). */
|
||||
static void proxy_asset(const request *r) {
|
||||
char p[1024];
|
||||
@@ -483,6 +808,13 @@ static cJSON *fs_folders(const char *path, char *err, int en) {
|
||||
static void route(request *r) {
|
||||
int fd = r->fd;
|
||||
int is_get = !strcmp(r->method, "GET"), is_post = !strcmp(r->method, "POST");
|
||||
/* Only our own pages and apps change anything: a page on another site
|
||||
* could otherwise post here from any browser on the network. */
|
||||
if (!is_get && strcmp(r->method, "HEAD") && cross_site(r)) {
|
||||
LOGW("refused %s %s from %s", r->method, r->path, r->origin[0] ? r->origin : "another site");
|
||||
send_error(fd, 403, "requests from other websites aren't accepted");
|
||||
return;
|
||||
}
|
||||
char err[256] = "";
|
||||
|
||||
if (is_get && (!strcmp(r->path, "/") || !strcmp(r->path, "/index.html"))) {
|
||||
@@ -522,10 +854,20 @@ static void route(request *r) {
|
||||
return;
|
||||
}
|
||||
if (is_get && !strcmp(r->path, "/api/diagnostics")) {
|
||||
char *txt = diagnostics_text();
|
||||
send_response(fd, 200, "text/plain; charset=utf-8", txt ? txt : "", txt ? strlen(txt) : 0,
|
||||
"Content-Disposition: attachment; filename=\"romm-sync-diagnostics.txt\"\r\n");
|
||||
free(txt);
|
||||
cJSON *j = diagnostics_save();
|
||||
size_t len = 0;
|
||||
char *zip = j ? read_file(jget_str(j, "path", ""), &len) : NULL;
|
||||
if (zip) send_response(fd, 200, "application/zip", zip, len,
|
||||
"Content-Disposition: attachment; filename=\"rommps-diagnostics.zip\"\r\n");
|
||||
else send_error(fd, 500, "couldn't make the bundle");
|
||||
free(zip);
|
||||
cJSON_Delete(j);
|
||||
return;
|
||||
}
|
||||
if (is_post && !strcmp(r->path, "/api/diagnostics")) {
|
||||
cJSON *j = diagnostics_save();
|
||||
if (j) send_json(fd, 200, j);
|
||||
else send_error(fd, 500, "couldn't make the bundle");
|
||||
return;
|
||||
}
|
||||
if (is_get && !strcmp(r->path, "/api/config")) {
|
||||
@@ -588,6 +930,47 @@ static void route(request *r) {
|
||||
else send_json(fd, 200, update_status());
|
||||
return;
|
||||
}
|
||||
/* Autostart works before pairing too: RommPS sets it up and keeps it
|
||||
* current whether or not RomM Sync is paired yet. */
|
||||
if (is_get && !strcmp(r->path, "/api/autostart")) {
|
||||
cJSON *a = autostart_status();
|
||||
config_lock();
|
||||
cJSON_AddBoolToObject(a, "chosen", g_cfg.autostart_chosen);
|
||||
config_unlock();
|
||||
send_json(fd, 200, a);
|
||||
return;
|
||||
}
|
||||
/* Turning it on or off is remembered, so RommPS's one-time switch-on
|
||||
* for versions that left it off never overrides someone's choice. */
|
||||
if (is_post && !strcmp(r->path, "/api/autostart")) {
|
||||
cJSON *j = body_json(r);
|
||||
int rc = autostart_set(cJSON_IsTrue(cJSON_GetObjectItemCaseSensitive(j, "enable")), NULL, 0, err, sizeof err);
|
||||
cJSON_Delete(j);
|
||||
if (rc) send_error(fd, 400, err);
|
||||
else {
|
||||
autostart_chosen();
|
||||
send_json(fd, 200, autostart_status());
|
||||
}
|
||||
return;
|
||||
}
|
||||
/* Installs the payload and turns autostart on (RommPS's setup and the
|
||||
* web UI's upload). */
|
||||
if (is_post && !strcmp(r->path, "/api/autostart/upload")) {
|
||||
int rc = autostart_set(1, r->body, r->body_len, err, sizeof err);
|
||||
if (rc) send_error(fd, 400, err);
|
||||
else {
|
||||
autostart_chosen();
|
||||
send_json(fd, 200, autostart_status());
|
||||
}
|
||||
return;
|
||||
}
|
||||
/* Installs the payload, leaving autostart on or off as it is. */
|
||||
if (is_post && !strcmp(r->path, "/api/autostart/install")) {
|
||||
int rc = autostart_install(r->body, r->body_len, err, sizeof err);
|
||||
if (rc) send_error(fd, 400, err);
|
||||
else send_json(fd, 200, autostart_status());
|
||||
return;
|
||||
}
|
||||
if (!config_is_paired() && !strncmp(r->path, "/api/", 5)) {
|
||||
send_error(fd, 409, "not paired with a RomM server yet");
|
||||
return;
|
||||
@@ -601,25 +984,45 @@ static void route(request *r) {
|
||||
* system several of them play to the root of its main emulator;
|
||||
* "also" lists, per system, the other emulators that keep it as
|
||||
* extras (downloads linked in, a save shared where the format is the
|
||||
* same); the rest exclude it. */
|
||||
* same); the rest exclude it. "systems" does the same per system,
|
||||
* and wins over both: {"ps2": {"main": root, "on": [roots],
|
||||
* "folders": {root: {"rom_dir", "save_dir", "state_dir"}}}}. An
|
||||
* emulator not "on" excludes it; folders go on that system's entry. */
|
||||
cJSON *j = body_json(r), *found = detect_emulators(), *profiles = cJSON_CreateArray();
|
||||
const cJSON *root, *c, *choices = cJSON_GetObjectItemCaseSensitive(j, "choices");
|
||||
const cJSON *also = cJSON_GetObjectItemCaseSensitive(j, "also");
|
||||
const cJSON *systems = cJSON_GetObjectItemCaseSensitive(j, "systems");
|
||||
cJSON_ArrayForEach(root, cJSON_GetObjectItemCaseSensitive(j, "roots")) {
|
||||
cJSON_ArrayForEach(c, found) {
|
||||
if (!cJSON_IsString(root) || strcmp(jget_str(c, "root", ""), root->valuestring) != 0) continue;
|
||||
if (cJSON_IsFalse(cJSON_GetObjectItemCaseSensitive(c, "ready"))) continue; /* no preset: "custom" below */
|
||||
cJSON *p = cJSON_Duplicate(cJSON_GetObjectItemCaseSensitive(c, "profile"), 1), *ex = cJSON_CreateArray(),
|
||||
*extra = cJSON_CreateArray();
|
||||
const cJSON *e, *x;
|
||||
cJSON *e;
|
||||
cJSON_ArrayForEach(e, cJSON_GetObjectItemCaseSensitive(p, "platforms")) {
|
||||
const char *key = profiles_system_key(e), *keeper = jget_str(choices, key, NULL);
|
||||
const cJSON *sys = cJSON_GetObjectItemCaseSensitive(systems, key);
|
||||
if (sys) {
|
||||
if (!lists_root(sys, "on", root->valuestring)) {
|
||||
cJSON_AddItemToArray(ex, cJSON_CreateString(key));
|
||||
continue;
|
||||
}
|
||||
if (strcmp(jget_str(sys, "main", ""), root->valuestring) != 0) /* on, not the main one */
|
||||
cJSON_AddItemToArray(extra, cJSON_CreateString(key));
|
||||
const cJSON *f = cJSON_GetObjectItemCaseSensitive(cJSON_GetObjectItemCaseSensitive(sys, "folders"),
|
||||
root->valuestring);
|
||||
static const char *const dirs[] = {"rom_dir", "save_dir", "state_dir"};
|
||||
for (int k = 0; f && k < 3; k++) {
|
||||
const char *d = jget_str(f, dirs[k], "");
|
||||
if (d[0] != '/') continue; /* empty: the default */
|
||||
cJSON_DeleteItemFromObjectCaseSensitive(e, dirs[k]);
|
||||
cJSON_AddStringToObject(e, dirs[k], d);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (!keeper || !strcmp(keeper, root->valuestring)) continue;
|
||||
/* "also": {"psx": [roots]} keeps the system on those as extras. */
|
||||
int kept = 0;
|
||||
cJSON_ArrayForEach(x, cJSON_GetObjectItemCaseSensitive(also, key))
|
||||
if (cJSON_IsString(x) && !strcmp(x->valuestring, root->valuestring)) kept = 1;
|
||||
cJSON_AddItemToArray(kept ? extra : ex, cJSON_CreateString(key));
|
||||
cJSON_AddItemToArray(lists_root(also, key, root->valuestring) ? extra : ex, cJSON_CreateString(key));
|
||||
}
|
||||
if (cJSON_GetArraySize(ex)) cJSON_AddItemToObject(p, "exclude", ex);
|
||||
else cJSON_Delete(ex);
|
||||
@@ -676,6 +1079,35 @@ static void route(request *r) {
|
||||
}
|
||||
cJSON_AddItemToArray(profiles, p);
|
||||
}
|
||||
/* Save sync per platform, on each platform entry ("sync": "off" or
|
||||
* "saves"; none is everything): "systems"{key}{"sync"} from the app,
|
||||
* else "sync"{root} (the web UI, per emulator), else what it was. */
|
||||
const cJSON *modes = cJSON_GetObjectItemCaseSensitive(j, "sync");
|
||||
config_lock();
|
||||
cJSON *pr;
|
||||
cJSON_ArrayForEach(pr, profiles) {
|
||||
const char *at = jget_str(pr, "root", "");
|
||||
const cJSON *old = NULL, *o;
|
||||
cJSON_ArrayForEach(o, g_cfg.profiles) if (!strcmp(jget_str(o, "root", ""), at) &&
|
||||
!strcmp(jget_str(o, "id", ""), jget_str(pr, "id", "")))
|
||||
old = o;
|
||||
cJSON *pe;
|
||||
cJSON_ArrayForEach(pe, cJSON_GetObjectItemCaseSensitive(pr, "platforms")) {
|
||||
const char *key = profiles_system_key(pe);
|
||||
const char *mode = jget_str(cJSON_GetObjectItemCaseSensitive(systems, key), "sync", NULL);
|
||||
if (!mode) mode = jget_str(modes, at, NULL);
|
||||
const cJSON *oe;
|
||||
cJSON_ArrayForEach(oe, cJSON_GetObjectItemCaseSensitive(old, "platforms")) if (!mode && !strcmp(profiles_system_key(oe), key))
|
||||
mode = jget_str(oe, "sync", NULL);
|
||||
if (!mode) mode = jget_str(old, "sync", NULL);
|
||||
char keep[8] = "";
|
||||
if (mode && (!strcmp(mode, "off") || !strcmp(mode, "saves"))) str_copy(keep, sizeof keep, mode);
|
||||
cJSON_DeleteItemFromObjectCaseSensitive(pe, "sync");
|
||||
if (keep[0]) cJSON_AddStringToObject(pe, "sync", keep);
|
||||
}
|
||||
cJSON_DeleteItemFromObjectCaseSensitive(pr, "sync");
|
||||
}
|
||||
config_unlock();
|
||||
cJSON_Delete(found);
|
||||
cJSON_Delete(j);
|
||||
cJSON *cfg = cJSON_CreateObject();
|
||||
@@ -697,6 +1129,38 @@ static void route(request *r) {
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (is_post && !strcmp(r->path, "/api/emulators/sync")) {
|
||||
/* One emulator's save sync, for every platform it plays: {"root", "sync": "off" | "saves" | "all"}. */
|
||||
cJSON *j = body_json(r), *p;
|
||||
const char *at = jget_str(j, "root", ""), *mode = jget_str(j, "sync", "");
|
||||
int found = 0, rc = -1;
|
||||
if (at[0] && (!strcmp(mode, "off") || !strcmp(mode, "saves") || !strcmp(mode, "all"))) {
|
||||
config_lock();
|
||||
cJSON_ArrayForEach(p, g_cfg.profiles) {
|
||||
if (strcmp(jget_str(p, "root", ""), at) != 0) continue;
|
||||
found = 1;
|
||||
cJSON_DeleteItemFromObjectCaseSensitive(p, "sync");
|
||||
cJSON *pe; /* every platform it plays */
|
||||
cJSON_ArrayForEach(pe, cJSON_GetObjectItemCaseSensitive(p, "platforms")) {
|
||||
cJSON_DeleteItemFromObjectCaseSensitive(pe, "sync");
|
||||
if (strcmp(mode, "all") != 0) cJSON_AddStringToObject(pe, "sync", mode);
|
||||
}
|
||||
}
|
||||
if (found) {
|
||||
g_cfg.profiles_custom = 1;
|
||||
rc = config_save();
|
||||
}
|
||||
config_unlock();
|
||||
}
|
||||
cJSON_Delete(j);
|
||||
if (!found) send_error(fd, 400, "unknown emulator or choice");
|
||||
else if (rc) send_error(fd, 500, "could not save config");
|
||||
else {
|
||||
LOGI("save sync for %s: %s", at, mode);
|
||||
send_ok(fd);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (is_get && !strcmp(r->path, "/api/setup/preview")) {
|
||||
cJSON *j = sync_preview(err, sizeof err);
|
||||
if (j) send_json(fd, 200, j);
|
||||
@@ -739,24 +1203,6 @@ static void route(request *r) {
|
||||
else send_ok(fd);
|
||||
return;
|
||||
}
|
||||
if (is_get && !strcmp(r->path, "/api/autostart")) {
|
||||
send_json(fd, 200, autostart_status());
|
||||
return;
|
||||
}
|
||||
if (is_post && !strcmp(r->path, "/api/autostart")) {
|
||||
cJSON *j = body_json(r);
|
||||
int rc = autostart_set(cJSON_IsTrue(cJSON_GetObjectItemCaseSensitive(j, "enable")), NULL, 0, err, sizeof err);
|
||||
cJSON_Delete(j);
|
||||
if (rc) send_error(fd, 400, err);
|
||||
else send_json(fd, 200, autostart_status());
|
||||
return;
|
||||
}
|
||||
if (is_post && !strcmp(r->path, "/api/autostart/upload")) {
|
||||
int rc = autostart_set(1, r->body, r->body_len, err, sizeof err);
|
||||
if (rc) send_error(fd, 400, err);
|
||||
else send_json(fd, 200, autostart_status());
|
||||
return;
|
||||
}
|
||||
if (is_post && !strcmp(r->path, "/api/sync")) {
|
||||
sync_request("manual");
|
||||
send_ok(fd);
|
||||
@@ -770,6 +1216,14 @@ static void route(request *r) {
|
||||
else send_ok(fd);
|
||||
return;
|
||||
}
|
||||
if (is_get && !strcmp(r->path, "/api/events")) {
|
||||
send_json(fd, 200, events_json(qint(r, "rom_id", 0), qint(r, "limit", 200)));
|
||||
return;
|
||||
}
|
||||
if (is_get && !strcmp(r->path, "/api/tracked")) {
|
||||
send_json(fd, 200, sync_tracked(qint(r, "rom_id", 0)));
|
||||
return;
|
||||
}
|
||||
if (is_get && !strcmp(r->path, "/api/history")) {
|
||||
cJSON *j = sync_history(qint(r, "rom_id", 0), err, sizeof err);
|
||||
if (j) send_json(fd, 200, j);
|
||||
@@ -830,7 +1284,7 @@ static void route(request *r) {
|
||||
}
|
||||
if (is_get && !strcmp(r->path, "/api/installed")) {
|
||||
int limit = qint(r, "limit", 20);
|
||||
send_json(fd, 200, library_installed(limit < 1 || limit > 200 ? 20 : limit));
|
||||
send_json(fd, 200, library_installed(limit < 1 ? 20 : limit > 5000 ? 5000 : limit));
|
||||
return;
|
||||
}
|
||||
if (is_post && !strcmp(r->path, "/api/download")) {
|
||||
@@ -865,8 +1319,9 @@ static void route(request *r) {
|
||||
cJSON_AddStringToObject(o, "platforms", maps[i].romm_slugs);
|
||||
cJSON_AddStringToObject(o, "emulator", maps[i].emulator);
|
||||
cJSON_AddStringToObject(o, "rom_dir", maps[i].rom_dir);
|
||||
cJSON_AddStringToObject(o, "root", maps[i].root);
|
||||
cJSON_AddStringToObject(o, "save_dir", maps[i].sync_saves ? maps[i].save_dir : "");
|
||||
cJSON_AddStringToObject(o, "state_dir", maps[i].sync_saves ? maps[i].state_dir : "");
|
||||
cJSON_AddStringToObject(o, "state_dir", maps[i].sync_saves && maps[i].sync_states ? maps[i].state_dir : "");
|
||||
cJSON_AddStringToObject(o, "bios_dir", maps[i].bios_dir);
|
||||
cJSON_AddStringToObject(o, "sync_note", maps[i].sync_note);
|
||||
cJSON_AddStringToObject(o, "save_layout", maps[i].save_layout);
|
||||
@@ -908,15 +1363,6 @@ static void route(request *r) {
|
||||
send_ok(fd);
|
||||
return;
|
||||
}
|
||||
if (is_post && !strcmp(r->path, "/api/tile")) {
|
||||
int port;
|
||||
config_lock();
|
||||
port = g_cfg.web_port;
|
||||
config_unlock();
|
||||
if (plat_install_tile(port) == 0) send_ok(fd);
|
||||
else send_error(fd, 500, plat_info()->has_tile ? "tile install failed" : "home screen tiles aren't supported on this console");
|
||||
return;
|
||||
}
|
||||
if (is_get && !strcmp(r->path, "/video")) {
|
||||
/* RommPS's Codec call: a YouTube video in the console's browser, its
|
||||
* player filling the page. YouTube's embeds refuse to play as a page
|
||||
|
||||
Executable
+114
@@ -0,0 +1,114 @@
|
||||
#!/usr/bin/env bash
|
||||
# Autostart on a console with two loaders (etaHEN and onionHEN folders) and a
|
||||
# payload manager's copy, against the host build.
|
||||
# make host && tests/autostart.sh
|
||||
set -euo pipefail
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
T="$(mktemp -d)"
|
||||
WEB_PORT=8783
|
||||
API="http://127.0.0.1:${WEB_PORT}"
|
||||
trap 'kill $(jobs -p) 2>/dev/null || true; rm -rf "$T"' EXIT
|
||||
|
||||
ETA="$T/etaHEN/payloads" ONION="$T/OnionHEN/payloads" PLD="$T/pldmgr/payloads/romm-sync/romm-sync.elf"
|
||||
fail() { echo "FAIL: $*"; echo "--- daemon log"; tail -30 "$T/daemon.log"; exit 1; }
|
||||
# A stand-in payload: an ELF header and, from 1.1.0 on, the version tag.
|
||||
elf() { if [[ -n "$1" ]]; then printf '\177ELF payload ROMM_SYNC_VERSION=%s\n' "$1"; else printf '\177ELF payload\n'; fi; }
|
||||
ver() { [[ -f "$1" ]] && { grep -ao 'ROMM_SYNC_VERSION=[0-9.]*' "$1" | cut -d= -f2 || echo untagged; } || echo none; }
|
||||
on() { [[ -f "$1/romm-sync.elf.auto_start" ]] && echo on || echo off; }
|
||||
start() {
|
||||
ROMM_SYNC_APP_INSTALLED="${APP_INSTALLED:-0}" ROMM_SYNC_APP_VERSION="${APP_VERSION:-}" ROMM_SYNC_SELF_UPDATE="${SELF_UPDATE:-1}" ROMM_SYNC_DATA="$T/data" ROMM_SYNC_HOMEBREW="$T/none" ROMM_SYNC_AUTOSTART="$ETA,$ONION" \
|
||||
ROMM_SYNC_PAYLOAD_COPIES="$PLD,$T/elsewhere/romm-sync.elf" "$ROOT/build/host/romm-sync" >>"$T/daemon.log" 2>&1 &
|
||||
for _ in $(seq 1 40); do curl -sf "$API/api/autostart" >/dev/null 2>&1 && return 0; sleep 0.25; done
|
||||
fail "the daemon didn't start"
|
||||
}
|
||||
stop() { kill $(jobs -p) 2>/dev/null || true; wait 2>/dev/null || true; }
|
||||
upload() { elf "$2" > "$T/up.elf"; curl -s -X POST "$API/api/autostart/$1" --data-binary @"$T/up.elf"; }
|
||||
expect() { # expect <what> <got> <want>
|
||||
[[ "$2" == "$3" ]] || fail "$1: got '$2', expected '$3'"; }
|
||||
|
||||
mkdir -p "$T/data" "$ETA" "$ONION" "$(dirname "$PLD")"
|
||||
echo "{\"web_port\": $WEB_PORT, \"sync_interval_min\": 0}" > "$T/data/config.json"
|
||||
|
||||
echo "1. at startup, every copy is brought up to the newest and onionHEN is set up too"
|
||||
elf 1.1.0 > "$ETA/romm-sync.elf"; : > "$ETA/romm-sync.elf.auto_start" # 1.1.0 installed it in etaHEN only
|
||||
elf "" > "$ETA/romm-sync.elf.bak" # an update's leftover
|
||||
elf "" > "$PLD" # 1.0.x kept in pldmgr's autoload
|
||||
start
|
||||
expect "etaHEN copy" "$(ver "$ETA/romm-sync.elf")" 1.1.0
|
||||
expect "onionHEN copy" "$(ver "$ONION/romm-sync.elf")" 1.1.0
|
||||
expect "onionHEN autostart" "$(on "$ONION")" on
|
||||
expect "pldmgr copy" "$(ver "$PLD")" 1.1.0
|
||||
[[ ! -e "$ETA/romm-sync.elf.bak" ]] || fail "the .bak wasn't removed"
|
||||
[[ ! -e "$T/elsewhere" ]] || fail "a copy that didn't exist was created"
|
||||
|
||||
chosen() { curl -sf "$API/api/autostart" | python3 -c "import json,sys; print(json.load(sys.stdin)['chosen'])"; }
|
||||
expect "a choice before anyone made one" "$(chosen)" False
|
||||
|
||||
echo "2. an older payload never replaces a newer one"
|
||||
upload upload 1.0.9 >/dev/null
|
||||
expect "etaHEN after an older upload" "$(ver "$ETA/romm-sync.elf")" 1.1.0
|
||||
expect "onionHEN after an older upload" "$(ver "$ONION/romm-sync.elf")" 1.1.0
|
||||
upload upload "" >/dev/null
|
||||
expect "etaHEN after an untagged (1.0.x) upload" "$(ver "$ETA/romm-sync.elf")" 1.1.0
|
||||
expect "pldmgr after an older upload" "$(ver "$PLD")" 1.1.0
|
||||
|
||||
echo "3. install puts the newer payload everywhere and leaves autostart on"
|
||||
r=$(upload install 1.1.1); [[ "$r" == *'"version":"1.1.1"'* ]] || fail "status after install: $r"
|
||||
for d in "$ETA" "$ONION"; do expect "$d" "$(ver "$d/romm-sync.elf")/$(on "$d")" 1.1.1/on; done
|
||||
expect "pldmgr" "$(ver "$PLD")" 1.1.1
|
||||
n=$(curl -sf "$API/api/autostart" | python3 -c "import json,sys; print(len(json.load(sys.stdin)['copies']))")
|
||||
expect "copies reported" "$n" 3
|
||||
|
||||
echo "4. off removes the payload from every loader, but not the payload manager's copy"
|
||||
curl -sf -X POST "$API/api/autostart" -d '{"enable":false}' >/dev/null
|
||||
for d in "$ETA" "$ONION"; do [[ -z "$(ls -A "$d")" ]] || fail "$d not empty after turning autostart off: $(ls "$d")"; done
|
||||
expect "pldmgr after off" "$(ver "$PLD")" 1.1.1
|
||||
|
||||
echo "5. install while off only updates copies that exist; it doesn't turn autostart on"
|
||||
upload install 1.1.2 >/dev/null
|
||||
for d in "$ETA" "$ONION"; do [[ -z "$(ls -A "$d")" ]] || fail "install while off wrote to $d"; done
|
||||
expect "pldmgr" "$(ver "$PLD")" 1.1.2
|
||||
|
||||
echo "6. upload turns it on in every installed loader; a loader that isn't installed is left alone"
|
||||
rm -rf "$T/OnionHEN"
|
||||
upload upload 1.1.2 >/dev/null
|
||||
expect "etaHEN" "$(ver "$ETA/romm-sync.elf")/$(on "$ETA")" 1.1.2/on
|
||||
[[ ! -e "$T/OnionHEN" ]] || fail "a folder was made for a loader that isn't installed"
|
||||
|
||||
expect "turning it on is remembered" "$(chosen)" True
|
||||
|
||||
echo "7. a loader installed later gets set up at the next start"
|
||||
stop; mkdir -p "$ONION"; start
|
||||
expect "the choice after a restart" "$(chosen)" True
|
||||
expect "onionHEN after restart" "$(ver "$ONION/romm-sync.elf")/$(on "$ONION")" 1.1.2/on
|
||||
|
||||
echo "8. while off, the next start adds nothing but still updates what's there"
|
||||
curl -sf -X POST "$API/api/autostart" -d '{"enable":false}' >/dev/null
|
||||
stop; elf 1.0.9 > "$ONION/romm-sync.elf"; start # a stray copy, not turned on
|
||||
expect "etaHEN while off" "$(ver "$ETA/romm-sync.elf")" none
|
||||
expect "stray onionHEN copy" "$(ver "$ONION/romm-sync.elf")/$(on "$ONION")" 1.1.2/off
|
||||
|
||||
echo "9. on the PS5 the payload doesn't update itself: RommPS brings the new one"
|
||||
stop; SELF_UPDATE=0 start
|
||||
r=$(curl -s -X POST "$API/api/update/install"); [[ "$r" == *"update RommPS from ProsperoStore"* ]] || fail "install with self-update off: $r"
|
||||
r=$(curl -sf "$API/api/update"); [[ "$r" == *'"self_update":false'* ]] || fail "update status: $r"
|
||||
|
||||
echo "10. a page on another website can't change anything; our own page and apps can"
|
||||
code() { curl -s -o /dev/null -w '%{http_code}' -X POST "$API/api/autostart" "$@"; }
|
||||
expect "post from another site" "$(code -H 'Origin: http://evil.example' -d '{"enable":true}')" 403
|
||||
expect "cross-site fetch" "$(code -H 'Sec-Fetch-Site: cross-site' -d '{"enable":true}')" 403
|
||||
expect "opaque origin" "$(code -H 'Origin: null' -d '{"enable":true}')" 403
|
||||
expect "etaHEN after refused posts" "$(ver "$ETA/romm-sync.elf")" none
|
||||
expect "our own page" "$(code -H "Origin: http://127.0.0.1:$WEB_PORT" -H 'Sec-Fetch-Site: same-origin' -d '{"enable":false}')" 200
|
||||
expect "an app (no Origin)" "$(code -d '{"enable":false}')" 200
|
||||
expect "reading from another site still works" "$(curl -s -o /dev/null -w '%{http_code}' -H 'Origin: http://evil.example' "$API/api/autostart")" 200
|
||||
|
||||
echo "11. with RommPS installed RomM Sync keeps quiet; without it, it says to install RommPS"
|
||||
stop; : > "$T/daemon.log"; SELF_UPDATE=0 APP_INSTALLED=1 start; sleep 1
|
||||
! grep -q NOTIFY "$T/daemon.log" || fail "a notification with RommPS installed: $(grep NOTIFY "$T/daemon.log")"
|
||||
stop; : > "$T/daemon.log"; SELF_UPDATE=0 APP_INSTALLED=1 APP_VERSION=1.1.0 start; sleep 1
|
||||
grep -q "NOTIFY.*RommPS 1.1.0 is out of date" "$T/daemon.log" || fail "no hint to update an older RommPS: $(grep NOTIFY "$T/daemon.log")"
|
||||
stop; : > "$T/daemon.log"; SELF_UPDATE=0 start; sleep 1
|
||||
grep -q "NOTIFY.*Install the RommPS app from ProsperoStore" "$T/daemon.log" || fail "no hint to install RommPS: $(grep NOTIFY "$T/daemon.log")"
|
||||
|
||||
echo PASS
|
||||
+405
-18
@@ -8,7 +8,7 @@ MOCK_PORT=8899 WEB_PORT=8781
|
||||
API="http://127.0.0.1:${WEB_PORT}"
|
||||
trap 'kill $(jobs -p) 2>/dev/null || true; rm -rf "$T"' EXIT
|
||||
|
||||
fail() { echo "FAIL: $*"; echo "--- daemon log"; tail -30 "$T/daemon.log"; exit 1; }
|
||||
fail() { echo "FAIL: $*"; echo "--- daemon log"; tail -80 "$T/daemon.log"; exit 1; }
|
||||
post() { curl -sf -X POST "$API$1" -d "${2:-{\}}"; }
|
||||
wait_sync() { # waits for the history length to reach $1
|
||||
for _ in $(seq 1 40); do
|
||||
@@ -62,6 +62,12 @@ ROMM_SYNC_HOMEBREW="$T/homebrew" ROMM_SYNC_DATA="$T/data" ROMM_SYNC_AUTOSTART="$
|
||||
ROMM_SYNC_UPDATE_URL="http://127.0.0.1:$MOCK_PORT/_github/release" "$ROOT/build/host/romm-sync" >"$T/daemon.log" 2>&1 &
|
||||
sleep 6 # let the (unpaired, skipped) startup sync pass
|
||||
|
||||
echo "web UI script parses"
|
||||
if command -v node >/dev/null; then
|
||||
python3 -c "import re,sys; print('\n'.join(re.findall(r'<script>(.*?)</script>', open(sys.argv[1]).read(), re.S)))" "$ROOT/ui/index.html" > "$T/ui.js"
|
||||
node --check "$T/ui.js" || fail "the web UI's script doesn't parse"
|
||||
fi
|
||||
|
||||
echo "0. self-signed HTTPS needs certificate checks turned off"
|
||||
r=$(curl -s -X POST "$API/api/pair/start" -d "{\"server_url\":\"https://127.0.0.1:$TLS_PORT\"}")
|
||||
[[ "$r" == *"doesn't trust"* ]] || fail "untrusted certificate not reported: $r"
|
||||
@@ -86,20 +92,41 @@ post /api/setup/finish '{"conflict_policy":"ask"}' >/dev/null
|
||||
wait_sync 1
|
||||
[[ "$(last reason)" == "first sync" && "$(last uploaded)" == 1 ]] || fail "expected the first sync to upload the local save"
|
||||
|
||||
echo "2. game download, then its server save"
|
||||
echo "2. game download brings its server save along"
|
||||
curl -sf -X POST "http://127.0.0.1:$MOCK_PORT/_admin/save" -d '{"rom_id":11,"slot":"autosave","file_name":"Super Metroid (USA).srm","content":"server-save"}' >/dev/null
|
||||
post /api/download '{"rom_id":11,"name":"Super Metroid"}' >/dev/null
|
||||
[[ "$(curl -sf "$API/api/downloads" | python3 -c "import json,sys; print([d['name'] for d in json.load(sys.stdin) if d.get('rom_id') == 11][-1])")" == "Super Metroid" ]] \
|
||||
|| fail "a download must show its name from the start"
|
||||
sleep 1
|
||||
wait_sync 2
|
||||
[[ -f "$RA/roms/snes/Super Metroid (USA).sfc" ]] || fail "ROM not downloaded"
|
||||
curl -sf -X POST "http://127.0.0.1:$MOCK_PORT/_admin/save" -d '{"rom_id":11,"slot":"autosave","file_name":"Super Metroid (USA).srm","content":"server-save"}' >/dev/null
|
||||
post /api/sync >/dev/null; wait_sync 2
|
||||
[[ "$(last reason)" == "game downloaded" ]] || fail "a finished download must sync the game's saves"
|
||||
[[ "$(cat "$RA/saves/Snes9x/Super Metroid (USA).srm")" == "server-save" ]] || fail "server save not downloaded"
|
||||
for _ in $(seq 1 20); do
|
||||
dl=$(curl -sf "$API/api/downloads" | python3 -c "import json,sys; d=[d for d in json.load(sys.stdin) if d.get('rom_id') == 11][-1]; print(d['status'], d.get('saves'))")
|
||||
[[ "$dl" == "done 1" ]] && break; sleep 0.25
|
||||
done
|
||||
[[ "$dl" == "done 1" ]] || fail "the download must end done, with the save it synced: $dl"
|
||||
|
||||
echo "2b. folder roms: one iso per folder, and a cue+bin set"
|
||||
post /api/download '{"rom_id":22,"name":"Downhill Domination"}' >/dev/null
|
||||
post /api/download '{"rom_id":25,"name":"War of the Monsters"}' >/dev/null
|
||||
dl_wait() { # wait for a downloaded path to exist
|
||||
for _ in $(seq 1 30); do [[ -e "$1" ]] && return 0; sleep 0.5; done; fail "not downloaded: $1"
|
||||
}
|
||||
D="$RA/roms/ps2/Downhill Domination (USA)"
|
||||
W="$RA/roms/ps2/War of the Monsters (USA)"
|
||||
dl_wait "$D/Downhill Domination (USA).iso"; dl_wait "$W/War of the Monsters (USA).cue"
|
||||
[[ -d "$D" && -d "$W" ]] || fail "a folder rom must land in a folder named after its fs_name"
|
||||
[[ "$(cat "$D/Downhill Domination (USA).iso")" == "ROM:Downhill Domination (USA).iso" ]] || fail "iso content wrong"
|
||||
[[ -f "$W/War of the Monsters (USA).bin" ]] || fail "cue's bin not downloaded"
|
||||
[[ "$(cat "$W/War of the Monsters (USA).bin")" == "ROM:War of the Monsters (USA).bin" ]] || fail "bin content wrong"
|
||||
[[ ! -f "$RA/roms/ps2/Downhill Domination (USA)" ]] || fail "folder rom written as a single extensionless file"
|
||||
|
||||
ci=$(curl -sf "$API/api/roms?platform_id=1" | python3 -c "import json,sys; print(sorted(json.load(sys.stdin)['char_index'].items()))")
|
||||
[[ "$ci" == "[('c', 0), ('e', 1), ('m', 2), ('s', 3)]" ]] || fail "library char_index for the A-Z ribbon: $ci"
|
||||
inst=$(curl -sf "$API/api/installed" | python3 -c "import json,sys; print([(g['id'], g['name'], g['platform_id'] > 0) for g in json.load(sys.stdin)])")
|
||||
[[ "$inst" == "[(11, 'Super Metroid', True), (10, 'Chrono Trigger', True)]" ]] || fail "the games on this console, for the app: $inst"
|
||||
inst=$(curl -sf "$API/api/installed" | python3 -c "import json,sys; print(sorted([(g['id'], g['name'], g['platform_id'] > 0) for g in json.load(sys.stdin)]))")
|
||||
[[ "$inst" == "[(10, 'Chrono Trigger', True), (11, 'Super Metroid', True), (22, 'Downhill Domination', True), (25, 'War of the Monsters', True)]" ]] \
|
||||
|| fail "the games on this console, for the app: $inst"
|
||||
all=$(curl -sf "$API/api/roms?platform_id=0&search=metroid" | python3 -c "import json,sys; print([(g['name'], g['platform_id']) for g in json.load(sys.stdin)['items']])")
|
||||
[[ "$all" == "[('Super Metroid', 1)]" ]] || fail "a search across every platform: $all"
|
||||
|
||||
@@ -181,9 +208,43 @@ n=$(dump | python3 -c "import json,sys; print(sum(1 for x in json.load(sys.stdin
|
||||
|
||||
echo "10. states download, save history and restore"
|
||||
post /api/config '{"states":"sync"}' >/dev/null
|
||||
curl -sf -X POST "http://127.0.0.1:$MOCK_PORT/_admin/state" -d '{"rom_id":11,"file_name":"Super Metroid (USA).state1","content":"srv-state"}' >/dev/null
|
||||
curl -sf -X POST "http://127.0.0.1:$MOCK_PORT/_admin/state" -d '{"rom_id":11,"emulator":"snes9x","file_name":"Super Metroid (USA).state1","content":"srv-state"}' >/dev/null
|
||||
# Made by another core (or with no emulator at all): no emulator here can load it.
|
||||
curl -sf -X POST "http://127.0.0.1:$MOCK_PORT/_admin/state" -d '{"rom_id":11,"emulator":"bsnes","file_name":"Super Metroid (USA) [bsnes].state5","content":"other-core"}' >/dev/null
|
||||
curl -sf -X POST "http://127.0.0.1:$MOCK_PORT/_admin/state" -d '{"rom_id":11,"file_name":"Super Metroid (USA) [unknown].state6","content":"no-emulator"}' >/dev/null
|
||||
syncnow
|
||||
! grep -rqs "other-core\|no-emulator" "$RA/states" || fail "a state from another core came down"
|
||||
grep -q "skipped: made by bsnes" "$T/daemon.log" || fail "a skipped state must say why"
|
||||
[[ "$(cat "$RA/states/Super Metroid (USA).state1" 2>/dev/null)" == "srv-state" ]] || fail "server state not downloaded"
|
||||
# Two web player states for one game: each gets its own file, and they don't
|
||||
# come down again on the next sync.
|
||||
for ts in "2026-10-14 01-00-00-000" "2026-10-14 01-00-05-000"; do
|
||||
curl -sf -X POST "http://127.0.0.1:$MOCK_PORT/_admin/state" -d "{\"rom_id\":11,\"emulator\":\"snes9x\",\"file_name\":\"Super Metroid (USA) [$ts].state\",\"content\":\"web-$ts\"}" >/dev/null
|
||||
done
|
||||
syncnow
|
||||
[[ "$(cat "$RA/states/Super Metroid (USA).state")" == "web-2026-10-14 01-00-00-000" ]] || fail "first web state should take slot 0"
|
||||
[[ "$(cat "$RA/states/Super Metroid (USA).state2")" == "web-2026-10-14 01-00-05-000" ]] || fail "second web state should take the next free slot"
|
||||
[[ "$(cat "$RA/states/Super Metroid (USA).state1")" == "srv-state" ]] || fail "a downloaded state replaced another one"
|
||||
syncnow
|
||||
[[ "$(last states_downloaded)" == 0 ]] || fail "states came down again: $(last states_downloaded)"
|
||||
# Saving over a state that came from elsewhere sends a new one; RomM's copy
|
||||
# of the web state stays as it was.
|
||||
echo "played-on-console" > "$RA/states/Super Metroid (USA).state"
|
||||
syncnow
|
||||
web=$(dump | python3 -c "import json,sys; print([s['file_name'] for s in json.load(sys.stdin)['states'] if s['rom_id']==11 and s['file_name'].endswith('.state')])")
|
||||
[[ "$web" == *"Super Metroid (USA) [2026-10-14 01-00-00-000].state"* && "$web" == *"Super Metroid (USA) [PlayStation 5].state"* ]] \
|
||||
|| fail "a state saved over a web state must go up as a new one: $web"
|
||||
orig=$(curl -sf "$API/api/history?rom_id=11" >/dev/null; dump | python3 -c "import json,sys; print([s['file_size_bytes'] for s in json.load(sys.stdin)['states'] if s['file_name']=='Super Metroid (USA) [2026-10-14 01-00-00-000].state'][0])")
|
||||
[[ "$orig" == 27 ]] || fail "the web state on RomM was changed (size $orig)"
|
||||
syncnow
|
||||
[[ "$(last states_downloaded)$(last states_uploaded)" == 00 ]] || fail "an uploaded state came back down or went up again"
|
||||
# The player's own slot is never taken by a state from elsewhere.
|
||||
echo "my-own-slot" > "$RA/states/Chrono Trigger (USA).state"
|
||||
syncnow
|
||||
curl -sf -X POST "http://127.0.0.1:$MOCK_PORT/_admin/state" -d '{"rom_id":10,"emulator":"snes9x","file_name":"Chrono Trigger (USA) [2026-10-14 02-00-00-000].state","content":"web-ct"}' >/dev/null
|
||||
syncnow
|
||||
[[ "$(cat "$RA/states/Chrono Trigger (USA).state")" == "my-own-slot" ]] || fail "a web state replaced the player's own slot"
|
||||
[[ "$(cat "$RA/states/Chrono Trigger (USA).state1")" == "web-ct" ]] || fail "the web state should land in the next free slot"
|
||||
old=$(curl -sf "$API/api/history?rom_id=10" | python3 -c "import json,sys; s=sorted(json.load(sys.stdin)['saves'], key=lambda x: x['updated_at']); print(s[0]['id'])")
|
||||
want="$(curl -sf "http://127.0.0.1:$MOCK_PORT/_admin/save_content?id=$old")"
|
||||
post /api/restore "{\"kind\":\"save\",\"id\":$old}" >/dev/null || fail "restore failed"
|
||||
@@ -239,6 +300,64 @@ syncnow
|
||||
[[ -f "$SD/ULUS10336GAMEDATA/BIG.BIN" ]] || fail "game data folder must be left alone"
|
||||
ls "$T/data/backups/30/" | grep -q "ULUS10336.zip" || fail "no backup of the PSP save"
|
||||
|
||||
echo "12b. GameCube saves stay in the form RomM has them: one .gci, or a whole memory card"
|
||||
GC="python3 $ROOT/tests/gccard.py"
|
||||
admin_save() { # rom_id file_name path
|
||||
python3 -c "import base64,json,sys; print(json.dumps({'rom_id':int(sys.argv[1]),'slot':'autosave','file_name':sys.argv[2],'content_b64':base64.b64encode(open(sys.argv[3],'rb').read()).decode()}))" "$1" "$2" "$3" > "$T/admin.json"
|
||||
curl -sf -X POST "http://127.0.0.1:$MOCK_PORT/_admin/save" -d @"$T/admin.json" >/dev/null
|
||||
}
|
||||
newest() { # rom_id: writes RomM's newest save for it to $T/newest, prints its name
|
||||
dump | python3 -c "
|
||||
import json,sys,re
|
||||
s=sorted([x for x in json.load(sys.stdin)['saves'] if x['rom_id']==$1], key=lambda x: (x['updated_at'], x['id']))
|
||||
open('$T/newest','wb').write(s[-1]['content'].encode('latin1')); print(re.sub(r' \[[^]]*\]', '', s[-1]['file_name']))"; }
|
||||
blocks_md5() { python3 -c "import hashlib,sys; print(hashlib.md5(open(sys.argv[1],'rb').read()[0x40:]).hexdigest())" "$1"; }
|
||||
for g in "41 Sunshine GMSE01" "42 Melee GALE01"; do
|
||||
set -- $g
|
||||
curl -sf -X POST "http://127.0.0.1:$MOCK_PORT/_admin/rom" -d "{\"id\":$1,\"platform_id\":4,\"name\":\"$2\",\"fs_name\":\"$2 (USA).iso\",\"files\":[]}" >/dev/null
|
||||
python3 "$ROOT/tests/make_disc.py" gc "$RA/roms/ngc/$2 (USA).iso" "$3"
|
||||
done
|
||||
# One .gci on RomM (another app's): it comes down into the GCI folder, and goes back up as one .gci.
|
||||
$GC gci "$T/sms.gci" GMSE01 super_mario_sunshine 2 0x11
|
||||
admin_save 41 "01-GMSE-super_mario_sunshine.gci" "$T/sms.gci"
|
||||
syncnow
|
||||
cmp -s "$T/sms.gci" "$CARD/01-GMSE-super_mario_sunshine.gci" || fail "RomM's .gci must land in the GCI folder"
|
||||
syncnow
|
||||
[[ "$(last uploaded)" == 0 ]] || fail "an unchanged .gci must not go up again"
|
||||
sleep 1.1; $GC gci "$CARD/01-GMSE-super_mario_sunshine.gci" GMSE01 super_mario_sunshine 3 0x22
|
||||
syncnow
|
||||
[[ "$(newest 41)" == "01-GMSE-super_mario_sunshine.gci" ]] || fail "a .gci on RomM must go back up as a .gci: $(newest 41)"
|
||||
cmp -s "$T/newest" "$CARD/01-GMSE-super_mario_sunshine.gci" || fail "the .gci RomM got isn't this console's"
|
||||
# A whole card on RomM with Melee's save and another game's: Melee's comes
|
||||
# down; this console's change goes back onto RomM's card, the other save untouched.
|
||||
$GC blank "$T/cardA.raw" 64
|
||||
$GC gci "$T/melee.gci" GALE01 SuperSmashBros0110290334 3 0x33; $GC put "$T/cardA.raw" "$T/melee.gci"
|
||||
$GC gci "$T/other.gci" GXXE01 other_game 2 0x44; $GC put "$T/cardA.raw" "$T/other.gci"
|
||||
admin_save 42 "MemoryCardA.USA.raw" "$T/cardA.raw"
|
||||
syncnow
|
||||
cmp -s "$T/melee.gci" "$CARD/01-GALE-SuperSmashBros0110290334.gci" 2>/dev/null ||
|
||||
[[ "$(blocks_md5 "$T/melee.gci")" == "$(blocks_md5 "$CARD/01-GALE-SuperSmashBros0110290334.gci" 2>/dev/null)" ]] || fail "Melee's save must come off RomM's card"
|
||||
[[ ! -e "$CARD/01-GXXE-other_game.gci" ]] || fail "only the game's own saves come off the card"
|
||||
syncnow
|
||||
[[ "$(last uploaded)" == 0 ]] || fail "an unchanged save must leave RomM's card alone"
|
||||
other_md5=$($GC saves "$T/cardA.raw" GXXE01)
|
||||
sleep 1.1; $GC gci "$CARD/01-GALE-SuperSmashBros0110290334.gci" GALE01 SuperSmashBros0110290334 4 0x55
|
||||
syncnow
|
||||
[[ "$(newest 42)" == "MemoryCardA.USA.raw" ]] || fail "a card on RomM must go back up as the card: $(newest 42)"
|
||||
dump | python3 -c "import json,sys; n=[x['file_name'] for x in json.load(sys.stdin)['saves'] if x['rom_id'] in (41,42)]; sys.exit(any(f.count('[') > 1 for f in n))" || fail "a name sent back must not pile up RomM's stamps"
|
||||
$GC check "$T/newest" || fail "the card sent to RomM must be one Dolphin reads"
|
||||
[[ "$($GC saves "$T/newest" GALE01 | cut -d' ' -f2)" == "$(blocks_md5 "$CARD/01-GALE-SuperSmashBros0110290334.gci")" ]] || fail "this console's Melee save must be on the card"
|
||||
[[ "$($GC saves "$T/newest" GXXE01)" == "$other_md5" ]] || fail "the card's other saves must stay as they were"
|
||||
# Another device changes the card: it comes down, and this console's file follows.
|
||||
cp "$T/newest" "$T/cardB.raw"; $GC gci "$T/melee2.gci" GALE01 SuperSmashBros0110290334 2 0x66; $GC put "$T/cardB.raw" "$T/melee2.gci"
|
||||
sleep 1.1; admin_save 42 "MemoryCardA.USA.raw" "$T/cardB.raw"
|
||||
syncnow
|
||||
[[ "$(blocks_md5 "$CARD/01-GALE-SuperSmashBros0110290334.gci")" == "$(blocks_md5 "$T/melee2.gci")" ]] || fail "a newer card on RomM must update the save here"
|
||||
ls "$T/data/backups/42/" 2>/dev/null | grep -q . || fail "no backup before the card's save replaced this console's"
|
||||
grep -q "Sunshine (USA).iso: RomM keeps its saves as gci (01-GMSE-super_mario_sunshine.gci)" "$T/daemon.log" &&
|
||||
grep -q "Melee (USA).iso: RomM keeps its saves as card (MemoryCardA.USA.raw)" "$T/daemon.log" ||
|
||||
fail "each game's form must be the one RomM has"
|
||||
|
||||
echo "13. in-app update: only a correctly signed release installs"
|
||||
SIGN="$ROOT/build/host/release-sign"
|
||||
upd() { curl -sf "$API/api/update" | python3 -c "import json,sys; u=json.load(sys.stdin); print(u['$1'])"; }
|
||||
@@ -277,13 +396,26 @@ publish ok; r="$(install_err)"; [[ "$r" == restarting ]] || fail "good upd
|
||||
for _ in $(seq 1 10); do [[ -f "$T/data/launched.elf" ]] && break; sleep 0.5; done
|
||||
cmp -s "$REL/romm-sync.elf" "$T/data/launched.elf" || fail "the new payload was not launched"
|
||||
cmp -s "$REL/romm-sync.elf" "$T/etaHEN/payloads/romm-sync.elf" || fail "the installed payload was not replaced"
|
||||
[[ "$(cat "$T/etaHEN/payloads/romm-sync.elf.bak")" == old-payload ]] || fail "no backup of the old payload"
|
||||
[[ ! -e "$T/etaHEN/payloads/romm-sync.elf.bak" ]] || fail "the update left a .bak of the old payload behind"
|
||||
|
||||
echo "14. diagnostics report leaves out the token"
|
||||
curl -sf "$API/api/diagnostics" > "$T/diag.txt" || fail "no diagnostics report"
|
||||
grep -q "RomM Sync diagnostics" "$T/diag.txt" || fail "diagnostics report is empty"
|
||||
tok="$(python3 -c "import json,sys; print(json.load(open(sys.argv[1]))['token'])" "$T/data/config.json")"
|
||||
[[ -n "$tok" ]] && ! grep -qF "$tok" "$T/diag.txt" || fail "the diagnostics report contains the token"
|
||||
echo "14. diagnostic bundle: the logs, nothing that names the person"
|
||||
echo "connecting to 10.0.16.84 from aa:bb:cc:dd:ee:ff, mail me@example.com, /user/home/1a2b3c4d/x, onlineId=SomePlayer99" >> "$T/data/romm-sync.log"
|
||||
curl -sf "$API/api/diagnostics" > "$T/diag.zip" || fail "no diagnostic bundle"
|
||||
python3 - "$T/diag.zip" "$T/data/config.json" <<'PY' || fail "diagnostic bundle"
|
||||
import json, sys, zipfile
|
||||
z = zipfile.ZipFile(sys.argv[1]); cfg = json.load(open(sys.argv[2]))
|
||||
names = z.namelist()
|
||||
for want in ("report.txt", "romm-sync.log", "sync-log.json", "emulators-found.json", "emulator-settings.json", "kernel.log"):
|
||||
assert want in names, (want, names)
|
||||
assert "RommPS diagnostics" in z.read("report.txt").decode()
|
||||
text = "\n".join(z.read(n).decode("utf-8", "replace") for n in names)
|
||||
host = cfg["server_url"].split("://")[1].split("/")[0].split(":")[0]
|
||||
for secret in (cfg["token"], cfg["server_url"], host, cfg["device_id"], "10.0.16.84", "aa:bb:cc:dd:ee:ff", "me@example.com", "1a2b3c4d", "SomePlayer99"):
|
||||
assert secret and secret not in text, secret
|
||||
assert "<ip>" in text and "<mac>" in text and "<email>" in text and "<server>" in text
|
||||
PY
|
||||
out=$(curl -sf -X POST "$API/api/diagnostics") || fail "saving a bundle refused"
|
||||
[[ -f "$(echo "$out" | python3 -c "import json,sys; print(json.load(sys.stdin)['path'])")" ]] || fail "the saved bundle isn't there: $out"
|
||||
|
||||
echo "15. one emulator per system when several play it"
|
||||
kinds=$(curl -sf "$API/api/setup/detect" | python3 -c "import json,sys; print([(c['kind'], c['title_id']) for c in json.load(sys.stdin)])")
|
||||
@@ -312,6 +444,10 @@ cat > "$T/catalog.json" <<EOF
|
||||
"profile": {"root": "$T/psxemu", "rom_dir": "{root}/games", "save_dir": "{root}/saves", "state_dir": "{root}/states",
|
||||
"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", "2": "mednafen_psx_hw"}},
|
||||
"platforms": [{"romm": ["psx", "ps1"], "dir": "psx", "save_format": "psx-card"}]}},
|
||||
{"id": "n64like", "name": "N64-like", "detect": ["$T/n64emu"],
|
||||
"profile": {"root": "$T/n64emu", "rom_dir": "{root}/games", "save_dir": "{root}/saves", "state_dir": "{root}/saves",
|
||||
@@ -328,7 +464,8 @@ cat > "$T/catalog.json" <<EOF
|
||||
EOF
|
||||
python3 "$ROOT/tests/make_disc.py" psxbin "$T/psxemu/games/Test Quest (USA).bin" SLUS_123.45
|
||||
printf 'card-from-psx' > "$T/psxemu/saves/SLUS-12345_1.mcd"
|
||||
printf 'state-from-psx' > "$T/psxemu/states/Test_Quest__USA_.state0"
|
||||
printf 'STv4 PCSXra test-build' > "$T/psxemu/states/Test_Quest__USA_.state0" # a bare PCSX-ReARMed state
|
||||
printf 'emulator=0\n' > "$T/psxemu/psxs5.ini"
|
||||
printf 'ROM:Test Racer (U).z64' > "$T/n64emu/games/Test Racer (U).z64"
|
||||
cp "$T/n64emu/games/Test Racer (U).z64" "$T/n64b/roms/Test Racer (U).z64"
|
||||
fnv=$(python3 -c "
|
||||
@@ -346,11 +483,11 @@ post /api/setup/emulators "{\"roots\":[\"$HB\",\"$T/psxemu\",\"$T/n64emu\",\"$T/
|
||||
for _ in $(seq 1 20); do [[ -f "$T/n64b/roms/Old Racer (U).z64" ]] && break; sleep 0.25; done
|
||||
[[ -f "$T/n64b/roms/Old Racer (U).z64" ]] || fail "turning an extra on must give it the games already there"
|
||||
for _ in $(seq 1 20); do grep -q "NOTIFY.*now also sync with" "$T/daemon.log" && break; sleep 0.25; done
|
||||
grep -q "NOTIFY\] RomM Sync: [0-9]* N64 games\{0,1\} now also sync with" "$T/daemon.log" || fail "no notice that the games now also sync with the extra"
|
||||
grep -q "NOTIFY\] RommPS: [0-9]* N64 games\{0,1\} now also sync with" "$T/daemon.log" || fail "no notice that the games now also sync with the extra"
|
||||
dirs=$(curl -sf "$API/api/paths" | python3 -c "import json,sys; print(sorted(m['rom_dir'] for m in json.load(sys.stdin)))")
|
||||
[[ "$dirs" != *"$T/nope"* ]] || fail "an emulator not supported yet got a profile: $dirs"
|
||||
post /api/config '{"states":"upload"}' >/dev/null
|
||||
post /api/sync >/dev/null; wait_sync 16
|
||||
syncnow
|
||||
dump() { curl -sf "http://127.0.0.1:$MOCK_PORT/_admin/dump"; }
|
||||
saves=$(dump | python3 -c "import json,sys; d=json.load(sys.stdin); print(sorted((s['rom_id'], s['file_name'], s['slot']) for s in (d['saves'].values() if isinstance(d['saves'], dict) else d['saves']) if s['rom_id'] in (50, 60)))")
|
||||
# (RomM puts the upload's time in the name: "SLUS-12345_1 [2026-...].mcd".)
|
||||
@@ -360,11 +497,50 @@ saves=$(dump | python3 -c "import json,sys; d=json.load(sys.stdin); print(sorted
|
||||
[[ "$(cat "$T/n64b/saves/Test Racer (U).srm" 2>/dev/null)" == "n64-save" ]] || fail "the shared save didn't reach the second emulator"
|
||||
states=$(dump | python3 -c "import json,sys; d=json.load(sys.stdin); print(sorted(s['file_name'] for s in (d['states'].values() if isinstance(d['states'], dict) else d['states']) if s['rom_id'] == 50))")
|
||||
[[ "$states" == "['Test_Quest__USA_"*".state0']" ]] || fail "the state named by the safe name didn't sync: $states"
|
||||
# A bare state goes up in RetroArch's container, as the core that made it, so
|
||||
# RomM's web player can load it.
|
||||
up=$(dump | python3 -c "import json,sys; print([(s['emulator'], s['file_size_bytes']) for s in json.load(sys.stdin)['states'] if s['rom_id'] == 50])")
|
||||
[[ "$up" == "[('pcsx_rearmed', 48)]" ]] || fail "a PSXS5 state must go up wrapped, as pcsx_rearmed: $up"
|
||||
c2=$(dump | python3 -c "import json,sys; print([c['versions'] for c in json.load(sys.stdin)['cards'] if c['emulator'] == 'ps2like' and c['name'].endswith('PS2-like slot 1')])")
|
||||
[[ "$c2" == "[1]" ]] || fail "the shared PS2 card wasn't backed up: $c2"
|
||||
# Web player states come down bare, into a slot of their own, and only for the
|
||||
# core the emulator runs now.
|
||||
post /api/config '{"states":"sync"}' >/dev/null
|
||||
rast() { python3 -c "import json,sys,struct; m=sys.argv[1].encode(); print(json.dumps((b'RASTATE\x01MEM '+struct.pack('<I',len(m))+m+b'\0'*(-len(m)%8)+b'END \0\0\0\0').decode('latin1')))" "$1"; }
|
||||
curl -sf -X POST "http://127.0.0.1:$MOCK_PORT/_admin/state" -d "{\"rom_id\":50,\"emulator\":\"pcsx_rearmed\",\"file_name\":\"Test Quest (USA) [web].state\",\"content\":$(rast 'STv4 PCSXra web-build')}" >/dev/null
|
||||
curl -sf -X POST "http://127.0.0.1:$MOCK_PORT/_admin/state" -d "{\"rom_id\":50,\"emulator\":\"mednafen_psx_hw\",\"file_name\":\"Test Quest (USA) [beetle].state\",\"content\":$(rast 'MDFNSVST beetle')}" >/dev/null
|
||||
syncnow
|
||||
[[ "$(cat "$T/psxemu/states/Test_Quest__USA_.state1")" == "STv4 PCSXra web-build" ]] || fail "a web state must come down bare, in the next free slot"
|
||||
[[ ! -e "$T/psxemu/states/Test_Quest__USA_.state2" ]] || fail "a Beetle state came down while the emulator runs PCSX-ReARMed"
|
||||
printf 'emulator=2\n' > "$T/psxemu/psxs5.ini"
|
||||
syncnow
|
||||
[[ "$(cat "$T/psxemu/states/Test_Quest__USA_.state2")" == "MDFNSVST beetle" ]] || fail "switched to Beetle, its state must come down"
|
||||
# With Beetle the card is named after the game file: still the game's card.
|
||||
sleep 1; printf 'card-beetle' > "$T/psxemu/saves/Test Quest (USA)_1.mcd"
|
||||
syncnow
|
||||
card=$(dump | python3 -c "import json,sys; s=sorted([x for x in json.load(sys.stdin)['saves'] if x['rom_id']==50], key=lambda x: x['updated_at']); print(s[-1]['content'])")
|
||||
[[ "$card" == "card-beetle" ]] || fail "the card Beetle names after the game file must sync: $card"
|
||||
# A blank card an emulator just made under the other name never beats one with a save on it.
|
||||
mkcard() { python3 -c "import sys; d=bytearray(131072); d[0:2]=b'MC'; d[128:128*16]=bytes([0xA0])*(128*15)
|
||||
if sys.argv[2]=='save': d[128]=0x51; d[0x2000:0x2004]=b'GAME'
|
||||
open(sys.argv[1],'wb').write(bytes(d))" "$1" "$2"; }
|
||||
mkcard "$T/psxemu/saves/SLUS-12345_1.mcd" save; sleep 1; mkcard "$T/psxemu/saves/Test Quest (USA)_1.mcd" blank
|
||||
syncnow
|
||||
kind=$(dump | python3 -c "import json,sys; s=sorted([x for x in json.load(sys.stdin)['saves'] if x['rom_id']==50], key=lambda x: x['updated_at']); print('save' if 'GAME' in s[-1]['content'] else 'blank')")
|
||||
[[ "$kind" == "save" ]] || fail "a blank card was uploaded over a card with a save"
|
||||
# Nor does a blank card from RomM replace one with saves: that's a conflict to settle.
|
||||
python3 -c "import base64,json,sys; d=bytearray(131072); d[0:2]=b'MC'; d[128:128*16]=bytes([0xA0])*(128*15)
|
||||
json.dump({'rom_id': 50, 'slot': 'autosave', 'file_name': 'SLUS-12345_1.mcd', 'content_b64': base64.b64encode(bytes(d)).decode()}, open(sys.argv[1], 'w'))" "$T/blank.json"
|
||||
sleep 1; curl -sf -X POST "http://127.0.0.1:$MOCK_PORT/_admin/save" -d @"$T/blank.json" >/dev/null
|
||||
syncnow
|
||||
python3 -c "import sys; d=open(sys.argv[1],'rb').read(); sys.exit(0 if d[128]==0x51 else 1)" "$T/psxemu/saves/SLUS-12345_1.mcd" \
|
||||
|| fail "a blank card from RomM replaced a card with saves"
|
||||
[[ "$(last conflicts)" -ge 1 ]] || fail "a blank card from RomM over one with saves must be a conflict"
|
||||
|
||||
post /api/config '{"states":"upload"}' >/dev/null
|
||||
# A save from another device reaches both N64 emulators.
|
||||
curl -sf -X POST "http://127.0.0.1:$MOCK_PORT/_admin/save" -d '{"rom_id":60,"slot":"autosave","file_name":"Test Racer (U).srm","content":"n64-from-elsewhere","emulator":"other"}' >/dev/null
|
||||
post /api/sync >/dev/null; wait_sync 17
|
||||
syncnow
|
||||
[[ "$(cat "$T/n64emu/saves/$fnv.sav")" == "n64-from-elsewhere" && "$(cat "$T/n64b/saves/Test Racer (U).srm")" == "n64-from-elsewhere" ]] \
|
||||
|| fail "a downloaded save must reach every emulator that shares it"
|
||||
# A download goes to the main emulator and is linked into the extra.
|
||||
@@ -373,6 +549,57 @@ post /api/download '{"rom_id":61}' >/dev/null; sleep 2
|
||||
python3 -c 'import os,sys; sys.exit(not os.path.samefile(sys.argv[1], sys.argv[2]))' \
|
||||
"$T/n64emu/games/Other Racer (U).z64" "$T/n64b/roms/Other Racer (U).z64" || fail "the extra copy should be a hard link"
|
||||
|
||||
echo "16b. two Dolphins for GameCube keep one save: the main one's .gci files reach the extra's folder"
|
||||
mkdir -p "$T/gcA/games" "$T/gcB/ngc"
|
||||
python3 - "$T/catalog.json" "$T" <<'PY'
|
||||
import json, sys
|
||||
cat = json.load(open(sys.argv[1]))
|
||||
# Dolphin A keeps GameCube and Wii games in one folder, like Porpoise;
|
||||
# Dolphin B a folder for each.
|
||||
# Their folders for GameCube are named differently ("gamecube", "ngc"), as
|
||||
# Porpoise's and RetroArch's are.
|
||||
for gid, name, roms, gc_dir in (("gcA", "Dolphin A", "{root}/games", "gamecube"), ("gcB", "Dolphin B", "{root}/{platform}", "ngc")):
|
||||
cat.append({"id": gid, "name": name, "detect": [f"{sys.argv[2]}/{gid}"],
|
||||
"profile": {"root": f"{sys.argv[2]}/{gid}", "rom_dir": roms, "save_dir": "{root}/User",
|
||||
"platforms": [{"romm": ["ngc"], "dir": gc_dir, "emulator": "dolphin",
|
||||
"save_layout": "gci", "bundle_dirs": "{root}/User/GC",
|
||||
**({"state_dir": "{root}/states/{game_id}", "state_exts": ".state",
|
||||
"state_format": "raw"} if gid == "gcA" else {})},
|
||||
{"romm": ["wii"], "dir": "wii", "emulator": "dolphin",
|
||||
"save_layout": "wii", "bundle_dirs": "{root}/User/Wii"}]}})
|
||||
json.dump(cat, open(sys.argv[1], "w"))
|
||||
PY
|
||||
python3 "$ROOT/tests/make_disc.py" gc "$T/gcA/games/Sunshine (USA).iso" GMSE01
|
||||
python3 "$ROOT/tests/make_disc.py" gc "$T/gcB/ngc/Sunshine (USA).iso" GMSE01
|
||||
post /api/setup/emulators "{\"roots\":[\"$HB\",\"$T/psxemu\",\"$T/n64emu\",\"$T/n64b\",\"$T/ps2emu\",\"$T/nope\",\"$T/gcA\",\"$T/gcB\"],
|
||||
\"choices\":{\"psx\":\"$T/psxemu\",\"n64\":\"$T/n64emu\",\"ps2\":\"$T/ps2emu\",\"ngc\":\"$T/gcA\",\"wii\":\"$T/gcA\"},
|
||||
\"also\":{\"n64\":[\"$T/n64b\"],\"ngc\":[\"$T/gcB\"],\"wii\":[\"$T/gcB\"]}}" >/dev/null \
|
||||
|| fail "setup with two Dolphins refused"
|
||||
A="$T/gcA/User/GC/USA/Card A"; Bc="$T/gcB/User/GC/USA/Card A"
|
||||
syncnow
|
||||
sleep 1; syncnow
|
||||
[[ ! -e "$T/gcB/wii/Sunshine (USA).iso" ]] || fail "a GameCube game in a folder shared with Wii must not go to the extra's Wii folder"
|
||||
cmp -s "$A/01-GMSE-super_mario_sunshine.gci" "$Bc/01-GMSE-super_mario_sunshine.gci" || fail "RomM's save must reach both Dolphins"
|
||||
sleep 1.1; $GC gci "$Bc/01-GMSE-super_mario_sunshine.gci" GMSE01 super_mario_sunshine 2 0x77
|
||||
syncnow
|
||||
cmp -s "$Bc/01-GMSE-super_mario_sunshine.gci" "$A/01-GMSE-super_mario_sunshine.gci" || fail "a save made in the extra must reach the main Dolphin"
|
||||
[[ "$(last uploaded)" == 1 ]] || fail "two Dolphins sharing a save must upload it once: $(last uploaded)"
|
||||
[[ "$(newest 41)" == "01-GMSE-super_mario_sunshine.gci" ]] && cmp -s "$T/newest" "$A/01-GMSE-super_mario_sunshine.gci" || fail "RomM must get the newer save, as one .gci"
|
||||
ls "$T/data/backups/41/" 2>/dev/null | grep -q "GMSE01-shared.zip" || fail "the save replaced in the main Dolphin must be backed up"
|
||||
# Porpoise's states: a folder per game id holding slot1.state, slot2.state...
|
||||
post /api/config '{"states":"sync"}' >/dev/null
|
||||
mkdir -p "$T/gcA/states/GMSE01"; printf 'dolphin-bare-state' > "$T/gcA/states/GMSE01/slot1.state"
|
||||
syncnow
|
||||
up=$(dump | python3 -c "import json,sys; print([(s['file_name'].split(' [')[0], s['emulator'], s['file_size_bytes']) for s in json.load(sys.stdin)['states'] if s['rom_id']==41])")
|
||||
[[ "$up" == "[('slot1', 'dolphin', 48)]" ]] || fail "a state in a folder per game must go up, wrapped: $up"
|
||||
curl -sf -X POST "http://127.0.0.1:$MOCK_PORT/_admin/state" -d "{\"rom_id\":41,\"emulator\":\"dolphin\",\"file_name\":\"Sunshine (USA).state3\",\"content\":$(rast 'dolphin-from-retroarch')}" >/dev/null
|
||||
syncnow
|
||||
[[ "$(cat "$T/gcA/states/GMSE01/slot2.state" 2>/dev/null)" == "dolphin-from-retroarch" ]] || fail "a state from RomM must land unwrapped in the game's next free slot"
|
||||
[[ "$(cat "$T/gcA/states/GMSE01/slot1.state")" == "dolphin-bare-state" ]] || fail "the state already there must be left alone"
|
||||
rm "$T/gcA/states/GMSE01/slot1.state"
|
||||
syncnow
|
||||
[[ "$(cat "$T/gcA/states/GMSE01/slot1.state" 2>/dev/null)" == "dolphin-bare-state" ]] || fail "a deleted state must come back bare, in its own emulator's form"
|
||||
|
||||
echo "17. paired with another server, the old server's game and save ids are forgotten"
|
||||
hist() { curl -sf "$API/api/status" | python3 -c "import json,sys; print(len(json.load(sys.stdin)['sync']['history']))"; }
|
||||
n=$(hist)
|
||||
@@ -412,6 +639,24 @@ import json,sys
|
||||
for p in json.load(sys.stdin)['profiles']:
|
||||
if p['id'].startswith('custom-'): print(p['id'], p.get('extra'), p['rom_dir'] == '$T/porp/games')")
|
||||
[[ "$porp" == "custom-newemu-snes ['snes'] True" ]] || fail "an emulator with no preset should be an extra next to RetroArch: $porp"
|
||||
# Each system's folders: detection says where an emulator keeps them, and folders
|
||||
# picked for one system in setup are used for it alone.
|
||||
found=$(curl -sf "$API/api/setup/detect" | python3 -c "
|
||||
import json,sys
|
||||
for c in json.load(sys.stdin):
|
||||
if c['root']=='$HB': print(c['dirs']['snes']['rom_dir'] != '', 'fixed' in c)")
|
||||
[[ "$found" == "True True" ]] || fail "detection must give each system's folders: $found"
|
||||
mkdir -p "$T/mine/snes" "$T/mine/snessaves"
|
||||
post /api/setup/emulators "{\"roots\":[\"$HB\"],\"systems\":{\"snes\":{\"main\":\"$HB\",\"on\":[\"$HB\"],
|
||||
\"folders\":{\"$HB\":{\"rom_dir\":\"$T/mine/snes\",\"save_dir\":\"$T/mine/snessaves\",\"state_dir\":\"\"}}}}}" >/dev/null \
|
||||
|| fail "setup with a system's own folders refused"
|
||||
sn=$(curl -sf "$API/api/paths" | python3 -c "
|
||||
import json,sys
|
||||
for m in json.load(sys.stdin):
|
||||
if 'snes' in m['platforms'].split(','): print(m['rom_dir'], m['save_dir'])
|
||||
elif m['rom_dir'].startswith('$T/mine/snes'): print('leaked', m['platforms'])")
|
||||
grep -q "^$T/mine/snes $T/mine/snessaves$" <<<"$sn" && ! grep -q "^leaked" <<<"$sn" || fail "a system's picked folders must apply to it alone: $sn"
|
||||
post /api/setup/emulators "{\"roots\":[\"$HB\"],\"custom\":[{\"id\":\"newemu-snes\",\"slugs\":[\"snes\",\"sfam\"],\"name\":\"NewEmu (SNES)\",\"rom_dir\":\"$T/porp/games\",\"save_dir\":\"$T/porp/saves\"}]}" >/dev/null
|
||||
|
||||
echo "19. every cover kept on the console, then only the missing and changed ones fetched"
|
||||
# Paired with the HTTPS mock since scenario 17.
|
||||
@@ -448,4 +693,146 @@ post /api/download '{"rom_id":13}' >/dev/null; sleep 1
|
||||
grep -q "systeminfo.txt isn't a game" "$T/daemon.log" || fail "a file that isn't a game was downloaded"
|
||||
[[ -z "$(find "$T" -name systeminfo.txt -not -path '*/data/*')" ]] || fail "systeminfo.txt reached an emulator's folder"
|
||||
|
||||
echo "21. one-file folder games: saves named after the file sync; old extensionless downloads are moved into the folder"
|
||||
curl -sfk -X POST "$TM/_admin/rom" -d '{"id":70,"platform_id":1,"name":"Folder Game","fs_name":"Folder Game",
|
||||
"files":[{"id":701,"file_name":"Folder Game (USA).sfc","content":"ROM:Folder Game (USA).sfc"}]}' >/dev/null
|
||||
post /api/download '{"rom_id":70}' >/dev/null
|
||||
for _ in $(seq 1 20); do got=$(find "$T" -path "*/Folder Game/Folder Game (USA).sfc" -not -path '*/data/*' | head -1); [[ -n "$got" ]] && break; sleep 0.25; done
|
||||
[[ -n "$got" ]] || fail "a one-file folder game must download into its folder"
|
||||
gdir=$(dirname "$(dirname "$got")")
|
||||
sdir=$(curl -sf "$API/api/paths" | python3 -c "import json,sys; print([m['save_dir'] for m in json.load(sys.stdin) if m['rom_dir']=='$gdir'][0])")
|
||||
[[ -n "$sdir" ]] || fail "no save folder for $gdir"
|
||||
mkdir -p "$sdir"; printf 'folder-save' > "$sdir/Folder Game (USA).srm"
|
||||
syncnow
|
||||
saved=$(curl -sfk "$TM/_admin/dump" | python3 -c "import json,sys; s=[x for x in json.load(sys.stdin)['saves'] if x['rom_id']==70]; print(s[-1]['content'] if s else 'none')")
|
||||
[[ "$saved" == "folder-save" ]] || fail "a save named after the file inside a one-file folder must sync: $saved"
|
||||
# What 1.1.1 left behind: the file named after the folder, no extension.
|
||||
curl -sfk -X POST "$TM/_admin/rom" -d '{"id":71,"platform_id":1,"name":"Bare Game","fs_name":"Bare Game",
|
||||
"files":[{"id":711,"file_name":"Bare Game (USA).sfc","content":"ROM:Bare Game (USA).sfc"}]}' >/dev/null
|
||||
printf 'ROM:Bare Game (USA).sfc' > "$gdir/Bare Game"; printf 'bare-save' > "$sdir/Bare Game.srm"
|
||||
syncnow
|
||||
[[ -f "$gdir/Bare Game/Bare Game (USA).sfc" ]] || fail "an extensionless game must be moved into its folder under the file's name"
|
||||
[[ "$(cat "$sdir/Bare Game (USA).srm" 2>/dev/null)" == "bare-save" && ! -e "$sdir/Bare Game.srm" ]] || fail "its save must take the file's name"
|
||||
|
||||
# A one-file folder game's save from RomM is named after the file inside, as the emulator names it.
|
||||
curl -sfk -X POST "$TM/_admin/rom" -d '{"id":76,"platform_id":1,"name":"Folder Two","fs_name":"Folder Two",
|
||||
"files":[{"id":761,"file_name":"Folder Two (USA).sfc","content":"ROM:Folder Two (USA).sfc"}]}' >/dev/null
|
||||
curl -sfk -X POST "$TM/_admin/save" -d '{"rom_id":76,"slot":"autosave","file_name":"Folder Two (USA).srm","content":"folder-two-save"}' >/dev/null
|
||||
post /api/download '{"rom_id":76}' >/dev/null
|
||||
for _ in $(seq 1 40); do [[ -f "$sdir/Folder Two (USA).srm" ]] && break; sleep 0.25; done
|
||||
[[ "$(cat "$sdir/Folder Two (USA).srm" 2>/dev/null)" == "folder-two-save" ]] || fail "a one-file folder game's save must take the file's name: $(ls "$sdir" | grep -i 'folder two')"
|
||||
[[ ! -e "$sdir/Folder Two.srm" ]] || fail "not the folder's name"
|
||||
|
||||
# A save from before RomM's sync slots (no slot) comes down with its game.
|
||||
curl -sfk -X POST "$TM/_admin/rom" -d '{"id":72,"platform_id":1,"name":"Old Save Game","fs_name":"Old Save Game (USA).sfc",
|
||||
"files":[{"id":721,"file_name":"Old Save Game (USA).sfc","content":"ROM:Old Save Game (USA).sfc"}]}' >/dev/null
|
||||
curl -sfk -X POST "$TM/_admin/save" -d '{"rom_id":72,"slot":null,"file_name":"Old Save Game (USA) [2026-08-01].srm","content":"from-before-slots"}' >/dev/null
|
||||
curl -sfk -X POST "$TM/_admin/save" -d '{"rom_id":72,"slot":null,"file_name":"Old Save Game (USA) [2026-08-01].rtc","content":"clock"}' >/dev/null
|
||||
post /api/download '{"rom_id":72}' >/dev/null
|
||||
for _ in $(seq 1 40); do [[ -f "$sdir/Old Save Game (USA).srm" ]] && break; sleep 0.25; done
|
||||
[[ "$(cat "$sdir/Old Save Game (USA).srm" 2>/dev/null)" == "from-before-slots" ]] || fail "a save with no slot must come down with its game"
|
||||
if [[ "$(curl -sf "$API/api/paths" | python3 -c "import json,sys; print([m for m in json.load(sys.stdin) if m['rom_dir']=='$gdir'][0].get('save_exts', '.srm,.rtc'))")" == *rtc* ]]; then
|
||||
[[ "$(cat "$sdir/Old Save Game (USA).rtc" 2>/dev/null)" == "clock" ]] || fail "the clock file beside it must come down too"
|
||||
fi
|
||||
syncnow
|
||||
up=$(curl -sfk "$TM/_admin/dump" | python3 -c "import json,sys; print(sorted(str(s['slot']) for s in json.load(sys.stdin)['saves'] if s['rom_id']==72))")
|
||||
[[ "$up" == "['None', 'None', 'autosave', 'autosave-rtc']" ]] || fail "they must then sync in this console's slots: $up"
|
||||
|
||||
# Another device's slot: never mixed into this console's save. A game with
|
||||
# none of its own takes the newest from elsewhere, then syncs in its own slot.
|
||||
for g in "74 Phone Game" "75 Both Game"; do
|
||||
set -- $g
|
||||
curl -sfk -X POST "$TM/_admin/rom" -d "{\"id\":$1,\"platform_id\":1,\"name\":\"$2 $3\",\"fs_name\":\"$2 $3 (USA).sfc\",
|
||||
\"files\":[{\"id\":${1}1,\"file_name\":\"$2 $3 (USA).sfc\",\"content\":\"ROM:$2 $3 (USA).sfc\"}]}" >/dev/null
|
||||
done
|
||||
curl -sfk -X POST "$TM/_admin/save" -d '{"rom_id":74,"slot":"phone","file_name":"Phone Game (USA).srm","content":"from-the-phone"}' >/dev/null
|
||||
curl -sfk -X POST "$TM/_admin/save" -d '{"rom_id":75,"slot":"phone","file_name":"Both Game (USA).srm","content":"phone-copy"}' >/dev/null
|
||||
sleep 1.1
|
||||
curl -sfk -X POST "$TM/_admin/save" -d '{"rom_id":75,"slot":"autosave","file_name":"Both Game (USA).srm","content":"this-slot"}' >/dev/null
|
||||
post /api/download '{"rom_id":74}' >/dev/null; post /api/download '{"rom_id":75}' >/dev/null
|
||||
for _ in $(seq 1 40); do [[ -f "$sdir/Phone Game (USA).srm" && -f "$sdir/Both Game (USA).srm" ]] && break; sleep 0.25; done
|
||||
syncnow
|
||||
[[ "$(cat "$sdir/Phone Game (USA).srm" 2>/dev/null)" == "from-the-phone" ]] || fail "a game with only another device's save must get it"
|
||||
[[ "$(cat "$sdir/Both Game (USA).srm" 2>/dev/null)" == "this-slot" ]] || fail "another device's slot must never replace this console's save"
|
||||
curl -sf "$API/api/events?rom_id=74" | grep -q "From another device" || fail "the sync log must say where it came from"
|
||||
|
||||
echo "22. the sync log and the tracked saves"
|
||||
ev=$(curl -sf "$API/api/events?limit=500" | python3 -c "
|
||||
import json,sys; L=json.load(sys.stdin)
|
||||
print(sorted({(e['kind'], e['action']) for e in L}))
|
||||
print(next((e['game'] for e in L if e['rom_id']==70 and e['action']=='uploaded'), 'none'))")
|
||||
grep -q "('save', 'uploaded')" <<<"$ev" && grep -q "('state', 'downloaded')" <<<"$ev" && grep -q "('state', 'skipped')" <<<"$ev" \
|
||||
|| fail "the sync log is missing kinds of entries: $ev"
|
||||
[[ "$(tail -1 <<<"$ev")" == "Folder Game" ]] || fail "a log entry must name its game: $ev"
|
||||
one=$(curl -sf "$API/api/events?rom_id=70" | python3 -c "import json,sys; print({e['rom_id'] for e in json.load(sys.stdin)})")
|
||||
[[ "$one" == "{70}" ]] || fail "the log for one game holds only that game: $one"
|
||||
tr=$(curl -sf "$API/api/tracked" | python3 -c "
|
||||
import json,sys; t=json.load(sys.stdin)
|
||||
g={x['rom_id']: x for p in t['platforms'] for x in p['games']}
|
||||
print(t['saves'] > 0, t['states'] > 0, g[70]['name'], [f['status'] for f in g[70]['files']])")
|
||||
[[ "$tr" == "True True Folder Game ['synced']" ]] || fail "tracked saves by platform: $tr"
|
||||
printf 'changed-again' > "$sdir/Folder Game (USA).srm"
|
||||
st=$(curl -sf "$API/api/tracked?rom_id=70" | python3 -c "import json,sys; print([f['status'] for p in json.load(sys.stdin)['platforms'] for g in p['games'] for f in g['files']])")
|
||||
[[ "$st" == "['changed']" ]] || fail "a save changed here must say so: $st"
|
||||
|
||||
echo "23. save sync off: RomM only as a game source"
|
||||
post /api/config '{"save_sync":false}' >/dev/null
|
||||
n=$(curl -sf "$API/api/status" | python3 -c "import json,sys; print(json.load(sys.stdin)['sync']['sync_count'])")
|
||||
curl -sfk -X POST "$TM/_admin/rom" -d '{"id":73,"platform_id":1,"name":"No Sync Game","fs_name":"No Sync Game (USA).sfc",
|
||||
"files":[{"id":731,"file_name":"No Sync Game (USA).sfc","content":"ROM:No Sync Game (USA).sfc"}]}' >/dev/null
|
||||
curl -sfk -X POST "$TM/_admin/save" -d '{"rom_id":73,"slot":"autosave","file_name":"No Sync Game (USA).srm","content":"stays-on-romm"}' >/dev/null
|
||||
post /api/download '{"rom_id":73}' >/dev/null; post /api/sync >/dev/null; sleep 3
|
||||
[[ -n "$(find "$T" -name "No Sync Game (USA).sfc" -not -path '*/data/*')" ]] || fail "games must still download with save sync off"
|
||||
[[ ! -e "$sdir/No Sync Game (USA).srm" ]] || fail "no save may come down with save sync off"
|
||||
[[ "$(curl -sf "$API/api/status" | python3 -c "import json,sys; print(json.load(sys.stdin)['sync']['sync_count'])")" == "$n" ]] || fail "no sync may run with save sync off"
|
||||
grep -q "skipped: save sync is off" "$T/daemon.log" || fail "a skipped sync must say why"
|
||||
post /api/config '{"save_sync":true}' >/dev/null
|
||||
syncnow
|
||||
[[ "$(cat "$sdir/No Sync Game (USA).srm" 2>/dev/null)" == "stays-on-romm" ]] || fail "turned back on, the save must come down"
|
||||
grep -q "\[NOTIFY\] RommPS: No Sync Game downloaded$" "$T/daemon.log" || fail "a download with save sync off must still say it's done"
|
||||
grep -qE "\[NOTIFY\] RommPS: ([0-9]+ (saves?|states?) (up|down)(, )?)+$" "$T/daemon.log" || fail "a sync's notification must say what moved"
|
||||
! grep -q "\[NOTIFY\] RommPS: 0 " "$T/daemon.log" || fail "a notification must not count nothing"
|
||||
grep -A1 -E "\[NOTIFY\] RommPS: [0-9]+ saves? (up|down)" "$T/daemon.log" | grep -q "No Sync Game" || fail "a sync's notification must name the games"
|
||||
|
||||
echo "24. save sync per emulator and platform: saves only, then off, then everything"
|
||||
path_of() { curl -sf "$API/api/paths" | python3 -c "import json,sys; print([m['$1'] for m in json.load(sys.stdin) if m['rom_dir']=='$gdir'][0])"; }
|
||||
emu_root=$(path_of root); stdir=$(path_of state_dir)
|
||||
[[ -n "$emu_root" && -n "$stdir" ]] || fail "no emulator folders for $gdir"
|
||||
post /api/config '{"states":"sync"}' >/dev/null
|
||||
post /api/emulators/sync "{\"root\":\"$emu_root\",\"sync\":\"saves\"}" >/dev/null || fail "choosing saves only refused"
|
||||
[[ -z "$(path_of state_dir)" && -n "$(path_of save_dir)" ]] || fail "saves only: states must show as not synced"
|
||||
curl -sfk -X POST "$TM/_admin/state" -d '{"rom_id":73,"emulator":"snes9x","file_name":"No Sync Game (USA).state1","content":"emu-state"}' >/dev/null
|
||||
printf 'saves-only' > "$sdir/No Sync Game (USA).srm"
|
||||
syncnow
|
||||
[[ ! -e "$stdir/No Sync Game (USA).state1" ]] || fail "saves only: a state came down"
|
||||
last73() { curl -sfk "$TM/_admin/dump" | python3 -c "import json,sys; s=[x for x in json.load(sys.stdin)['saves'] if x['rom_id']==73]; print(s[-1]['content'] if s else 'none')"; }
|
||||
[[ "$(last73)" == "saves-only" ]] || fail "saves only: the save must still go up: $(last73)"
|
||||
post /api/emulators/sync "{\"root\":\"$emu_root\",\"sync\":\"off\"}" >/dev/null
|
||||
sleep 1.1; printf 'not-synced' > "$sdir/No Sync Game (USA).srm"
|
||||
syncnow
|
||||
[[ "$(last73)" == "saves-only" ]] || fail "off: a save went up"
|
||||
[[ -z "$(path_of save_dir)" ]] || fail "off: saves must show as not synced"
|
||||
post /api/setup/emulators "{\"roots\":[\"$emu_root\"]}" >/dev/null || true
|
||||
[[ "$(curl -sf "$API/api/config" | python3 -c "import json,sys; print([e.get('sync','all') for p in json.load(sys.stdin)['profiles'] if p.get('root')=='$emu_root' for e in p['platforms']])")" == *"'off'"* ]] || fail "setting up again must keep each platform's choice"
|
||||
post /api/emulators/sync "{\"root\":\"$emu_root\",\"sync\":\"all\"}" >/dev/null
|
||||
syncnow
|
||||
[[ "$(last73)" == "not-synced" ]] || fail "back on: the save must go up: $(last73)"
|
||||
[[ "$(cat "$stdir/No Sync Game (USA).state1" 2>/dev/null)" == "emu-state" ]] || fail "back on: the state must come down"
|
||||
curl -s -X POST "$API/api/emulators/sync" -d '{"root":"/nope","sync":"off"}' | grep -q error || fail "an unknown emulator must be refused"
|
||||
# Per platform, the way the app sets it: only this system's saves stop.
|
||||
post /api/setup/emulators "{\"roots\":[\"$emu_root\"],\"systems\":{\"snes\":{\"main\":\"$emu_root\",\"on\":[\"$emu_root\"],\"sync\":\"off\"}}}" >/dev/null
|
||||
modes=$(curl -sf "$API/api/config" | python3 -c "import json,sys; print(sorted((e['romm'][0], e.get('sync','all')) for p in json.load(sys.stdin)['profiles'] if p.get('root')=='$emu_root' for e in p['platforms']))")
|
||||
[[ "$modes" == *"('snes', 'off')"* && "$modes" == *"('ps2', 'all')"* ]] || fail "save sync must be set for one platform only: $modes"
|
||||
[[ -z "$(path_of save_dir)" ]] || fail "a platform with save sync off must show its saves as not synced"
|
||||
post /api/setup/emulators "{\"roots\":[\"$emu_root\"],\"systems\":{\"snes\":{\"main\":\"$emu_root\",\"on\":[\"$emu_root\"],\"sync\":\"all\"}}}" >/dev/null
|
||||
|
||||
echo "25. a save or state deleted from the console comes back from RomM"
|
||||
rm -f "$sdir/No Sync Game (USA).srm"
|
||||
syncnow
|
||||
[[ "$(cat "$sdir/No Sync Game (USA).srm" 2>/dev/null)" == "not-synced" ]] || fail "a deleted save must come back"
|
||||
curl -sf "$API/api/events?rom_id=73" | grep -q "Missing here" || fail "the sync log must say why it came back"
|
||||
rm -f "$stdir/No Sync Game (USA).state1"
|
||||
syncnow
|
||||
[[ "$(cat "$stdir/No Sync Game (USA).state1" 2>/dev/null)" == "emu-state" ]] || fail "a deleted state must come back"
|
||||
|
||||
echo "PASS"
|
||||
+158
@@ -0,0 +1,158 @@
|
||||
#!/usr/bin/env python3
|
||||
"""GameCube memory cards for the e2e test, written apart from src/gcmem.c so
|
||||
each checks the other.
|
||||
|
||||
gccard.py blank CARD NBLOCKS a formatted, empty card
|
||||
gccard.py gci OUT ID FILE BLOCKS FILL a .gci: ID "GZLE01", FILE its name on the card
|
||||
gccard.py put CARD GCI puts a .gci on the card
|
||||
gccard.py saves CARD ID "<file name> <md5 of the save's blocks>" per save
|
||||
gccard.py check CARD exits 1 unless the card is one Dolphin reads
|
||||
"""
|
||||
import hashlib
|
||||
import struct
|
||||
import sys
|
||||
|
||||
B = 0x2000
|
||||
|
||||
|
||||
def be16(b, o):
|
||||
return struct.unpack_from(">H", b, o)[0]
|
||||
|
||||
|
||||
def sums(b):
|
||||
a = c = 0
|
||||
for i in range(0, len(b), 2):
|
||||
w = be16(b, i)
|
||||
a = (a + w) & 0xFFFF
|
||||
c = (c + (w ^ 0xFFFF)) & 0xFFFF
|
||||
return (0 if a == 0xFFFF else a), (0 if c == 0xFFFF else c)
|
||||
|
||||
|
||||
def seal(card, blk, is_dir):
|
||||
o = blk * B
|
||||
a, c = sums(card[o:o + 0x1FFC]) if is_dir else sums(card[o + 4:o + B])
|
||||
struct.pack_into(">HH", card, o + (0x1FFC if is_dir else 0), a, c)
|
||||
|
||||
|
||||
def current(card):
|
||||
def ok(blk, is_dir):
|
||||
o = blk * B
|
||||
a, c = sums(card[o:o + 0x1FFC]) if is_dir else sums(card[o + 4:o + B])
|
||||
return (a, c) == struct.unpack_from(">HH", card, o + (0x1FFC if is_dir else 0))
|
||||
|
||||
def newer(x, y, at):
|
||||
cx, cy = ok(x, at == 0x1FFA), ok(y, at == 0x1FFA)
|
||||
if cx and cy:
|
||||
sx, sy = (struct.unpack_from(">h", card, z * B + at)[0] for z in (x, y))
|
||||
return y if sy > sx else x
|
||||
return x if cx else y if cy else None
|
||||
|
||||
return newer(1, 2, 0x1FFA), newer(3, 4, 4)
|
||||
|
||||
|
||||
def entries(card, d):
|
||||
for i in range(127):
|
||||
o = d * B + i * 0x40
|
||||
if card[o:o + 4] != b"\xff" * 4:
|
||||
yield i, o
|
||||
|
||||
|
||||
def chain(card, t, first, count):
|
||||
blk, out = first, []
|
||||
for _ in range(count):
|
||||
out.append(blk)
|
||||
blk = be16(card, t * B + 0xA + (blk - 5) * 2)
|
||||
return out
|
||||
|
||||
|
||||
def blank(nblocks):
|
||||
card = bytearray(b"\xff" * (nblocks * B))
|
||||
card[0:0x26] = bytes(0x26)
|
||||
struct.pack_into(">H", card, 0x22, nblocks // 16)
|
||||
a, c = sums(card[0:0x1FC])
|
||||
struct.pack_into(">HH", card, 0x1FC, a, c)
|
||||
for k in (1, 2):
|
||||
struct.pack_into(">H", card, k * B + 0x1FFA, k - 1)
|
||||
seal(card, k, True)
|
||||
for k in (3, 4):
|
||||
card[k * B:(k + 1) * B] = bytes(B)
|
||||
struct.pack_into(">HHH", card, k * B + 4, k - 3, nblocks - 5, 4)
|
||||
seal(card, k, False)
|
||||
return card
|
||||
|
||||
|
||||
def put(card, g):
|
||||
"""Like Dolphin's import: the same save is dropped, new blocks follow the
|
||||
last one handed out, and the edited copies replace the older ones."""
|
||||
d, t = current(card)
|
||||
nd, nt = (2 if d == 1 else 1), (4 if t == 3 else 3)
|
||||
card[nd * B:(nd + 1) * B] = card[d * B:(d + 1) * B]
|
||||
card[nt * B:(nt + 1) * B] = card[t * B:(t + 1) * B]
|
||||
nblocks = len(card) // B
|
||||
for i, o in list(entries(card, nd)):
|
||||
if card[o:o + 6] == g[0:6] and card[o + 8:o + 40] == g[8:40]:
|
||||
for blk in chain(card, nt, be16(card, o + 0x36), be16(card, o + 0x38)):
|
||||
struct.pack_into(">H", card, nt * B + 0xA + (blk - 5) * 2, 0)
|
||||
struct.pack_into(">H", card, nt * B + 6, be16(card, nt * B + 6) + be16(card, o + 0x38))
|
||||
card[o:o + 0x40] = b"\xff" * 0x40
|
||||
count = be16(g, 0x38)
|
||||
start = be16(card, nt * B + 8) + 1
|
||||
if not 5 <= start < nblocks:
|
||||
start = 5
|
||||
free = [b for b in (5 + (start - 5 + k) % (nblocks - 5) for k in range(nblocks - 5))
|
||||
if be16(card, nt * B + 0xA + (b - 5) * 2) == 0][:count]
|
||||
slot = next(i for i in range(127) if card[nd * B + i * 0x40:nd * B + i * 0x40 + 4] == b"\xff" * 4)
|
||||
for k, blk in enumerate(free):
|
||||
card[blk * B:(blk + 1) * B] = g[0x40 + k * B:0x40 + (k + 1) * B]
|
||||
struct.pack_into(">H", card, nt * B + 0xA + (blk - 5) * 2, free[k + 1] if k + 1 < count else 0xFFFF)
|
||||
struct.pack_into(">H", card, nt * B + 6, be16(card, nt * B + 6) - count)
|
||||
struct.pack_into(">H", card, nt * B + 8, free[-1])
|
||||
o = nd * B + slot * 0x40
|
||||
card[o:o + 0x40] = g[0:0x40]
|
||||
struct.pack_into(">H", card, o + 0x36, free[0])
|
||||
for blk, at in ((nd, 0x1FFA), (nt, 4)):
|
||||
struct.pack_into(">H", card, blk * B + at, (be16(card, blk * B + at) + 1) & 0xFFFF)
|
||||
seal(card, nd, True)
|
||||
seal(card, nt, False)
|
||||
|
||||
|
||||
def saves(card, gid):
|
||||
d, t = current(card)
|
||||
for i, o in entries(card, d):
|
||||
if card[o:o + 6] == gid.encode():
|
||||
data = b"".join(card[b * B:(b + 1) * B] for b in chain(card, t, be16(card, o + 0x36), be16(card, o + 0x38)))
|
||||
yield card[o + 8:o + 40].rstrip(b"\0").decode(), hashlib.md5(data).hexdigest()
|
||||
|
||||
|
||||
def check(card):
|
||||
d, t = current(card)
|
||||
assert d and t, "no directory or table checks out"
|
||||
nblocks, used = len(card) // B, set()
|
||||
for i, o in entries(card, d):
|
||||
blocks = chain(card, t, be16(card, o + 0x36), be16(card, o + 0x38))
|
||||
assert all(5 <= b < nblocks for b in blocks), "a chain leaves the card"
|
||||
assert not used & set(blocks), "a block in two saves"
|
||||
used |= set(blocks)
|
||||
zeros = sum(1 for b in range(5, nblocks) if be16(card, t * B + 0xA + (b - 5) * 2) == 0)
|
||||
assert zeros == be16(card, t * B + 6) == nblocks - 5 - len(used), "free count is off"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
cmd, a = sys.argv[1], sys.argv[2:]
|
||||
if cmd == "blank":
|
||||
open(a[0], "wb").write(blank(int(a[1])))
|
||||
elif cmd == "gci":
|
||||
gid, name, blocks, fill = a[1].encode(), a[2].encode(), int(a[3]), int(a[4], 0)
|
||||
head = bytearray(b"\xff" * 0x40)
|
||||
head[0:6], head[8:40] = gid, name.ljust(32, b"\0")
|
||||
struct.pack_into(">HH", head, 0x36, 0, blocks)
|
||||
open(a[0], "wb").write(bytes(head) + b"".join(bytes([(fill + k) & 0xFF]) * B for k in range(blocks)))
|
||||
elif cmd == "put":
|
||||
card = bytearray(open(a[0], "rb").read())
|
||||
put(card, open(a[1], "rb").read())
|
||||
open(a[0], "wb").write(card)
|
||||
elif cmd == "saves":
|
||||
for name, md5 in saves(bytearray(open(a[0], "rb").read()), a[1]):
|
||||
print(name, md5)
|
||||
elif cmd == "check":
|
||||
check(bytearray(open(a[0], "rb").read()))
|
||||
@@ -0,0 +1,201 @@
|
||||
/* GameCube memory cards: saves go on, come off and are replaced, and the
|
||||
* card stays one Dolphin reads (checksums, chains, free count).
|
||||
* cc -Isrc tests/gcmem_test.c src/gcmem.c -o build/host/gcmem_test */
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
#include "gcmem.h"
|
||||
|
||||
static int failures;
|
||||
#define CHECK(x) \
|
||||
do { \
|
||||
if (!(x)) { \
|
||||
fprintf(stderr, "%s:%d: %s\n", __FILE__, __LINE__, #x); \
|
||||
failures++; \
|
||||
} \
|
||||
} while (0)
|
||||
|
||||
static unsigned be16(const unsigned char *p) { return (unsigned)p[0] << 8 | p[1]; }
|
||||
static void put16(unsigned char *p, unsigned v) { p[0] = (unsigned char)(v >> 8), p[1] = (unsigned char)v; }
|
||||
|
||||
static void sums(const unsigned char *p, size_t n, unsigned *a, unsigned *b) {
|
||||
unsigned x = 0, y = 0;
|
||||
for (size_t i = 0; i < n; i += 2) x = (x + be16(p + i)) & 0xFFFF, y = (y + (be16(p + i) ^ 0xFFFF)) & 0xFFFF;
|
||||
*a = x == 0xFFFF ? 0 : x, *b = y == 0xFFFF ? 0 : y;
|
||||
}
|
||||
|
||||
/* A blank card as Dolphin formats one: header, two empty directories, two
|
||||
* tables with every data block free. */
|
||||
static unsigned char *blank(int nblocks) {
|
||||
unsigned char *c = malloc((size_t)nblocks * GC_BLOCK);
|
||||
memset(c, 0xFF, (size_t)nblocks * GC_BLOCK);
|
||||
memset(c, 0, 0x26);
|
||||
put16(c + 0x22, (unsigned)nblocks / 16);
|
||||
unsigned a, b;
|
||||
sums(c, 0x1FC, &a, &b);
|
||||
put16(c + 0x1FC, a), put16(c + 0x1FE, b);
|
||||
for (int k = 1; k <= 2; k++) {
|
||||
unsigned char *d = c + k * GC_BLOCK;
|
||||
put16(d + 0x1FFA, (unsigned)k - 1);
|
||||
sums(d, 0x1FFC, &a, &b);
|
||||
put16(d + 0x1FFC, a), put16(d + 0x1FFE, b);
|
||||
}
|
||||
for (int k = 3; k <= 4; k++) {
|
||||
unsigned char *t = c + k * GC_BLOCK;
|
||||
memset(t, 0, GC_BLOCK);
|
||||
put16(t + 4, (unsigned)k - 3);
|
||||
put16(t + 6, (unsigned)nblocks - 5);
|
||||
put16(t + 8, 4);
|
||||
sums(t + 4, GC_BLOCK - 4, &a, &b);
|
||||
put16(t, a), put16(t + 2, b);
|
||||
}
|
||||
return c;
|
||||
}
|
||||
|
||||
static unsigned char *gci(const char *id, const char *file, int blocks, unsigned char fill, size_t *size) {
|
||||
*size = GC_DENTRY + (size_t)blocks * GC_BLOCK;
|
||||
unsigned char *g = malloc(*size);
|
||||
memset(g, 0xFF, GC_DENTRY);
|
||||
memcpy(g, id, 6);
|
||||
memset(g + 8, 0, 32);
|
||||
memcpy(g + 8, file, strlen(file));
|
||||
put16(g + 0x36, 0x1234); /* where it sat on another card: ignored */
|
||||
put16(g + 0x38, (unsigned)blocks);
|
||||
for (int k = 0; k < blocks; k++) memset(g + GC_DENTRY + (size_t)k * GC_BLOCK, fill + k, GC_BLOCK);
|
||||
return g;
|
||||
}
|
||||
|
||||
/* What Dolphin checks, from the current copies: checksums, every chain
|
||||
* inside the card and no block in two, the free count right. */
|
||||
static void check_card(const gc_card *c) {
|
||||
const unsigned char *d = c->data + (size_t)c->dir * GC_BLOCK, *t = c->data + (size_t)c->bat * GC_BLOCK;
|
||||
unsigned a, b;
|
||||
sums(d, 0x1FFC, &a, &b);
|
||||
CHECK(a == be16(d + 0x1FFC) && b == be16(d + 0x1FFE));
|
||||
sums(t + 4, GC_BLOCK - 4, &a, &b);
|
||||
CHECK(a == be16(t) && b == be16(t + 2));
|
||||
char used[4096] = {0};
|
||||
int in_use = 0;
|
||||
for (int i = 0; i < 127; i++) {
|
||||
const unsigned char *e = d + i * GC_DENTRY;
|
||||
if (e[0] == 0xFF && e[1] == 0xFF && e[2] == 0xFF && e[3] == 0xFF) continue;
|
||||
unsigned blk = be16(e + 0x36), n = be16(e + 0x38);
|
||||
for (unsigned k = 0; k < n; k++) {
|
||||
CHECK(blk >= 5 && blk < (unsigned)c->nblocks);
|
||||
if (blk < 5 || blk >= (unsigned)c->nblocks) break;
|
||||
CHECK(!used[blk]);
|
||||
used[blk] = 1;
|
||||
in_use++;
|
||||
unsigned next = be16(t + 0xA + (blk - 5) * 2);
|
||||
if (k + 1 == n) CHECK(next == 0xFFFF);
|
||||
blk = next;
|
||||
}
|
||||
}
|
||||
int zeros = 0;
|
||||
for (int blk = 5; blk < c->nblocks; blk++) {
|
||||
unsigned m = be16(t + 0xA + (blk - 5) * 2);
|
||||
zeros += m == 0;
|
||||
CHECK((m == 0) == !used[blk]);
|
||||
}
|
||||
CHECK(be16(t + 6) == (unsigned)zeros);
|
||||
CHECK(zeros + in_use == c->nblocks - 5);
|
||||
}
|
||||
|
||||
typedef struct { unsigned char *g[8]; size_t size[8]; int n; } got;
|
||||
static void keep(const unsigned char *g, size_t size, void *ud) {
|
||||
got *x = ud;
|
||||
x->g[x->n] = malloc(size);
|
||||
memcpy(x->g[x->n], g, size);
|
||||
x->size[x->n++] = size;
|
||||
}
|
||||
static void got_free(got *x) { for (int i = 0; i < x->n; i++) free(x->g[i]); x->n = 0; }
|
||||
|
||||
int main(void) {
|
||||
const int nblocks = 64; /* a 59-block card */
|
||||
unsigned char *raw = blank(nblocks);
|
||||
gc_card c;
|
||||
CHECK(gc_card_open(&c, raw, (size_t)nblocks * GC_BLOCK) == 0);
|
||||
check_card(&c);
|
||||
|
||||
size_t za, zb, zc;
|
||||
unsigned char *a = gci("GZLE01", "gczelda", 3, 0x10, &za), *b = gci("GMSE01", "super_mario_sunshine", 7, 0x40, &zb);
|
||||
CHECK(gc_gci_valid(a, za) && gc_gci_valid(b, zb));
|
||||
CHECK(gc_card_put(&c, a, za) == 0);
|
||||
CHECK(gc_card_put(&c, b, zb) == 0);
|
||||
check_card(&c);
|
||||
|
||||
got x = {0};
|
||||
CHECK(gc_card_saves(&c, "GZLE01", keep, &x) == 1);
|
||||
/* The same save comes back, at its place on this card. */
|
||||
CHECK(x.n == 1 && x.size[0] == za && !memcmp(x.g[0] + GC_DENTRY, a + GC_DENTRY, za - GC_DENTRY));
|
||||
CHECK(x.n == 1 && !memcmp(x.g[0], a, 0x36) && !memcmp(x.g[0] + 0x38, a + 0x38, 8));
|
||||
got_free(&x);
|
||||
|
||||
/* Same save again, bigger: replaced, not added. */
|
||||
unsigned char *a2 = gci("GZLE01", "gczelda", 5, 0x70, &zc);
|
||||
CHECK(gc_card_put(&c, a2, zc) == 0);
|
||||
check_card(&c);
|
||||
CHECK(gc_card_saves(&c, "GZLE01", keep, &x) == 1);
|
||||
CHECK(x.n == 1 && x.size[0] == zc && !memcmp(x.g[0] + GC_DENTRY, a2 + GC_DENTRY, zc - GC_DENTRY));
|
||||
got_free(&x);
|
||||
CHECK(gc_card_saves(&c, "GMSE01", keep, &x) == 1);
|
||||
CHECK(x.n == 1 && !memcmp(x.g[0] + GC_DENTRY, b + GC_DENTRY, zb - GC_DENTRY));
|
||||
got_free(&x);
|
||||
|
||||
/* A second file of the same game sits beside the first. */
|
||||
size_t zd;
|
||||
unsigned char *d = gci("GZLE01", "gczelda_opt", 1, 0x90, &zd);
|
||||
CHECK(gc_card_put(&c, d, zd) == 0);
|
||||
CHECK(gc_card_saves(&c, "GZLE01", NULL, NULL) == 2);
|
||||
check_card(&c);
|
||||
|
||||
/* Removing a game frees its blocks and leaves the others. */
|
||||
CHECK(gc_card_remove(&c, "GZLE01") == 2);
|
||||
CHECK(gc_card_saves(&c, "GZLE01", NULL, NULL) == 0);
|
||||
CHECK(gc_card_saves(&c, "GMSE01", NULL, NULL) == 1);
|
||||
check_card(&c);
|
||||
|
||||
/* Too big for what's left: refused, card untouched. */
|
||||
size_t zbig;
|
||||
unsigned char *big = gci("GBIG01", "big", 60, 0x01, &zbig);
|
||||
unsigned char *before = malloc(c.size);
|
||||
memcpy(before, c.data, c.size);
|
||||
CHECK(gc_card_put(&c, big, zbig) == -1);
|
||||
CHECK(!memcmp(before, c.data, c.size));
|
||||
|
||||
/* The card reopens from its bytes, with the newer copies current. */
|
||||
gc_card again;
|
||||
CHECK(gc_card_open(&again, c.data, c.size) == 0);
|
||||
CHECK(again.dir == c.dir && again.bat == c.bat);
|
||||
CHECK(gc_card_saves(&again, "GMSE01", NULL, NULL) == 1);
|
||||
check_card(&again);
|
||||
|
||||
/* A spoilt current directory: the previous copy is used. */
|
||||
again.data[(size_t)again.dir * GC_BLOCK + 100] ^= 0xFF;
|
||||
gc_card fallback;
|
||||
CHECK(gc_card_open(&fallback, again.data, again.size) == 0);
|
||||
CHECK(fallback.dir != again.dir);
|
||||
gc_card_free(&fallback);
|
||||
|
||||
char name[128];
|
||||
gc_gci_name(a, name, sizeof name);
|
||||
CHECK(!strcmp(name, "01-GZLE-gczelda.gci"));
|
||||
size_t zq;
|
||||
unsigned char *q = gci("GQQE01", "a/b?", 1, 0, &zq);
|
||||
gc_gci_name(q, name, sizeof name);
|
||||
CHECK(!strcmp(name, "01-GQQE-a__2f__b__3f__.gci"));
|
||||
|
||||
CHECK(gc_card_open(&fallback, raw, 1000) == -1);
|
||||
CHECK(!gc_gci_valid(a, za - 1));
|
||||
|
||||
gc_card_free(&again);
|
||||
gc_card_free(&c);
|
||||
free(raw), free(a), free(a2), free(b), free(d), free(big), free(before), free(q);
|
||||
if (failures) {
|
||||
fprintf(stderr, "%d failed\n", failures);
|
||||
return 1;
|
||||
}
|
||||
puts("gcmem: ok");
|
||||
return 0;
|
||||
}
|
||||
+45
-3
@@ -58,6 +58,14 @@ DB = {
|
||||
14: {"id": 14, "platform_id": 1, "name": "Mega Test", "fs_name": "Mega Test (USA).md"},
|
||||
20: {"id": 20, "platform_id": 2, "name": "Okami", "fs_name": "Okami.iso"},
|
||||
21: {"id": 21, "platform_id": 2, "name": "Other", "fs_name": "Other.iso"},
|
||||
22: {"id": 22, "platform_id": 2, "name": "Downhill Domination", "fs_name": "Downhill Domination (USA)",
|
||||
"files": [{"id": 23, "file_name": "Downhill Domination (USA).iso",
|
||||
"content": b"ROM:Downhill Domination (USA).iso"}]},
|
||||
25: {"id": 25, "platform_id": 2, "name": "War of the Monsters", "fs_name": "War of the Monsters (USA)",
|
||||
"files": [{"id": 26, "file_name": "War of the Monsters (USA).cue",
|
||||
"content": b'FILE "War of the Monsters (USA).bin" BINARY\n'},
|
||||
{"id": 27, "file_name": "War of the Monsters (USA).bin",
|
||||
"content": b"ROM:War of the Monsters (USA).bin"}]},
|
||||
30: {"id": 30, "platform_id": 3, "name": "Crisis Core", "fs_name": "Crisis Core (USA).iso"},
|
||||
40: {"id": 40, "platform_id": 4, "name": "Wind Waker", "fs_name": "Wind Waker (USA).iso"},
|
||||
50: {"id": 50, "platform_id": 5, "name": "Test Quest", "fs_name": "Test Quest (USA).bin"},
|
||||
@@ -87,14 +95,24 @@ def next_id():
|
||||
|
||||
def rom_public(r):
|
||||
p = next(p for p in DB["platforms"] if p["id"] == r["platform_id"])
|
||||
stem = r["fs_name"].rsplit(".", 1)[0]
|
||||
stem = r["fs_name"].rsplit(".", 1)[0] if "." in r["fs_name"] else r["fs_name"]
|
||||
cover = "/assets/romm/resources/roms/%d/%d/cover/small.png?ts=%s" % (
|
||||
r["platform_id"], r["id"], DB["cover_ts"].get(r["id"], "2026-01-01 00:00:00"))
|
||||
if r.get("files"):
|
||||
files = [{"id": f["id"], "file_name": f["file_name"], "file_size_bytes": len(f["content"]),
|
||||
"category": f.get("category", "content")} for f in r["files"]]
|
||||
return {**{k: v for k, v in r.items() if k != "files"}, "fs_name_no_ext": stem,
|
||||
"fs_size_bytes": sum(f["file_size_bytes"] for f in files),
|
||||
"platform_slug": p["slug"], "platform_fs_slug": p["fs_slug"],
|
||||
"has_multiple_files": len(files) > 1, "files": files,
|
||||
"md5_hash": hashlib.md5(b"".join(f["content"] for f in r["files"])).hexdigest(),
|
||||
"path_cover_small": cover}
|
||||
content = ("ROM:" + r["fs_name"]).encode()
|
||||
return {**r, "fs_name_no_ext": stem, "fs_size_bytes": len(content), "platform_slug": p["slug"],
|
||||
"platform_fs_slug": p["fs_slug"], "has_multiple_files": False, "files": [],
|
||||
"md5_hash": hashlib.md5(content).hexdigest(),
|
||||
# Like RomM's: the cover's path carries a stamp that changes with it.
|
||||
"path_cover_small": "/assets/romm/resources/roms/%d/%d/cover/small.png?ts=%s" % (
|
||||
r["platform_id"], r["id"], DB["cover_ts"].get(r["id"], "2026-01-01 00:00:00"))}
|
||||
"path_cover_small": cover}
|
||||
|
||||
|
||||
def save_public(s):
|
||||
@@ -230,6 +248,12 @@ class H(BaseHTTPRequestHandler):
|
||||
raw = base64.b64decode(b["content_b64"]) if "content_b64" in b else b["content"].encode()
|
||||
s = new_save(b["rom_id"], b.get("slot"), b.get("emulator", "other-emu"), b.get("file_name", "x.srm"), raw)
|
||||
return self.reply(200, save_public(s))
|
||||
if p == "/_admin/rom" and method == "POST": # e.g. a game RomM keeps as a folder holding one file
|
||||
b = self.json_body()
|
||||
for f in b.get("files", []):
|
||||
f["content"] = f["content"].encode()
|
||||
DB["roms"][b["id"]] = b
|
||||
return self.reply(200, rom_public(b))
|
||||
if p == "/_admin/state" and method == "POST":
|
||||
b = self.json_body()
|
||||
sid = next_id()
|
||||
@@ -275,7 +299,25 @@ class H(BaseHTTPRequestHandler):
|
||||
return self.reply(200, rom_public(DB["roms"][int(parts[2])]))
|
||||
if len(parts) == 5 and parts[:2] == ["api", "roms"] and parts[3] == "content":
|
||||
r = DB["roms"][int(parts[2])]
|
||||
if r.get("files"):
|
||||
if len(r["files"]) == 1:
|
||||
return self.reply(200, raw=r["files"][0]["content"], ctype="application/octet-stream")
|
||||
import io, zipfile
|
||||
buf = io.BytesIO()
|
||||
with zipfile.ZipFile(buf, "w") as zf:
|
||||
for f in r["files"]:
|
||||
zf.writestr(f["file_name"], f["content"])
|
||||
zf.writestr(r["fs_name"] + ".m3u",
|
||||
"\n".join(f["file_name"] for f in r["files"]).encode())
|
||||
return self.reply(200, raw=buf.getvalue(), ctype="application/zip")
|
||||
return self.reply(200, raw=("ROM:" + r["fs_name"]).encode(), ctype="application/octet-stream")
|
||||
if len(parts) == 6 and parts[:2] == ["api", "roms"] and parts[3:5] == ["files", "content"]:
|
||||
# One file of a folder rom, by its file id.
|
||||
for r in DB["roms"].values():
|
||||
for f in r.get("files") or []:
|
||||
if f["id"] == int(parts[2]):
|
||||
return self.reply(200, raw=f["content"], ctype="application/octet-stream")
|
||||
return self.reply(404, {"detail": "file not found"})
|
||||
if p == "/api/firmware":
|
||||
pid = int(qs("platform_id", 0))
|
||||
return self.reply(200, [{"id": f["id"], "file_name": f["file_name"], "file_size_bytes": len(f["content"])}
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
/* Where hard links work on the console, and why they fail where they don't. */
|
||||
#include <errno.h>
|
||||
#include <fcntl.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
#include <sys/mount.h>
|
||||
#include <sys/stat.h>
|
||||
#include <unistd.h>
|
||||
|
||||
static const char *dirs[] = {
|
||||
"/user/data",
|
||||
"/data",
|
||||
"/data/PCSX2/games",
|
||||
"/data/PSXS5/games",
|
||||
"/data/homebrew",
|
||||
"/data/homebrew/PPSA99169/content/ps2",
|
||||
NULL,
|
||||
};
|
||||
|
||||
/* From folder, to folder, what it tests. */
|
||||
static const char *pairs[][3] = {
|
||||
{"/data/PCSX2/games", "/data/PCSX2/games", "A: same folder"},
|
||||
{"/data/PCSX2/games", "/data/PSXS5/games", "B: two folders under /data"},
|
||||
{"/data/homebrew/PPSA99169/content/ps2", "/data/homebrew/PPSA99169/content/ps2", "C: inside the homebrew mount"},
|
||||
{"/data/homebrew/PPSA99169/content/ps2", "/data/PCSX2/games", "D: across the mount (what RommPS does)"},
|
||||
{"/user/data/PCSX2/games", "/user/data/PCSX2/games", "E1: Same folder through underlying path"},
|
||||
{"/user/data/homebrew/PPSA99169/content/ps2", "/user/data/PCSX2/games", "E2: What we need to do through underlying path"},
|
||||
{NULL, NULL, NULL},
|
||||
};
|
||||
|
||||
static void show_mount(const char *dir) {
|
||||
struct statfs fs;
|
||||
struct stat st;
|
||||
if (statfs(dir, &fs) != 0 || stat(dir, &st) != 0) {
|
||||
printf("%s: %s\n", dir, strerror(errno));
|
||||
return;
|
||||
}
|
||||
printf("%s\n device %llu: %s mounted on %s (%s)\n", dir, (unsigned long long)st.st_dev, fs.f_mntfromname,
|
||||
fs.f_mntonname, fs.f_fstypename);
|
||||
}
|
||||
|
||||
static void try_link(const char *from_dir, const char *to_dir, const char *what) {
|
||||
char from[512], to[512];
|
||||
snprintf(from, sizeof from, "%s/rommps-linktest-a", from_dir);
|
||||
snprintf(to, sizeof to, "%s/rommps-linktest-b", to_dir);
|
||||
printf("%s\n %s -> %s\n", what, from_dir, to_dir);
|
||||
int fd = open(from, O_WRONLY | O_CREAT | O_TRUNC, 0644);
|
||||
if (fd < 0) {
|
||||
printf(" can't create the test file: %s\n", strerror(errno));
|
||||
return;
|
||||
}
|
||||
if (write(fd, "test\n", 5) != 5) printf(" short write\n");
|
||||
close(fd);
|
||||
unlink(to);
|
||||
if (link(from, to) == 0) {
|
||||
struct stat a, b;
|
||||
stat(from, &a);
|
||||
stat(to, &b);
|
||||
printf(" linked: inodes %llu and %llu, %u links\n", (unsigned long long)a.st_ino,
|
||||
(unsigned long long)b.st_ino, (unsigned)a.st_nlink);
|
||||
} else {
|
||||
printf(" failed: %s (errno %d)\n", strerror(errno), errno);
|
||||
}
|
||||
unlink(to);
|
||||
unlink(from);
|
||||
}
|
||||
|
||||
static void try_symlink(const char *dir) {
|
||||
char target[512], link_path[512];
|
||||
snprintf(target, sizeof target, "%s/rommps-linktest-a", dir);
|
||||
snprintf(link_path, sizeof link_path, "%s/rommps-linktest-sym", dir);
|
||||
printf("S: symlink in %s\n", dir);
|
||||
int fd = open(target, O_WRONLY | O_CREAT | O_TRUNC, 0644);
|
||||
if (fd < 0) {
|
||||
printf(" can't create the test file: %s\n", strerror(errno));
|
||||
return;
|
||||
}
|
||||
close(fd);
|
||||
unlink(link_path);
|
||||
if (symlink(target, link_path) == 0) {
|
||||
struct stat st;
|
||||
printf(" made; lstat says %s, stat %s\n", lstat(link_path, &st) == 0 && S_ISLNK(st.st_mode) ? "a link" : "not a link",
|
||||
stat(link_path, &st) == 0 ? "reaches the file" : "can't reach the file");
|
||||
} else {
|
||||
printf(" failed: %s (errno %d)\n", strerror(errno), errno);
|
||||
}
|
||||
unlink(link_path);
|
||||
unlink(target);
|
||||
}
|
||||
|
||||
int main(void) {
|
||||
printf("== running as uid %d, euid %d\n", (int)getuid(), (int)geteuid());
|
||||
printf("== mounts\n");
|
||||
for (int i = 0; dirs[i]; i++) show_mount(dirs[i]);
|
||||
printf("== links\n");
|
||||
for (int i = 0; pairs[i][0]; i++) try_link(pairs[i][0], pairs[i][1], pairs[i][2]);
|
||||
try_symlink("/data/PCSX2/games");
|
||||
try_symlink("/user/data/PCSX2/games");
|
||||
try_symlink("/system_tmp");
|
||||
printf("== done\n");
|
||||
fflush(stdout);
|
||||
return 0;
|
||||
}
|
||||
Executable
+125
@@ -0,0 +1,125 @@
|
||||
#!/usr/bin/env bash
|
||||
# Upgrade test: each released version sets up and syncs a console against
|
||||
# tests/mock_romm.py, then this build takes over its data folder. Nothing may
|
||||
# move that didn't change, the settings and emulators stay, and syncing goes on.
|
||||
# make host && tests/upgrade.sh [old-binary ...]
|
||||
# With no arguments, the releases are built from their tags first (git worktrees
|
||||
# in build/upgrade/).
|
||||
set -euo pipefail
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
NEW="$ROOT/build/host/romm-sync"
|
||||
MOCK_PORT=8897 WEB_PORT=8783
|
||||
API="http://127.0.0.1:${WEB_PORT}"
|
||||
|
||||
olds=("$@")
|
||||
if [[ ${#olds[@]} -eq 0 ]]; then
|
||||
mkdir -p "$ROOT/build/upgrade"
|
||||
for t in $(git -C "$ROOT" tag --sort=version:refname | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$'); do
|
||||
w="$ROOT/build/upgrade/$t"
|
||||
[[ -d "$w" ]] || git -C "$ROOT" worktree add -q --detach "$w" "$t"
|
||||
[[ -x "$w/build/host/romm-sync" ]] || make -C "$w" host >/dev/null
|
||||
olds+=("$w/build/host/romm-sync")
|
||||
done
|
||||
fi
|
||||
|
||||
T=""
|
||||
cleanup() { kill $(jobs -p) 2>/dev/null || true; if [[ -n "$T" ]]; then rm -rf "$T"; fi; }
|
||||
trap cleanup EXIT
|
||||
fail() { echo "FAIL ($label): $*"; echo "--- log"; tail -40 "$T/daemon.log"; exit 1; }
|
||||
post() { curl -sf -X POST "$API$1" -d "${2:-{\}}"; }
|
||||
status() { curl -sf "$API/api/status"; }
|
||||
count() { status | python3 -c "import json,sys; s=json.load(sys.stdin)['sync']; print(s['sync_count'] if not s['running'] else -1)"; }
|
||||
syncnow() {
|
||||
local n; n=$(count); post /api/sync >/dev/null
|
||||
for _ in $(seq 1 60); do [[ "$(count)" -gt "$n" ]] && return 0; sleep 0.5; done; fail "the sync didn't finish"
|
||||
}
|
||||
last() { status | python3 -c "import json,sys; h=json.load(sys.stdin)['sync']['history'][-1]; print(h.get('$1', h.get('error','')))"; }
|
||||
up() { for _ in $(seq 1 40); do curl -sf "$API/api/status" >/dev/null 2>&1 && return 0; sleep 0.25; done; fail "the web UI didn't come up"; }
|
||||
dump() { curl -sf "http://127.0.0.1:$MOCK_PORT/_admin/dump"; }
|
||||
snapshot() { # every save and state here, with its hash
|
||||
(cd "$RA" && find saves states roms -type f -exec md5sum {} + 2>/dev/null | sort)
|
||||
}
|
||||
start() { # binary
|
||||
ROMM_SYNC_DATA="$T/data" ROMM_SYNC_HOMEBREW="$T/none" ROMM_SYNC_AUTOSTART="$T/autostart" ROMM_SYNC_APP_DIRS="$T/none" \
|
||||
ROMM_SYNC_CATALOG="$T/catalog.json" ROMM_SYNC_FS_ROOTS="$T" "$1" >>"$T/daemon.log" 2>&1 &
|
||||
PID=$!
|
||||
up
|
||||
}
|
||||
|
||||
for OLD in "${olds[@]}"; do
|
||||
label="$(basename "$(dirname "$(dirname "$(dirname "$OLD")")")")" # build/upgrade/<tag>/build/host/romm-sync
|
||||
T="$(mktemp -d)"
|
||||
python3 "$ROOT/tests/mock_romm.py" $MOCK_PORT 2>"$T/mock.log" &
|
||||
MOCK=$!
|
||||
sleep 0.5
|
||||
RA="$T/RetroArch"; mkdir -p "$RA/roms/snes" "$RA/saves/Snes9x" "$RA/states/Snes9x" "$T/data" "$T/autostart" "$T/none"
|
||||
echo '[]' > "$T/catalog.json"
|
||||
printf 'savefile_directory = ":/saves"\nsort_savefiles_enable = "true"\nsavestate_directory = ":/states"\nsort_savestates_enable = "true"\n' > "$RA/retroarch.cfg"
|
||||
cat > "$T/data/config.json" <<EOF
|
||||
{"web_port": $WEB_PORT, "sync_interval_min": 0, "exit_delay_sec": 1, "profiles_custom": true, "conflict_policy": "ask",
|
||||
"states": "sync", "notify": false, "keep_backups": 3,
|
||||
"profiles": [{"id":"retroarch","name":"RetroArch","enabled":true,"root":"$RA",
|
||||
"rom_dir":"{root}/roms/{platform}","save_dir":"{root}/saves","state_dir":"{root}/states",
|
||||
"bios_dir":"{root}/system","save_exts":".srm","state_exts":".state*","retroarch_cfg":"{root}/retroarch.cfg",
|
||||
"platforms":[{"romm":["snes"],"dir":"snes","emulator":"snes9x","core_name":"Snes9x"},
|
||||
{"romm":["ngc"],"dir":"ngc","emulator":"dolphin","core_name":"Dolphin","save_layout":"gci"}]}]}
|
||||
EOF
|
||||
echo "ROM" > "$RA/roms/snes/Chrono Trigger (USA).sfc"
|
||||
echo "local-save-v1" > "$RA/saves/Snes9x/Chrono Trigger (USA).srm"
|
||||
printf 'local-state-1' > "$RA/states/Snes9x/Chrono Trigger (USA).state1"
|
||||
echo "ROM" > "$RA/roms/snes/Super Metroid (USA).sfc"
|
||||
# GameCube: a game with a .gci save (synced as a zip), from RomM's catalog of the mock.
|
||||
mkdir -p "$RA/roms/ngc" "$RA/saves/dolphin-emu/User/GC/USA/Card A"
|
||||
python3 "$ROOT/tests/make_disc.py" gc "$RA/roms/ngc/Wind Waker (USA).iso" GZLE01
|
||||
python3 "$ROOT/tests/gccard.py" gci "$RA/saves/dolphin-emu/User/GC/USA/Card A/01-GZLE-gczelda.gci" GZLE01 gczelda 1 0x10
|
||||
# A state on RomM the old version brings down.
|
||||
curl -sf -X POST "http://127.0.0.1:$MOCK_PORT/_admin/state" -d '{"rom_id":11,"emulator":"snes9x","file_name":"Super Metroid (USA).state1","content":"srv-state"}' >/dev/null
|
||||
curl -sf -X POST "http://127.0.0.1:$MOCK_PORT/_admin/save" -d '{"rom_id":11,"slot":"autosave","file_name":"Super Metroid (USA).srm","content":"server-save"}' >/dev/null
|
||||
|
||||
# The old version: paired, set up, synced twice.
|
||||
start "$OLD"
|
||||
post /api/pair/start "{\"server_url\":\"http://127.0.0.1:$MOCK_PORT\"}" >/dev/null || fail "pairing refused"
|
||||
for _ in $(seq 1 20); do [[ "$(status | python3 -c "import json,sys; print(json.load(sys.stdin)['paired'])")" == True ]] && break; sleep 0.5; done
|
||||
post /api/setup/finish '{"conflict_policy":"ask"}' >/dev/null 2>&1 || true
|
||||
syncnow; syncnow
|
||||
[[ "$(cat "$RA/saves/Snes9x/Super Metroid (USA).srm" 2>/dev/null)" == "server-save" ]] || fail "the old version didn't sync (setup)"
|
||||
[[ -n "$(find "$RA/states" -name 'Super Metroid*' 2>/dev/null)" ]] || fail "the old version didn't bring the state down (setup)"
|
||||
dump | python3 -c "import json,sys; sys.exit(0 if any(s['rom_id']==40 for s in json.load(sys.stdin)['saves']) else 1)" ||
|
||||
fail "the old version didn't send the GameCube save (setup)"
|
||||
before=$(snapshot)
|
||||
saves_before=$(dump | python3 -c "import json,sys; d=json.load(sys.stdin); print(len(d['saves']), len(d['states']))")
|
||||
kill "$PID"; wait "$PID" 2>/dev/null || true
|
||||
|
||||
# This build takes over.
|
||||
start "$NEW"
|
||||
for _ in $(seq 1 40); do [[ "$(count)" -ge 1 ]] && break; sleep 0.5; done # its startup sync
|
||||
syncnow
|
||||
v=$(status | python3 -c "import json,sys; print(json.load(sys.stdin)['version'])")
|
||||
[[ "$(snapshot)" == "$before" ]] || fail "files here changed on upgrade: $(diff <(echo "$before") <(snapshot) | head -5)"
|
||||
[[ "$(dump | python3 -c "import json,sys; d=json.load(sys.stdin); print(len(d['saves']), len(d['states']))")" == "$saves_before" ]] ||
|
||||
fail "RomM got something new on upgrade"
|
||||
h=$(status | python3 -c "import json,sys; print([(h['reason'], h['uploaded'], h['downloaded'], h['conflicts'], h['states_uploaded'], h['states_downloaded']) for h in json.load(sys.stdin)['sync']['history'][-2:]])")
|
||||
[[ "$h" != *", 1"* && "$h" != *"conflict"* ]] || true
|
||||
python3 - "$h" <<'PY' || fail "a sync after the upgrade moved something: $h"
|
||||
import ast, sys
|
||||
for reason, up, down, conflicts, sup, sdown in ast.literal_eval(sys.argv[1]):
|
||||
assert up == down == conflicts == sup == sdown == 0
|
||||
PY
|
||||
cfg=$(curl -sf "$API/api/config" | python3 -c "
|
||||
import json,sys; c=json.load(sys.stdin)
|
||||
print(c['conflict_policy'], c['states'], c['save_sync'], c['notify'], c['keep_backups'], [p['id'] for p in c['profiles']])")
|
||||
[[ "$cfg" == "ask sync True False 3 ['retroarch']" ]] || fail "settings changed on upgrade: $cfg"
|
||||
tr=$(curl -sf "$API/api/tracked" | python3 -c "
|
||||
import json,sys; t=json.load(sys.stdin)
|
||||
print(sorted((f['status']) for p in t['platforms'] for g in p['games'] for f in g['files']))")
|
||||
[[ "$tr" != *"changed"* && "$tr" != *"conflict"* && "$tr" == *"synced"* ]] || fail "tracked saves after the upgrade: $tr"
|
||||
curl -sf "$API/api/events" | python3 -c "import json,sys; assert isinstance(json.load(sys.stdin), list)" || fail "no sync log"
|
||||
# And it keeps syncing.
|
||||
sleep 1.1; echo "local-save-v2" > "$RA/saves/Snes9x/Chrono Trigger (USA).srm"
|
||||
syncnow
|
||||
[[ "$(last uploaded)" == 1 ]] || fail "a changed save after the upgrade must go up"
|
||||
echo "ok $label -> $v"
|
||||
kill "$PID" "$MOCK" 2>/dev/null || true; wait 2>/dev/null || true
|
||||
rm -rf "$T"; T=""
|
||||
done
|
||||
echo "PASS"
|
||||
+188
-58
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>RomM Sync</title>
|
||||
<title>RommPS</title>
|
||||
<script>
|
||||
// The consoles' browsers can't blur fast enough: themes drop the blur there.
|
||||
if (/PlayStation/.test(navigator.userAgent)) document.documentElement.className += " low-gpu";
|
||||
@@ -17,7 +17,7 @@ if (/PlayStation/.test(navigator.userAgent)) document.documentElement.className
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>RomM <span>Sync</span></h1>
|
||||
<h1>Romm<span>PS</span></h1>
|
||||
<nav id="tabs">
|
||||
<button class="tab active" data-tab="home">Status</button>
|
||||
<button class="tab" data-tab="library">Library</button>
|
||||
@@ -38,7 +38,7 @@ if (/PlayStation/.test(navigator.userAgent)) document.documentElement.className
|
||||
<p class="muted">RomM 5.2.0 or newer.</p>
|
||||
<label for="srv">Server address</label>
|
||||
<input id="srv" placeholder="https://romm.example.com or 192.168.1.10:8080" autocomplete="off">
|
||||
<div class="toggle"><input type="checkbox" id="wzInsecure"><label for="wzInsecure">Skip certificate checks (for self-signed HTTPS)</label></div>
|
||||
<div class="toggle"><input type="checkbox" id="wzInsecure"><label for="wzInsecure">Don't verify TLS certificate</label></div>
|
||||
<div class="row" style="margin-top:14px"><button class="primary" id="pairStart">Continue</button></div>
|
||||
<details style="margin-top:18px">
|
||||
<summary class="muted">Use a pairing code instead</summary>
|
||||
@@ -91,7 +91,10 @@ if (/PlayStation/.test(navigator.userAgent)) document.documentElement.className
|
||||
|
||||
<div class="wz" id="wz4">
|
||||
<h2>First sync</h2>
|
||||
<p class="muted">Nothing has been transferred yet. This is what the first sync will do.</p>
|
||||
<div class="toggle"><input type="checkbox" id="wzSaveSync" checked><label for="wzSaveSync">Sync saves with RomM</label></div>
|
||||
<p class="muted" id="wzGamesOnly" style="display:none">RomM is only for games. Turn save sync on any time in Settings.</p>
|
||||
<div id="wzSyncPart">
|
||||
<p class="muted">What the first sync will do.</p>
|
||||
<div id="preview"><p class="muted">Checking</p></div>
|
||||
<label for="wzPolicy">If a save is different here and on RomM</label>
|
||||
<select id="wzPolicy">
|
||||
@@ -101,19 +104,22 @@ if (/PlayStation/.test(navigator.userAgent)) document.documentElement.className
|
||||
<option value="server">Keep the one on RomM</option>
|
||||
</select>
|
||||
<div class="notice">
|
||||
<b>Back up your saves first.</b> Before the first sync, copy your save folders somewhere safe (FTP or a USB drive). RomM Sync keeps local backups before it replaces a file, but a copy of your own is the only guarantee.
|
||||
<b>Back up your saves first.</b> Before the first sync, copy your save folders somewhere safe (FTP or a USB drive). RommPS keeps local backups before it replaces a file, but a copy of your own is the only guarantee.
|
||||
<div class="toggle" style="margin-top:10px"><input type="checkbox" id="wzBackedUp"><label for="wzBackedUp">I have backed up my saves</label></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row"><button id="wzBack4">Back</button><button id="wzRefresh">Check again</button><button class="primary" id="wzPreviewOk" disabled>Continue</button></div>
|
||||
</div>
|
||||
|
||||
<div class="wz" id="wz5">
|
||||
<h2>Background sync</h2>
|
||||
<div id="wzSyncOpts">
|
||||
<div class="toggle"><input type="checkbox" id="wzChange" checked><label for="wzChange">Upload saves as soon as they change</label></div>
|
||||
<div class="toggle"><input type="checkbox" id="wzExit" checked><label for="wzExit">Sync when a game closes</label></div>
|
||||
<div class="toggle"><input type="checkbox" id="wzStart" checked><label for="wzStart">Download new saves when a game starts</label></div>
|
||||
<div class="toggle"><input type="checkbox" id="wzNotify" checked><label for="wzNotify">Notifications</label></div>
|
||||
<label for="wzInterval">Sync every (minutes, 0 to turn off)</label><input id="wzInterval" type="number" min="0" value="15">
|
||||
</div>
|
||||
<div class="toggle"><input type="checkbox" id="wzNotify" checked><label for="wzNotify">Notifications</label></div>
|
||||
<h3>Start with the console</h3>
|
||||
<div id="autostart"><p class="muted">Checking autostart</p></div>
|
||||
<div class="row" style="margin-top:16px"><button id="wzBack5">Back</button><button class="primary" id="wzFinish">Finish</button></div>
|
||||
@@ -124,7 +130,8 @@ if (/PlayStation/.test(navigator.userAgent)) document.documentElement.className
|
||||
|
||||
<section id="home" class="active">
|
||||
<div class="notice" id="updNotice" style="display:none"></div>
|
||||
<div class="card">
|
||||
<div class="card" id="syncOff" style="display:none"><h2 style="margin:0">Save sync is off</h2><p class="muted">RomM is only for games. Turn it on in Settings.</p></div>
|
||||
<div class="card syncCard">
|
||||
<div class="row">
|
||||
<h2 style="margin:0">Saves</h2>
|
||||
<button class="primary" id="syncNow">Sync now</button>
|
||||
@@ -137,10 +144,24 @@ if (/PlayStation/.test(navigator.userAgent)) document.documentElement.className
|
||||
<p class="muted">Changed here and on RomM. The one you don't keep is backed up.</p>
|
||||
<table><thead><tr><th>Game</th><th>This console</th><th>RomM</th><th></th></tr></thead><tbody id="conflicts"></tbody></table>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card syncCard">
|
||||
<h2>Recent syncs</h2>
|
||||
<table><thead><tr><th>When</th><th>Reason</th><th>Uploaded</th><th>Downloaded</th><th>States</th><th>Result</th></tr></thead><tbody id="history"></tbody></table>
|
||||
</div>
|
||||
<div class="card syncCard">
|
||||
<div class="row">
|
||||
<h2 style="margin:0">Activity</h2>
|
||||
<select id="actFilter" class="fixed" aria-label="Show">
|
||||
<option value="">Everything</option><option value="save">Saves</option><option value="state">Save states</option><option value="problem">Problems</option>
|
||||
</select>
|
||||
</div>
|
||||
<table><thead><tr><th>When</th><th>Game</th><th>Result</th><th>Details</th></tr></thead><tbody id="activity"></tbody></table>
|
||||
</div>
|
||||
<div class="card syncCard">
|
||||
<h2>Tracked saves and states</h2>
|
||||
<p class="muted" id="trackedSum"></p>
|
||||
<div id="tracked"></div>
|
||||
</div>
|
||||
<div class="card" id="strayCard" style="display:none">
|
||||
<h2 class="warn">Saves in the wrong folder</h2>
|
||||
<p class="muted">The emulator doesn't read these, so they aren't synced.</p>
|
||||
@@ -189,7 +210,7 @@ if (/PlayStation/.test(navigator.userAgent)) document.documentElement.className
|
||||
<div class="card">
|
||||
<h2>Server</h2>
|
||||
<p id="srvInfo"></p>
|
||||
<div class="row"><button id="tileBtn">Add home screen tile</button><button id="editEmus">Emulators</button><button id="rerunSetup">Run setup again</button><button class="danger" id="forget">Unpair</button></div>
|
||||
<div class="row"><button id="editEmus">Emulators</button><button id="rerunSetup">Run setup again</button><button class="danger" id="forget">Unpair</button></div>
|
||||
<h3>PS4 and PS5 packages</h3>
|
||||
<p class="muted">Add these feeds to FPKGi. They use your RomM username and password.</p>
|
||||
<p><code id="feed4"></code><br><code id="feed5"></code></p>
|
||||
@@ -205,13 +226,16 @@ if (/PlayStation/.test(navigator.userAgent)) document.documentElement.className
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2>Sync</h2>
|
||||
<div class="toggle"><input type="checkbox" id="saveSync"><label for="saveSync">Sync saves and states with RomM</label></div>
|
||||
<label for="devname">Device name</label><input id="devname">
|
||||
<div class="toggle"><input type="checkbox" id="notify"><label for="notify">Notifications</label></div>
|
||||
<div class="toggle"><input type="checkbox" id="tls"><label for="tls">Verify TLS certificate</label></div>
|
||||
<label for="concurrency">Downloads at once</label><select id="concurrency"></select>
|
||||
<div id="syncOpts">
|
||||
<label for="interval">Sync every (minutes, 0 to turn off)</label><input id="interval" type="number" min="0">
|
||||
<div class="toggle"><input type="checkbox" id="onchange"><label for="onchange">Upload saves as soon as they change</label></div>
|
||||
<div class="toggle"><input type="checkbox" id="onexit"><label for="onexit">Sync when a game closes</label></div>
|
||||
<div class="toggle"><input type="checkbox" id="onstart"><label for="onstart">Download new saves when a game starts</label></div>
|
||||
<div class="toggle"><input type="checkbox" id="notify"><label for="notify">Notifications</label></div>
|
||||
<div class="toggle"><input type="checkbox" id="tls"><label for="tls">Verify TLS certificates</label></div>
|
||||
<label for="policy">If a save is different here and on RomM</label>
|
||||
<select id="policy">
|
||||
<option value="ask">Ask me each time</option>
|
||||
@@ -221,14 +245,15 @@ if (/PlayStation/.test(navigator.userAgent)) document.documentElement.className
|
||||
</select>
|
||||
<label for="states">Save states</label>
|
||||
<select id="states">
|
||||
<option value="off">Don't sync</option>
|
||||
<option value="upload">Upload only</option>
|
||||
<option value="sync">Upload and download</option>
|
||||
<option value="off">Off</option>
|
||||
<option value="upload">Back up to RomM</option>
|
||||
<option value="sync">Back up and restore</option>
|
||||
</select>
|
||||
<p class="muted">States only load where they were made.</p>
|
||||
<label for="serverVersions">Versions kept on RomM per save (0 keeps all)</label><input id="serverVersions" type="number" min="0" max="100">
|
||||
<label for="slot">RomM save slot</label><input id="slot">
|
||||
<label for="concurrency">Downloads at once</label><select id="concurrency"></select>
|
||||
<label for="backups">Local backups per save</label><input id="backups" type="number" min="0">
|
||||
</div>
|
||||
<div class="row" style="margin-top:16px"><button class="primary" id="saveCfg">Save</button></div>
|
||||
</div>
|
||||
<div class="card" id="ps2Card" style="display:none">
|
||||
@@ -242,13 +267,13 @@ if (/PlayStation/.test(navigator.userAgent)) document.documentElement.className
|
||||
</div>
|
||||
<div class="card" id="systemsCard" style="display:none">
|
||||
<h2>Emulator for each system</h2>
|
||||
<p class="muted">These systems can be played by more than one of your emulators. Games download to, and saves sync with, the one picked here.</p>
|
||||
<p class="muted">Pick the emulator for each system.</p>
|
||||
<div id="systems"></div>
|
||||
</div>
|
||||
<div class="card" data-collapse="folders">
|
||||
<div class="row"><h2 style="margin:0">Folders on this console</h2><button class="small fixed" data-toggle>Expand</button></div>
|
||||
<div class="collapsible">
|
||||
<table style="margin-top:12px"><thead><tr><th>Platforms</th><th>Games</th><th>Saves</th><th>States</th><th>BIOS</th></tr></thead><tbody id="paths"></tbody></table>
|
||||
<table id="pathsTable" style="margin-top:12px"><thead><tr><th>Platforms</th><th>Games</th><th class="syncCol">Saves</th><th class="syncCol">States</th><th>BIOS</th></tr></thead><tbody id="paths"></tbody></table>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card" data-collapse="profiles">
|
||||
@@ -261,8 +286,8 @@ if (/PlayStation/.test(navigator.userAgent)) document.documentElement.className
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="log"><div class="card"><div class="row"><h2 style="margin:0">Log</h2><button id="refreshLog">Refresh</button><button id="diagBtn">Download diagnostics</button></div>
|
||||
<p class="muted">The diagnostics file has the console details, settings and full log, without your RomM credentials. Attach it when reporting a problem.</p><pre id="logText"></pre></div></section>
|
||||
<section id="log"><div class="card"><div class="row"><h2 style="margin:0">Log</h2><button id="refreshLog">Refresh</button><button id="diagBtn">Diagnostic bundle</button></div>
|
||||
<p class="muted">Logs and settings for a bug report. Your server, names and addresses are left out.</p><pre id="logText"></pre></div></section>
|
||||
</main>
|
||||
<div id="modal"><div class="card">
|
||||
<div class="row"><h2 style="margin:0" id="histTitle">History</h2><button id="histClose">Close</button></div>
|
||||
@@ -311,7 +336,7 @@ function fmtTime(t) { return t ? new Date(t * 1000).toLocaleString() : "never";
|
||||
function ago(t) {
|
||||
if (!t) return "never";
|
||||
var s = Math.round(Date.now() / 1000 - t);
|
||||
if (s < 60) return s + "s ago"; if (s < 3600) return Math.round(s / 60) + " min ago"; if (s < 86400) return Math.round(s / 3600) + " h ago";
|
||||
if (s < 120) return s + "s ago"; if (s < 7200) return Math.round(s / 60) + " min ago"; if (s < 86400) return Math.round(s / 3600) + " h ago";
|
||||
return fmtTime(t);
|
||||
}
|
||||
function fmtEta(sec) {
|
||||
@@ -340,7 +365,6 @@ each(".tab", function (t) { t.onclick = function () { showTab(t.dataset.tab); };
|
||||
// Status
|
||||
function renderStatus(s) {
|
||||
state.status = s;
|
||||
$("tileBtn").style.display = s.has_tile === false ? "none" : "";
|
||||
$("conn").innerHTML = s.paired
|
||||
? '<span class="ok">●</span> ' + esc(s.username || "") + " at " + esc(s.server_url.replace(/^https?:\/\//, ""))
|
||||
: '<span class="warn">●</span> Not paired';
|
||||
@@ -351,6 +375,9 @@ function renderStatus(s) {
|
||||
}
|
||||
if (document.querySelector("#pair.active") && !wz.editing) showTab("home");
|
||||
var sy = s.sync;
|
||||
$("syncOff").style.display = s.save_sync === false ? "" : "none";
|
||||
each("#home .syncCard", function (x) { x.style.display = s.save_sync === false ? "none" : ""; });
|
||||
if (sy.sync_count !== state.shownSyncs && !sy.running) { state.shownSyncs = sy.sync_count; loadActivity(); loadTracked(); }
|
||||
$("syncNow").disabled = sy.running;
|
||||
$("syncPhase").textContent = sy.running ? "Syncing: " + sy.phase : "Last sync " + ago(sy.last_run) + (s.foreground ? ", playing " + s.foreground : "");
|
||||
var last = sy.history.length ? sy.history[sy.history.length - 1] : null;
|
||||
@@ -367,12 +394,12 @@ function renderStatus(s) {
|
||||
'<td><button class="small" data-keep="local" data-path="' + esc(c.path) + '">Keep this one</button> <button class="small" data-keep="server" data-path="' + esc(c.path) + '">Keep RomM</button></td></tr>';
|
||||
}).join("");
|
||||
|
||||
$("history").innerHTML = sy.history.slice().reverse().slice(0, 10).map(function (h) {
|
||||
$("history").innerHTML = sy.history.slice().sort(function (a, b) { return b.time - a.time; }).slice(0, 10).map(function (h) {
|
||||
var result = h.error ? '<span class="err">' + esc(h.error) + "</span>"
|
||||
: h.conflicts ? '<span class="warn">' + h.conflicts + " conflict" + (h.conflicts > 1 ? "s" : "") + "</span>"
|
||||
: '<span class="ok">OK</span>';
|
||||
if (h.failed) result += ' <span class="err">' + h.failed + " failed</span>";
|
||||
if (h.cards_uploaded) result += " " + pill("memory card backed up");
|
||||
if (h.cards_uploaded) result += " " + pill("memory card up");
|
||||
return "<tr><td>" + ago(h.time) + "</td><td>" + esc(h.reason) + "</td><td>" + h.uploaded + "</td><td>" + h.downloaded +
|
||||
(h.deferred ? ' <small class="muted">(' + h.deferred + " waiting for the game to close)</small>" : "") + "</td><td>" + h.states_uploaded + "</td><td>" + result + "</td></tr>";
|
||||
}).join("") || '<tr><td colspan=6 class="muted">No syncs yet.</td></tr>';
|
||||
@@ -389,7 +416,7 @@ function renderStatus(s) {
|
||||
function poll() {
|
||||
poll.n = (poll.n || 0) + 1;
|
||||
if (poll.n % 10 === 0) loadUpdate();
|
||||
api("GET", "/api/status").then(renderStatus).catch(function () { $("conn").innerHTML = '<span class="err">●</span> Can\'t reach RomM Sync'; });
|
||||
api("GET", "/api/status").then(renderStatus).catch(function () { $("conn").innerHTML = '<span class="err">●</span> Can\'t reach RommPS'; });
|
||||
if (document.querySelector("#downloads.active")) loadDownloads();
|
||||
if (document.querySelector("#library.active") && $("romCard").style.display !== "none") pollLibraryDownloads();
|
||||
}
|
||||
@@ -415,8 +442,19 @@ function wzGo(n) {
|
||||
each("#wzSteps span", function (x) { var k = +x.dataset.s; x.classList.toggle("on", k === n); x.classList.toggle("done", k < n); });
|
||||
$("pairErr").textContent = "";
|
||||
if (n === 3 && !wz.loaded[3]) loadEmus();
|
||||
if (n === 4) loadPreview();
|
||||
if (n === 5) loadAutostart();
|
||||
if (n === 4) { loadPreview(); wzLoadSettings(); }
|
||||
if (n === 5) { wzLoadSettings(); loadAutostart(); }
|
||||
}
|
||||
// The wizard's choices start from the current settings, not fixed defaults:
|
||||
// running it again (or after RommPS's setup) keeps what was chosen.
|
||||
function wzLoadSettings() {
|
||||
if (wz.loaded.settings) return;
|
||||
wz.loaded.settings = true;
|
||||
api("GET", "/api/config").then(function (c) {
|
||||
$("wzPolicy").value = c.conflict_policy; $("wzChange").checked = c.sync_on_change; $("wzExit").checked = c.sync_on_game_exit;
|
||||
$("wzStart").checked = c.sync_on_game_start; $("wzNotify").checked = c.notify; $("wzInterval").value = c.sync_interval_min;
|
||||
$("wzSaveSync").checked = c.save_sync !== false; wzSaveSyncShown();
|
||||
}).catch(function () {});
|
||||
}
|
||||
function renderQr(url) {
|
||||
if (wz.qrFor === url) return;
|
||||
@@ -468,10 +506,14 @@ function emuCard(c) {
|
||||
}).join("");
|
||||
var on = wz.editing ? !!wz.current[c.root] : true;
|
||||
return '<div class="emu"><label class="head"><input type="checkbox" data-root="' + esc(c.root) + '"' + (on ? " checked" : "") + "><b>" + esc(c.name) + "</b>" +
|
||||
(c.title_id ? pill(c.title_id, "off") : "") + (wz.editing && !wz.current[c.root] ? pill("new", "ok") : "") + "</label>" +
|
||||
(c.title_id ? pill(c.title_id, "off") : "") + "</label>" +
|
||||
'<div class="path">' + esc(c.root) + "</div>" +
|
||||
(c.cores.length ? '<div class="group"><small>Cores</small><div>' + c.cores.map(function (x) { return pill(x); }).join("") + "</div></div>" : "") +
|
||||
(plats.length ? '<div class="group"><small>Platforms</small><div>' + platPills + "</div></div>" : "") + "</div>";
|
||||
(plats.length ? '<div class="group"><small>Platforms</small><div>' + platPills + "</div></div>" : "") +
|
||||
($("wzSaveSync").checked ? '<div class="group"><small>Save sync</small><div><select data-sync="' + esc(c.root) + '">' +
|
||||
[["all", "Saves and states"], ["saves", "Saves"], ["off", "Off"]].concat((wz.sync || {})[c.root] === "mixed" ? [["mixed", "Per platform"]] : []).map(function (o) {
|
||||
return '<option value="' + o[0] + '"' + (((wz.sync || {})[c.root] || "all") === o[0] ? " selected" : "") + ">" + o[1] + "</option>";
|
||||
}).join("") + "</select></div></div>" : "") + "</div>";
|
||||
}
|
||||
// Names for system keys (the first RomM slug of a platform), until RomM's own load.
|
||||
var SYSTEM_NAMES = { psx: "PlayStation", ps2: "PlayStation 2", psp: "PSP", n64: "Nintendo 64", nds: "Nintendo DS",
|
||||
@@ -495,7 +537,8 @@ function emuConflicts() {
|
||||
if (!key) return;
|
||||
if (!by[key]) { by[key] = []; order.push(key); }
|
||||
if (by[key].some(function (o) { return o.root === c.root; })) return;
|
||||
by[key].push({ root: c.root, label: c.name + (p.core_name ? " (" + p.core_name + ")" : ""), retroarch: c.kind === "retroarch" });
|
||||
by[key].push({ root: c.root, label: c.name + (p.core_name ? " (" + p.core_name + ")" : ""), retroarch: c.kind === "retroarch",
|
||||
games: (c.dirs && c.dirs[key] && c.dirs[key].games) || 0 });
|
||||
});
|
||||
});
|
||||
return order.filter(function (k) { return by[k].length > 1; }).map(function (k) { return { key: k, options: by[k] }; });
|
||||
@@ -505,11 +548,13 @@ function renderEmuChoices() {
|
||||
if (!list.length) { $("emuChoices").innerHTML = ""; return; }
|
||||
var also = wz.also || {};
|
||||
$("emuChoices").innerHTML = "<h3>Which emulator plays what</h3>" +
|
||||
'<p class="muted">Some systems have more than one emulator here. Pick the main one for each, and tick any others that should get the same games. You can change this later in Settings.</p>' +
|
||||
'<p class="muted">Pick the main emulator for each system. Tick others that should get the same games.</p>' +
|
||||
'<div class="row"><button id="allRetroarch">Use RetroArch for all</button></div>' +
|
||||
list.map(function (s) {
|
||||
/* Not picked yet: where the system's games already are (most), else RetroArch. */
|
||||
var most = s.options.reduce(function (b, o) { return o.games > (b ? b.games : 0) || (b && o.games === b.games && o.games > 0 && o.retroarch) ? o : b; }, null);
|
||||
var pick = prev[s.key] && s.options.some(function (o) { return o.root === prev[s.key]; }) ? prev[s.key]
|
||||
: (s.options.filter(function (o) { return o.retroarch; })[0] || s.options[0]).root;
|
||||
: (most || s.options.filter(function (o) { return o.retroarch; })[0] || s.options[0]).root;
|
||||
return '<div class="emu"><div class="head"><b>' + esc(systemName(s.key)) + "</b></div><small>Main</small>" + s.options.map(function (o) {
|
||||
return '<label class="toggle"><input type="radio" name="sys-' + esc(s.key) + '" data-sys="' + esc(s.key) + '" value="' + esc(o.root) + '"' +
|
||||
(o.root === pick ? " checked" : "") + ">" + esc(o.label) + "</label>";
|
||||
@@ -563,7 +608,7 @@ function renderOwn() {
|
||||
wz.own = wz.own || {};
|
||||
var systems = uncoveredSystems().length, all = ownEntries();
|
||||
if (!all.length) { $("ownSystems").innerHTML = ""; return; }
|
||||
var names = ["Games", "Saves", "States"];
|
||||
var names = $("wzSaveSync").checked ? ["Games", "Saves", "States"] : ["Games"]; /* save sync off: only games folders */
|
||||
var card = function (e) {
|
||||
var o = wz.own[e.key] = wz.own[e.key] || { on: false, folders: ["", "", ""] };
|
||||
return '<div class="emu"><label class="head"><input type="checkbox" data-own="' + esc(e.key) + '"' + (o.on ? " checked" : "") + "><b>" +
|
||||
@@ -575,10 +620,10 @@ function renderOwn() {
|
||||
};
|
||||
$("ownSystems").innerHTML =
|
||||
(systems ? "<h3>No emulator found for these</h3>" +
|
||||
'<p class="muted">Turn one on to pick where its games, saves and states go, or leave it off for now.</p>' +
|
||||
'<p class="muted">Turn one on to pick its folders.</p>' +
|
||||
all.slice(0, systems).map(card).join("") : "") +
|
||||
(all.length > systems ? "<h3>Emulators with no preset yet</h3>" +
|
||||
'<p class="muted">RomM Sync doesn\'t know where these keep their files yet. If yours works, turn it on and pick its folders.</p>' +
|
||||
'<p class="muted">Folders unknown. Turn one on to pick them.</p>' +
|
||||
all.slice(systems).map(card).join("") : "");
|
||||
each("#ownSystems input[data-own]", function (x) { x.onchange = function () { wz.own[x.dataset.own].on = x.checked; renderOwn(); }; });
|
||||
each("#ownSystems button[data-pick]", function (b) { b.onclick = function () { openPicker(b.dataset.pick, +b.dataset.which); }; });
|
||||
@@ -629,7 +674,7 @@ function loadEmus() {
|
||||
$("emus").innerHTML = '<p class="muted">Looking for emulators</p>';
|
||||
var current = wz.editing ? api("GET", "/api/config").then(function (cfg) {
|
||||
// From Settings: what's set up now (which emulators, their main systems and extras).
|
||||
wz.current = {}; wz.choices = {}; wz.also = {}; wz.own = {};
|
||||
wz.current = {}; wz.choices = {}; wz.also = {}; wz.own = {}; wz.sync = {};
|
||||
(cfg.profiles || []).forEach(function (p) {
|
||||
if (p.enabled === false) return;
|
||||
if ((p.id || "").indexOf("custom-") === 0) {
|
||||
@@ -639,6 +684,9 @@ function loadEmus() {
|
||||
return;
|
||||
}
|
||||
wz.current[p.root] = true;
|
||||
// One setting for the emulator, or "mixed" when its platforms differ.
|
||||
var modes = (p.platforms || []).map(function (e) { return e.sync || p.sync || "all"; });
|
||||
wz.sync[p.root] = modes.every(function (m) { return m === modes[0]; }) ? (modes[0] || "all") : "mixed";
|
||||
(p.platforms || []).forEach(function (e) {
|
||||
var key = (e.romm || [])[0];
|
||||
if (!key) return;
|
||||
@@ -650,7 +698,7 @@ function loadEmus() {
|
||||
current.then(function () { return api("GET", "/api/setup/detect"); }).then(function (list) {
|
||||
wz.loaded[3] = true;
|
||||
if (!list.length) {
|
||||
$("emus").innerHTML = '<p class="warn">No emulators found.</p><p class="muted">Turn on the systems below to choose their folders, or continue with the default RetroArch profile and change folders later in Settings.</p>';
|
||||
$("emus").innerHTML = '<p class="warn">No emulators found.</p><p class="muted">Turn on systems to pick their folders, or keep RetroArch\'s defaults.</p>';
|
||||
$("ps2Choice").style.display = "none";
|
||||
wz.emus = [];
|
||||
api("GET", "/api/platforms").then(function (p) { state.platforms = wz.platforms = p; renderOwn(); }).catch(function () {});
|
||||
@@ -678,7 +726,10 @@ $("wzEmus").onclick = function () {
|
||||
each("#emus input[data-root]", function (x) { if (x.checked) roots.push(x.dataset.root); });
|
||||
var custom = ownCustom(), missing = custom.filter(function (c) { return !c.rom_dir; })[0];
|
||||
if (missing) { $("pairErr").textContent = "Choose a games folder for " + missing.name + ", or turn it off."; return; }
|
||||
api("POST", "/api/setup/emulators", { roots: roots, choices: emuChoices(), also: emuAlso(), custom: custom })
|
||||
var sync = {};
|
||||
// Only what was changed here, so per-platform settings made in the app stay.
|
||||
each("#emus select[data-sync]", function (x) { if (x.value !== ((wz.sync || {})[x.dataset.sync] || "all")) sync[x.dataset.sync] = x.value; });
|
||||
api("POST", "/api/setup/emulators", { roots: roots, choices: emuChoices(), also: emuAlso(), custom: custom, sync: sync })
|
||||
.then(function () { return api("POST", "/api/config", { ps2_cards: ps2Mode() }); })
|
||||
.then(function () {
|
||||
if (!wz.editing) { wzGo(4); return; }
|
||||
@@ -708,6 +759,13 @@ function loadPreview() {
|
||||
}).catch(function (e) { $("preview").innerHTML = '<p class="err">' + esc(e.message) + "</p>"; });
|
||||
}
|
||||
$("wzBackedUp").onchange = function () { $("wzPreviewOk").disabled = !this.checked; };
|
||||
function wzSaveSyncShown() {
|
||||
var on = $("wzSaveSync").checked;
|
||||
$("wzSyncPart").style.display = on ? "" : "none"; $("wzGamesOnly").style.display = on ? "none" : "";
|
||||
$("wzSyncOpts").style.display = on ? "" : "none"; $("wzFinish").textContent = on ? "Finish" : "Finish";
|
||||
$("wzPreviewOk").disabled = on && !$("wzBackedUp").checked;
|
||||
}
|
||||
$("wzSaveSync").onchange = wzSaveSyncShown;
|
||||
$("wzBack4").onclick = function () { wz.loaded[3] = false; wzGo(3); };
|
||||
$("wzRefresh").onclick = loadPreview;
|
||||
$("wzPreviewOk").onclick = function () { wzGo(5); };
|
||||
@@ -720,7 +778,8 @@ function loadAutostart() {
|
||||
else if (a.enabled && a.hint) h = '<p class="ok">Copied to <code>' + esc(a.path) + "</code>.</p><p>" + esc(a.hint) + '</p><button id="asOff">Remove it</button>';
|
||||
else if (a.enabled) h = '<p class="ok">Starts with ' + ld + '.</p><button id="asOff">Turn off</button>';
|
||||
else if (a.installed) h = '<p class="muted">Installed at <code>' + esc(a.path) + '</code></p><button class="primary" id="asOn">Start with the console</button>';
|
||||
else h = '<p class="muted">' + ld + " needs a copy of " + pl + '. Pick the file from a computer, or run <code>make install</code>.</p><div class="row"><input type="file" id="asFile" accept=".elf"><button class="primary" id="asUpload">Upload</button></div>';
|
||||
else h = '<p class="muted">' + ld + " needs a copy of " + pl + "." + (state.status && state.status.platform === "ps5" ? " Open RommPS on the console and it sets this up, or pick the file from a computer." : " Pick the file from a computer.") +
|
||||
'</p><div class="row"><input type="file" id="asFile" accept=".elf"><button class="primary" id="asUpload">Upload</button></div>';
|
||||
$("autostart").innerHTML = h;
|
||||
if ($("asOn")) $("asOn").onclick = function () { api("POST", "/api/autostart", { enable: true }).then(loadAutostart).catch(function (e) { toast(e.message, 1); }); };
|
||||
if ($("asOff")) $("asOff").onclick = function () { api("POST", "/api/autostart", { enable: false }).then(loadAutostart); };
|
||||
@@ -736,9 +795,9 @@ $("wzBack5").onclick = function () { wzGo(4); };
|
||||
$("wzFinish").onclick = function () {
|
||||
$("wzFinish").disabled = true;
|
||||
api("POST", "/api/setup/finish", {
|
||||
conflict_policy: $("wzPolicy").value, sync_on_change: $("wzChange").checked, sync_on_game_exit: $("wzExit").checked,
|
||||
save_sync: $("wzSaveSync").checked, conflict_policy: $("wzPolicy").value, sync_on_change: $("wzChange").checked, sync_on_game_exit: $("wzExit").checked,
|
||||
sync_on_game_start: $("wzStart").checked, notify: $("wzNotify").checked, sync_interval_min: +$("wzInterval").value
|
||||
}).then(function () { $("wzFinish").disabled = false; toast("First sync started"); state.status.setup_complete = true; showTab("home"); poll(); })
|
||||
}).then(function () { $("wzFinish").disabled = false; toast($("wzSaveSync").checked ? "All set. Let the first sync finish before you play" : "All set"); state.status.setup_complete = true; showTab("home"); poll(); })
|
||||
.catch(function (e) { $("wzFinish").disabled = false; $("pairErr").textContent = e.message; });
|
||||
};
|
||||
// Settings' Emulators: just that step, from what's set up now, without pausing sync.
|
||||
@@ -800,18 +859,29 @@ $("viewGrid").onclick = function () { setView("grid"); };
|
||||
$("viewTable").onclick = function () { setView("table"); };
|
||||
|
||||
var CLOCK_ICON = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></svg>';
|
||||
function romJob(g) { var j = state.dl[g.id]; return j && (j.status === "queued" || j.status === "downloading") ? j : null; }
|
||||
function romJob(g) { var j = state.dl[g.id]; return j && (j.status === "queued" || j.status === "downloading" || j.status === "syncing") ? j : null; }
|
||||
function jobPct(j) { return j.total ? Math.min(100, Math.floor(j.done * 100 / j.total)) : 0; }
|
||||
function jobLabel(j) { return j.status === "queued" ? "Queued" : j.status === "syncing" ? "Syncing saves" : jobPct(j) + "%"; }
|
||||
function plural(n, what) { return n + " " + what + (n === 1 ? "" : "s"); }
|
||||
/* What the sync after a download brought. */
|
||||
function jobSaves(d) {
|
||||
if (d.saves_failed) return '<span class="err">saves could not sync</span>';
|
||||
if (d.save_conflicts) return '<span class="warn">save conflict to resolve</span>';
|
||||
var got = [];
|
||||
if (d.saves) got.push(plural(d.saves, "save"));
|
||||
if (d.states) got.push(plural(d.states, "state"));
|
||||
return got.length ? got.join(" and ") + " synced" : "";
|
||||
}
|
||||
function romButton(g) {
|
||||
if (!state.platform.profile) return "";
|
||||
var j = romJob(g);
|
||||
if (j) return "<button disabled>" + (j.status === "queued" ? "Queued" : jobPct(j) + "%") + "</button>";
|
||||
if (j) return "<button disabled>" + jobLabel(j) + "</button>";
|
||||
return g.installed ? '<button class="icon" data-hist="' + g.id + '" title="History" aria-label="History">' + CLOCK_ICON + '</button> <button class="danger" data-del="' + g.id + '">Remove</button>'
|
||||
: '<button class="primary" data-dl="' + g.id + '" data-name="' + esc(g.name || "") + '">Download</button>';
|
||||
}
|
||||
function romStatus(g) {
|
||||
var j = romJob(g);
|
||||
if (j) return pill(j.status === "queued" ? "Queued" : jobPct(j) + "%", "warn");
|
||||
if (j) return pill(jobLabel(j), "warn");
|
||||
return g.installed ? pill("Downloaded", "ok") : "";
|
||||
}
|
||||
// Updates the open platform as downloads progress, keeping focus on the same game.
|
||||
@@ -844,7 +914,7 @@ function renderRoms() {
|
||||
$("roms").innerHTML = '<div class="grid">' + items.map(function (g) {
|
||||
var cover = g.cover ? 'style="background-image:url(\'' + coverUrl(g) + '\')"' : "";
|
||||
var j = romJob(g);
|
||||
var badge = j ? '<span class="badge dl">' + (j.status === "queued" ? "Queued" : jobPct(j) + "%") + "</span>" : g.installed ? '<span class="badge">Downloaded</span>' : "";
|
||||
var badge = j ? '<span class="badge dl">' + jobLabel(j) + "</span>" : g.installed ? '<span class="badge">Downloaded</span>' : "";
|
||||
return '<div class="rom" data-rom="' + g.id + '"><div class="cover" ' + cover + ">" + badge + '</div><div class="t">' + esc(g.name) + '</div><div class="s">' + (g.version ? esc(g.version) + " · " : "") + fmtSize(g.size) + "</div>" + (g.installed && !romJob(g) ? '<div class="btns">' + romButton(g) + "</div>" : romButton(g)) + "</div>";
|
||||
}).join("") + "</div>";
|
||||
}
|
||||
@@ -924,8 +994,12 @@ function showHistory(romId) {
|
||||
$("histTitle").textContent = g ? g.name : "History";
|
||||
$("histBody").innerHTML = '<p class="muted">Loading</p>';
|
||||
$("modal").classList.add("on");
|
||||
api("GET", "/api/history?rom_id=" + romId).then(function (h) {
|
||||
$("histBody").innerHTML = "<h3>Saves</h3>" + historyTable("save", h.saves) + "<h3>Save states</h3>" + historyTable("state", h.states);
|
||||
Promise.all([api("GET", "/api/history?rom_id=" + romId), api("GET", "/api/tracked?rom_id=" + romId), api("GET", "/api/events?limit=30&rom_id=" + romId)]).then(function (r) {
|
||||
var h = r[0], here = [], log = (r[2] || []).slice().sort(function (a, b) { return b.t - a.t; });
|
||||
r[1].platforms.forEach(function (p) { p.games.forEach(function (g) { here = here.concat(g.files); }); });
|
||||
$("histBody").innerHTML = "<h3>On this console</h3>" + (here.length ? trackedFiles(here) : '<p class="muted">No saves or states here yet.</p>') +
|
||||
"<h3>Saves on RomM</h3>" + historyTable("save", h.saves) + "<h3>Save states on RomM</h3>" + historyTable("state", h.states) +
|
||||
"<h3>Log</h3>" + (log.length ? "<table><tbody>" + log.map(function (e) { return eventRow(e, false); }).join("") + "</tbody></table>" : '<p class="muted">Nothing synced yet.</p>');
|
||||
var first = $("histBody").querySelector("button"); if (first) first.focus();
|
||||
}).catch(function (e) { $("histBody").innerHTML = '<p class="err">' + esc(e.message) + "</p>"; });
|
||||
}
|
||||
@@ -938,6 +1012,51 @@ $("histBody").onclick = function (ev) {
|
||||
api("POST", "/api/restore", { kind: kind, id: +b.dataset.restoreId }).then(function () { toast("Restored"); $("modal").classList.remove("on"); }).catch(function (e) { b.disabled = false; toast(e.message, 1); });
|
||||
};
|
||||
|
||||
// Activity and tracked saves
|
||||
var ACTION_PILL = { uploaded: "ok", downloaded: "ok", restored: "ok", resolved: "ok", "backed up": "ok", conflict: "warn", skipped: "off", failed: "err" };
|
||||
var STATUS_LABEL = { synced: ["In sync", "ok"], changed: ["Changed here", "warn"], conflict: ["Conflict", "warn"], missing: ["Missing", "err"], no_game: ["Game not here", "off"] };
|
||||
function cap(s) { return s ? s.charAt(0).toUpperCase() + s.slice(1) : ""; }
|
||||
function eventRow(e, withGame) {
|
||||
var what = [e.kind === "card" ? "Memory card" : cap(e.kind)];
|
||||
if (e.detail) what.push(e.detail);
|
||||
if (e.emulator && e.action !== "skipped") what.push(e.emulator);
|
||||
return "<tr><td>" + ago(e.t) + "</td>" +
|
||||
(withGame ? "<td>" + esc(e.game || (e.kind === "card" ? "Memory card" : "Game " + e.rom_id)) + "</td>" : "") +
|
||||
"<td>" + pill(cap(e.action), ACTION_PILL[e.action]) + '</td><td title="' + esc(e.path) + '">' + esc(what.join(" · ")) + "</td></tr>";
|
||||
}
|
||||
function loadActivity() {
|
||||
var f = $("actFilter").value;
|
||||
api("GET", "/api/events?limit=300").then(function (list) {
|
||||
if (f === "problem") list = list.filter(function (e) { return e.action === "failed" || e.action === "conflict" || e.action === "skipped"; });
|
||||
else if (f) list = list.filter(function (e) { return e.kind === f; });
|
||||
list.sort(function (a, b) { return b.t - a.t; }); /* newest first */
|
||||
$("activity").innerHTML = list.slice(0, 100).map(function (e) { return eventRow(e, true); }).join("") ||
|
||||
'<tr><td colspan=4 class="muted">Nothing yet.</td></tr>';
|
||||
}).catch(function () {});
|
||||
}
|
||||
$("actFilter").onchange = loadActivity;
|
||||
function trackedFiles(files) {
|
||||
return files.map(function (x) {
|
||||
var st = STATUS_LABEL[x.status] || [x.status, ""];
|
||||
return '<div class="tfile" title="' + esc(x.path.replace(/#.*$/, "")) + '">' + pill(cap(x.kind), "off") + " " + pill(st[0], st[1]) +
|
||||
' <small class="muted">' + (x.synced_at ? "synced " + ago(x.synced_at) : "") + (x.kind === "state" && !x.from_here && x.server_id ? " · from another device" : "") + "</small></div>";
|
||||
}).join("");
|
||||
}
|
||||
function loadTracked() {
|
||||
api("GET", "/api/tracked").then(function (t) {
|
||||
$("trackedSum").textContent = t.saves + " saves, " + t.states + " states" + (t.conflicts ? ", " + t.conflicts + " in conflict" : "");
|
||||
t.platforms.sort(function (a, b) { return systemName(a.platform) < systemName(b.platform) ? -1 : 1; });
|
||||
$("tracked").innerHTML = t.platforms.map(function (p) {
|
||||
p.games.sort(function (a, b) { return a.name < b.name ? -1 : 1; });
|
||||
var files = p.games.reduce(function (n, g) { return n + g.files.length; }, 0);
|
||||
return '<details class="tplat"><summary><b>' + esc(systemName(p.platform) || "Other") + '</b> <small class="muted">' + p.games.length + " game" + (p.games.length === 1 ? "" : "s") + ", " + files + " file" + (files === 1 ? "" : "s") + "</small></summary>" +
|
||||
p.games.map(function (g) {
|
||||
return '<div class="tgame"><b>' + esc(g.name || ("Game " + g.rom_id)) + "</b>" + (g.emulator ? ' <small class="muted">' + esc(g.emulator) + "</small>" : "") + trackedFiles(g.files) + "</div>";
|
||||
}).join("") + "</details>";
|
||||
}).join("") || '<p class="muted">Nothing tracked yet.</p>';
|
||||
}).catch(function () {});
|
||||
}
|
||||
|
||||
function pollLibraryDownloads() { api("GET", "/api/downloads").then(syncLibraryDownloads).catch(function () {}); }
|
||||
|
||||
// Downloads
|
||||
@@ -953,14 +1072,15 @@ function downloadRow(d) {
|
||||
var pct = d.total ? Math.min(100, Math.round(d.done * 100 / d.total)) : (d.status === "done" ? 100 : 0);
|
||||
var meta;
|
||||
if (d.status === "error") meta = '<span class="err">' + esc(d.error) + "</span>";
|
||||
else if (d.status === "done") meta = '<span class="ok">Done</span> ' + fmtSize(d.total);
|
||||
else if (d.status === "done") meta = '<span class="ok">Done</span> ' + fmtSize(d.total) + (jobSaves(d) ? ", " + jobSaves(d) : "");
|
||||
else if (d.status === "syncing") meta = "Syncing saves";
|
||||
else if (d.status === "cancelled") meta = '<span class="muted">Cancelled</span>';
|
||||
else if (d.status === "cancelling") meta = "Cancelling";
|
||||
else if (d.status === "queued") meta = "Queued";
|
||||
else meta = fmtSize(d.done) + (d.total ? " of " + fmtSize(d.total) : "") + (d.speed > 0 ? ", " + fmtSize(d.speed) + "/s" + (d.total ? ", " + fmtEta((d.total - d.done) / d.speed) : "") : "");
|
||||
else meta = (d.note ? esc(d.note) + ": " : "") + fmtSize(d.done) + (d.total ? " of " + fmtSize(d.total) : "") + (d.speed > 0 ? ", " + fmtSize(d.speed) + "/s" + (d.total ? ", " + fmtEta((d.total - d.done) / d.speed) : "") : "");
|
||||
return '<div class="dl-item"><div class="row"><div>' + (d.platform ? pill(d.platform) : "") + (d.type === "firmware" ? pill("BIOS", "off") : "") + "<b>" + esc(d.name) + "</b>" +
|
||||
'<div class="meta">' + meta + "</div></div>" + (active ? '<button class="small" data-cancel="' + d.id + '">Cancel</button>' : "") + "</div>" +
|
||||
(active || d.status === "cancelling" ? '<div class="bar"><i style="width:' + pct + '%"></i></div>' : "") + "</div>";
|
||||
(active || d.status === "cancelling" || d.status === "syncing" ? '<div class="bar"><i style="width:' + pct + '%"></i></div>' : "") + "</div>";
|
||||
}
|
||||
function loadDownloads() {
|
||||
api("GET", "/api/downloads").then(function (list) {
|
||||
@@ -976,12 +1096,15 @@ function loadSettings() {
|
||||
'<br><small class="muted">Device ' + esc(c.device_id) + "</small>";
|
||||
$("feed4").textContent = c.server_url + "/api/feeds/fpkgi/ps4";
|
||||
$("feed5").textContent = c.server_url + "/api/feeds/fpkgi/ps5";
|
||||
$("saveSync").checked = c.save_sync !== false; $("syncOpts").style.display = $("saveSync").checked ? "" : "none";
|
||||
$("pathsTable").classList.toggle("noSaves", !$("saveSync").checked);
|
||||
$("devname").value = c.device_name; $("interval").value = c.sync_interval_min;
|
||||
$("onchange").checked = c.sync_on_change; $("onexit").checked = c.sync_on_game_exit; $("onstart").checked = c.sync_on_game_start;
|
||||
$("states").value = c.states; $("serverVersions").value = c.server_versions; $("notify").checked = c.notify; $("tls").checked = c.tls_verify;
|
||||
$("policy").value = c.conflict_policy; $("slot").value = c.slot; $("concurrency").value = c.download_concurrency; $("backups").value = c.keep_backups;
|
||||
$("profiles").value = JSON.stringify(c.profiles, null, 2);
|
||||
$("updAuto").checked = c.update_check;
|
||||
state.cfg = c;
|
||||
$("themeSel").innerHTML = THEMES.map(function (t) { return '<option value="' + t.id + '"' + (t.id === c.ui_theme ? " selected" : "") + ">" + esc(t.name) + "</option>"; }).join("");
|
||||
});
|
||||
loadUpdate();
|
||||
@@ -992,8 +1115,8 @@ function loadSettings() {
|
||||
var saves = !m.save_dir ? pill("not synced", "off") + (m.sync_note ? '<br><small class="warn">' + esc(m.sync_note) + "</small>" : "")
|
||||
: m.save_layout ? pill(layouts[m.save_layout] || m.save_layout, "ok") + "<br><small class=muted>synced as one zip per game</small>"
|
||||
: "<code>" + esc(m.save_dir) + "</code>";
|
||||
return "<tr><td>" + esc(m.platforms.split(",")[0]) + "<br><small class=muted>" + esc(m.emulator) + "</small></td><td><code>" + esc(m.rom_dir) + "</code></td><td>" + saves +
|
||||
"</td><td>" + (m.state_dir ? "<code>" + esc(m.state_dir) + "</code>" : "") + "</td><td><code>" + esc(m.bios_dir) + "</code></td></tr>";
|
||||
return "<tr><td>" + esc(m.platforms.split(",")[0]) + "<br><small class=muted>" + esc(m.emulator) + "</small></td><td><code>" + esc(m.rom_dir) + "</code></td><td class=syncCol>" + saves +
|
||||
"</td><td class=syncCol>" + (m.state_dir ? "<code>" + esc(m.state_dir) + "</code>" : "") + "</td><td><code>" + esc(m.bios_dir) + "</code></td></tr>";
|
||||
}).join("");
|
||||
var ps2 = list.filter(function (m) { return m.emulator === "pcsx2"; })[0];
|
||||
loadPs2(ps2);
|
||||
@@ -1049,13 +1172,19 @@ $("ps2Status").onclick = function (ev) {
|
||||
b.disabled = true;
|
||||
api("POST", "/api/memcards/restore", { slot: +b.dataset.restore }).then(function () { toast("Memory card restored"); loadSettings(); }).catch(function (e) { b.disabled = false; toast(e.message, 1); });
|
||||
};
|
||||
$("saveSync").onchange = function () { $("syncOpts").style.display = this.checked ? "" : "none"; $("pathsTable").classList.toggle("noSaves", !this.checked); };
|
||||
$("saveCfg").onclick = function () {
|
||||
api("POST", "/api/config", {
|
||||
var form = {
|
||||
device_name: $("devname").value, sync_interval_min: +$("interval").value, sync_on_change: $("onchange").checked,
|
||||
sync_on_game_exit: $("onexit").checked, sync_on_game_start: $("onstart").checked, states: $("states").value, server_versions: +$("serverVersions").value,
|
||||
notify: $("notify").checked, tls_verify: $("tls").checked, conflict_policy: $("policy").value, slot: $("slot").value,
|
||||
keep_backups: +$("backups").value, download_concurrency: +$("concurrency").value
|
||||
}).then(function () { toast("Saved"); }).catch(function (e) { toast(e.message, 1); });
|
||||
keep_backups: +$("backups").value, download_concurrency: +$("concurrency").value, save_sync: $("saveSync").checked
|
||||
};
|
||||
// Only what was changed here: RommPS may have changed the rest since this page loaded.
|
||||
var changed = {}, any = false, was = state.cfg || {};
|
||||
Object.keys(form).forEach(function (k) { if (form[k] !== was[k]) { changed[k] = form[k]; any = true; } });
|
||||
if (!any) { toast("Nothing changed"); return; }
|
||||
api("POST", "/api/config", changed).then(function () { toast("Saved"); loadSettings(); }).catch(function (e) { toast(e.message, 1); });
|
||||
};
|
||||
$("saveProfiles").onclick = function () {
|
||||
var p; try { p = JSON.parse($("profiles").value); } catch (e) { toast("Invalid JSON: " + e.message, 1); return; }
|
||||
@@ -1078,6 +1207,8 @@ function renderUpdate(u) {
|
||||
var pct = u.total ? Math.round(u.done * 100 / u.total) : 0;
|
||||
h += "<p>" + esc(u.message || u.state) + (u.state === "downloading" && u.total ? " (" + pct + "%)" : "") + "</p>";
|
||||
if (u.state === "downloading") h += '<div class="bar"><i style="width:' + pct + '%"></i></div>';
|
||||
} else if (u.available && u.self_update === false) {
|
||||
h += '<p class="ok">RommPS ' + esc(u.latest) + " is available. Update it in ProsperoStore.</p>";
|
||||
} else if (u.available) {
|
||||
h += '<p class="ok">Version ' + esc(u.latest) + " is available.</p>";
|
||||
if (!u.autostart) h += '<p class="warn">' + (u.autostart_supported ? "Autostart isn't set up, so the" : "The") + " update only lasts until the console restarts.</p>";
|
||||
@@ -1090,7 +1221,7 @@ function renderUpdate(u) {
|
||||
$("updBody").innerHTML = h;
|
||||
if ($("updInstall")) $("updInstall").onclick = installUpdate;
|
||||
$("updNotice").style.display = u.available && !busy ? "" : "none";
|
||||
$("updNotice").innerHTML = "<b>RomM Sync " + esc(u.latest) + ' is available.</b> <button class="small" id="updGo">See what\'s new</button>';
|
||||
$("updNotice").innerHTML = "<b>RommPS " + esc(u.latest) + ' is available.</b> <button class="small" id="updGo">See what\'s new</button>';
|
||||
$("updGo").onclick = function () { showTab("settings"); };
|
||||
}
|
||||
function loadUpdate() { return api("GET", "/api/update").then(renderUpdate).catch(function () {}); }
|
||||
@@ -1106,13 +1237,12 @@ function watchUpdate() {
|
||||
});
|
||||
}
|
||||
function installUpdate() {
|
||||
if (!confirm("Update RomM Sync to " + state.update.latest + "? It restarts once no sync, download or game is running.")) return;
|
||||
if (!confirm("Update RommPS to " + state.update.latest + "? It restarts once no sync, download or game is running.")) return;
|
||||
api("POST", "/api/update/install").then(function (u) { renderUpdate(u); watchUpdate(); }).catch(function (e) { toast(e.message, 1); });
|
||||
}
|
||||
$("updCheck").onclick = function () { api("POST", "/api/update/check").then(function (u) { renderUpdate(u); watchUpdate(); }).catch(function (e) { toast(e.message, 1); }); };
|
||||
$("updAuto").onchange = function () { api("POST", "/api/config", { update_check: this.checked }).then(function () { toast("Saved"); }).catch(function (e) { toast(e.message, 1); }); };
|
||||
|
||||
$("tileBtn").onclick = function () { api("POST", "/api/tile").then(function () { toast("Tile added"); }).catch(function (e) { toast(e.message, 1); }); };
|
||||
$("forget").onclick = function () { if (confirm("Unpair from this RomM server? Files on the console are kept.")) api("POST", "/api/pair/forget").then(function () { poll(); showTab("pair"); }); };
|
||||
|
||||
// Collapsible settings cards, collapsed until opened. The choice sticks per browser.
|
||||
@@ -1184,7 +1314,7 @@ api("GET", "/api/status").then(function (s) {
|
||||
if (!ready) { wzGo(s.paired ? 3 : 1); wizardFromStatus(s); }
|
||||
api("GET", "/api/config").then(function (c) { $("wzInsecure").checked = !c.tls_verify; }).catch(function () {});
|
||||
loadUpdate().then(function () { if (state.update && updBusy(state.update)) watchUpdate(); });
|
||||
}).catch(function () { $("conn").textContent = "Can't reach RomM Sync"; });
|
||||
}).catch(function () { $("conn").textContent = "Can't reach RommPS"; });
|
||||
setInterval(poll, 3000);
|
||||
</script>
|
||||
</body>
|
||||
|
||||
+6
-1
@@ -1,4 +1,4 @@
|
||||
/* RomM Sync web UI: layout and components. Colours, shapes and effects come
|
||||
/* RommPS web UI: layout and components. Colours, shapes and effects come
|
||||
* from the theme tokens in ui/themes/ (classic.css lists them all). */
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; background-color: var(--page); background-image: var(--backdrop); background-attachment: fixed; color: var(--text); font: 18px/1.45 var(--font); }
|
||||
@@ -103,3 +103,8 @@ a { color: var(--accent-text); }
|
||||
.ribbon button:disabled { opacity: .3; }
|
||||
.ribbon, [data-rom] { scroll-margin-top: 90px; } /* clear of the sticky header */
|
||||
#updBody details { margin-top: 14px; }
|
||||
.tplat { border-top: var(--hairline); padding: 8px 0; }
|
||||
.tplat summary { cursor: pointer; padding: 4px 0; }
|
||||
.tgame { margin: 10px 0 10px 14px; }
|
||||
.tfile { margin: 4px 0 0 10px; word-break: break-all; }
|
||||
.noSaves .syncCol { display: none; }
|
||||
@@ -1,4 +1,4 @@
|
||||
/* Classic: RomM Sync's original dark look. Also the defaults, so this file
|
||||
/* Classic: RommPS's original dark look. Also the defaults, so this file
|
||||
* lists every token ui/style.css uses; other themes override what they change
|
||||
* under [data-theme="<id>"]. */
|
||||
:root {
|
||||
|
||||
Reference in new issue
Block a user