20 Commits
Author SHA1 Message Date
LaRazaDelSolandClaude Sonnet 5.5 7c63a09d86 0.1.2: hero-shot Store carousel, resilient multipart downloads, faster Pegasus transfers
Store: the featured carousel is now a full-width hero shot (title, View game, heart) with the grid's
header and first row peeking under it; Down slides the page up. The game page and the hero show one
banner picture whole (Prosperopatches or IGDB, alternating per open) instead of swapping pictures.
The known-problem note slides with the header.

Download all parts: each part is asked up to three times with backoff, automated provider sessions are
waited on before the next part starts, a failing part is retried on another host that carries the same
part, failed parts are listed and the run goes on; link-starting requests get a 30 s limit. The busy
and report texts are no longer cut off.

Experimental, on in this build: a hash-gated in-memory patch of the player's own Pegasus DL copy that
keeps one slow disk write from stalling the other connections (about double the throughput with 8
connections).

Version 00.001.002.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GUzMaa8Kn5ZRrkw8QbJtCe
2026-10-13 00:29:53 -04:00
LaRazaDelSolandClaude Sonnet 5.5 98798a4726 Carousel always shows (falls back to the most popular games); known-problem note stays beside the facts panel
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GUzMaa8Kn5ZRrkw8QbJtCe
2026-10-12 19:33:33 -04:00
github-actions[bot] de8e29ec6c chore: heartbeat 2026-10-12T22:54:55Z 2026-10-12 22:54:56 +00:00
LaRazaDelSolandClaude Sonnet 5.5 be590ffa78 Tests: a long note is accepted
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GUzMaa8Kn5ZRrkw8QbJtCe
2026-10-12 18:53:45 -04:00
LaRazaDelSolandClaude Sonnet 5.5 4f49824057 Notes have no length limit; the page shrinks the type to fit
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GUzMaa8Kn5ZRrkw8QbJtCe
2026-10-12 18:50:05 -04:00
LaRazaDelSolandClaude Sonnet 5.5 5e32d2806d Open the page on the cached 16:9 art, default download folder, notes and carousel
- Game page holds the blurred cover back briefly when its 16:9 picture is on disk
- Set a default download folder when Pegasus has none; ask once on the first download
- Known-problem note on the game page and a curated carousel on the Store (both switchable)
- Workflow can publish the optional notes and featured lists

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GUzMaa8Kn5ZRrkw8QbJtCe
2026-10-12 18:27:27 -04:00
LaRazaDelSolandClaude Sonnet 5.5 341ecfe2db Fetch the focused tile's 16:9 picture ahead of time
When the cursor rests on a Store tile for half a second, its game-page picture
is fetched into the disk cache (once per landing, at most one every 6 seconds,
following the Art sources setting) so the page finds it there.

The disk cache gives game-page pictures a budget of their own (a quarter of the
cache) so they cannot push covers out; a picture fetched ahead of time and not
opened for half an hour is evicted before ones that were read. README and the
privacy guide now list the picture hosts.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SHcwRjnNjFZaRkbpYQoCKL
2026-10-12 17:36:30 -04:00
github-actions[bot] 36c284f79f chore: heartbeat 2026-10-12T20:56:34Z 2026-10-12 20:56:35 +00:00
LaRazaDelSolandClaude Sonnet 5.5 56d7534209 Build identity, update check, art sources, safer CI report
- Every build is stamped (assets/build-info.json: build time and test/release
  channel); tools/mark-release-wsl.sh turns a tested package into a release
  package without compiling again and re-verifies the image.
- Wings checks the project's latest release at start-up (6 h cache, switch in
  Settings), announces a newer one and shows the manual update steps.
- Popularity data is refetched on the first start of a new build; a newer data
  format says to update Wings; no more refetch on every start for data without
  hero art.
- Settings > Art sources: cover icons from the catalogue or Prosperopatches,
  16:9 game art from all sources or Prosperopatches only.
- The CI report lists title IDs only (the report artifact is public).
- Docs: updating guide, privacy table, build notes. Version 00.001.001.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SHcwRjnNjFZaRkbpYQoCKL
2026-10-12 16:51:02 -04:00
LaRazaDelSol bb1b908cd2 Fix badge link in README for GitHub release 2026-10-12 16:33:02 -04:00
LaRazaDelSol fdaac4b8c5 Enhance README with badges and alignment
Added badges and centered text in the README.
2026-10-12 16:29:52 -04:00
github-actions[bot] 48794c17b2 chore: heartbeat 2026-10-12T19:58:23Z 2026-10-12 19:58:23 +00:00
LaRazaDelSolandClaude Sonnet 5.5 b8a6d0e87a Repo cleanup: drop internal docs and dev-only files, pin build files to LF
Remove internal-only material from the public tree (first-console test
script, colour design notes, custom-catalogue note, route parity table,
and the link-parser reference scripts) and fix the links and comments that
pointed at them. The uninstall steps now live in the README.

.gitattributes keeps checksum files, the runtime and tooling folders, the
Makefile, the Dockerfile and the CA bundle on LF line endings: a Windows
checkout used to turn them into CRLF and the build rejected them.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SHcwRjnNjFZaRkbpYQoCKL
2026-10-12 15:51:19 -04:00
LaRazaDelSolandClaude Sonnet 5.5 bef0e8ca44 Game page: hero art always fills the screen, small image first, no box edges
The hero is scaled uniformly and cropped to cover the whole screen (never
stretched, never black below it). Images that would need an extreme zoom
(wider than 3.3:1) or that are nearly square are not used and the page
keeps the cover wash.

The page asks for the small heroLow image first and fades the full hero in
over it; the small one stays if the full one fails. A saved popularity copy
with no hero art is re-downloaded instead of waiting out the 24 h cache.
The data size cap is 512 KB and the hero cache holds 2 images.

The "more below" fade at the bottom of the page is now full width and fades
into the colour the hero band ends on, so it no longer shows a box with
edges.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SHcwRjnNjFZaRkbpYQoCKL
2026-10-12 15:51:08 -04:00
LaRazaDelSolandClaude Sonnet 5.5 60d86cba2d Popularity index: choose hero art by real shape, add pic0 and heroLow
hero is now picked in this order: the console's own 1920x1080 background
(pic0, looked up per title and verified by its WebP header), the IGDB
screenshot closest to 16:9, the IGDB artwork closest to 16:9, then Steam's
library_hero. IGDB images need a width of at least 1280 and a shape within
15% of 16:9, so wide banners are never chosen. heroLow is a small IGDB
screenshot the app can show while the full image loads.

The per-title fetchedAt field is gone (the app never read it). The size
limit is 500000 bytes. pic0 lookups are polite (1 request per second),
cached, and can never fail a run.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SHcwRjnNjFZaRkbpYQoCKL
2026-10-12 15:51:02 -04:00
LaRazaDelSolandClaude Sonnet 5.5 aa630b62cb README: link VirusTotal scans for the release files
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SHcwRjnNjFZaRkbpYQoCKL
2026-10-12 14:19:15 -04:00
github-actions[bot] 728b15750f chore: heartbeat 2026-10-12T17:57:17Z 2026-10-12 17:57:18 +00:00
LaRazaDelSol 2e077854c1 Update README to emphasize legal use of games
Clarified that Wings only allows use of legally owned games.
2026-10-12 13:35:15 -04:00
LaRazaDelSol fe57e59e5d Clarify screenshot rendering description in README
Updated the description of the screenshot rendering to clarify that it is from a stand-in back-end and may not represent actual results.
2026-10-12 13:33:23 -04:00
LaRazaDelSolandClaude Sonnet 5.5 26a1264ba8 Fix build docs and script defaults for the published layout; absolute release link
Adds docs/build.md (the stock Orbit notes were wrong for this repo), makes tools/build-wings-wsl.sh use the repository
root as the source by default, and links the latest release with an absolute URL.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TDzhvMMBBFhS9sLJNGtK4k
2026-10-12 13:32:27 -04:00
127 changed files with 9498 additions and 1718 deletions

No files matched your search

+6
View File
@@ -3,3 +3,9 @@
*.ps1 text eol=crlf
*.png binary
*.ffpkg binary
*.sha256 text eol=lf
app/runtime/** text eol=lf
app/tooling/** text eol=lf
app/Makefile text eol=lf
app/Dockerfile text eol=lf
*.crt text eol=lf
+9 -3
View File
@@ -50,12 +50,18 @@ jobs:
WINGS_CONTACT: ${{ vars.WINGS_CONTACT }}
TWITCH_CLIENT_ID: ${{ secrets.TWITCH_CLIENT_ID }}
TWITCH_CLIENT_SECRET: ${{ secrets.TWITCH_CLIENT_SECRET }}
# IGDB is opt-in: set repository variable WINGS_PROVIDERS to "wikipedia,steam,igdb" to enable it.
WINGS_PROVIDERS: ${{ vars.WINGS_PROVIDERS || 'wikipedia,steam' }}
# IGDB is opt-in: set repository variable WINGS_PROVIDERS to "wikipedia,steam,pic0,igdb" to enable it.
WINGS_PROVIDERS: ${{ vars.WINGS_PROVIDERS || 'wikipedia,steam,pic0' }}
run: |
python build_index.py --catalogue catalogue-titles.json --overrides overrides.json \
--providers "$WINGS_PROVIDERS" --out site/popularity.v1.json --report out/report.md \
--max-bytes 400000
--max-bytes 500000 --report-ids-only
- name: Build editorial files (optional)
# Each secret is optional; unset or empty means that file is simply not published. Content is never printed.
env:
NOTES_JSON: ${{ secrets.NOTES_JSON }}
CURATED_JSON: ${{ secrets.CURATED_JSON }}
run: python build_editorial.py --notes-env NOTES_JSON --curated-env CURATED_JSON --out site
- name: Save provider response cache (also after a failure, so a retry is fast)
if: always()
uses: actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 # v4
+28 -9
View File
@@ -1,8 +1,20 @@
<p align="center">
<img src="./app/sce_sys/icon0.png" alt="Wings Logo" width="25%%">
</p>
<p align="center">
<a href="https://github.com/LaRazaDelSol/wings-app/releases/tag/v0.1.0"><img alt="Release" src="https://img.shields.io/github/v/release/LaRazaDelSol/wings-app?label=release&color=24292f"></a>
<a href="https://github.com/LaRazaDelSol/wings-app/releases/download/v0.1.0/PPSA99178.ffpkg"><img alt="Download" src="https://img.shields.io/badge/download-release-24292f"></a>
<img alt="PS5 homebrew" src="https://img.shields.io/badge/PS5-homebrew-24292f">
</p>
<p align="center">
A native PS5 front end for Pegasus DL, made for the couch and a controller.
</p>
<div align="center">
# Wings
### A native PS5 front end for Pegasus DL, made for the couch and a controller.
---
One clean page per game. See what every link is before you press it. Manage your files. Let Pegasus do the downloading.
@@ -13,7 +25,7 @@ One clean page per game. See what every link is before you press it. Manage your
| What Wings is | What Wings is not |
|---|---|
| A PS5 app (title ID `PPSA99178`) that is a front end for a **Pegasus DL you already run** on the same console, through its local API at `127.0.0.1:6970`. | Not Pegasus DL, not affiliated with its author, and not a replacement for it. Pegasus does the downloading, installing and extracting. |
| A better way to browse what Pegasus already knows: games grouped one per tile, links explained, files managed with a controller. | Not a source of games. Wings ships **no catalogues, no sources and no download links**. You add your own sources in Pegasus. |
| A better way to browse what Pegasus already knows: games grouped one per tile, links explained, files managed with a controller. | Not a source of games. Wings ships **no catalogues, no sources and no download links**. Only play and use games you legally own and are permitted to install. |
| Free software, GPL-3.0-or-later, built on the TV app of Orbit Store. | Not Orbit Store. None of Orbit's backend (payload, loader, pairing, update feeds) is in Wings. |
> **Use it responsibly.** Wings is a front end: it ships no catalogues, sources or links. Use it only with content you own or have permission to download, the same as Pegasus DL asks.
@@ -22,7 +34,7 @@ One clean page per game. See what every link is before you press it. Manage your
## Screenshots
Rendered from the PC preview against a stand-in backend, so the titles are invented.
Rendered from a stand-in back-end. May not represent actual.
<p>
<img src="docs/screenshots/store-grid.png" width="49%" alt="The Store grid, one tile per game">
@@ -67,6 +79,8 @@ Libraries, fonts and versions are listed in full in [CREDITS.md](CREDITS.md). If
- **Cover art.** Store posters are downloaded over HTTPS by Wings itself and cached on the console (up to 96 MiB). **On by default**; turn it off in Settings.
- **Optional popularity.** Sort by most popular or highest rated, and show a score and rating on tiles, from a small public data file. **On by default**; turn it off in Settings. See [Popularity data and privacy](docs/guides/popularity-and-privacy.md).
- **Tile details setting.** Store tiles show their link tier and regions Always, On focus (default) or Never.
- **Choose where art comes from.** Settings > Wings > Art sources: cover icons from the catalogue (default) or from Prosperopatches (each title's PS5 icon), and the wide picture behind a game's page from every source (default) or from Prosperopatches only (the PS5's own background).
- **Knows when it is out of date.** Wings checks this project's latest release when it starts and tells you if a newer one exists, with the steps to install it. It never installs itself. See [Updating Wings](docs/guides/updating.md).
## Install
@@ -77,13 +91,14 @@ Libraries, fonts and versions are listed in full in [CREDITS.md](CREDITS.md). If
This is the same setup Orbit Store asks for. Pegasus DL must be running on the console when you open Wings.
1. Download `PPSA99178.ffpkg` from the [latest release](../../releases/latest).
1. Download `PPSA99178.ffpkg` from the [latest release](https://github.com/LaRazaDelSol/wings-app/releases/latest).
2. Check the file. On Windows, in PowerShell: `Get-FileHash .\PPSA99178.ffpkg -Algorithm SHA256`. The value must equal the SHA-256 shown on the release page (case does not matter). If it does not, download again.
VirusTotal scans: [PPSA99178.ffpkg](https://www.virustotal.com/gui/file/7597dc7c53f4174302af89aafa1d049745564c0b8f76433b6fade88e4a05105d) · [PPSA99178-Alternative.zip](https://www.virustotal.com/gui/file/f14f05763f85e9863a8d9b63c0b4ce0c48344cc68b7fa8e2e17026aec84b610e)
3. Copy the file to `/data/homebrew/` on the console (FTP or your usual file transfer). Check that the size on the console equals the size on the release page.
4. Wait about ten seconds. ShadowMountPlus registers it and a **Wings** tile appears in the Games row.
5. Open **Wings**.
To replace an older build under the same title ID, replace the file, remove the staged copies ShadowMountPlus made, then restart the jailbreak chain so the new package is mounted. To remove Wings, delete the `.ffpkg`; see section 10 of [FIRST-CONSOLE-TEST.md](docs/FIRST-CONSOLE-TEST.md).
To replace an older build under the same title ID (see [Updating Wings](docs/guides/updating.md)), replace the file, remove the staged copies ShadowMountPlus made, then restart the jailbreak chain so the new package is mounted. To remove Wings, close it, delete `/data/homebrew/PPSA99178.ffpkg` (or the folder `/data/homebrew/PPSA99178/`), and restart the jailbreak chain so the tile disappears. Optionally delete `/data/wings/` (diagnostics and a copy of the settings). Pegasus DL and its downloads are not touched.
Wings writes only to its own folders: `/download0/wings/` (cover cache, settings, diagnostics, inside the title's storage) and `/data/wings/` (diagnostics and a copy of the settings, so you can read them in the file manager).
@@ -108,6 +123,7 @@ More detail: [Getting started](docs/guides/getting-started.md).
| [The Files tab](docs/guides/files-tab.md) | Browse, select, copy, move, extract, install, cancel |
| [Archive passwords](docs/guides/archive-passwords.md) | The auto-try, the prompt, "choose what to keep" |
| [After a download: ShadowMount scan](docs/guides/after-install-scan.md) | Why a new game waits for Wings to close, and the banner |
| [Updating Wings](docs/guides/updating.md) | How Wings tells you about a new release, and how to install it |
| [Popularity data and privacy](docs/guides/popularity-and-privacy.md) | What is fetched, from where, how to stop it |
| [Troubleshooting and FAQ](docs/guides/troubleshooting.md) | Waiting for Pegasus, covers, "extraction failed", diagnostics |
@@ -139,7 +155,10 @@ Wings has no account, no telemetry that this project operates, and no analytics.
| Cover art **on** | The cover servers named in your catalogue's poster URLs, over HTTPS | Downloading the images. Those servers see the console's IP address. |
| Only when you press Check, or at most every 6 hours while Settings > Pegasus DL is open (needs Cover art on) | `api.github.com` (the author's release list, one small file) | Showing the latest official Pegasus DL version. GitHub sees the console's IP address. |
| Only when you press Download and confirm | `github.com`, then GitHub's `objects.githubusercontent.com` | The official `Pegasus-dl.elf` (about 20 MB), checked against GitHub's SHA-256 before it is saved. Nothing else is accepted. |
| Popularity **on** (default) | `larazadelsol.github.io` (GitHub Pages), one file, at most once per 24 hours | Scores and ratings, keyed by title ID. GitHub sees the console's IP address. The file has no user data and Wings sends no identifier beyond a fixed `WingsTV/0.1` User-Agent. |
| Popularity **on** (default) | `larazadelsol.github.io` (GitHub Pages), one file, at most once per 24 hours and once after Wings is updated | Scores and ratings, keyed by title ID. GitHub sees the console's IP address. The file has no user data and Wings sends no identifier beyond a fixed `WingsTV/0.1` User-Agent. |
| Popularity **on** and Cover art **on**: game-page pictures | The hosts named in the popularity data: `cdn.prosperopatches.com`, `images.igdb.com`, `cdn.cloudflare.steamstatic.com` (HTTPS) | The 16:9 picture behind a game's page, fetched when you open the page and, ahead of time, for the one game your cursor rests on for half a second (at most one every 6 seconds). Those servers see the console's IP address and the picture asked for. Art sources > *Prosperopatches only* limits this to the first host. |
| Art sources set to **Prosperopatches** (off by default; needs Cover art on) | `cdn.prosperopatches.com` over HTTPS | Cover icons and game-page backgrounds, for the titles the popularity data lists there. Prosperopatches sees the console's IP address and the image asked for. |
| Update check **on** (default; needs Cover art on) | `api.github.com` (the latest release of this project, one small file), when Wings starts and then at most every 6 hours | Telling you when a newer Wings exists. GitHub sees the console's IP address and the same fixed User-Agent. Turn it off in Settings > Wings > Updates. |
Turn **Cover art** off and Popularity (which needs it) stops too: Wings then makes **no internet connection at all**. Pegasus DL has its own network behaviour (sources, debrid, downloads); that is Pegasus, not Wings. Details: [Popularity data and privacy](docs/guides/popularity-and-privacy.md).
+108 -1
View File
@@ -487,6 +487,28 @@ void FakeBackend::set_capture_steps(int steps)
capture_steps_ = steps;
}
void FakeBackend::set_capture_script(std::vector<std::string> states, bool requires_browser,
std::string message)
{
Lock lock(mutex_);
script_states_ = std::move(states);
script_pos_ = 0;
script_browser_ = requires_browser;
script_message_ = std::move(message);
}
void FakeBackend::set_capture_hook(std::function<void(int)> hook)
{
Lock lock(mutex_);
capture_hook_ = std::move(hook);
}
int FakeBackend::capture_reads() const
{
Lock lock(mutex_);
return script_reads_;
}
int FakeBackend::cancelled_captures() const
{
Lock lock(mutex_);
@@ -908,6 +930,13 @@ Result<LinkResult> FakeBackend::start_link(std::string_view package_id, int link
out.capture.message = capture_automated_
? "Browser opened. Waiting for Cloudflare verification..."
: "Browser opened. Waiting for the download URL...";
if (!script_states_.empty())
{
script_pos_ = 0;
out.capture.requires_browser = script_browser_;
if (!script_browser_)
out.capture.state = "queueing";
}
break;
default:
out.kind = LinkResult::Kind::browser_opened;
@@ -944,6 +973,52 @@ Result<LinkResult> FakeBackend::debrid_select(std::string_view selection_id,
Result<CaptureView> FakeBackend::capture_status(std::string_view session_id)
{
{
Result<CaptureView> scripted;
std::function<void(int)> hook;
int reads = 0;
{
Lock lock(mutex_);
if (!script_states_.empty() && mode_ != FakeMode::offline)
{
reads = ++script_reads_;
hook = capture_hook_;
const std::size_t at = std::min(script_pos_, script_states_.size() - 1);
++script_pos_;
scripted.status = 200;
CaptureView &view = scripted.value;
view.session_id = std::string(session_id);
view.title = capture_title_;
view.automated = capture_automated_;
view.requires_browser = script_browser_;
view.state = script_states_[at];
view.active = !view.terminal();
if (view.state == "completed")
{
view.message = "Supported download queued.";
add_job_locked(capture_title_, "queued", 20.0);
view.result.queued = true;
view.result.has_jobs = true;
view.result.jobs = jobs_;
view.result.toast = view.message;
}
else if (view.terminal())
{
view.message = script_message_;
}
else
{
view.message = "Link resolving is in process. Please wait.";
}
}
}
if (reads > 0)
{
if (hook)
hook(reads);
return scripted;
}
}
Lock lock(mutex_);
if (mode_ == FakeMode::offline)
return refuse<CaptureView>();
@@ -1254,7 +1329,7 @@ Result<Art> FakeBackend::art(std::string_view key, ArtKind kind)
}
result.status = 200;
result.value.content_type = "image/png";
result.value.bytes = make_art(name, kind == ArtKind::hero);
result.value.bytes = make_art(name, kind != ArtKind::cover);
return result;
}
@@ -1371,6 +1446,7 @@ Result<SettingsView> FakeBackend::settings_update(const SettingsPatch &patch)
return result;
}
settings_.download_root = patch.root;
last_root_written_ = patch.root;
}
if (patch.has_connections)
settings_.connections = patch.connections;
@@ -1502,6 +1578,13 @@ Result<bool> FakeBackend::mkdir(std::string_view full_path)
return refuse<bool>();
Result<bool> result;
const std::string path(full_path);
mkdir_calls_.push_back(path);
if (mkdir_fails_)
{
result.status = 403;
result.error = "That location is read-only.";
return result;
}
bool found = false;
const FolderListing parent = list_locked(parent_of(path), true, &found);
if (!found || !parent.writable)
@@ -2126,6 +2209,30 @@ int FakeBackend::settings_reads() const
return settings_reads_;
}
void FakeBackend::set_download_root(const std::string &root)
{
Lock lock(mutex_);
settings_.download_root = root;
}
std::vector<std::string> FakeBackend::mkdir_calls() const
{
Lock lock(mutex_);
return mkdir_calls_;
}
void FakeBackend::set_mkdir_fails(bool fails)
{
Lock lock(mutex_);
mkdir_fails_ = fails;
}
std::string FakeBackend::last_root_written() const
{
Lock lock(mutex_);
return last_root_written_;
}
int FakeBackend::settings_writes() const
{
Lock lock(mutex_);
+30
View File
@@ -14,6 +14,7 @@
#include "orbit/backend.hpp"
#include "orbit/thread.hpp"
#include <functional>
#include <map>
#include <string>
#include <vector>
@@ -45,6 +46,18 @@ class FakeBackend final : public Backend
void set_failing_link(const std::string &package_id, int link_index);
// start_link() calls that reached a link (tests).
int started_links() const;
// Scripted capture (tests): while `states` is not empty, an automated or
// capture link starts a session with this requires_browser value, and each
// capture_status() answers the next of `states` ("priming", "queueing",
// "completed", "failed", ...), the last one repeating. `message` goes with a
// "failed" or "cancelled" state; "completed" queues a job.
void set_capture_script(std::vector<std::string> states, bool requires_browser = false,
std::string message = std::string());
// Called at every scripted capture_status(), outside the fake's lock, with
// the number of scripted reads so far (tests: act in the middle of a wait).
void set_capture_hook(std::function<void(int)> hook);
// Scripted capture_status() calls seen (tests).
int capture_reads() const;
// Store posters ("pkg:" keys). Off (default): no artwork, as with the HTTPS
// art service switched off. On: stands in for the service, so the preview and
// tests see covers arriving: the first two asks per key answer "pending",
@@ -137,6 +150,14 @@ class FakeBackend final : public Backend
std::vector<std::string> pkg_installs() const;
std::string last_token(bool torbox) const;
int settings_writes() const;
// Starts a test with this download root ("" = Pegasus reports none yet).
void set_download_root(const std::string &root);
// Every mkdir() call received, in order (also the refused ones).
std::vector<std::string> mkdir_calls() const;
// mkdir() answers a host error (403) without creating anything.
void set_mkdir_fails(bool fails);
// Folder of the last accepted root update ("" before any).
std::string last_root_written() const;
int settings_reads() const; // settings() calls that were answered
int logs_cleared() const;
int apr_updates() const;
@@ -187,6 +208,9 @@ class FakeBackend final : public Backend
std::string last_collision_;
bool apr_manifest_ok_ = true;
int settings_writes_ = 0;
std::vector<std::string> mkdir_calls_;
bool mkdir_fails_ = false;
std::string last_root_written_;
int settings_reads_ = 0;
int logs_cleared_ = 0;
int apr_updates_ = 0;
@@ -222,6 +246,12 @@ class FakeBackend final : public Backend
bool capture_automated_ = false;
int cancelled_ = 0;
std::string failing_link_; // "<packageId>#<linkIndex>"
std::vector<std::string> script_states_;
std::size_t script_pos_ = 0;
bool script_browser_ = false;
std::string script_message_;
int script_reads_ = 0;
std::function<void(int)> capture_hook_;
int started_links_ = 0;
bool store_art_ = false;
std::map<std::string, int> art_asks_;
+1 -1
View File
@@ -15,7 +15,7 @@
"conceptId": "99178",
"contentBadgeType": 1,
"contentId": "UP9000-PPSA99178_00-WINGSPEGASUS0001",
"contentVersion": "00.001.000",
"contentVersion": "00.001.002",
"downloadDataSize": 256,
"gameIntent": {
"permittedIntents": [
+139 -17
View File
@@ -1,4 +1,4 @@
// ps5-homebrew-ui - The app version, read from the packaged param.json.
// ps5-homebrew-ui - The app version, read from the packaged param.json, and which build of it this is.
// Copyright (C) 2026 BlackBearReloaded
// SPDX-License-Identifier: GPL-3.0-or-later
@@ -11,28 +11,39 @@
namespace hui
{
std::string content_version(std::string_view param_json)
namespace
{
constexpr std::string_view kKey = "\"contentVersion\"";
const std::size_t key = param_json.find(kKey);
if (key == std::string_view::npos)
bool digit(char c)
{
return std::isdigit(static_cast<unsigned char>(c)) != 0;
}
// The text of "key": "..." (no escapes in what the build writes), or "" when it is not there.
std::string_view string_value(std::string_view json, std::string_view key)
{
const std::size_t at = json.find(key);
if (at == std::string_view::npos)
return {};
const std::size_t open = param_json.find('"', param_json.find(':', key + kKey.size()));
const std::size_t colon = json.find(':', at + key.size());
if (colon == std::string_view::npos)
return {};
const std::size_t open = json.find('"', colon);
if (open == std::string_view::npos)
return {};
const std::size_t close = param_json.find('"', open + 1);
const std::size_t close = json.find('"', open + 1);
if (close == std::string_view::npos)
return {};
const std::string_view value = param_json.substr(open + 1, close - open - 1);
// NN.NNN.NNN, as the build and release tooling require.
if (value.size() != 10 || value[2] != '.' || value[6] != '.')
return {};
for (std::size_t i = 0; i < value.size(); ++i)
{
if (i != 2 && i != 6 && std::isdigit(static_cast<unsigned char>(value[i])) == 0)
return {};
}
return std::string(value);
return json.substr(open + 1, close - open - 1);
}
} // namespace
std::string content_version(std::string_view param_json)
{
const std::string_view value = string_value(param_json, "\"contentVersion\"");
int parts[3];
return parse_content_version(value, parts) ? std::string(value) : std::string();
}
std::string read_content_version(const std::string &path)
@@ -41,4 +52,115 @@ std::string read_content_version(const std::string &path)
return save::read_file(path, &text) ? content_version(text) : std::string();
}
bool parse_content_version(std::string_view value, int out[3])
{
// NN.NNN.NNN, as the build and release tooling require.
if (value.size() != 10 || value[2] != '.' || value[6] != '.')
return false;
for (std::size_t i = 0; i < value.size(); ++i)
{
if (i != 2 && i != 6 && !digit(value[i]))
return false;
}
out[0] = (value[0] - '0') * 10 + (value[1] - '0');
out[1] = (value[3] - '0') * 100 + (value[4] - '0') * 10 + (value[5] - '0');
out[2] = (value[7] - '0') * 100 + (value[8] - '0') * 10 + (value[9] - '0');
return true;
}
bool parse_release_tag(std::string_view tag, int out[3])
{
if (tag.size() < 6 || tag.size() > 12 || tag[0] != 'v')
return false;
int part = 0;
int digits = 0;
int value = 0;
for (std::size_t i = 1; i <= tag.size(); ++i)
{
if (i < tag.size() && digit(tag[i]))
{
value = value * 10 + (tag[i] - '0');
if (++digits > 3)
return false;
}
else if ((i == tag.size() || tag[i] == '.') && digits > 0 && part < 3)
{
out[part++] = value;
digits = 0;
value = 0;
}
else
{
return false;
}
}
return part == 3;
}
int compare_versions(const int a[3], const int b[3])
{
for (int i = 0; i < 3; ++i)
{
if (a[i] != b[i])
return a[i] < b[i] ? -1 : 1;
}
return 0;
}
std::string release_version_text(std::string_view content_version)
{
int v[3];
if (!parse_content_version(content_version, v))
return {};
return std::to_string(v[0]) + "." + std::to_string(v[1]) + "." + std::to_string(v[2]);
}
BuildInfo parse_build_info(std::string_view json)
{
BuildInfo info;
const std::string_view build = string_value(json, "\"build\"");
std::string_view channel = string_value(json, "\"channel\"");
// YYYYMMDD-HHMMSS
if (build.size() != 15 || build[8] != '-')
return info;
for (std::size_t i = 0; i < build.size(); ++i)
{
if (i != 8 && !digit(build[i]))
return info;
}
// The channel is padded to a fixed width so the stamp can be changed in place.
while (!channel.empty() && channel.back() == ' ')
channel.remove_suffix(1);
if (channel != "test" && channel != "release")
return info;
info.known = true;
info.release = channel == "release";
info.build = std::string(build);
return info;
}
BuildInfo read_build_info(const std::string &path)
{
std::string text;
if (!save::read_file(path, &text, 1024))
return {};
return parse_build_info(text);
}
std::string version_label(std::string_view content_version, const BuildInfo &info)
{
std::string label = release_version_text(content_version);
if (!label.empty() && info.known && !info.release)
label += " \xC2\xB7 test " + info.build;
return label;
}
std::string build_tag(std::string_view content_version, const BuildInfo &info)
{
std::string tag(content_version);
if (info.known)
tag += "+" + info.build;
return tag;
}
} // namespace hui
+29 -2
View File
@@ -1,4 +1,4 @@
// ps5-homebrew-ui - The app version, read from the packaged param.json.
// ps5-homebrew-ui - The app version, read from the packaged param.json, and which build of it this is.
// Copyright (C) 2026 BlackBearReloaded
// SPDX-License-Identifier: GPL-3.0-or-later
@@ -12,10 +12,37 @@ namespace hui
// The "contentVersion" value (PlayStation NN.NNN.NNN, e.g. "01.000.000") from
// param.json text, or "" when it is missing or malformed. Release tags use the
// same string, so what the app shows matches the GitHub release.
// same numbers (v1.0.0), so what the app shows matches the GitHub release.
std::string content_version(std::string_view param_json);
// Reads param.json from path; "" when it cannot be read.
std::string read_content_version(const std::string &path);
// "00.001.001" -> {0, 1, 1}. False for anything but NN.NNN.NNN.
bool parse_content_version(std::string_view text, int out[3]);
// "v0.1.1" -> {0, 1, 1}. A release tag is v<major>.<minor>.<patch>: digits only, one to three each.
bool parse_release_tag(std::string_view tag, int out[3]);
// -1, 0, 1.
int compare_versions(const int a[3], const int b[3]);
// "00.001.001" -> "0.1.1", the form the release page and its tag use. "" for a malformed one.
std::string release_version_text(std::string_view content_version);
// Which build of that version this is: assets/build-info.json, written by the build
// (tools/wings-stamp.py): {"build":"20261012-203105","channel":"test "}.
struct BuildInfo
{
bool known = false; // the stamp was there and valid
bool release = false; // channel "release" (a test package says "test")
std::string build; // UTC build time, YYYYMMDD-HHMMSS
};
BuildInfo parse_build_info(std::string_view json);
BuildInfo read_build_info(const std::string &path); // not known when it cannot be read
// What the player reads: "0.1.1" for a release build, "0.1.1 · test 20261012-203105" for a test build.
// An unknown stamp shows the version alone.
std::string version_label(std::string_view content_version, const BuildInfo &info);
// Identity of this exact build ("00.001.001+20261012-203105"; the version alone when the stamp is
// unknown). Saved data is tagged with it, so a new build fetches fresh data.
std::string build_tag(std::string_view content_version, const BuildInfo &info);
} // namespace hui
+66 -4
View File
@@ -16,8 +16,11 @@
#include "orbit/art.hpp"
#include "orbit/cover_art.hpp"
#include "orbit/pegasus_manager.hpp"
#include "orbit/editorial.hpp"
#include "orbit/game_header.hpp"
#include "orbit/popularity.hpp"
#include "orbit/steam_reviews.hpp"
#include "orbit/wings_update.hpp"
#include "orbit/prefs.hpp"
#include "orbit/diagnostics.hpp"
#include "orbit/gl_textures.hpp"
@@ -356,10 +359,11 @@ int main()
hui::sys::log("[WINGS] language=%s system=%d", orbit::i18n::language().data(), system_language);
orbit::Store store(backend);
store.log = [](const std::string &line) { hui::sys::log("%s", line.c_str()); };
// ---- Cover art (wings/art): https posters, fetched by the app itself ----
// Start-up facts first, so the very first console run is informative even
// if everything after this fails: see docs/FIRST-CONSOLE-TEST.md.
// if everything after this fails.
const std::int64_t boot_clock = wall_clock_seconds();
sys::log("[WINGS-ART] clock %s UTC plausible=%d", orbit::format_utc(boot_clock).c_str(),
orbit::clock_plausible(boot_clock) ? 1 : 0);
@@ -425,13 +429,24 @@ int main()
// Popularity data: off unless the Wings preference is on; then a background fetch (never
// blocks the frame). The saved copy and the local download counter live beside the art cache.
ConsoleDocFetcher doc_fetcher(art_service);
// Which Wings this is (param.json) and which build of it (assets/build-info.json, written by the build).
// Saved data is tagged with the build, so a new build fetches fresh data on its first start.
const std::string app_content_version = read_content_version("/app0/sce_sys/param.json");
const BuildInfo app_build = read_build_info(std::string(kAssets) + "/build-info.json");
orbit::PopularityServiceConfig pop_config;
pop_config.cache_paths = {std::string(kWingsDir) + "/popularity.v1"};
pop_config.app_tag = build_tag(app_content_version, app_build);
orbit::PopularityService popularity(orbit::popularity_hub(), doc_fetcher, pop_config);
orbit::popularity_hub().apply_hook = [&popularity](bool on, const std::string &url)
{ popularity.apply(on, url); };
orbit::popularity_hub().refresh_hook = [&popularity] { popularity.refresh(); };
orbit::popularity_hub().set_downloads_paths({std::string(kWingsDir) + "/downloads.v1"});
// Editorial data (game notes, store carousel): same fetcher, same 24 h and build rule as the popularity data.
orbit::EditorialServiceConfig editorial_config;
editorial_config.cache_paths = {std::string(kWingsDir) + "/editorial.v1", "/data/wings/editorial.v1"};
editorial_config.app_tag = pop_config.app_tag;
orbit::EditorialService editorial(orbit::editorial_hub(), doc_fetcher, editorial_config);
orbit::editorial_hub().apply_hook = [&editorial](bool notes, bool carousel) { editorial.apply(notes, carousel); };
// Pegasus DL on this console: the release list on demand (6 h cache), a download only on a confirm.
ConsolePegasusDownloader pegasus_downloader(art_service);
{
@@ -439,8 +454,39 @@ int main()
pm.docs = &doc_fetcher;
pm.downloader = &pegasus_downloader;
pm.cache_paths = {std::string(kWingsDir) + "/pegasus_releases.v1", "/data/wings/pegasus_releases.v1"};
// EXPERIMENTAL BUILD ONLY - set back to false before any real release package. Turns on the
// P0 Pegasus DL patch (docs/research/pegasus-dl-patch.md): before a send, pegasus_patch.hpp
// tries its catalogue against the exact verified stock file; it only ever changes bytes when
// the file's SHA-256 matches a catalogue entry precisely, and it never touches the original
// on disk (it writes a `.patched.elf` sibling and sends that instead, falling back to the
// stock file unchanged on any mismatch or error).
pm.experimental_patch_pegasus = true;
orbit::pegasus_manager().attach(std::move(pm));
}
// A newer Wings: a look at the project's latest release once the network is ready (wings_update.hpp).
{
orbit::WingsUpdateConfig wu;
wu.docs = &doc_fetcher;
wu.ready = [&art_service](std::string *why)
{
const wings::art::ServiceState state = art_service.state();
if (state.phase == wings::art::Phase::ready)
return true;
*why = state.detail.empty() ? std::string("The network is off. Turn on cover downloads first.")
: state.detail;
return false;
};
wu.cache_paths = {std::string(kWingsDir) + "/wings_update.v1", "/data/wings/wings_update.v1"};
wu.running = app_content_version;
orbit::wings_update().attach(std::move(wu));
}
struct UpdateStop
{
~UpdateStop()
{
orbit::wings_update().wait_idle();
}
} update_stop;
// The worker uses the downloader above: stop it before that goes away.
struct PegasusStop
{
@@ -458,7 +504,9 @@ int main()
{
orbit::WingsLocalPrefs local;
orbit::load_local_prefs(wings_prefs_paths(), &local);
orbit::wings_update().set_enabled(local.update_check);
popularity.apply(local.popularity_enabled, local.popularity_url);
orbit::editorial_hub().apply_prefs(local.notes_enabled, local.carousel_enabled);
}
// Package id -> poster URL, refreshed from the Store's snapshot in the frame
@@ -479,15 +527,26 @@ int main()
orbit::Context context(type, fonts, store, art);
context.cover_art = &cover_art;
context.posters = &posters;
context.catalogue_paths = catalogue_paths();
context.wings_prefs_paths = wings_prefs_paths();
// Files tab diagnostics (request ids, drops, errors) go to the title log.
context.log_line = [](const std::string &line) { sys::log("[WINGS-FILES] %s", line.c_str()); };
context.prefetch_art = [&art_source](const std::vector<std::string> &keys)
{ art_source.prefetch_keys(keys, orbit::ArtKind::cover); };
// The focused tile's 16:9 picture, into the disk cache only (the background lane: never ahead of what is on screen).
context.prefetch_hero = [&art_service](const std::string &url)
{
const std::size_t queued = art_service.prefetch({url}, wings::art::Kind::hero, wings::art::Lane::prefetch);
hui::sys::log("[WINGS-ART] hero prefetch %s", queued > 0 ? "queued" : "skipped (cached or not allowed)");
};
// The game page holds the blurred cover back when its 16:9 picture is already on disk (game_header.hpp).
orbit::hero_hooks().cached = [&art_service](const std::string &url) { return art_service.is_cached(url); };
orbit::hero_hooks().log = [](const std::string &line) { hui::sys::log("%s", line.c_str()); };
context.mark = mark;
context.loader_mark = loader_mark;
context.app_version = read_content_version("/app0/sce_sys/param.json");
context.app_version = app_content_version;
context.app_label = version_label(app_content_version, app_build);
context.open_pegasus_ui = []
{ return sceSystemServiceLaunchWebBrowser(kBrowserUrl, nullptr) == 0; };
// ShadowMount's local API (loopback only): after a download finishes, Wings asks it to scan.
@@ -514,7 +573,9 @@ int main()
orbit::App app(context);
orbit::Frame frame;
frame.glass_texture = renderer.glass_texture();
sys::log("[WINGS] ready version=%s", context.app_version.c_str());
sys::log("[WINGS] ready version=%s build=%s channel=%s", context.app_version.c_str(),
app_build.known ? app_build.build.c_str() : "unknown",
app_build.known ? (app_build.release ? "release" : "test") : "unknown");
// The diagnostics file: at start, 10 s later (Pegasus and the catalogue have
// answered by then), then every minute and whenever the player changes a
@@ -528,7 +589,8 @@ int main()
const wings::art::ServiceState st = art_service.state();
const wings::art::ArtStats stats = art_service.stats();
orbit::DiagnosticsInput in;
in.app_version = context.app_version;
in.app_version = context.app_label.empty() ? context.app_version : context.app_label;
in.app_build = app_build.known ? app_build.build + (app_build.release ? " (release)" : " (test)") : std::string();
in.title_id = kTitleId;
in.unix_time = wall_clock_seconds();
in.uptime_s = static_cast<double>(now_us - start_us) / 1e6;
+35
View File
@@ -9,6 +9,7 @@
#include "orbit/mark.hpp"
#include "orbit/screens.hpp"
#include "orbit/i18n.hpp"
#include "orbit/wings_update.hpp"
#include <algorithm>
#include <cmath>
@@ -63,6 +64,14 @@ App::App(Context &context)
context.after_install = &after_install_;
// The Store ranks links with the preferences Settings > Wings keeps.
store_.use_prefs(&settings_.local_prefs());
// The first download asks about the download folder once (panels.hpp FolderAsk); the answer is a Wings pref.
context.folder_confirmed = [this] { return settings_.local_prefs().folder_confirmed; };
context.confirm_folder = [this]
{
WingsLocalPrefs prefs = settings_.local_prefs();
prefs.folder_confirmed = true;
settings_.set_local_prefs(prefs);
};
// Settings > Wings > Reset Store grid: sort and filters only, favourites stay.
context.reset_store_grid = [this]
{
@@ -144,11 +153,37 @@ void App::update(const InputFrame &input, float dt, Feedback &feedback)
ctx_.store.want_logs(false);
ctx_.store.want_installed(false);
toast_time_ = std::max(0.0f, toast_time_ - dt);
// Pegasus DL without a download folder gets the default one, once (shown in Settings > Pegasus DL).
if (ctx_.store.state().have_settings)
ctx_.store.ensure_default_download_folder();
// A finished download asks ShadowMount to scan (whichever tab shows; the Downloads tab
// draws the answer). The setting is Settings > Pegasus DL > After a download finishes.
after_install_.set_mode(settings_.local_prefs().after_download);
after_install_.update(static_cast<double>(ctx_.clock), ctx_.store.state().jobs,
ctx_.store.state().have_queue);
// A newer Wings: ask GitHub once the network is ready (at most every 6 hours; the service decides),
// and say so once per start. Checked every two seconds, not every frame.
update_poll_ += dt;
if (update_poll_ >= 2.0f)
{
update_poll_ = 0.0f;
wings_update().auto_check();
if (!update_announced_)
{
const WingsUpdateStatus up = wings_update().status();
if (up.attached && up.enabled && up.advice == WingsAdvice::newer)
{
update_announced_ = true;
show_toast(wings_update_toast(up), 8.0f);
feedback.play(hui::audio::Cue::notify);
}
}
}
// The cover icon source (Settings > Art sources): covers are asked for again when what they resolve to changes.
if (ctx_.posters != nullptr &&
ctx_.posters->set_icon_source(settings_.local_prefs().icon_source == IconSource::prospero,
popularity_hub().index()))
ctx_.art.reset_covers();
// The one wash: aimed at the tab's hue, then at the section the screen reports.
wash_.retarget(tab_, screen().section_accent());
wash_.update(dt);
+35 -3
View File
@@ -23,12 +23,23 @@ constexpr std::size_t kMaxQueued = 96;
std::string make_key(const std::string &art_key, ArtKind kind)
{
return art_key + (kind == ArtKind::hero ? "#hero" : "#cover");
switch (kind)
{
case ArtKind::hero:
return art_key + "#hero";
case ArtKind::hero_low:
return art_key + "#low";
default:
return art_key + "#cover";
}
}
// Backgrounds of either kind (they share one budget).
bool is_hero(const std::string &key)
{
return key.size() > 5 && key.compare(key.size() - 5, 5, "#hero") == 0;
const auto ends = [&key](const char *tail, std::size_t n)
{ return key.size() > n && key.compare(key.size() - n, n, tail) == 0; };
return ends("#hero", 5) || ends("#low", 4);
}
} // namespace
@@ -116,6 +127,8 @@ void ArtCache::tick()
if (found == entries_.end())
continue; // dropped while it was decoding
Entry &entry = found->second;
if (entry.state == State::ready)
continue; // an older request for the same image finished late: keep what is shown
if (!decoded.ok)
{
entry.state = State::failed;
@@ -183,6 +196,25 @@ void ArtCache::clear()
uploads_.clear();
}
void ArtCache::reset_covers()
{
for (auto it = entries_.begin(); it != entries_.end();)
{
if (is_hero(it->first))
{
++it;
continue;
}
if (it->second.state == State::ready)
{
sink_.destroy(it->second.art.texture);
if (it->second.art.ambient != 0)
sink_.destroy(it->second.art.ambient);
}
it = entries_.erase(it); // a decode still on its way finds no entry and is dropped
}
}
void ArtCache::retry_failed()
{
for (auto &[key, entry] : entries_)
@@ -237,7 +269,7 @@ bool ArtCache::work_once()
}
if (art.ok() && !art.value.pending)
{
const bool hero = request.kind == ArtKind::hero;
const bool hero = request.kind != ArtKind::cover;
const int width = hero ? limits_.hero_width : limits_.cover_size;
const int height = hero ? limits_.hero_height : limits_.cover_size;
decoded.ok = image::decode(art.value.bytes, width, height, &decoded.pixels);
+4 -1
View File
@@ -48,7 +48,7 @@ struct ArtLimits
int hero_width = 1920; // backgrounds fit inside this ...
int hero_height = 1080; // ... and this
int max_covers = 96; // textures kept before the least recent go (4K grids)
int max_heroes = 4;
int max_heroes = 3; // an open page: the small one and the full one; the Store's hero: the same two and the next small one
int uploads_per_frame = 2;
double retry_failed = 60.0; // seconds before a failed image is asked again
// Decoded images waiting for upload: the worker stops decoding at this many
@@ -74,6 +74,9 @@ class ArtCache
bool idle();
// Releases every texture (before the GL context goes away).
void clear();
// Frame thread: forgets every cover (not the game-page backgrounds); they are asked for again as they are
// drawn. For when what the covers resolve to changed (Settings > Art sources).
void reset_covers();
// Frame thread: images that failed are asked for again the next time they are
// drawn instead of after limits.retry_failed (Settings turned something on,
// or the player asked to retry).
+158
View File
@@ -0,0 +1,158 @@
// Wings TV app - Where art comes from (see art_sources.hpp).
// SPDX-License-Identifier: GPL-3.0-or-later
#include "orbit/art_sources.hpp"
#include "orbit/popularity.hpp"
namespace orbit
{
const char *icon_source_key(IconSource source)
{
return source == IconSource::prospero ? "prospero" : "catalogue";
}
bool parse_icon_source(std::string_view text, IconSource *out)
{
if (text == "catalogue")
*out = IconSource::catalogue;
else if (text == "prospero")
*out = IconSource::prospero;
else
return false;
return true;
}
const char *hero_source_key(HeroSource source)
{
return source == HeroSource::prospero ? "prospero" : "all";
}
bool parse_hero_source(std::string_view text, HeroSource *out)
{
if (text == "all")
*out = HeroSource::all;
else if (text == "prospero")
*out = HeroSource::prospero;
else
return false;
return true;
}
bool is_prospero_pic0_url(std::string_view url)
{
constexpr std::string_view head = "https://cdn.prosperopatches.com/titles/PPSA";
constexpr std::string_view tail = "/pic0.webp";
// head + 5 digits + "_" + 64 hex + tail
if (url.size() != head.size() + 5 + 1 + 64 + tail.size() || url.substr(0, head.size()) != head ||
url.substr(url.size() - tail.size()) != tail)
return false;
std::size_t at = head.size();
for (int i = 0; i < 5; ++i, ++at)
if (url[at] < '0' || url[at] > '9')
return false;
if (url[at++] != '_')
return false;
for (int i = 0; i < 64; ++i, ++at)
if (!((url[at] >= '0' && url[at] <= '9') || (url[at] >= 'a' && url[at] <= 'f')))
return false;
return true;
}
std::string prospero_icon_url(std::string_view pic0_url)
{
if (!is_prospero_pic0_url(pic0_url))
return {};
std::string out(pic0_url.substr(0, pic0_url.size() - std::string_view("pic0.webp").size()));
out += "icon0.webp";
return out;
}
bool hero_url_allowed(std::string_view url, HeroSource source)
{
return source == HeroSource::all || is_prospero_pic0_url(url);
}
std::string choose_hero(const PopularityIndex &index, const std::vector<std::string> &title_ids, HeroSource source)
{
for (const std::string &id : title_ids)
{
const PopRecord *record = index.find(id);
if (record != nullptr && !record->hero.empty() && hero_url_allowed(record->hero, source))
return record->hero;
}
return {};
}
std::string igdb_full_url(std::string_view low_url)
{
constexpr std::string_view kPrefix = "https://images.igdb.com/igdb/image/upload/";
constexpr std::string_view kSmall = "t_screenshot_big/";
if (low_url.substr(0, kPrefix.size()) != kPrefix)
return {};
const std::string_view rest = low_url.substr(kPrefix.size());
if (rest.substr(0, kSmall.size()) != kSmall || rest.size() == kSmall.size())
return {};
return std::string(kPrefix) + "t_1080p/" + std::string(rest.substr(kSmall.size()));
}
HeroPick pick_hero(const PopularityIndex &index, const std::vector<std::string> &title_ids, HeroSource source,
bool use_second)
{
HeroPick pick;
pick.full = choose_hero(index, title_ids, source);
// The small IGDB picture is not used when only Prosperopatches art is wanted.
if (source == HeroSource::all)
pick.low = index.hero_low(title_ids);
const std::string igdb = igdb_full_url(pick.low);
pick.has_both = is_prospero_pic0_url(pick.full) && !igdb.empty();
if (pick.has_both)
{
if (use_second)
{
pick.second = true;
pick.full = igdb; // its small stand-in is the same picture: the swap is only sharpening
}
else
{
pick.low.clear(); // Prosperopatches' picture alone: no other picture shown first
}
}
return pick;
}
std::string hero_art_key(const std::string &art_key, bool second)
{
return second ? art_key + kHeroSecondTail : art_key;
}
bool HeroPrefetchGate::update(const std::string &game_id, double now_s)
{
if (game_id.empty())
{
game_.clear();
done_ = false;
return false;
}
if (game_id != game_)
{
game_ = game_id;
since_ = now_s;
done_ = false;
return false;
}
if (done_ || now_s - since_ < kDwellS || now_s - last_fetch_ < kMinGapS)
return false;
done_ = true;
last_fetch_ = now_s;
return true;
}
void HeroPrefetchGate::reset()
{
game_.clear();
done_ = false;
}
} // namespace orbit
+96
View File
@@ -0,0 +1,96 @@
// Wings TV app - Where art comes from: the cover icon source and the 16:9 game-page art source.
// SPDX-License-Identifier: GPL-3.0-or-later
//
// Two player choices (Settings > Wings > Art sources), both kept in WingsLocalPrefs:
// * Cover icons: "Catalogue" (default: the poster link of each catalogue entry) or "Prosperopatches": the
// title's PS5 icon from cdn.prosperopatches.com. The popularity data already carries the address of that
// title's 16:9 picture (.../pic0.webp); the icon (.../icon0.webp) sits next to it, so no extra lookup is
// made. A title Prosperopatches has no art for keeps its catalogue cover.
// * 16:9 game-page art: "All sources" (default: Prosperopatches first, then IGDB, Steam) or "Prosperopatches
// only" (the PS5's own background, nothing from IGDB or Steam; the page keeps the cover wash otherwise).
// Only the exact address shape Prosperopatches uses is ever derived or accepted.
#pragma once
#include <cstdint>
#include <string>
#include <string_view>
#include <vector>
namespace orbit
{
class PopularityIndex;
enum class IconSource : std::uint8_t
{
catalogue,
prospero
};
enum class HeroSource : std::uint8_t
{
all,
prospero
};
const char *icon_source_key(IconSource source); // "catalogue" / "prospero"
bool parse_icon_source(std::string_view text, IconSource *out);
const char *hero_source_key(HeroSource source); // "all" / "prospero"
bool parse_hero_source(std::string_view text, HeroSource *out);
// https://cdn.prosperopatches.com/titles/PPSA12345_<64 hex>/pic0.webp, exactly (no query, no other host).
bool is_prospero_pic0_url(std::string_view url);
// The icon0.webp next to such a pic0.webp; "" for any other address.
std::string prospero_icon_url(std::string_view pic0_url);
// Whether a hero address may be used under `source`.
bool hero_url_allowed(std::string_view url, HeroSource source);
// The 16:9 picture address for a game: the first of its title ids whose record has one the source allows. "" none.
std::string choose_hero(const PopularityIndex &index, const std::vector<std::string> &title_ids, HeroSource source);
// Two equally good pictures: for many games the data has Prosperopatches' picture (hero) AND a different IGDB
// screenshot (heroLow, small). Showing the small one first and then swapping to the other made the page flash
// one picture for half a second before another. Instead the page (and the Store's hero shot) shows ONE of them,
// whole and sharp, and the choice alternates: the picture a game showed last is not the one it shows next time.
// The IGDB variant lives under its own art key (art_key + kHeroSecondTail) so the two never share a texture.
constexpr const char *kHeroSecondTail = "~b";
// .../t_screenshot_big/<id>.jpg -> .../t_1080p/<id>.jpg (the same IGDB picture at full size); "" for any other
// address.
std::string igdb_full_url(std::string_view low_url);
struct HeroPick
{
std::string full; // the picture to show ("" none)
std::string low; // a small stand-in of THE SAME picture ("" none)
bool second = false; // the IGDB variant: its art key carries kHeroSecondTail
bool has_both = false; // the game has the two different pictures, so the choice alternates
};
// use_second picks the IGDB picture when the game has both; otherwise it is Prosperopatches'. A game with only one
// kind of picture gets it, with its own small stand-in when the data has one (as before).
HeroPick pick_hero(const PopularityIndex &index, const std::vector<std::string> &title_ids, HeroSource source,
bool use_second);
// The art key a pick's pictures are cached under.
std::string hero_art_key(const std::string &art_key, bool second);
// Decides when the Store grid fetches a game's 16:9 picture ahead of time (the focused tile). The picture goes to
// the disk cache only, so that opening the page finds it there. Rules: focus has to rest on the same game for
// `kDwellS` (scrolling past fetches nothing), a game is fetched once per time focus lands on it, and at most
// one fetch every `kMinGapS` seconds (a long run of resting on tiles cannot pull megabytes).
class HeroPrefetchGate
{
public:
static constexpr double kDwellS = 0.5;
static constexpr double kMinGapS = 6.0;
// Every frame with the focused game's id ("" when no game is focused) and the time in seconds.
// True when its picture should be fetched now.
bool update(const std::string &game_id, double now_s);
// The grid or the setting changed under it: forget what was fetched, so a game can be fetched again.
void reset();
private:
std::string game_;
double since_ = 0.0;
bool done_ = false;
double last_fetch_ = -1.0e9;
};
} // namespace orbit
+2 -1
View File
@@ -46,7 +46,8 @@ template <typename T> struct Result
enum class ArtKind : std::uint8_t
{
cover,
hero,
hero, // the full background
hero_low, // a small, fast stand-in for it
};
struct Art
+119
View File
@@ -0,0 +1,119 @@
// Wings TV app - The Store's curated carousel: the pure rules (carousel.hpp).
// SPDX-License-Identifier: GPL-3.0-or-later
#include "orbit/carousel.hpp"
#include <algorithm>
namespace orbit
{
std::vector<int> resolve_carousel(const std::vector<std::string> &ids, const std::vector<GameGroup> &games)
{
std::vector<int> out;
for (const std::string &id : ids)
{
if (out.size() >= kCarouselMaxItems)
break;
for (std::size_t g = 0; g < games.size(); ++g)
{
const std::vector<std::string> &have = games[g].title_ids;
if (std::find(have.begin(), have.end(), id) == have.end())
continue;
if (std::find(out.begin(), out.end(), static_cast<int>(g)) == out.end())
out.push_back(static_cast<int>(g));
break;
}
}
return out;
}
bool carousel_shown(bool pref_on, std::size_t resolved, bool query_empty, std::size_t tiles)
{
return pref_on && resolved > 0 && query_empty && tiles > 0;
}
std::vector<int> pick_most_popular(const std::vector<long> &keys, std::size_t count)
{
std::vector<int> order;
bool any = false;
for (std::size_t i = 0; i < keys.size(); ++i)
{
order.push_back(static_cast<int>(i));
any = any || keys[i] >= 0;
}
if (any)
std::stable_sort(order.begin(), order.end(), [&keys](int a, int b)
{ return keys[static_cast<std::size_t>(a)] > keys[static_cast<std::size_t>(b)]; });
if (order.size() > count)
order.resize(count);
return order;
}
int carousel_wrap(int slide, int delta, int count)
{
if (count <= 0)
return 0;
const int at = (slide + delta) % count;
return at < 0 ? at + count : at;
}
CarouselStep carousel_step(int slide, int button, int count, hui::Direction nav)
{
CarouselStep step;
step.slide = slide;
step.button = button;
if (nav == hui::Direction::right)
{
if (button == kCarouselView)
{
step.button = kCarouselFavourite;
step.moved = true;
}
else if (count > 1)
{
step.slide = carousel_wrap(slide, 1, count);
step.moved = step.slide_changed = true;
}
}
else if (nav == hui::Direction::left)
{
if (button == kCarouselFavourite)
{
step.button = kCarouselView;
step.moved = true;
}
else if (count > 1)
{
step.slide = carousel_wrap(slide, -1, count);
step.moved = step.slide_changed = true;
}
}
return step;
}
bool CarouselClock::update(float dt, bool paused, int count)
{
if (paused || count < 2)
{
elapsed_ = 0.0f;
return false;
}
elapsed_ += dt;
if (elapsed_ < kCarouselAdvanceS)
return false;
elapsed_ = 0.0f;
return true;
}
float grid_scroll_target(int row, float pitch, float extra)
{
return extra + std::max(0.0f, static_cast<float>(row - 1) * pitch);
}
float carousel_fade(float scroll)
{
return std::clamp(1.0f - scroll / (kCarouselExtra * 0.8f), 0.0f, 1.0f);
}
} // namespace orbit
+94
View File
@@ -0,0 +1,94 @@
// Wings TV app - The Store's curated carousel: which games, when it shows, the pad rules, the timer and the
// grid offset it adds. Pure and tested on the host; drawing and input wiring are in store_screen.cpp.
// SPDX-License-Identifier: GPL-3.0-or-later
//
// The carousel is the project's featured games (editorial.hpp, curated.v1.json), the Store's hero shot in its
// default view only: the first thing on the page, full width, with the grid below it. Each slide is one game of
// the player's OWN catalogue (found by title id; ids they do not have are hidden) with a 16:9 picture, the title,
// a View game button (opens the game page, as Cross on the tile does) and a heart (Favourite).
//
// Pad rules (the focus is always on one of the two buttons; the slide is whatever the buttons belong to):
// Right on View game -> heart Left on heart -> View game
// Right on heart -> next slide, focus stays on the heart (the last slide wraps to the first)
// Left on View game -> previous slide, focus stays on View game (the first slide wraps to the last)
// Down scrolls the page up to the grid (its header and first row), Up leaves to the top bar. Up from the grid's
// first row comes back to the hero, on View game. The Store opens with the focus on the hero. Holding Right or
// Left therefore walks through the slides after two steps.
#pragma once
#include "core/input.hpp"
#include "orbit/game_group.hpp"
#include <cstddef>
#include <string>
#include <vector>
namespace orbit
{
constexpr std::size_t kCarouselMaxItems = 8;
constexpr float kCarouselAdvanceS = 8.0f;
// Geometry (virtual 1920 x 1080). The hero is the page's first viewport: the picture fills the whole width from
// the top of the screen (under the app bar) down to kCarouselArtH and fades into the page at its foot; the title
// and the buttons sit over it at the left. The grid is pushed down by kCarouselExtra, so its header and the top of
// its first row peek in under the hero (as Orbit's first rail does); Down scrolls the page up by that much.
constexpr float kCarouselArtH = 800.0f;
constexpr float kCarouselExtra = 590.0f; // what the grid is pushed down by
// The games of the curated ids, in the file's order: the first game of the player's catalogue that carries each
// id (a game that comes up twice is kept once), at most kCarouselMaxItems. Indexes into `games`.
std::vector<int> resolve_carousel(const std::vector<std::string> &ids, const std::vector<GameGroup> &games);
// Shown whenever the preference is on and there is something to show: at least one game, no search under way and
// something in the grid below it. Filters and the sort do not hide it.
bool carousel_shown(bool pref_on, std::size_t resolved, bool query_empty, std::size_t tiles);
// How many games the carousel falls back to when the featured list gives none (nothing fetched, or none of the
// featured games is in the catalogue).
constexpr std::size_t kCarouselFallbackItems = 5;
// The `count` games with the largest popularity key (keys[i] belongs to games[i]; -1 = no data, sorts last). With
// no data at all it is simply the first `count` games. Indexes into the list the keys came from.
std::vector<int> pick_most_popular(const std::vector<long> &keys, std::size_t count);
// The two focus stops.
constexpr int kCarouselView = 0;
constexpr int kCarouselFavourite = 1;
struct CarouselStep
{
int slide = 0;
int button = 0;
bool moved = false; // the focus or the slide changed (the pad gets a sound; false: a refusal)
bool slide_changed = false;
};
// Left / Right on the buttons (the rules above). Other directions change nothing. count is >= 1.
CarouselStep carousel_step(int slide, int button, int count, hui::Direction nav);
// slide + delta, wrapping inside 0..count-1.
int carousel_wrap(int slide, int delta, int count);
// The auto-advance clock.
class CarouselClock
{
public:
// Every frame. paused: the carousel has focus, a dialog or panel is open, it is not on screen. True when the
// slide should advance now (the clock then starts again).
bool update(float dt, bool paused, int count);
void restart() { elapsed_ = 0.0f; }
float elapsed() const { return elapsed_; }
private:
float elapsed_ = 0.0f;
};
// The grid's scroll target (px) with `extra` px of carousel above row 0. Row 0 scrolls the carousel away (the
// header and the first row then sit where they do without one); from there the focused row sits second from the
// top once the list scrolls. With extra == 0 this is exactly the grid's own rule: max(0, (row - 1) * pitch).
float grid_scroll_target(int row, float pitch, float extra);
// How much of the hero is left at a page scroll (1 at rest, 0 once it is far enough up to be out of the way of
// the header that rides up with the page): the hero fades as it slides, so it never meets the header's words.
float carousel_fade(float scroll);
} // namespace orbit
+1
View File
@@ -50,6 +50,7 @@ class CoreTransport final : public wings::http::Transport
mine.body = request.body;
mine.max_bytes = request.max_bytes == 0 ? http::kDefaultLimit
: std::min(request.max_bytes, kMaxReply);
mine.timeout_ms = request.timeout_ms;
http::Response reply = transport_.send(mine);
out.status = reply.status;
out.body = std::move(reply.body);
+36 -1
View File
@@ -4,7 +4,9 @@
#include "orbit/cover_art.hpp"
#include "core/save_file.hpp"
#include "orbit/art_sources.hpp"
#include "orbit/game_header.hpp"
#include "orbit/popularity.hpp"
namespace orbit
{
@@ -18,15 +20,21 @@ bool PosterIndex::sync(std::uint64_t serial, const std::vector<Package> &package
}
// Built outside the lock: the art worker is never held up by 3,000 inserts.
std::unordered_map<std::string, std::string> next;
std::unordered_map<std::string, std::string> next_titles;
next.reserve(packages.size());
for (const Package &package : packages)
{
if (package.id.empty() || package.poster_url.compare(0, 8, "https://") != 0)
if (package.id.empty())
continue;
if (!package.title_id.empty())
next_titles.emplace(package.id, package.title_id);
if (package.poster_url.compare(0, 8, "https://") != 0)
continue;
next.emplace(package.id, package.poster_url);
}
Lock lock(mutex_);
urls_.swap(next);
title_ids_.swap(next_titles);
serial_ = serial;
return true;
}
@@ -39,12 +47,39 @@ std::string PosterIndex::resolve(std::string_view key, ArtKind kind) const
// URL from the popularity index). Grid tiles never ask, so nothing is prefetched.
if (kind == ArtKind::hero)
return hero_urls().find(key);
if (kind == ArtKind::hero_low)
return hero_urls().find_low(key);
const std::string id(key.substr(4));
Lock lock(mutex_);
if (prospero_active_)
{
// The icon sits next to the title's 16:9 picture in the popularity data.
const auto title = title_ids_.find(id);
if (title != title_ids_.end())
{
const PopRecord *record = index_->find(title->second);
if (record != nullptr)
{
const std::string icon = prospero_icon_url(record->hero);
if (!icon.empty())
return icon;
}
}
}
const auto found = urls_.find(id);
return found == urls_.end() ? std::string() : found->second;
}
bool PosterIndex::set_icon_source(bool prospero, std::shared_ptr<const PopularityIndex> index)
{
Lock lock(mutex_);
const bool active = prospero && index != nullptr;
const bool changed = active != prospero_active_ || (active && index.get() != index_.get());
prospero_active_ = active;
index_ = active ? std::move(index) : nullptr;
return changed;
}
std::size_t PosterIndex::size() const
{
Lock lock(mutex_);
+11
View File
@@ -18,6 +18,7 @@
#include "orbit/thread.hpp"
#include <cstdint>
#include <memory>
#include <string>
#include <string_view>
#include <unordered_map>
@@ -26,6 +27,8 @@
namespace orbit
{
class PopularityIndex;
// The sentence Settings shows beside the DNS-over-HTTPS switch.
inline constexpr const char *kDohPrivacySentence =
"Looking up cover servers this way sends their host names (for example "
@@ -44,10 +47,18 @@ class PosterIndex
std::size_t size() const;
// Frame thread. `prospero`: covers resolve to the title's Prosperopatches icon where the popularity data
// has one (art_sources.hpp); every other title keeps its catalogue cover. Returns true when what covers
// resolve to changed, so the caller drops the covers it holds and they are asked for again.
bool set_icon_source(bool prospero, std::shared_ptr<const PopularityIndex> index);
private:
mutable Mutex mutex_;
std::unordered_map<std::string, std::string> urls_;
std::unordered_map<std::string, std::string> title_ids_; // package id -> title id (when it has one)
std::uint64_t serial_ = ~0ull;
bool prospero_active_ = false; // the Prosperopatches source is chosen and the data to use it is there
std::shared_ptr<const PopularityIndex> index_;
};
struct CoverArtStatus
+2
View File
@@ -117,6 +117,8 @@ std::string format_diagnostics(const DiagnosticsInput &in)
line("");
line("App");
line(" version: " + (in.app_version.empty() ? std::string("unknown") : in.app_version));
if (!in.app_build.empty())
line(" build: " + in.app_build);
line(" title id: " + (in.title_id.empty() ? std::string("unknown") : in.title_id));
line(" uptime: " + std::to_string(static_cast<long long>(in.uptime_s)) + " s");
line("");
+1
View File
@@ -25,6 +25,7 @@ struct DiagnosticsInput
{
// Identity
std::string app_version;
std::string app_build; // "20261012-203105 (test)"; empty when the package carries no stamp
std::string title_id;
// Time
std::int64_t unix_time = 0; // 0 when the clock cannot be read
+228 -40
View File
@@ -12,6 +12,29 @@
namespace orbit
{
namespace
{
// "7", "7 and 12", "7, 12 and 33", "7, 12, 33 and 4 more".
std::string compact_list(const std::vector<std::string> &items)
{
constexpr std::size_t kShown = 3;
if (items.empty())
return {};
if (items.size() == 1)
return items[0];
const std::size_t head_count = items.size() <= kShown ? items.size() - 1 : kShown;
std::string head = items[0];
for (std::size_t i = 1; i < head_count; ++i)
head += ", " + items[i];
if (items.size() <= kShown)
return tr("{first} and {last}", {{"first", head}, {"last", items.back()}});
return trn(static_cast<long>(items.size() - kShown), "{list} and {count} more", "{list} and {count} more",
{{"list", head}});
}
} // namespace
std::string part_failure_reason(const LinkStart &outcome)
{
if (!outcome.error.empty())
@@ -22,39 +45,93 @@ std::string part_failure_reason(const LinkStart &outcome)
return tr("the debrid service asks which files to take: open this part on its own");
if (outcome.kind == LinkResult::Kind::browser_opened)
return tr("it opened in the PS5 browser instead of the queue");
if (outcome.kind == LinkResult::Kind::capture)
return tr("still resolving"); // a capture that started is not a queued part
return {};
}
void DownloadAll::begin(std::vector<LinkRef> refs, std::vector<int> numbers, std::string host_name, Start start,
void DownloadAll::begin(std::vector<PartPlan> parts, std::string host_name, Start start,
std::function<void(const LinkRef &)> queued, std::function<void()> finished)
{
++generation_;
refs_ = std::move(refs);
numbers_ = std::move(numbers);
numbers_.resize(refs_.size(), 0);
++call_; // a Done still out from an earlier run is ignored
parts_ = std::move(parts);
host = std::move(host_name);
total = static_cast<int>(refs_.size());
total = static_cast<int>(parts_.size());
done = 0;
ok = 0;
next_ = 0;
end = End::none;
current = 0;
attempt = 0;
trying_host.clear();
waiting = 0.0f;
failed.clear();
via_alternate = 0;
alternates.clear();
failed_number = 0;
failed_why.clear();
part_ = 0;
ref_ = 0;
tries_ = 0;
in_a_row_ = 0;
last_why_.clear();
in_flight_ = false;
waiting_ = false;
cancel_ = false;
start_ = std::move(start);
queued_ = std::move(queued);
finished_ = std::move(finished);
active = total > 0;
step();
if (active)
attempt_now();
}
void DownloadAll::begin(std::vector<LinkRef> refs, std::vector<int> numbers, std::string host_name, Start start,
std::function<void(const LinkRef &)> queued, std::function<void()> finished)
{
std::vector<PartPlan> parts;
parts.reserve(refs.size());
for (std::size_t i = 0; i < refs.size(); ++i)
{
PartPlan part;
part.number = i < numbers.size() ? numbers[i] : 0;
part.refs.push_back(std::move(refs[i]));
part.hosts.push_back(host_name);
parts.push_back(std::move(part));
}
begin(std::move(parts), std::move(host_name), std::move(start), std::move(queued), std::move(finished));
}
void DownloadAll::tick(float dt)
{
if (!active || !waiting_ || in_flight_)
return;
if (dt > 0.0f)
waiting -= dt;
if (waiting > 0.0f)
return;
waiting = 0.0f;
waiting_ = false;
attempt_now();
}
void DownloadAll::cancel()
{
if (active)
cancel_ = true;
if (!active)
return;
cancel_ = true;
if (!in_flight_)
finish(End::cancelled); // nothing is out: a wait ends at once
}
void DownloadAll::step()
const std::string &DownloadAll::host_of(std::size_t ref) const
{
const PartPlan &part = parts_[part_];
if (ref < part.hosts.size() && !part.hosts[ref].empty())
return part.hosts[ref];
return host;
}
void DownloadAll::attempt_now()
{
if (!active)
return;
@@ -63,42 +140,114 @@ void DownloadAll::step()
finish(End::cancelled);
return;
}
if (next_ >= total)
const PartPlan &part = parts_[part_];
current = part.number;
if (ref_ >= part.refs.size())
{
finish(End::finished);
// Every host of this part was tried (or it has none).
part_failed(last_why_.empty() ? std::string(tr("no link for this part")) : last_why_);
return;
}
const int index = next_;
const int generation = generation_;
start_(refs_[static_cast<std::size_t>(index)],
[this, index, generation](const LinkStart &outcome)
{
if (generation == generation_ && active)
on_result(index, outcome);
});
attempt = tries_ + 1;
trying_host = host_of(ref_);
waiting = 0.0f;
waiting_ = false;
in_flight_ = true;
const int call = ++call_;
const LinkRef ref = part.refs[ref_]; // a copy: Done may run inside start_ and move the series on
start_(ref, [this, call](const LinkStart &outcome) { on_result(call, outcome); });
}
void DownloadAll::on_result(int index, const LinkStart &outcome)
void DownloadAll::on_result(int call, const LinkStart &outcome)
{
++done;
if (call != call_ || !in_flight_ || !active)
return; // an older start, a second Done, or a series that ended
in_flight_ = false;
const std::string why = part_failure_reason(outcome);
if (!why.empty())
if (why.empty())
{
failed_number = numbers_[static_cast<std::size_t>(index)];
failed_why = why;
finish(End::failed);
part_queued();
return;
}
// A capture without an error has not decided anything yet: worth asking again.
const bool resolving = outcome.error.empty() && outcome.kind == LinkResult::Kind::capture;
const bool transient = outcome.transient || resolving;
last_why_ = parts_[part_].refs.size() > 1 ? host_of(ref_) + ": " + why : why;
++tries_;
if (cancel_)
{
finish(End::cancelled);
return;
}
if (transient && tries_ < kPartAttempts)
{
waiting = tries_ == 1 ? kFirstBackoff : kSecondBackoff;
waiting_ = true;
return; // tick() starts the next attempt
}
++ref_; // the next host of the same part, or the part failed
tries_ = 0;
attempt_now();
}
void DownloadAll::part_queued()
{
const PartPlan &part = parts_[part_];
++done;
++ok;
in_a_row_ = 0;
if (ref_ > 0)
{
++via_alternate;
alternates.push_back(AlternatePart{part.number, host_of(ref_)});
}
const LinkRef ref = part.refs[ref_];
if (queued_)
queued_(refs_[static_cast<std::size_t>(index)]);
next_ = index + 1;
step();
queued_(ref);
next_part();
}
void DownloadAll::part_failed(const std::string &why)
{
++done;
failed.push_back(FailedPart{current, why});
failed_number = current;
failed_why = why;
++in_a_row_;
if (in_a_row_ >= kStopAfterFailedParts && part_ + 1 < parts_.size())
{
finish(End::stopped);
return;
}
next_part();
}
void DownloadAll::next_part()
{
++part_;
ref_ = 0;
tries_ = 0;
attempt = 0;
last_why_.clear();
if (part_ >= parts_.size())
{
finish(failed.empty() ? End::finished : End::finished_with_failures);
return;
}
if (cancel_)
{
finish(End::cancelled);
return;
}
attempt_now();
}
void DownloadAll::finish(End how)
{
active = false;
in_flight_ = false;
waiting_ = false;
waiting = 0.0f;
end = how;
std::function<void()> callback = finished_;
if (callback)
@@ -107,26 +256,65 @@ void DownloadAll::finish(End how)
std::string DownloadAll::toast() const
{
return trn(ok, "{count} part queued from {host}", "{count} parts queued from {host}", {{"host", host}});
std::string out =
trn(ok, "{count} part queued from {host}", "{count} parts queued from {host}", {{"host", host}});
if (via_alternate > 0)
out += " " + trn(via_alternate, "({count} from another host)", "({count} from other hosts)");
return out;
}
std::string DownloadAll::report_title() const
{
const std::string ok_text = std::to_string(ok);
const std::string total_text = std::to_string(total);
if (end == End::cancelled)
return tr("Cancelled: {ok} of {total} parts queued",
{{"ok", std::to_string(ok)}, {"total", std::to_string(total)}});
return tr("{ok} of {total} parts queued", {{"ok", std::to_string(ok)}, {"total", std::to_string(total)}});
return tr("Cancelled: {ok} of {total} parts queued", {{"ok", ok_text}, {"total", total_text}});
if (end == End::stopped)
return tr("Stopped: {ok} of {total} parts queued", {{"ok", ok_text}, {"total", total_text}});
return tr("{ok} of {total} parts queued", {{"ok", ok_text}, {"total", total_text}});
}
std::string DownloadAll::report_message() const
{
const std::string retry = tr("Choose \"Download all\" again to continue: parts already queued are left out.");
std::string out;
const auto add = [&out](const std::string &sentence)
{
if (sentence.empty())
return;
if (!out.empty())
out += ' ';
out += sentence;
};
if (end == End::cancelled)
return tr("Stopped on your request after part {n} of {total}.",
{{"n", std::to_string(done)}, {"total", std::to_string(total)}}) +
" " + retry;
return tr("Stopped at part {n}: {why}.", {{"n", std::to_string(failed_number)}, {"why", failed_why}}) + " " +
trn(ok, "{count} part was queued before it.", "{count} parts were queued before it.") + " " + retry;
add(tr("Stopped on your request after part {n} of {total}.",
{{"n", std::to_string(done)}, {"total", std::to_string(total)}}));
else if (end == End::stopped)
add(trn(kStopAfterFailedParts,
"Pegasus seems stuck: {count} part in a row did not queue, so the rest was not tried.",
"Pegasus seems stuck: {count} parts in a row did not queue, so the rest was not tried."));
if (failed.size() == 1)
{
add(tr("Part {n} did not queue: {why}.", {{"n", std::to_string(failed[0].number)}, {"why", failed[0].why}}));
}
else if (failed.size() > 1)
{
std::vector<std::string> numbers;
for (const FailedPart &part : failed)
numbers.push_back(std::to_string(part.number));
add(tr("Parts {list} did not queue. Last reason: {why}.",
{{"list", compact_list(numbers)}, {"why", failed.back().why}}));
}
add(trn(ok, "{count} part was queued.", "{count} parts were queued."));
if (!alternates.empty())
{
std::vector<std::string> items;
for (const AlternatePart &part : alternates)
items.push_back(tr("part {n} from {host}", {{"n", std::to_string(part.number)}, {"host", part.host}}));
add(trn(via_alternate, "{count} part came from another host: {list}.",
"{count} parts came from other hosts: {list}.", {{"list", compact_list(items)}}));
}
add(tr("Choose \"Download all\" again to continue: parts already queued are left out."));
return out;
}
// ---- the record of queued parts ----
+82 -23
View File
@@ -3,10 +3,15 @@
//
// The series runner starts one part through the existing per-link queue call,
// waits for its outcome, and only then starts the next (never a flood of
// commands). The first hard failure stops it and says how many parts were
// queued; Circle stops it after the part in flight. The (packageId, linkIndex)
// pairs queued are kept in a small versioned file of the checked save container
// (prefs kind), so a second press, or a new run of the app, skips them.
// commands). A part that does not queue is asked again after a short wait when
// the error may pass by itself (timed out, Pegasus busy), then tried on the
// other hosts that carry the same part; when every one fails the part is
// listed and the series goes on with the next. Only a row of failed parts
// (Pegasus seems stuck) or Circle stops it early. The waits are driven by
// tick() on the frame thread: nothing sleeps, no thread is started. The
// (packageId, linkIndex) pairs queued are kept in a small versioned file of the
// checked save container (prefs kind), so a second press, or a new run of the
// app, skips them.
#pragma once
@@ -14,6 +19,7 @@
#include "orbit/game_view.hpp"
#include "orbit/store.hpp"
#include <cstdint>
#include <functional>
#include <string>
#include <string_view>
@@ -23,54 +29,107 @@ namespace orbit
{
// Empty when the part was queued; otherwise why it was not ("Part n: ..." is built by the caller).
// A capture that started without an error is not a queued part: "still resolving".
std::string part_failure_reason(const LinkStart &outcome);
// One part of a set: refs[0] is the link of the chosen host, refs[1..] the same
// part on other hosts, tried in order. `hosts` names them (parallel to refs).
struct PartPlan
{
int number = 0;
std::vector<LinkRef> refs;
std::vector<std::string> hosts;
};
struct FailedPart
{
int number = 0;
std::string why;
};
struct AlternatePart
{
int number = 0;
std::string host; // the host the part was queued from
};
// Attempts per link while the error is the kind that may pass by itself.
constexpr int kPartAttempts = 3;
// Waits before the second and the third attempt, in seconds.
constexpr float kFirstBackoff = 2.0f;
constexpr float kSecondBackoff = 6.0f;
// This many failed parts in a row stop the series ("Pegasus seems stuck").
constexpr int kStopAfterFailedParts = 4;
class DownloadAll
{
public:
using Done = std::function<void(const LinkStart &)>;
// Starts one part and calls Done once, on the frame thread, when it ended.
// Starts one part and calls Done once, on the frame thread, when its fate is known.
using Start = std::function<void(const LinkRef &, Done)>;
enum class End : std::uint8_t
{
none, // never ran, or running
finished, // every part queued
failed, // stopped at the first part that did not queue
cancelled, // stopped by the player after the part in flight
none, // never ran, or running
finished, // every part queued
finished_with_failures, // went to the end; some parts did not queue
stopped, // kStopAfterFailedParts failed parts in a row
cancelled, // stopped by the player
};
// Public so the screen can draw progress: "Queueing part {done+1} of {total}".
// Public so the screen can draw progress: "Queueing part {current} ({done+1} of {total})".
bool active = false;
std::string host;
std::string host; // the chosen host of the set
int total = 0;
int done = 0; // parts whose outcome came back
int done = 0; // parts decided (queued or failed)
int ok = 0; // parts queued by this run
End end = End::none;
int failed_number = 0; // the part number that stopped it
int current = 0; // number of the part being tried
int attempt = 0; // 1-based attempt on the current link
std::string trying_host; // host of the link being tried
float waiting = 0.0f; // seconds left before the next attempt; 0 when none is pending
std::vector<FailedPart> failed;
int via_alternate = 0; // parts queued from another host than the chosen one
std::vector<AlternatePart> alternates;
// The last part that did not queue (kept for older callers; `failed` has them all).
int failed_number = 0;
std::string failed_why;
// `numbers` are the parts' own numbers (for messages), same length as `refs`.
// `queued` is called for every part that was queued, `finished` once at the end.
// `queued` is called for every part that was queued, with the link that worked;
// `finished` once at the end.
void begin(std::vector<PartPlan> parts, std::string host_name, Start start,
std::function<void(const LinkRef &)> queued, std::function<void()> finished);
// One link per part, no other hosts. `numbers` are the parts' own numbers (for messages).
void begin(std::vector<LinkRef> refs, std::vector<int> numbers, std::string host_name, Start start,
std::function<void(const LinkRef &)> queued, std::function<void()> finished);
// Stops after the part in flight (it still counts if it queues). Nothing else is started.
// Every frame: counts down a wait between attempts and starts the next one.
void tick(float dt);
// Stops after the part in flight (it still counts if it queues); a wait ends at once.
void cancel();
// The toast of a clean run, and the notice (title + message) of a stopped one.
// The toast of a clean run, and the notice (title + message) of any other end.
std::string toast() const;
std::string report_title() const;
std::string report_message() const;
private:
void step();
void on_result(int index, const LinkStart &outcome);
void attempt_now();
void on_result(int call, const LinkStart &outcome);
void part_queued();
void part_failed(const std::string &why);
void next_part();
void finish(End how);
const std::string &host_of(std::size_t ref) const;
std::vector<LinkRef> refs_;
std::vector<int> numbers_;
int next_ = 0;
int generation_ = 0;
std::vector<PartPlan> parts_;
std::size_t part_ = 0; // index in parts_
std::size_t ref_ = 0; // index in the part's refs
int tries_ = 0; // attempts made on the current link
int in_a_row_ = 0; // failed parts in a row
std::string last_why_; // why the current part's last attempt failed
bool in_flight_ = false;
bool waiting_ = false;
int call_ = 0; // numbers every start; a Done of an older one is ignored
bool cancel_ = false;
Start start_;
std::function<void(const LinkRef &)> queued_;
+34
View File
@@ -0,0 +1,34 @@
// Wings TV app - The download folder: the one-time default and the first-download confirm.
// SPDX-License-Identifier: GPL-3.0-or-later
#include "orbit/download_folder.hpp"
namespace orbit
{
FolderNeed folder_need(bool have_settings, const std::string &download_root,
bool already_defaulted)
{
FolderNeed need;
if (!have_settings)
{
need.root_unknown = true;
return need;
}
need.set_default = download_root.empty() && !already_defaulted;
return need;
}
bool needs_first_confirm(bool folder_confirmed, bool have_settings)
{
return have_settings && !folder_confirmed;
}
FirstDownloadGate::Verdict FirstDownloadGate::check(bool folder_confirmed, bool have_settings) const
{
if (answered_ || !needs_first_confirm(folder_confirmed, have_settings))
return Verdict::proceed;
return Verdict::ask;
}
} // namespace orbit
+80
View File
@@ -0,0 +1,80 @@
// Wings TV app - The download folder: the one-time default and the first-download confirm.
// SPDX-License-Identifier: GPL-3.0-or-later
//
// Pure logic, no drawing. Two jobs (docs B-13):
// 1. When Pegasus reports an EMPTY downloadRoot, Wings sets a default folder
// once (Store::ensure_default_download_folder). A non-empty root is never touched.
// 2. The first download the player tries asks them to confirm or change the
// folder; the answer is remembered (WingsLocalPrefs::folder_confirmed) so it
// is asked once. FirstDownloadGate is the check every download entry point calls.
#pragma once
#include <functional>
#include <string>
#include <utility>
namespace orbit
{
namespace download_folder
{
// The folder Wings sets when Pegasus has none.
inline constexpr const char *default_path = "/data/pegasus-dl/downloads";
} // namespace download_folder
struct FolderNeed
{
bool set_default = false; // create the default folder and make it the root
bool root_unknown = false; // settings not read yet: decide nothing
};
// have_settings: Pegasus' settings were read. download_root: what it reported.
// already_defaulted: this run already tried (success or failure; never retried).
FolderNeed folder_need(bool have_settings, const std::string &download_root,
bool already_defaulted);
// First-download gate. Frame thread only.
class FirstDownloadGate
{
public:
enum class Verdict
{
proceed, // start the download now
ask, // show the "Download folder" confirm first, then resume
};
// Unknown settings never block: the root is not known, there is nothing to
// confirm yet, and an offline Pegasus fails the download by itself.
Verdict check(bool folder_confirmed, bool have_settings) const;
// The player answered (kept the folder or picked another): never ask again
// this run, even before the persisted flag is saved. The caller also sets
// WingsLocalPrefs::folder_confirmed = true and saves.
void answered()
{
answered_ = true;
}
// Convenience: remember what to do once the confirm closes (the download
// that was interrupted). take_pending() returns it (empty if none).
void defer(std::function<void()> resume)
{
pending_ = std::move(resume);
}
std::function<void()> take_pending()
{
std::function<void()> out = std::move(pending_);
pending_ = nullptr;
return out;
}
private:
bool answered_ = false;
std::function<void()> pending_;
};
// Free function form of the same rule (used by the gate and by tests).
bool needs_first_confirm(bool folder_confirmed, bool have_settings);
} // namespace orbit
+19 -5
View File
@@ -32,7 +32,7 @@ void DownloadsScreen::focus()
bool DownloadsScreen::modal() const
{
return prompt_.is_open() || confirm_.is_open() || list_.is_open() || probing_;
return prompt_.is_open() || confirm_.is_open() || list_.is_open() || folder_ask_.is_open() || probing_;
}
void DownloadsScreen::rebuild()
@@ -192,6 +192,13 @@ Intent DownloadsScreen::update(const InputFrame &input, float dt, Feedback &feed
const int count = static_cast<int>(order_.size());
index_ = std::clamp(index_, 0, std::max(0, count - 1));
// The first-download folder question takes the whole input while it is open.
if (folder_ask_.is_open())
{
folder_ask_.update(ctx_, input, dt, feedback);
return intent;
}
const bool had_prompt = prompt_.is_open();
const bool had_confirm = confirm_.is_open();
const bool had_list = list_.is_open();
@@ -318,10 +325,16 @@ Intent DownloadsScreen::update(const InputFrame &input, float dt, Feedback &feed
}
else if (pending_ == Pending::direct_link)
{
ctx_.store.queue_direct(state.probe, "");
probing_ = true; // wait for the queue reply
seen_probe_action_ = ctx_.store.probe_action().serial;
seen_probe_ = state.probe_serial;
const auto queue = [this]
{
const Snapshot &now = ctx_.store.state();
ctx_.store.queue_direct(now.probe, "");
probing_ = true; // wait for the queue reply
seen_probe_action_ = ctx_.store.probe_action().serial;
seen_probe_ = now.probe_serial;
};
if (!folder_ask_.guard(ctx_, queue))
queue();
}
}
if (confirmed != ConfirmDialog::Result::none)
@@ -650,6 +663,7 @@ void DownloadsScreen::draw(Frame &frame, bool focused) const
prompt_.draw(ctx_, frame);
confirm_.draw(ctx_, frame);
list_.draw(ctx_, frame);
folder_ask_.draw(ctx_, frame);
}
std::vector<hui::ui::Hint> DownloadsScreen::hints() const
+405
View File
@@ -0,0 +1,405 @@
// Wings TV app - Editorial data: see editorial.hpp.
// SPDX-License-Identifier: GPL-3.0-or-later
#include "orbit/editorial.hpp"
#include "core/save_file.hpp"
#include "orbit/i18n.hpp"
#include "orbit/json.hpp"
#include <algorithm>
#include <cstdlib>
namespace orbit
{
namespace
{
// Version check shared by both files; same wording as the popularity data.
bool check_header(std::string_view text, std::size_t max_bytes, json::Value *root, std::string *error)
{
const auto fail = [&](const std::string &why)
{
if (error != nullptr)
*error = why;
return false;
};
if (text.size() > max_bytes)
return fail("the file is too large");
std::string why;
if (!json::parse(text, root, &why) || !root->is_object())
return fail("the file is not valid JSON");
if (!(*root)["schemaVersion"].is_number())
return fail("unsupported data version");
if ((*root)["schemaVersion"].as_int(0) > 1)
return fail(tr("this data needs a newer Wings (Settings > Wings > Updates)"));
if ((*root)["schemaVersion"].as_int(0) != 1)
return fail("unsupported data version");
return true;
}
// At least one character, no control characters (the length is not limited; the page scales the type).
bool valid_note(const std::string &text)
{
std::size_t chars = 0;
for (const char c : text)
{
const unsigned char u = static_cast<unsigned char>(c);
if (u < 0x20 || u == 0x7f)
return false;
if ((u & 0xC0) != 0x80)
++chars;
}
return chars >= 1;
}
enum class Part
{
ok,
absent, // HTTP 404: the file is not published
failed
};
Part fetch_part(DocFetcher &fetcher, const std::string &url, std::size_t max_bytes, bool is_notes, std::string *body,
std::string *problem)
{
if (!valid_popularity_url(url))
{
*problem = "the source URL must start with https://";
return Part::failed;
}
const DocResult doc = fetcher.get(url, max_bytes);
if (doc.status == 404 && doc.error.empty())
return Part::absent;
if (!doc.error.empty() || doc.status == 0)
{
*problem = doc.error.empty() ? std::string("no answer") : doc.error;
return Part::failed;
}
if (doc.status != 200)
{
*problem = "HTTP " + std::to_string(doc.status);
return Part::failed;
}
EditorialData probe;
const bool good = is_notes ? EditorialData::parse_notes(doc.body, &probe, problem)
: EditorialData::parse_curated(doc.body, &probe, problem);
if (!good)
return Part::failed;
*body = doc.body;
return Part::ok;
}
} // namespace
// ---- the data ----
bool valid_editorial_id(std::string_view id)
{
if (id.size() != 9 || (id.substr(0, 4) != "PPSA" && id.substr(0, 4) != "CUSA"))
return false;
for (std::size_t i = 4; i < 9; ++i)
if (id[i] < '0' || id[i] > '9')
return false;
return true;
}
bool EditorialData::parse_notes(std::string_view text, EditorialData *into, std::string *error)
{
json::Value root;
if (!check_header(text, kEditorialNotesMaxBytes, &root, error))
return false;
const json::Value &notes = root["notes"];
if (!notes.is_object())
{
if (error != nullptr)
*error = "the file has no notes";
return false;
}
std::map<std::string, std::string> kept;
for (const std::string &id : notes.keys())
{
if (kept.size() >= kEditorialMaxNotes)
break;
const json::Value &v = notes[id];
if (!valid_editorial_id(id) || !v.is_string() || !valid_note(v.as_string()))
continue;
kept[id] = v.as_string();
}
into->notes_ = std::move(kept);
return true;
}
bool EditorialData::parse_curated(std::string_view text, EditorialData *into, std::string *error)
{
json::Value root;
if (!check_header(text, kEditorialCuratedMaxBytes, &root, error))
return false;
const json::Value &items = root["items"];
if (!items.is_array())
{
if (error != nullptr)
*error = "the file has no items";
return false;
}
std::vector<std::string> kept;
for (const json::Value &item : items.items())
{
if (kept.size() >= kEditorialMaxCurated)
break;
if (!item.is_string() || !valid_editorial_id(item.as_string()))
continue;
bool seen = false;
for (const std::string &id : kept)
seen = seen || id == item.as_string();
if (!seen)
kept.push_back(item.as_string());
}
into->curated_ = std::move(kept);
return true;
}
std::string EditorialData::note(const std::vector<std::string> &title_ids) const
{
for (const std::string &id : title_ids)
{
const auto it = notes_.find(id);
if (it != notes_.end())
return it->second;
}
return {};
}
// ---- the hub ----
void EditorialHub::set_enabled(bool notes, bool carousel)
{
Lock lock(mutex_);
if (notes_ != notes || carousel_ != carousel)
{
notes_ = notes;
carousel_ = carousel;
++serial_;
}
}
bool EditorialHub::notes_enabled() const
{
Lock lock(mutex_);
return notes_;
}
bool EditorialHub::carousel_enabled() const
{
Lock lock(mutex_);
return carousel_;
}
void EditorialHub::set_data(std::shared_ptr<const EditorialData> data)
{
Lock lock(mutex_);
data_ = std::move(data);
++serial_;
}
std::shared_ptr<const EditorialData> EditorialHub::data() const
{
Lock lock(mutex_);
return data_;
}
std::string EditorialHub::note(const std::vector<std::string> &title_ids) const
{
std::shared_ptr<const EditorialData> data;
{
Lock lock(mutex_);
if (notes_)
data = data_;
}
return data ? data->note(title_ids) : std::string();
}
std::vector<std::string> EditorialHub::curated_ids() const
{
std::shared_ptr<const EditorialData> data;
{
Lock lock(mutex_);
if (carousel_)
data = data_;
}
return data ? data->curated_ids() : std::vector<std::string>();
}
void EditorialHub::set_status(const EditorialStatus &status)
{
Lock lock(mutex_);
status_ = status;
++serial_;
}
EditorialStatus EditorialHub::status() const
{
Lock lock(mutex_);
return status_;
}
std::uint64_t EditorialHub::serial() const
{
Lock lock(mutex_);
return serial_;
}
EditorialHub &editorial_hub()
{
static EditorialHub hub;
return hub;
}
// ---- the service ----
EditorialService::EditorialService(EditorialHub &hub, DocFetcher &fetcher, EditorialServiceConfig config)
: hub_(hub), fetcher_(fetcher), config_(std::move(config))
{
if (!config_.now)
config_.now = [] { return static_cast<std::int64_t>(unix_seconds()); };
}
EditorialService::~EditorialService()
{
thread_.join();
}
void EditorialService::apply(bool notes, bool carousel)
{
hub_.set_enabled(notes, carousel);
if ((!notes && !carousel) || started_)
return;
started_ = true;
EditorialStatus status = hub_.status();
status.busy = true;
hub_.set_status(status);
if (!thread_.start([this] { run(); }, 256u << 10))
{
status.busy = false;
status.message = "Could not start the update";
hub_.set_status(status);
}
}
void EditorialService::wait_idle()
{
thread_.join();
}
void EditorialService::run()
{
EditorialStatus status;
status.busy = true;
std::string notes_body; // the raw files in use; "" = none
std::string curated_body;
bool cache_ok = false;
bool cache_current = false;
std::int64_t fetched = 0;
const std::size_t cap = kEditorialNotesMaxBytes + kEditorialCuratedMaxBytes + 4096;
// 1. The saved copy: "fetched=<unix>\napp=<build tag>\nnotes=<bytes>\ncurated=<bytes>\n<notes><curated>".
for (const std::string &path : config_.cache_paths)
{
std::string data;
if (!hui::save::read_file(path, &data, cap + 4096))
continue;
const hui::save::Decoded d = hui::save::decode(hui::save::Kind::stats, data);
if (!d.ok || d.version != 1)
continue;
const std::string &p = d.payload;
std::size_t at = 0;
std::string tag;
long long t = 0, nlen = -1, clen = -1;
bool header_ok = true;
for (int line = 0; line < 4 && header_ok; ++line)
{
const std::size_t nl = p.find('\n', at);
if (nl == std::string::npos)
{
header_ok = false;
break;
}
const std::string row = p.substr(at, nl - at);
at = nl + 1;
if (line == 0 && row.compare(0, 8, "fetched=") == 0)
t = std::atoll(row.c_str() + 8);
else if (line == 1 && row.compare(0, 4, "app=") == 0)
tag = row.substr(4);
else if (line == 2 && row.compare(0, 6, "notes=") == 0)
nlen = std::atoll(row.c_str() + 6);
else if (line == 3 && row.compare(0, 8, "curated=") == 0)
clen = std::atoll(row.c_str() + 8);
else
header_ok = false;
}
if (!header_ok || nlen < 0 || clen < 0 || static_cast<std::size_t>(nlen + clen) != p.size() - at)
continue;
notes_body = p.substr(at, static_cast<std::size_t>(nlen));
curated_body = p.substr(at + static_cast<std::size_t>(nlen));
fetched = t;
cache_ok = true;
cache_current = tag == config_.app_tag;
break;
}
// Parses what is held and hands it to the hub; a half that no longer parses is dropped.
const auto publish = [&]()
{
auto data = std::make_shared<EditorialData>();
std::string why;
if (!notes_body.empty() && !EditorialData::parse_notes(notes_body, data.get(), &why))
notes_body.clear();
if (!curated_body.empty() && !EditorialData::parse_curated(curated_body, data.get(), &why))
curated_body.clear();
hub_.set_data(data);
};
publish();
const std::int64_t now = config_.now();
if (cache_ok)
{
status.updated = fetched;
status.message = "Using the saved copy";
}
// A file that was not there is looked for again soon: it may simply not have been published yet.
const int ttl = (notes_body.empty() || curated_body.empty()) ? std::min(config_.ttl_s, 600) : config_.ttl_s;
if (cache_ok && cache_current && fetched <= now && now - fetched < ttl)
{
status.busy = false;
hub_.set_status(status);
return;
}
hub_.set_status(status);
// 2. The fetch of both files. A failed file keeps its saved copy; a 404 means that file is not published.
std::string problem;
const Part notes = fetch_part(fetcher_, config_.notes_url, kEditorialNotesMaxBytes, true, &notes_body, &problem);
if (notes == Part::absent)
notes_body.clear();
std::string curated_problem;
const Part curated =
fetch_part(fetcher_, config_.curated_url, kEditorialCuratedMaxBytes, false, &curated_body, &curated_problem);
if (curated == Part::absent)
curated_body.clear();
if (problem.empty())
problem = curated_problem;
if (notes != Part::failed || curated != Part::failed)
{
publish();
write_everywhere(config_.cache_paths,
hui::save::encode(hui::save::Kind::stats, 1,
"fetched=" + std::to_string(now) + "\napp=" + config_.app_tag +
"\nnotes=" + std::to_string(notes_body.size()) +
"\ncurated=" + std::to_string(curated_body.size()) + "\n" + notes_body +
curated_body),
nullptr);
status.updated = now;
status.message = problem.empty() ? "Updated" : "Updated in part: " + problem;
}
else
status.message = "Could not refresh: " + problem + (cache_ok ? ". Using the saved copy" : "");
status.busy = false;
hub_.set_status(status);
}
} // namespace orbit
+131
View File
@@ -0,0 +1,131 @@
// Wings TV app - Editorial data: short per-game notes and the curated store carousel.
// SPDX-License-Identifier: GPL-3.0-or-later
//
// Two small JSON files Wings fetches over https from the project site, like the popularity data:
// notes.v1.json {schemaVersion:1, notes:{titleId:"text, any length"}} (<= 600 notes, <= 64000 bytes)
// curated.v1.json {schemaVersion:1, items:[titleId, ...]} (ordered, <= 24 ids)
// Both are switched by Wings preferences (notes_enabled, carousel_enabled). EditorialService loads the saved copy and
// fetches both in one background job through a DocFetcher; a failure keeps the saved copy and a missing file (404)
// just means "none". Everything here is platform neutral and tested on the host.
#pragma once
#include "orbit/popularity.hpp"
#include "orbit/thread.hpp"
#include <cstdint>
#include <functional>
#include <map>
#include <memory>
#include <string>
#include <string_view>
#include <vector>
namespace orbit
{
constexpr std::size_t kEditorialNotesMaxBytes = 64000;
constexpr std::size_t kEditorialCuratedMaxBytes = 4096;
constexpr std::size_t kEditorialMaxNotes = 600;
constexpr std::size_t kEditorialMaxCurated = 24;
constexpr const char *kEditorialNotesUrl = "https://larazadelsol.github.io/wings-app/notes.v1.json";
constexpr const char *kEditorialCuratedUrl = "https://larazadelsol.github.io/wings-app/curated.v1.json";
// "PPSA01234" / "CUSA01234": the only title id shape the files may use.
bool valid_editorial_id(std::string_view id);
class EditorialData
{
public:
// Each fills its own half of `*into` (the other half is left as it is). Oversize text, bad JSON and a
// missing/newer schemaVersion are errors; single bad entries are skipped. An empty result is fine.
static bool parse_notes(std::string_view text, EditorialData *into, std::string *error);
static bool parse_curated(std::string_view text, EditorialData *into, std::string *error);
// The note of the first of the game's title ids that has one; "" none.
std::string note(const std::vector<std::string> &title_ids) const;
// The carousel's title ids in the file's order (deduplicated, valid only).
const std::vector<std::string> &curated_ids() const { return curated_; }
std::size_t note_count() const { return notes_.size(); }
void clear_notes() { notes_.clear(); }
void clear_curated() { curated_.clear(); }
private:
std::map<std::string, std::string> notes_;
std::vector<std::string> curated_;
};
struct EditorialStatus
{
std::string message; // "Updated", "Using the saved copy", "Could not refresh: ..." (never a popup)
std::int64_t updated = 0;
bool busy = false;
};
// Shared by the worker (data, status) and the frame thread (everything else).
class EditorialHub
{
public:
void set_enabled(bool notes, bool carousel);
bool notes_enabled() const;
bool carousel_enabled() const;
void set_data(std::shared_ptr<const EditorialData> data); // null: forget
std::shared_ptr<const EditorialData> data() const; // regardless of the switches
// The game's note while notes are on; "" otherwise.
std::string note(const std::vector<std::string> &title_ids) const;
// The carousel's ids while the carousel is on; empty otherwise.
std::vector<std::string> curated_ids() const;
void set_status(const EditorialStatus &status);
EditorialStatus status() const;
std::uint64_t serial() const; // changes with the data and the switches
// Settings and startup call this; the app wires the hook to its EditorialService (none in the preview and in
// tests: then only the switches change).
void apply_prefs(bool notes, bool carousel)
{
set_enabled(notes, carousel);
if (apply_hook)
apply_hook(notes, carousel);
}
std::function<void(bool, bool)> apply_hook;
private:
mutable Mutex mutex_;
bool notes_ = false;
bool carousel_ = false;
std::shared_ptr<const EditorialData> data_;
EditorialStatus status_;
std::uint64_t serial_ = 0;
};
EditorialHub &editorial_hub();
struct EditorialServiceConfig
{
std::vector<std::string> cache_paths; // first readable wins; both writable ones are written
std::string notes_url = kEditorialNotesUrl;
std::string curated_url = kEditorialCuratedUrl;
int ttl_s = 24 * 3600;
std::string app_tag; // as PopularityServiceConfig::app_tag
std::function<std::int64_t()> now; // unix seconds (default: the system clock)
};
// Runs the cache load and the fetch of both files on one background thread.
class EditorialService
{
public:
EditorialService(EditorialHub &hub, DocFetcher &fetcher, EditorialServiceConfig config);
~EditorialService();
// Frame thread. Sets the hub's switches; the first time either is on, loads the saved copy and fetches when it
// is missing, from another build or older than the TTL.
void apply(bool notes, bool carousel);
void wait_idle(); // tests
private:
void run();
EditorialHub &hub_;
DocFetcher &fetcher_;
EditorialServiceConfig config_;
Thread thread_;
bool started_ = false;
};
} // namespace orbit
+128 -6
View File
@@ -74,6 +74,11 @@ bool valid_hero_url(std::string_view url)
return url.size() <= kHeroUrlMax && valid_popularity_url(url);
}
namespace
{
const char *const kLowTail = "#low";
}
bool HeroUrls::set(const std::string &art_key, const std::string &url)
{
if (art_key.empty() || art_key.size() > 64 || !valid_hero_url(url))
@@ -85,6 +90,23 @@ bool HeroUrls::set(const std::string &art_key, const std::string &url)
return true;
}
bool HeroUrls::set_low(const std::string &art_key, const std::string &url)
{
if (art_key.empty() || art_key.size() > 64 || !valid_hero_url(url))
return false;
const std::string key = art_key + kLowTail;
Lock lock(mutex_);
if (urls_.size() >= 8 && urls_.find(key) == urls_.end())
urls_.clear();
urls_[key] = url;
return true;
}
std::string HeroUrls::find_low(std::string_view art_key) const
{
return find(std::string(art_key) + kLowTail);
}
std::string HeroUrls::find(std::string_view art_key) const
{
Lock lock(mutex_);
@@ -110,6 +132,31 @@ HeroUrls &hero_urls()
return urls;
}
HeroPlacement place_hero(int width, int height, const ui::Rect &band)
{
HeroPlacement p;
if (width <= 0 || height <= 0 || band.w <= 0.0f || band.h <= 0.0f)
return p;
const float a = static_cast<float>(width) / static_cast<float>(height);
if (a < kHeroAspectMin || a > kHeroAspectMax)
return p;
p.usable = true;
p.dest = band;
// Fill the band, cropping what overflows (a bit more from the bottom than the top, where the faces are).
const float box = band.w / band.h;
if (a > box)
{
p.uv.w = box / a;
p.uv.x = (1.0f - p.uv.w) * 0.5f;
}
else
{
p.uv.h = a / box;
p.uv.y = (1.0f - p.uv.h) * 0.3f;
}
return p;
}
// ---- cards ----
std::vector<HeaderCard> build_header_cards(const HeaderFacts &f)
@@ -263,12 +310,36 @@ void GameHeaderState::reset()
for (float &f : focus)
f = 0.0f;
hero_url.clear();
hero_low_url.clear();
hero_key.clear();
hero_resolved = false;
hero_cached = false;
open_s = 0.0f;
hero_logged = false;
more.clear();
}
bool hold_cover_wash(bool has_hero_address, bool cached, float since_open_s, bool hero_refused)
{
return has_hero_address && cached && !hero_refused && since_open_s < kHeroWashHoldS;
}
HeroHooks &hero_hooks()
{
static HeroHooks hooks;
return hooks;
}
bool hero_is_cached(const std::string &url)
{
const HeroHooks &h = hero_hooks();
return !url.empty() && h.cached && h.cached(url);
}
void GameHeaderState::update(float dt, bool focused)
{
if (open_s < 3600.0f)
open_s += dt;
slide.target = on_header ? 0.0f : 1.0f;
slide.update(dt, 10.0f);
for (int i = 0; i < kHeaderButtons; ++i)
@@ -372,16 +443,67 @@ void draw_game_header(Context &ctx, hui::gfx::DrawList &list, const HeaderView &
const ui::Rect band{0.0f, 0.0f, kPageW, hero::kScreenH};
list.gradient_rect(band, 0.0f, indigo, indigo_deep);
float hero_alpha = 0.0f;
bool hero_refused = false;
if (!view.hero_key.empty())
{
if (const Artwork *art = ctx.art.get(view.hero_key, ArtKind::hero))
// The full picture is asked for first and the small one after it: the worker serves the newest
// request first, so the small one arrives first. Once the full one is there the small one is
// not asked for any more. A picture of an unusable shape (place_hero) counts as not there.
const Artwork *full = view.hero_full ? ctx.art.get(view.hero_key, ArtKind::hero) : nullptr;
const HeroPlacement full_at =
full != nullptr ? place_hero(full->width, full->height, band) : HeroPlacement{};
const float full_alpha = full_at.usable ? fade_in(*full) : 0.0f;
hero_refused = full != nullptr && !full_at.usable;
// The small one: until the full one is fully faded in (it stays when the full one is refused).
if (view.hero_low && full_alpha < 1.0f)
{
hero_alpha = fade_in(*art);
ui::image_cover(list, art->texture, art->width, art->height, band,
ui::Color::rgb(0xffffff, hero_alpha), 0.0f, 0.5f, 0.3f);
if (const Artwork *low = ctx.art.get(view.hero_key, ArtKind::hero_low))
{
const HeroPlacement at = place_hero(low->width, low->height, band);
if (at.usable)
{
hero_alpha = fade_in(*low);
list.image(low->texture, at.dest, at.uv, ui::Color::rgb(0xffffff, hero_alpha), 0.0f);
}
}
}
if (full_at.usable)
{
hero_alpha = std::max(hero_alpha, full_alpha);
list.image(full->texture, full_at.dest, full_at.uv, ui::Color::rgb(0xffffff, full_alpha), 0.0f);
}
}
if (hero_alpha < 1.0f && !view.page->art_key.empty())
// Once per page open: how long until the first frame with the picture on screen (or why none).
if (!state.hero_logged)
{
const HeroHooks &hooks = hero_hooks();
char msg[96];
bool say = false;
if (hero_alpha > 0.0f)
{
std::snprintf(msg, sizeof msg, "[WINGS-ART] hero first frame %d ms after page open%s",
static_cast<int>(state.open_s * 1000.0f), state.hero_cached ? " (cached)" : "");
say = true;
}
else if (hero_refused || (state.hero_resolved && view.hero_key.empty()))
{
std::snprintf(msg, sizeof msg, "[WINGS-ART] hero: no hero%s", hero_refused ? " (refused shape)" : "");
say = true;
}
else if (state.open_s > 8.0f)
{
std::snprintf(msg, sizeof msg, "[WINGS-ART] hero: none drawn after 8000 ms");
say = true;
}
if (say)
{
state.hero_logged = true;
if (hooks.log)
hooks.log(msg);
}
}
const bool hold_wash = hold_cover_wash(view.hero_full, state.hero_cached, state.open_s, hero_refused);
if (hero_alpha < 1.0f && !view.page->art_key.empty() && !hold_wash)
{
// No hero (or not yet): the cover, scaled up, as a blurred wash.
if (const Artwork *cover = ctx.art.get(view.page->art_key, ArtKind::cover))
@@ -394,7 +516,7 @@ void draw_game_header(Context &ctx, hui::gfx::DrawList &list, const HeaderView &
ui::Color::rgb(0xffffff, 0.30f * a), 0.0f, 0.5f, 0.3f);
}
}
// The Store hue veil (docs/DESIGN-colour.md layer 4), then the dark left-to-right veil under the
// The Store hue veil (colour layer 4), then the dark left-to-right veil under the
// words, the top veil under the app bar and the fade into the page where the list starts.
list.rounded_rect(band, 0.0f, blue_wash);
const ui::Color left_clear = ui::Color::rgb(0x03061a, 0.0f);
+50 -1
View File
@@ -21,6 +21,7 @@
#include "orbit/ui.hpp"
#include <cstdint>
#include <functional>
#include <map>
#include <string>
#include <string_view>
@@ -45,7 +46,10 @@ class HeroUrls
public:
// False (and nothing stored) for an invalid URL or an empty key.
bool set(const std::string &art_key, const std::string &url);
// The small stand-in shown while the full one loads (same rules; stored beside it).
bool set_low(const std::string &art_key, const std::string &url);
std::string find(std::string_view art_key) const;
std::string find_low(std::string_view art_key) const;
void clear();
std::size_t size() const;
@@ -55,6 +59,44 @@ class HeroUrls
};
HeroUrls &hero_urls();
// ---- hero placement ----
// Where an image of width x height goes in the band, by its shape (a = width / height). The art is only
// ever scaled uniformly (its proportions never change) until it covers the whole band, and the overflow is
// cropped: nothing is stretched and the band is always full. A very wide strip would have to zoom so far
// that a few letters fill the screen, and a square logo would show almost nothing of itself, so those are
// not used and the page keeps the cover wash:
// 1.5 .. 3.3 used (Steam's 3.1:1 art loses its sides),
// anything else is not used.
constexpr float kHeroAspectMin = 1.5f;
constexpr float kHeroAspectMax = 3.3f;
struct HeroPlacement
{
bool usable = false;
ui::Rect dest; // inside the band
ui::Rect uv{0.0f, 0.0f, 1.0f, 1.0f};
};
HeroPlacement place_hero(int width, int height, const ui::Rect &band);
// ---- holding the cover wash back for a cached hero (B-14 step 1) ----
constexpr float kHeroWashHoldS = 0.3f;
// True while the blurred cover wash should NOT be drawn (only the dark gradient), because the
// 16:9 picture is on disk and is about to fade in. False when there is no hero address, the file
// is not cached, the picture was refused (unusable shape), or the hold (300 ms from page open) ran out.
bool hold_cover_wash(bool has_hero_address, bool cached, float since_open_s, bool hero_refused);
// Hooks the app wires once (main.cpp); both optional. cached answers "is this URL on disk";
// log writes one line to the title log.
struct HeroHooks
{
std::function<bool(const std::string &url)> cached;
std::function<void(const std::string &line)> log;
};
HeroHooks &hero_hooks();
// The cached fact for a url ("" or no hook: false). Call where hero_url is set.
bool hero_is_cached(const std::string &url);
// ---- the info cards ----
enum class CardTone : std::uint8_t
@@ -166,7 +208,12 @@ struct GameHeaderState
ui::Ease slide; // 0: header shown, 1: slid up and the list is at the top
float focus[kHeaderButtons] = {0.0f, 0.0f, 0.0f};
std::string hero_url; // registered for this page ("" none)
std::string hero_low_url; // the small stand-in registered for this page ("" none)
std::string hero_key; // the art key the hero pictures were registered under ("" none; art_sources.hpp)
bool hero_resolved = false; // the popularity index has been asked for this page
bool hero_cached = false; // the hero_url's file is already on disk (hero_is_cached, set with hero_url)
float open_s = 0.0f; // seconds since the page opened (update)
mutable bool hero_logged = false; // the once-per-open "first hero frame" line has been written
std::vector<HeaderMore> more; // the open "more" menu's rows
void reset(); // a page opens
@@ -186,7 +233,9 @@ struct HeaderView
bool failed = false;
std::string status; // "Loading the links...", or the error
std::string meta_line; // "22 links · 5 releases · 2 sources"
std::string hero_key; // art key to ask for ArtKind::hero ("" none registered)
std::string hero_key; // art key to ask for ArtKind::hero / hero_low ("" none registered)
bool hero_full = false; // a full hero is registered
bool hero_low = false; // a small one is registered
};
// The measured layout (the title's width decides one or two lines).
+106
View File
@@ -675,6 +675,108 @@ std::string best_summary(const GameView &view)
return join(view.page.best_chips, kDot);
}
int mirror_part_number(const GamePage &page, int mirror, const LinkRef &ref)
{
if (mirror < 0 || mirror >= static_cast<int>(page.mirrors.size()))
return 0;
const Mirror &m = page.mirrors[static_cast<std::size_t>(mirror)];
for (std::size_t i = 0; i < m.links.size(); ++i)
{
const GameLink &l = page.links[static_cast<std::size_t>(m.links[i])];
if (l.index == ref.link_index && page.packages[static_cast<std::size_t>(l.package)].id == ref.package_id)
return i < m.numbers.size() ? m.numbers[i] : static_cast<int>(i) + 1;
}
return 0;
}
namespace
{
// A set whose numbers can be trusted part for part: 1..N, each once, one run, no gap.
bool aligned_set(const Mirror &m)
{
return m.status == PartStatus::complete && m.runs.size() <= 1 && m.missing.empty() && !m.links.empty() &&
m.numbers.size() == m.links.size();
}
std::vector<int> sorted_numbers(const Mirror &m)
{
std::vector<int> out = m.numbers;
std::sort(out.begin(), out.end());
return out;
}
// plan->numbers, alternates and alternate_hosts for plan->refs: the same part
// on the other hosts of the same release, when both sets line up exactly.
void plan_alternates(const GamePage &page, int mirror, const StartedLinks &started, PartsPlan *plan)
{
const std::size_t count = plan->refs.size();
plan->numbers.assign(count, 0);
plan->alternates = std::vector<std::vector<LinkRef>>(count);
plan->alternate_hosts = std::vector<std::vector<std::string>>(count);
for (std::size_t i = 0; i < count; ++i)
plan->numbers[i] = mirror_part_number(page, mirror, plan->refs[i]);
const Mirror &m = page.mirrors[static_cast<std::size_t>(mirror)];
if (!aligned_set(m))
return;
const std::vector<int> numbers = sorted_numbers(m);
std::vector<int> others;
for (std::size_t o = 0; o < page.mirrors.size(); ++o)
{
const Mirror &other = page.mirrors[o];
if (static_cast<int>(o) == mirror || other.release != m.release || other.host == m.host ||
!aligned_set(other) || other.links.size() != m.links.size() || sorted_numbers(other) != numbers)
continue;
others.push_back(static_cast<int>(o));
}
std::stable_sort(others.begin(), others.end(),
[&](int a, int b)
{
const Mirror &ma = page.mirrors[static_cast<std::size_t>(a)];
const Mirror &mb = page.mirrors[static_cast<std::size_t>(b)];
const int ra = tier_rank(ma.tier);
const int rb = tier_rank(mb.tier);
return ra != rb ? ra < rb : ma.host < mb.host;
});
for (std::size_t i = 0; i < count; ++i)
{
if (plan->numbers[i] <= 0)
continue;
for (const int o : others)
{
const Mirror &other = page.mirrors[static_cast<std::size_t>(o)];
for (std::size_t k = 0; k < other.links.size(); ++k)
{
if (other.numbers[k] != plan->numbers[i])
continue;
const GameLink &gl = page.links[static_cast<std::size_t>(other.links[k])];
LinkRef ref{page.packages[static_cast<std::size_t>(gl.package)].id, gl.index};
const bool blocked = gl.tier == LinkTier::unsupported || (gl.needs_browser && !m.needs_browser);
if (!gl.duplicate && !blocked && !started.has(ref))
{
plan->alternates[i].push_back(std::move(ref));
plan->alternate_hosts[i].push_back(other.host);
}
break;
}
}
}
}
// " If a part keeps failing, the same part is tried on Rootz, 1fichier." or "".
std::string alternates_sentence(const PartsPlan &plan)
{
std::vector<std::string> hosts;
for (const std::vector<std::string> &part : plan.alternate_hosts)
for (const std::string &h : part)
add_unique(&hosts, h);
if (hosts.empty())
return {};
return " " + tr("If a part keeps failing, the same part is tried on {hosts}.", {{"hosts", join(hosts, ", ")}});
}
} // namespace
PartsPlan plan_parts(const GameView &view, int mirror, const StartedLinks &started)
{
PartsPlan plan;
@@ -715,6 +817,8 @@ PartsPlan plan_parts(const GameView &view, int mirror, const StartedLinks &start
{{"host", plan.host}});
if (!m.warning().empty())
plan.message += " " + tr("Careful: {warning}.", {{"warning", m.warning()}});
plan_alternates(page, mirror, started, &plan);
plan.message += alternates_sentence(plan);
return plan;
}
plan.action = m.status == PartStatus::complete || m.status == PartStatus::unnumbered
@@ -734,6 +838,8 @@ PartsPlan plan_parts(const GameView &view, int mirror, const StartedLinks &start
if (!m.warning().empty())
message += " " + tr("Careful: {warning}.", {{"warning", m.warning()}});
message += " " + std::string(tr("It is a multi-part archive: you need every part to extract it."));
plan_alternates(page, mirror, started, &plan);
message += alternates_sentence(plan);
plan.message = std::move(message);
return plan;
}
+9
View File
@@ -234,10 +234,19 @@ MirrorCard mirror_card(const GameView &view, int mirror, const LinkPrefs &prefs,
// The Best card: "Debrid · USA · BP 4.xx · 1 file" and the reason.
std::string best_summary(const GameView &view);
// The part number a link has inside its mirror (by position when unnumbered); 0 not in it.
int mirror_part_number(const GamePage &page, int mirror, const LinkRef &ref);
// "Download all parts": what would be queued and how to say it.
struct PartsPlan
{
std::vector<LinkRef> refs; // to queue, catalogue order, already-started ones left out
// Parallel to refs: each part's number, and the same part on the release's
// other hosts (best tier first, then host A to Z) to try when it keeps
// failing. Only between sets numbered 1..N exactly alike; never guessed.
std::vector<int> numbers;
std::vector<std::vector<LinkRef>> alternates;
std::vector<std::vector<std::string>> alternate_hosts;
int total = 0; // links in the set
int skipped = 0; // already started by this app
bool one_at_a_time = false; // the parts open the PS5 browser: one per check, never all at once
+4 -2
View File
@@ -269,9 +269,11 @@ Response SocketTransport::send(const Request &request)
response.error = "socket failed";
return response;
}
// Per request (no shared state): the worker and the catalogue thread share this transport.
const int limit_ms = request.timeout_ms > 0 ? request.timeout_ms : timeout_ms_;
timeval timeout{};
timeout.tv_sec = timeout_ms_ / 1000;
timeout.tv_usec = (timeout_ms_ % 1000) * 1000;
timeout.tv_sec = limit_ms / 1000;
timeout.tv_usec = (limit_ms % 1000) * 1000;
setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &timeout, sizeof(timeout));
setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, &timeout, sizeof(timeout));
#ifdef SO_NOSIGPIPE
+1
View File
@@ -22,6 +22,7 @@ struct Request
std::string body; // JSON for POST
std::string bearer; // pairing token, sent as Authorization
std::size_t max_bytes = 0; // reply size limit; 0 = kDefaultLimit
int timeout_ms = 0; // socket time limit for this request; 0 = the transport's own
};
struct Response
+1 -1
View File
@@ -6,7 +6,7 @@
// expression of the reference became a small matcher below; the comment on
// each quotes the expression it replaces. Matchers find the leftmost match, as
// Python's re.search does, and take() cuts it out of the working text, leaving
// a space. app/tools/linkref/ holds the reference and a dump script; the unit
// a space. The unit
// test compares both over the recorded catalogue.
#include "orbit/link_meta.hpp"
+43
View File
@@ -0,0 +1,43 @@
// Wings TV app - Is a failed "start this link" worth asking again later?
// SPDX-License-Identifier: GPL-3.0-or-later
//
// "Download all parts" asks Pegasus for one part at a time. When a part fails,
// the series needs to know whether the failure may pass by itself (Pegasus busy
// or slow, a host error) or is a definite answer (not supported, not found, the
// link is gone). Pure, so it is tested on its own.
#pragma once
#include <cctype>
#include <string>
#include <string_view>
namespace orbit
{
// status: the HTTP status (0 when Pegasus was not reached); error: the text;
// unreachable: Result::unreachable() (connection refused, timed out, ...).
inline bool transient_link_error(int status, std::string_view error, bool unreachable)
{
if (unreachable)
return true;
std::string text(error);
for (char &c : text)
c = static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
const auto has = [&text](const char *word) { return text.find(word) != std::string::npos; };
// Answers that will not change, whatever the status says.
if (has("not found") || has("expired") || has("gone") || has("removed") || has("deleted") ||
has("not supported") || has("unsupported") || has("blocked") || has("invalid"))
return false;
if (status >= 500 || status == 408 || status == 425 || status == 429)
return true;
if (has("timed out") || has("timeout") || has("busy") || has("try again") || has("temporarily") ||
has("in progress") || has("connection refused") || has("connect failed") || has("no response"))
return true;
// "A capture session is already running" and the like: Pegasus does one at a time.
if (has("already") && (has("capture") || has("session") || has("resolv") || has("running") || has("active")))
return true;
return false;
}
} // namespace orbit
+97 -4
View File
@@ -184,16 +184,19 @@ void ConfirmDialog::draw(Context &ctx, Frame &frame) const
const float a = std::clamp(in_.value, 0.0f, 1.0f);
draw_scrim(list, a);
const float width = 900.0f;
// Measure the message to size the panel.
const float message_lines = 5.0f;
const float height = 72.0f + 50.0f + message_lines * 36.0f + 40.0f + ui::kButtonHeight + 44.0f;
// Measure the message to size the panel: room for five lines at least, and
// taller for a longer message (a "Download all parts" report) up to 14.
const int message_lines = std::clamp(
static_cast<int>(type.regular.font->wrap(message_, 25.0f, width - 96.0f).size()), 5, 14);
const float height = 72.0f + 50.0f + static_cast<float>(message_lines) * 36.0f + 40.0f +
ui::kButtonHeight + 44.0f;
const ui::Rect r{960.0f - width * 0.5f, 540.0f - height * 0.5f + (1.0f - a) * 20.0f, width, height};
list.push_opacity(a);
ui::modal(list, r);
ui::text_fit(list, type.light, title_, r.x + 48.0f, r.y + 82.0f, 38.0f, r.w - 96.0f,
ui::color::text);
ui::paragraph(list, type.regular, message_, r.x + 48.0f, r.y + 140.0f, 25.0f, r.w - 96.0f,
36.0f, ui::color::text2, 5);
36.0f, ui::color::text2, message_lines);
const float y = r.y + r.h - 44.0f - ui::kButtonHeight;
const ui::Rect confirm{r.x + r.w - 48.0f - kButtonWidth, y, kButtonWidth, ui::kButtonHeight};
const ui::Rect cancel{confirm.x - 20.0f - kButtonWidth, y, kButtonWidth, ui::kButtonHeight};
@@ -793,4 +796,94 @@ void FolderPicker::draw(Context &ctx, Frame &frame) const
prompt_.draw(ctx, frame);
}
// ---- FolderAsk ----
bool FolderAsk::guard(Context &ctx, std::function<void()> resume)
{
if (!ctx.folder_confirmed || is_open())
return is_open();
const Snapshot &state = ctx.store.state();
if (ctx.folder_gate.check(ctx.folder_confirmed(), state.have_settings) ==
FirstDownloadGate::Verdict::proceed)
return false;
ctx.folder_gate.defer(std::move(resume));
std::vector<ListItem> items(2);
items[0].label = tr("Keep this folder");
items[1].label = tr("Choose folder");
list_.open(tr("Download folder"),
tr("Downloads will be saved to {folder}. Keep this folder or choose another.",
{{"folder", state.settings.download_root}}),
std::move(items), ListDialog::Mode::single);
opened_ = true;
return true;
}
void FolderAsk::answered(Context &ctx)
{
ctx.folder_gate.answered();
if (ctx.confirm_folder)
ctx.confirm_folder();
if (std::function<void()> resume = ctx.folder_gate.take_pending())
resume();
}
void FolderAsk::update(Context &ctx, const hui::InputFrame &input, float dt, hui::ui::Feedback &feedback)
{
static const hui::InputFrame idle{};
if (opened_)
feedback.play(hui::audio::Cue::modal_open);
opened_ = false;
const bool had_list = list_.is_open();
const bool had_picker = picker_.is_open();
const bool had_notice = notice_.is_open();
if (notice_.update(had_notice ? input : idle, dt, feedback) != ConfirmDialog::Result::none)
return;
const ListDialog::Result listed = list_.update(had_list ? input : idle, dt, feedback);
if (listed == ListDialog::Result::chosen)
{
if (list_.chosen() == 0)
answered(ctx);
else
{
picker_.open(ctx.store, tr("Download folder"), ctx.store.state().settings.download_root);
feedback.play(hui::audio::Cue::modal_open);
}
}
else if (listed == ListDialog::Result::cancelled)
{
(void)ctx.folder_gate.take_pending(); // nothing starts; the next download asks again
}
const FolderPicker::Result picked = picker_.update(ctx.store, had_picker ? input : idle, dt, feedback);
if (picked == FolderPicker::Result::chosen)
{
SettingsPatch patch;
patch.has_root = true;
patch.root = picker_.chosen();
ctx.store.call<SettingsView>(
Store::lane_admin, [patch](Backend &b) { return b.settings_update(patch); },
[this, &ctx](Result<SettingsView> &&r)
{
if (!r.ok())
{
(void)ctx.folder_gate.take_pending();
notice_.notice(tr("Pegasus DL did not accept that"), r.error);
return;
}
ctx.store.apply_settings(r.value);
answered(ctx);
});
}
else if (picked == FolderPicker::Result::cancelled)
{
(void)ctx.folder_gate.take_pending();
}
}
void FolderAsk::draw(Context &ctx, Frame &frame) const
{
list_.draw(ctx, frame);
picker_.draw(ctx, frame);
notice_.draw(ctx, frame);
}
} // namespace orbit
+31
View File
@@ -15,6 +15,7 @@
#include "ui/components/input_prompt.hpp"
#include "ui/feedback.hpp"
#include <functional>
#include <string>
#include <string_view>
#include <vector>
@@ -112,6 +113,14 @@ class ConfirmDialog
{
return open_;
}
const std::string &title() const
{
return title_;
}
const std::string &message() const
{
return message_;
}
Result update(const hui::InputFrame &input, float dt, hui::ui::Feedback &feedback);
void draw(Context &ctx, Frame &frame) const;
@@ -292,6 +301,28 @@ class FolderPicker
std::vector<float> row_focus_;
};
// The first-download "Download folder" question (docs B-13), shared by the Store and Downloads tabs: each owns one.
// guard() is called where a download would start; true means it was held back and the question is open. Keep or a
// picked folder runs the held action; Back leaves nothing started (it is asked again next time).
class FolderAsk
{
public:
bool guard(Context &ctx, std::function<void()> resume);
bool is_open() const
{
return list_.is_open() || picker_.is_open() || notice_.is_open();
}
void update(Context &ctx, const hui::InputFrame &input, float dt, hui::ui::Feedback &feedback);
void draw(Context &ctx, Frame &frame) const;
private:
void answered(Context &ctx);
ListDialog list_;
FolderPicker picker_;
ConfirmDialog notice_;
bool opened_ = false; // the question opened since the last update (plays the cue)
};
// The dim veil behind every panel.
void draw_scrim(hui::gfx::DrawList &list, float amount);
+1 -1
View File
@@ -1545,7 +1545,7 @@ Result<Art> PegasusBackend::art(std::string_view key, ArtKind kind)
wings::InstalledGame game;
game.titleId = std::string(key.substr(4));
const auto reply =
a->fetchAppdbArt(wings::logic::installedArtUrl(game, kind == ArtKind::hero ? "pic0" : "icon"));
a->fetchAppdbArt(wings::logic::installedArtUrl(game, kind != ArtKind::cover ? "pic0" : "icon"));
if (!reply.ok())
{
fail(&out, reply.error);
+100 -3
View File
@@ -7,6 +7,7 @@
#include "orbit/i18n.hpp"
#include "orbit/json.hpp"
#include "orbit/pegasus_load_flow.hpp" // sha256_file
#include "orbit/pegasus_patch.hpp"
#include "orbit/pegasus_version.hpp"
#include <arpa/inet.h>
@@ -989,7 +990,9 @@ void PegasusManager::run_verify_send(PegasusRelease release)
finish(tr("Pegasus DL answered, so nothing was sent."), false);
return;
}
run_send(path);
std::string note;
const std::string send_path = apply_experimental_patch(path, &note);
run_send(send_path, note);
}
bool PegasusManager::send_to_loader(bool pegasus_online)
@@ -1001,10 +1004,102 @@ bool PegasusManager::send_to_loader(bool pegasus_online)
return false;
path = status_.path;
}
return start_job(PegasusPhase::sending, [this, path] { run_send(path); });
return start_job(PegasusPhase::sending,
[this, path]
{
std::string note;
const std::string send_path = apply_experimental_patch(path, &note);
run_send(send_path, note);
});
}
void PegasusManager::run_send(std::string path)
// Experimental, off unless config_.experimental_patch_pegasus: writes a patched sibling of the
// verified `stock_path` next to it (never overwrites the original) and returns its path, or
// `stock_path` unchanged when the config flag is off, no catalogue patch matched this exact file, or
// anything about writing the copy failed. A send never blocks on this; worst case it sends the same
// verified stock file it always did.
std::string PegasusManager::apply_experimental_patch(const std::string &stock_path, std::string *note) const
{
if (!config_.experimental_patch_pegasus)
return stock_path;
// This build compiles with -fno-exceptions (confirmed from the actual target compile command),
// so there is no try/catch to fall back on here: an allocation failure anywhere below aborts
// directly rather than throwing. The re-read-and-verify step after the write is the hardening
// that IS possible under that constraint - it catches a torn/short write or filesystem
// corruption before anything downstream (the send, the loader) ever sees a bad file.
{
std::string data;
if (!hui::save::read_file(stock_path, &data, kPegasusDownloadCap))
{
*note = tr("experimental patch: could not read the verified file, sent unchanged");
return stock_path;
}
std::vector<std::uint8_t> bytes(data.begin(), data.end());
data.clear();
data.shrink_to_fit(); // drop the string copy before the byte-vector copies below
pegasus_patch::Result result;
std::vector<std::uint8_t> patched =
pegasus_patch::apply(bytes, pegasus_patch::builtin_catalogue(), &result);
bytes.clear();
bytes.shrink_to_fit(); // one fewer ~20MB buffer alive during apply_structural's own copy
// P1 (the structural writer-thread patch) runs on whatever P0 produced; the two touch
// disjoint regions of the file, so order does not matter. Independent of whether P0
// matched - either, both or neither may apply to a given build.
pegasus_patch::StructuralResult structural;
patched = pegasus_patch::apply_structural(patched, pegasus_patch::builtin_structural_catalogue(), &structural);
std::vector<std::string> applied = result.applied;
if (structural.changed)
applied.push_back(structural.id);
if (applied.empty())
{
*note = tr("experimental patch: no catalogue entry matched this exact build, sent unchanged");
return stock_path;
}
std::string ids;
for (const std::string &id : applied)
ids += (ids.empty() ? "" : ", ") + id;
const std::string out_path = stock_path + ".patched.elf";
const std::string part = out_path + ".part";
FILE *f = std::fopen(part.c_str(), "wb");
bool wrote = f != nullptr;
if (f != nullptr)
{
wrote = std::fwrite(patched.data(), 1, patched.size(), f) == patched.size();
wrote = (std::fflush(f) == 0) && wrote;
wrote = (std::fclose(f) == 0) && wrote;
}
if (wrote)
{
std::remove(out_path.c_str());
wrote = std::rename(part.c_str(), out_path.c_str()) == 0;
}
if (!wrote)
{
std::remove(part.c_str());
*note = tr("experimental patch: {ids} matched but the patched copy could not be saved, sent "
"unchanged",
{{"ids", ids}});
return stock_path;
}
// Re-read the file we just wrote and verify, byte for byte, that it matches what we
// intended to write (catches a torn/short write, a filesystem that silently truncates, or
// disk corruption on this specific console, before anything downstream ever touches it).
std::string verify;
if (!hui::save::read_file(out_path, &verify, kPegasusDownloadCap) || verify.size() != patched.size() ||
!std::equal(verify.begin(), verify.end(), reinterpret_cast<const char *>(patched.data())))
{
std::remove(out_path.c_str());
*note = tr("experimental patch: {ids} matched but the saved copy failed verification, sent "
"unchanged",
{{"ids", ids}});
return stock_path;
}
*note = tr("experimental patch applied: {ids}", {{"ids", ids}});
return out_path;
}
}
void PegasusManager::run_send(std::string path, std::string note)
{
std::string error;
const std::string where = config_.loader_host + ":" + std::to_string(config_.loader_port);
@@ -1033,6 +1128,8 @@ void PegasusManager::run_send(std::string path)
status_.sent = true;
status_.message = tr("Sent to the payload loader at {where}. Wait for Wings to say Pegasus DL is running.",
{{"where", where}});
if (!note.empty())
status_.message += " \xC2\xB7 " + note;
status_.message_is_error = false;
}
+10 -1
View File
@@ -187,6 +187,11 @@ struct PegasusManagerConfig
std::string loader_host = "127.0.0.1";
std::uint16_t loader_port = kPegasusLoaderPort;
std::function<std::int64_t()> now; // unix seconds (default: the system clock)
// Experimental, off by default everywhere: before a send, try the patches in
// pegasus_patch.hpp against the verified stock file. A patch only ever applies to the exact
// source SHA-256 it was derived from; anything else (a different Pegasus build, a pattern that
// moved) sends the stock file unchanged. docs/research/pegasus-dl-patch.md has the detail.
bool experimental_patch_pegasus = false;
};
class PegasusManager
@@ -225,8 +230,12 @@ class PegasusManager
private:
void run_check();
void run_download(PegasusRelease release);
void run_send(std::string path);
void run_send(std::string path, std::string note = {});
void run_verify_send(PegasusRelease release);
// Experimental (config_.experimental_patch_pegasus only): a patched sibling of the verified
// `stock_path`, or `stock_path` itself unchanged when nothing applied or anything went wrong.
// Never fails the send; *note describes what happened, for the caller to report.
std::string apply_experimental_patch(const std::string &stock_path, std::string *note) const;
void finish(const std::string &message, bool is_error);
bool gate(std::string *why) const;
void refresh_downloaded_locked();
+523
View File
@@ -0,0 +1,523 @@
// Wings TV app - Experimental: byte-level patches to a verified pegasus_dl.elf (see pegasus_patch.hpp).
// SPDX-License-Identifier: GPL-3.0-or-later
#include "orbit/pegasus_patch.hpp"
#include "orbit/pegasus_manager.hpp" // sha256_hex
#include <algorithm>
namespace orbit::pegasus_patch
{
bool hex_decode(std::string_view hex, std::vector<std::uint8_t> *out)
{
out->clear();
if (hex.size() % 2 != 0)
return false;
out->reserve(hex.size() / 2);
const auto nibble = [](char c) -> int
{
if (c >= '0' && c <= '9')
return c - '0';
if (c >= 'a' && c <= 'f')
return c - 'a' + 10;
if (c >= 'A' && c <= 'F')
return c - 'A' + 10;
return -1;
};
for (std::size_t i = 0; i < hex.size(); i += 2)
{
const int hi = nibble(hex[i]);
const int lo = nibble(hex[i + 1]);
if (hi < 0 || lo < 0)
{
out->clear();
return false;
}
out->push_back(static_cast<std::uint8_t>((hi << 4) | lo));
}
return true;
}
namespace
{
// Every offset in `data` where `pattern` matches under `mask` ('?' = any byte). Stops early past
// `limit` matches (the catalogue only ever needs to know "0", "1" or "more than 1").
std::vector<std::size_t> find_matches(const std::vector<std::uint8_t> &data, const std::vector<std::uint8_t> &pattern,
std::string_view mask, std::size_t limit)
{
std::vector<std::size_t> hits;
if (pattern.empty() || pattern.size() != mask.size() || data.size() < pattern.size())
return hits;
const std::size_t last = data.size() - pattern.size();
for (std::size_t at = 0; at <= last; ++at)
{
bool ok = true;
for (std::size_t i = 0; i < pattern.size() && ok; ++i)
ok = mask[i] == '?' || data[at + i] == pattern[i];
if (ok)
{
hits.push_back(at);
if (hits.size() >= limit)
break;
}
}
return hits;
}
} // namespace
const std::vector<Patch> &builtin_catalogue()
{
// P0 - the per-segment write buffer in download_segmented()/segment_write()/segment_flush() is
// capped at 256 KiB (0x00040000) before each pwrite(). Orbit Store's equivalent path holds up to
// 8 MiB total, split across the active ranges, written by a separate thread. This patch only
// raises the cap (to 2 MiB, Orbit's per-range share at 4 connections) so a flush needs 1/8th as
// many pwrite() calls; it does NOT move the write off the socket-servicing thread (that is P1,
// still a design in docs/research/pegasus-dl-patch.md, not implemented here).
//
// Anchor (16 bytes, unique in the v1.11.0 ELF this was derived from - see the doc for how to
// re-derive it for a later release):
// 48 81 FF 00 00 04 00 cmp rdi, 0x40000
// B8 00 00 04 00 mov eax, 0x40000
// 48 0F 43 F8 cmovae rdi, rax
static const std::vector<Patch> kCatalogue = {
Patch{
"p0-segment-buffer-2m",
"Raise the 256 KiB per-segment write buffer to 2 MiB (Orbit Store's per-range share at 4 "
"connections), so each flush needs 8x fewer pwrite() calls. Diagnostic only; does not move "
"the write off the network thread.",
"32b69c2089bb545abfabd04d5827444698f9c4f1bc4915d72247cb781e8c23d9",
"4881ff00000400b800000400480f43f8",
"xxxxxxxxxxxxxxxx",
"4881ff00002000b800002000480f43f8",
},
};
return kCatalogue;
}
std::vector<std::uint8_t> apply(const std::vector<std::uint8_t> &data, const std::vector<Patch> &catalogue,
Result *out_result, bool require_all)
{
Result local;
Result &result = out_result != nullptr ? *out_result : local;
result = Result{};
std::vector<std::uint8_t> out = data;
if (data.empty())
{
result.error = "empty input";
return out;
}
const std::string source_hash =
sha256_hex(std::string_view(reinterpret_cast<const char *>(data.data()), data.size()));
for (const Patch &patch : catalogue)
{
if (patch.source_sha256.empty() || patch.pattern_hex.empty())
{
if (require_all)
{
result.error = "patch '" + patch.id + "' is incomplete";
return data;
}
continue;
}
if (source_hash != patch.source_sha256)
continue; // a different Pegasus build: never guess, always leave it alone
std::vector<std::uint8_t> pattern, replacement;
if (!hex_decode(patch.pattern_hex, &pattern) || !hex_decode(patch.replacement_hex, &replacement) ||
pattern.size() != patch.mask.size() || pattern.size() != replacement.size())
{
if (require_all)
{
result.error = "patch '" + patch.id + "' has a malformed pattern";
return data;
}
continue;
}
const std::vector<std::size_t> hits = find_matches(out, pattern, patch.mask, 2);
if (hits.size() != 1)
{
if (require_all)
{
result.error = "patch '" + patch.id + "' anchor is " +
(hits.empty() ? "absent" : "not unique") + " in this build";
return data;
}
continue; // 0 or >1 matches: this build is not the one byte-for-byte shape expected
}
std::copy(replacement.begin(), replacement.end(), out.begin() + static_cast<std::ptrdiff_t>(hits[0]));
result.applied.push_back(patch.id);
result.changed = true;
}
return result.changed ? out : data;
}
namespace
{
std::uint64_t read_u64(const std::vector<std::uint8_t> &d, std::size_t at)
{
std::uint64_t v = 0;
for (int i = 7; i >= 0; --i)
v = (v << 8) | d[at + static_cast<std::size_t>(i)];
return v;
}
std::uint32_t read_u32(const std::vector<std::uint8_t> &d, std::size_t at)
{
std::uint32_t v = 0;
for (int i = 3; i >= 0; --i)
v = (v << 8) | d[at + static_cast<std::size_t>(i)];
return v;
}
std::uint16_t read_u16(const std::vector<std::uint8_t> &d, std::size_t at)
{
return static_cast<std::uint16_t>(d[at] | (d[at + 1] << 8));
}
void write_u64(std::vector<std::uint8_t> &d, std::size_t at, std::uint64_t v)
{
for (int i = 0; i < 8; ++i)
d[at + static_cast<std::size_t>(i)] = static_cast<std::uint8_t>(v >> (8 * i));
}
void write_u32(std::vector<std::uint8_t> &d, std::size_t at, std::uint32_t v)
{
for (int i = 0; i < 4; ++i)
d[at + static_cast<std::size_t>(i)] = static_cast<std::uint8_t>(v >> (8 * i));
}
// One attempt at `patch`; true and *out mutated on success, false (and *out untouched) on any
// mismatch - never a partial edit.
bool try_apply_one(const std::vector<std::uint8_t> &in, const StructuralPatch &patch,
std::vector<std::uint8_t> *out, std::string *why)
{
if (in.size() < 64)
{
*why = "file too small to be an ELF";
return false;
}
const std::uint64_t e_phoff = read_u64(in, 32);
const std::uint16_t e_phentsize = read_u16(in, 54);
const std::uint16_t e_phnum = read_u16(in, 56);
if (e_phentsize != 56 || patch.phdr_index >= e_phnum ||
e_phoff > in.size() || static_cast<std::uint64_t>(e_phnum) * e_phentsize > in.size() - e_phoff)
{
*why = "program header table does not look as expected";
return false;
}
const std::size_t ph = static_cast<std::size_t>(e_phoff) + patch.phdr_index * e_phentsize;
const std::uint32_t p_type = read_u32(in, ph);
const std::uint32_t p_flags = read_u32(in, ph + 4);
const std::uint64_t p_offset = read_u64(in, ph + 8);
const std::uint64_t p_vaddr = read_u64(in, ph + 16);
const std::uint64_t p_filesz = read_u64(in, ph + 32);
const std::uint64_t p_memsz = read_u64(in, ph + 40);
if (p_type != 1 || p_offset != patch.expect_p_offset || p_vaddr != patch.expect_p_vaddr ||
p_filesz != patch.expect_p_filesz || p_memsz != patch.expect_p_memsz)
{
*why = "target PT_LOAD entry does not match exactly";
return false;
}
if (patch.call_site_va < patch.text_p_vaddr)
{
*why = "call-site VA precedes its expected text segment";
return false;
}
const std::uint64_t call_site_off = patch.call_site_va - patch.text_p_vaddr + patch.text_p_offset;
if (call_site_off > in.size() || in.size() - call_site_off < 6 ||
in[call_site_off] != 0xFF || in[call_site_off + 1] != 0x15)
{
*why = "call site is not the expected FF 15 indirect call";
return false;
}
const std::int32_t old_disp = static_cast<std::int32_t>(read_u32(in, call_site_off + 2));
const std::int64_t old_target = static_cast<std::int64_t>(patch.call_site_va) + 6 + old_disp;
if (old_target < 0 || static_cast<std::uint64_t>(old_target) != patch.call_site_expected_target_va)
{
*why = "call site no longer targets the expected address";
return false;
}
std::vector<std::uint8_t> blob;
if (!hex_decode(patch.blob_hex, &blob) || blob.empty())
{
*why = "patch blob is malformed or empty";
return false;
}
if (p_filesz > UINT64_MAX - blob.size() || p_offset > UINT64_MAX - p_filesz)
{
*why = "PT_LOAD size arithmetic overflow";
return false;
}
// The PT_LOAD's file-backed content ends exactly where the zero-filled cave begins.
// Overlay there instead of inserting bytes: inserting would shift later segments and ELF
// metadata. Require the whole cave to be in-file, all-zero, and free of any other PT_LOAD.
const std::uint64_t cave_off = p_offset + p_filesz;
if (cave_off > UINT64_MAX - blob.size())
{
*why = "patch cave offset overflows";
return false;
}
const std::uint64_t cave_end_off = cave_off + blob.size();
const std::uint64_t code_va = p_vaddr + p_filesz;
const std::uint64_t code_end_va = code_va + blob.size();
if (cave_end_off > in.size() || code_end_va < code_va)
{
*why = "patch blob does not fit in the file-backed cave";
return false;
}
if (!std::all_of(in.begin() + static_cast<std::ptrdiff_t>(cave_off),
in.begin() + static_cast<std::ptrdiff_t>(cave_end_off),
[](std::uint8_t b) { return b == 0; }))
{
*why = "executable cave is not all-zero padding";
return false;
}
for (std::uint16_t i = 0; i < e_phnum; ++i)
{
if (i == patch.phdr_index)
continue;
const std::size_t other_ph = static_cast<std::size_t>(e_phoff) + i * e_phentsize;
if (read_u32(in, other_ph) != 1) // only PT_LOAD ranges matter here
continue;
const std::uint64_t other_off = read_u64(in, other_ph + 8);
const std::uint64_t other_va = read_u64(in, other_ph + 16);
const std::uint64_t other_filesz = read_u64(in, other_ph + 32);
const std::uint64_t other_memsz = read_u64(in, other_ph + 40);
if (other_off <= UINT64_MAX - other_filesz &&
cave_off < other_off + other_filesz && other_off < cave_end_off)
{
*why = "patch cave overlaps another PT_LOAD file range";
return false;
}
if (other_va <= UINT64_MAX - other_memsz &&
code_va < other_va + other_memsz && other_va < code_end_va)
{
*why = "patch code overlaps another PT_LOAD memory range";
return false;
}
}
const std::uint64_t new_filesz = p_filesz + blob.size();
const std::uint64_t new_memsz = std::max(p_memsz, new_filesz);
if (new_memsz < new_filesz || p_vaddr > UINT64_MAX - new_memsz)
{
*why = "new PT_LOAD size overflows";
return false;
}
const std::int64_t new_disp =
static_cast<std::int64_t>(code_va) - static_cast<std::int64_t>(patch.call_site_va + 5);
if (new_disp < -(1LL << 31) || new_disp >= (1LL << 31))
{
*why = "CALL rel32 displacement out of range";
return false;
}
// P0 and P1 must compose regardless of application order. P0's ordinary patch may already
// have changed this one anchor, or P1 can apply it here atomically with the structural edit.
std::vector<std::uint8_t> p0_before, p0_after;
bool p0_already_applied = false;
if (!patch.p0_before_hex.empty() || !patch.p0_after_hex.empty())
{
if (!hex_decode(patch.p0_before_hex, &p0_before) ||
!hex_decode(patch.p0_after_hex, &p0_after) || p0_before.empty() ||
p0_before.size() != p0_after.size() ||
patch.p0_file_offset > in.size() || in.size() - patch.p0_file_offset < p0_before.size())
{
*why = "P0 companion anchor is malformed";
return false;
}
const std::string exact_mask(p0_before.size(), 'x');
const auto before_hits = find_matches(in, p0_before, exact_mask, 2);
const auto after_hits = find_matches(in, p0_after, exact_mask, 2);
if (before_hits.size() == 1 && before_hits[0] == patch.p0_file_offset && after_hits.empty())
p0_already_applied = false;
else if (after_hits.size() == 1 && after_hits[0] == patch.p0_file_offset && before_hits.empty())
p0_already_applied = true;
else
{
*why = "P0 companion anchor is absent, duplicated, or not at the verified offset";
return false;
}
}
// Format v2: validate EVERY extra E8 call redirect against the live file before any byte is
// changed, so one bad site refuses the whole patch (never a partial P1).
struct ResolvedRedirect
{
std::uint64_t off;
std::int32_t disp;
};
const auto ranges_overlap = [](std::uint64_t a, std::uint64_t alen, std::uint64_t b, std::uint64_t blen)
{ return a < b + blen && b < a + alen; };
std::vector<ResolvedRedirect> redirects;
for (const DirectCallRedirect &r : patch.direct_call_redirects)
{
if (r.va < patch.text_p_vaddr || r.blob_entry_offset >= blob.size())
{
*why = "direct call redirect is outside its text segment or the blob";
return false;
}
const std::uint64_t off = r.va - patch.text_p_vaddr + patch.text_p_offset;
if (off > in.size() || in.size() - off < 5 || in[off] != 0xE8)
{
*why = "direct call redirect site is not the expected E8 call";
return false;
}
const std::int64_t old_target =
static_cast<std::int64_t>(r.va) + 5 + static_cast<std::int32_t>(read_u32(in, off + 1));
if (old_target < 0 || static_cast<std::uint64_t>(old_target) != r.expected_target_va)
{
*why = "direct call redirect site no longer targets the expected address";
return false;
}
const std::int64_t disp = static_cast<std::int64_t>(code_va + r.blob_entry_offset) -
static_cast<std::int64_t>(r.va + 5);
if (disp < -(1LL << 31) || disp >= (1LL << 31))
{
*why = "direct call redirect displacement out of range";
return false;
}
bool clash = ranges_overlap(off, 5, call_site_off, 6) || ranges_overlap(off, 5, cave_off, blob.size());
if (!p0_before.empty())
clash = clash || ranges_overlap(off, 5, patch.p0_file_offset, p0_before.size());
for (const ResolvedRedirect &prev : redirects)
clash = clash || ranges_overlap(off, 5, prev.off, 5);
if (clash)
{
*why = "direct call redirect overlaps another edit";
return false;
}
redirects.push_back({off, static_cast<std::int32_t>(disp)});
}
std::vector<std::uint8_t> result = in;
if (!p0_already_applied && !p0_before.empty())
std::copy(p0_after.begin(), p0_after.end(),
result.begin() + static_cast<std::ptrdiff_t>(patch.p0_file_offset));
std::copy(blob.begin(), blob.end(), result.begin() + static_cast<std::ptrdiff_t>(cave_off));
result[call_site_off] = 0xE8; // CALL rel32
write_u32(result, static_cast<std::size_t>(call_site_off + 1), static_cast<std::uint32_t>(new_disp));
result[call_site_off + 5] = 0x90; // NOP: preserve the original six-byte instruction footprint
for (const ResolvedRedirect &r : redirects) // format v2: same-length E8 -> E8, new rel32 only
write_u32(result, static_cast<std::size_t>(r.off + 1), static_cast<std::uint32_t>(r.disp));
// Segment #0 is already RWE; preserve p_flags and every section/header/tail byte.
(void)p_flags;
write_u64(result, ph + 32, new_filesz);
write_u64(result, ph + 40, new_memsz);
*out = std::move(result);
return true;
}
} // namespace
const std::vector<StructuralPatch> &builtin_structural_catalogue()
{
// P1 is hash-gated to the exact stock ELF. The embedded blob is relocation-resolved code
// generated from w1.c plus the zero-initialized g_w1 state; the ring itself is host-malloc'd.
static const std::vector<StructuralPatch> kCatalogue = {
StructuralPatch{
"p1-writer-thread",
"Redirect segment_flush's pwrite() to a bounded ring-buffered background writer thread, "
"and drain it (reporting async write failures) before fsync/close. "
"Experimental.",
"32b69c2089bb545abfabd04d5827444698f9c4f1bc4915d72247cb781e8c23d9",
/*phdr_index*/ 0,
/*expect p_offset/p_vaddr/p_filesz/p_memsz*/ 0x4000, 0x0, 0x5f1a65, 0x5f1a65,
/*call_site_va*/ 0x4e22b,
/*call_site_expected_target_va*/ 0x7ef838,
/*text_p_offset/text_p_vaddr*/ 0x4000, 0x0,
/*already-resolved code + zeroed g_w1 state*/
"554889e54157415641554154534883ec3848894dc0488955c84189ff8b055106000083f8020f84c0010000b901000000"
"31c0f00fb10d390600000f85a00100004989f6488d0589dd1f00488b1848891db7050000488d0578da1f004c8b184c89"
"1dae050000488d0567dc1f004c8b104c8915a5050000488d0586d91f004c8b084c890d9c050000488d058dd91f004c8b"
"004c890593050000488d05a4df1f00488b3848893d8a050000488d054bdc1f00488b3048893581050000488d058adf1f"
"00488b1048891578050000488d05c1d91f00488b0848890d6f050000488d0590d91f00488b00488905660500004885db"
"0f84cc0000004d85db0f84c30000004d85d20f84ba0000004d85c90f84b10000004d85c00f84a80000004885ff0f849f"
"0000004885f60f84960000004885d20f848d0000004885c90f84840000004885c0747f488d05f1d81f00488b004885c0"
"7470bfc0008000ffd0488905ab0500004885c0745d488d3def04000031f6ff15a704000085c07532488d3de404000031"
"f6ff15ac04000085c0751f488d3dd9040000488d15ea01000031f631c9ff15700400004c89f685c07425488b3d5a0500"
"00ff159404000048c7054905000000000000c705ab040000010000004c89f6c7059a04000002000000eb100f1f440000"
"8b058d04000083f80275f58b058604000085c0741931c0488945d0488b45d04883c4385b415c415d415e415f5dc34181"
"ffff0300007622488b05ed0300004489ff488b55c8488b4dc04883c4385b415c415d415e415f5dffe0488975b84489f8"
"c1e806488945a0b8010000004c897db0c4e281f7c0488945a84531f64c8d2df80300004c8d3df9030000eb1a0f1f4000"
"4c89efff15b20300004c8975d04885db0f8575ffffff4c8b65c84d29f40f86e70000004981fc00001000b8000010004c"
"0f43e04c89efff1577030000833d5004000008752066662e0f1f8400000000004c89ff4c89eeff156f030000833d3004"
"00000874eb488b45a0488d0d2b030000488b5cc17048235da87585488b0d190400008b05070400004869d01800100048"
"8b45b0890411488b45c04c01f0488944110844896411104585e47424488b45b84c01f0488d4c111431d2660f1f440000"
"0fb634104088341148ffc24939d475f08b05b9030000ffc083e0078905ae030000ff05ac0300004c89ffff15e3020000"
"4c89efff15c20200004d01e64885db0f8411ffffffe981feffff488b45c8e974feffff554889e5415741564155415453"
"4883ec28488d1dc00200004c8d35c1020000eb3f6666666666662e0f1f8400000000008b0542030000ffc083e0078905"
"37030000ff0d390300004c8d35920200004c89f7ff15690200004889dfff15480200004889dfff1537020000833d1003"
"000000752366666666662e0f1f8400000000004c89f74889deff152c020000833ded0200000074eb4889df488b1de902"
"00008b05d30200004c69f018001000468b3c334a8b443308488945b8428b4433108945d44489f9c1e9064489fa83e23f"
"b801000000488955c8c462e9f7e8488d05a601000048894dc04c236cc870ff15b70100004e8d7433144531e431db0f1f"
"4400004d85ed75398b45d489c229da763089d9498d340e48034db84489ffff15670100004885c089c1ba000000000f4e"
"ca01cb4885c0b801000000440f4ee07fc2488d1d930100004889dfff15520100004585e40f84d9feffffb80100000048"
"8b4dc8c4e2f1f7c0488d0d1c010000488b55c0480944d170e9b6feffff554889e541574156535089fb8b056401000085"
"c00f84dc000000660f1f44000083f801750b8b054b01000083f80174f58b054401000085c07417488b05150100004885"
"c00f848f000000ffd0e982000000488d3d06010000ff15c8000000833da101000000742e4c8d35f80000004c8d3de900"
"000066662e0f1f8400000000004c89f74c89feff15b2000000833d730100000075eb81fbff030000774c89d8c1e80648"
"8d0d65000000488b54c170480fa3da733583e33f480fb3da488954c170488d3d97000000ff1561000000ff1583000000"
"c70005000000b8ffffffff4883c4085b415e415f5dc3488d3d6e000000ff153800000089df4883c4085b415e415f5de9"
"07b8a5ff9000000000000000000000000000000000000000000000000000000000000000000000000000000000000000"
"000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000"
"000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000"
"000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000"
"000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000"
"0000000000000000000000000000000000000000000000000000000000000000000000",
/*known P0-only source hash*/ "9e13e060f8810f1c2dda4e25f6169f4a34deb02d889556dfeac8f4a224d7d5bf",
/*P0 file offset*/ 0x4FE77,
/*P0 before/after*/ "4881ff00000400b800000400480f43f8",
"4881ff00002000b800002000480f43f8",
// Both `call sync_segment_output` sites in download_segmented() -> w1_sync (the
// completion barrier). Entry offset = w1_sync's offset in the blob.
/*direct-call redirects*/
{
{0x4c1ae, 0x4d870, 0x4fd},
{0x4c24a, 0x4d870, 0x4fd},
}
},
};
return kCatalogue;
}
std::vector<std::uint8_t> apply_structural(const std::vector<std::uint8_t> &data,
const std::vector<StructuralPatch> &catalogue,
StructuralResult *out_result, bool require_all)
{
StructuralResult local;
StructuralResult &result = out_result != nullptr ? *out_result : local;
result = StructuralResult{};
if (data.empty())
{
result.error = "empty input";
return data;
}
const std::string source_hash =
sha256_hex(std::string_view(reinterpret_cast<const char *>(data.data()), data.size()));
for (const StructuralPatch &patch : catalogue)
{
if (source_hash != patch.source_sha256 &&
(patch.source_sha256_p0.empty() || source_hash != patch.source_sha256_p0))
continue;
std::vector<std::uint8_t> patched;
std::string why;
if (!try_apply_one(data, patch, &patched, &why))
{
if (require_all)
{
result.error = "patch '" + patch.id + "': " + why;
return data;
}
continue;
}
result.changed = true;
result.id = patch.id;
return patched; // one structural patch per file; there is exactly one in the catalogue
}
return data;
}
} // namespace orbit::pegasus_patch
+136
View File
@@ -0,0 +1,136 @@
// Wings TV app - Experimental: byte-level patches to a verified pegasus_dl.elf (see pegasus_patch.cpp).
// SPDX-License-Identifier: GPL-3.0-or-later
//
// Off by default everywhere. Nothing here runs unless the caller explicitly asks for a named patch
// set, and every patch is matched by an exact source SHA-256 plus a byte pattern that must occur
// EXACTLY once in the file; anything else (wrong version, pattern missing, pattern not unique) is
// refused, never guessed. The caller always has the original verified bytes and can fall back to
// sending those unchanged.
//
// This exists to test one hypothesis (docs/research/pegasus-dl-patch.md): Pegasus DL's segmented
// downloader writes each range's buffer with a direct pwrite() on the thread that also drives every
// socket in the batch, so a slow write can stall transfers that have nothing to do with it. Orbit
// Store decouples this with a ring buffer and a dedicated writer thread. The patches below are a
// diagnostic, not a redistributed binary: Wings never ships a patched pegasus_dl.elf, it only patches
// the player's own already-downloaded, already-verified copy, in memory, when the player turns this
// on in an experimental build.
#pragma once
#include <cstddef>
#include <cstdint>
#include <string>
#include <string_view>
#include <vector>
namespace orbit::pegasus_patch
{
// One edit: `pattern` (with `mask`, 'x' = must match, '?' = any byte) must occur in `data` exactly
// once, at a file offset inside the loaded .text segment; `replacement` is written over the matched
// bytes (same length). `source_sha256` is the stock pegasus_dl.elf this was derived against; a file
// with a different hash never reaches the matcher, so a future Pegasus release always falls back to
// its own stock bytes until someone re-derives and adds a new entry (docs/research/pegasus-dl-patch.md
// says how).
struct Patch
{
std::string id; // "p0-segment-buffer-256k", stable, goes in the cache file name
std::string description; // one line, shown in diagnostics
std::string source_sha256; // lower-case 64 hex; the exact stock file this patch targets
std::string pattern_hex; // the bytes to find, hex, no spaces ("48 81 FF.." -> "4881ff..")
std::string mask; // same length as pattern_hex/2, one char per byte: 'x' or '?'
std::string replacement_hex; // same length as pattern_hex; what to write over the match
};
// The built-in catalogue. One entry today (P0, the 256 KiB segment buffer cap); new entries are
// appended as new Pegasus releases are checked, never edited in place once shipped (the source hash
// keys each one, so stale entries are simply never matched again).
const std::vector<Patch> &builtin_catalogue();
struct Result
{
bool changed = false; // true only if at least one patch matched and was applied
std::vector<std::string> applied; // ids, in catalogue order
std::string error; // set only when `changed` could not become true as asked
};
// Applies every patch in `catalogue` whose `source_sha256` matches sha256_hex(data) to a COPY of
// `data` (the input is never modified). A patch whose pattern is absent or not unique in `data` is
// skipped, not an error, unless `require_all` is true (used by the unit test, not by the manager).
// Returns the possibly-patched bytes; `out_result` (optional) says what happened.
std::vector<std::uint8_t> apply(const std::vector<std::uint8_t> &data, const std::vector<Patch> &catalogue,
Result *out_result = nullptr, bool require_all = false);
// Decodes one hex string ("4881ff") into bytes; false on odd length or a non-hex character.
bool hex_decode(std::string_view hex, std::vector<std::uint8_t> *out);
// ---- P1: adds the writer-thread module (docs/research/pegasus-dl-patch.md) ----
//
// A `Patch` is a same-length byte swap; P1 needs more than that (bytes written into a verified
// cave and a PT_LOAD's sizes grown). `StructuralPatch` is the data for that kind
// of edit; `apply_structural` is the one, generic, hash-gated engine for it - every number in a
// `StructuralPatch` is re-checked against the live file before anything is touched, and any
// mismatch (wrong hash, a program header that doesn't look exactly as expected, a call site that
// isn't the exact instruction this was derived from) refuses that entry, same rule as `Patch`.
// Format v2 (additive; an empty `direct_call_redirects` is exactly the v1 behaviour).
// A direct-call redirect repoints an existing "E8 <rel32>" call (5 bytes, no NOP needed) at an
// entry point INSIDE the blob. P1 uses two of these to put its completion barrier in front of the
// host's fsync: both `call sync_segment_output` sites in download_segmented() (VA 0x4c1ae and
// 0x4c24a) are redirected to w1_sync, which drains the write ring, reports async failures, and
// only then tail-calls the original sync function.
struct DirectCallRedirect
{
std::uint64_t va = 0; // VA of the E8 opcode, inside the text PT_LOAD
std::uint64_t expected_target_va = 0; // the callee it must currently resolve to, or refuse
std::uint64_t blob_entry_offset = 0; // entry point, as a byte offset into the blob
};
struct StructuralPatch
{
std::string id;
std::string description;
std::string source_sha256;
// The PT_LOAD entry this grows (its index in the program header table) - checked against the
// live file's e_phoff/e_phnum/e_phentsize, never assumed to be at a fixed byte offset.
std::size_t phdr_index = 0;
std::uint64_t expect_p_offset = 0, expect_p_vaddr = 0, expect_p_filesz = 0, expect_p_memsz = 0;
// The one call site this redirects: an "FF 15 <disp32>" indirect call (6 bytes) at this VA,
// which must currently resolve to `call_site_expected_target_va` or the patch refuses.
// text_p_offset`/`text_p_vaddr` locate the PT_LOAD that VA lives in (to turn it into a file
// offset), checked the same way.
std::uint64_t call_site_va = 0, call_site_expected_target_va = 0;
std::uint64_t text_p_offset = 0, text_p_vaddr = 0;
// Already relocation-resolved code plus the zero-initialized g_w1 state object. The blob
// overlays the verified all-zero padding immediately after this PT_LOAD's current file content;
// the engine grows this PT_LOAD's p_filesz/p_memsz without shifting or truncating the ELF.
std::string blob_hex;
// P0 can be applied before or after P1. Accept the known P0-only hash as well as stock,
// and ensure the P0 anchor is in the corresponding before/after state as part of P1.
std::string source_sha256_p0;
std::size_t p0_file_offset = 0;
std::string p0_before_hex;
std::string p0_after_hex;
// Format v2: extra E8 call redirects into the blob (see DirectCallRedirect). Every site is
// validated - exact opcode, exact original target, in-file, inside the blob, no overlap with
// another edit - before ANY byte of the file is modified; one bad entry refuses the whole patch.
std::vector<DirectCallRedirect> direct_call_redirects;
};
struct StructuralResult
{
bool changed = false;
std::string id; // empty if nothing applied
std::string error; // set only when require_all asked for a patch that could not apply
};
const std::vector<StructuralPatch> &builtin_structural_catalogue();
std::vector<std::uint8_t> apply_structural(const std::vector<std::uint8_t> &data,
const std::vector<StructuralPatch> &catalogue,
StructuralResult *out_result = nullptr, bool require_all = false);
} // namespace orbit::pegasus_patch
+40 -6
View File
@@ -25,6 +25,7 @@ long clamp_count(const json::Value &v)
const double n = v.as_number(-1.0);
return n < 0.0 || n > 1e9 ? -1 : static_cast<long>(n);
}
} // namespace
void write_everywhere(const std::vector<std::string> &paths, const std::string &data, int *written)
{
@@ -37,7 +38,6 @@ void write_everywhere(const std::vector<std::string> &paths, const std::string &
++*written;
}
}
} // namespace
// ---- the index ----
@@ -50,12 +50,18 @@ bool PopularityIndex::parse(std::string_view text, PopularityIndex *out, std::st
return false;
};
if (text.size() > kPopularityMaxBytes)
return fail("the file is larger than 400 KB");
return fail("the file is too large");
json::Value root;
std::string why;
if (!json::parse(text, &root, &why) || !root.is_object())
return fail("the file is not valid JSON");
if (!root["schemaVersion"].is_number() || root["schemaVersion"].as_int(0) != 1)
if (!root["schemaVersion"].is_number())
return fail("unsupported data version");
// A newer format goes to a new address and old apps keep the old file; if a source still serves one,
// the way out is a newer Wings.
if (root["schemaVersion"].as_int(0) > 1)
return fail(tr("this data needs a newer Wings (Settings > Wings > Updates)"));
if (root["schemaVersion"].as_int(0) != 1)
return fail("unsupported data version");
const json::Value &titles = root["titles"];
if (!titles.is_object())
@@ -89,6 +95,9 @@ bool PopularityIndex::parse(std::string_view text, PopularityIndex *out, std::st
if (r["hero"].is_string() && r["hero"].as_string().size() <= 300 &&
valid_popularity_url(r["hero"].as_string()))
rec.hero = r["hero"].as_string();
if (r["heroLow"].is_string() && r["heroLow"].as_string().size() <= 300 &&
valid_popularity_url(r["heroLow"].as_string()))
rec.hero_low = r["heroLow"].as_string();
index.records_[id] = std::move(rec);
}
if (index.records_.empty())
@@ -129,6 +138,17 @@ std::string PopularityIndex::hero(const std::vector<std::string> &title_ids) con
return {};
}
std::string PopularityIndex::hero_low(const std::vector<std::string> &title_ids) const
{
for (const std::string &id : title_ids)
{
const PopRecord *r = find(id);
if (r != nullptr && !r->hero_low.empty())
return r->hero_low;
}
return {};
}
bool valid_popularity_url(std::string_view url)
{
if (url.size() > 300 || url.size() < 12 || url.substr(0, 8) != "https://")
@@ -466,6 +486,7 @@ void PopularityService::run(bool force, std::string url)
PopularityStatus status;
status.busy = true;
bool cache_ok = false;
bool cache_current = false; // the saved copy was fetched by this very build of the app
std::int64_t fetched = 0;
// 1. The saved copy. Missing or damaged is fine.
for (const std::string &path : config_.cache_paths)
@@ -479,9 +500,20 @@ void PopularityService::run(bool force, std::string url)
const std::size_t nl = d.payload.find('\n');
if (nl == std::string::npos)
continue;
// "fetched=<unix>\napp=<build tag>\n<file>"; a copy from before the tag has no app line.
std::size_t body_at = nl + 1;
std::string saved_tag;
if (d.payload.compare(body_at, 4, "app=") == 0)
{
const std::size_t nl2 = d.payload.find('\n', body_at);
if (nl2 == std::string::npos)
continue;
saved_tag = d.payload.substr(body_at + 4, nl2 - body_at - 4);
body_at = nl2 + 1;
}
auto index = std::make_shared<PopularityIndex>();
std::string why;
if (!PopularityIndex::parse(std::string_view(d.payload).substr(nl + 1), index.get(), &why))
if (!PopularityIndex::parse(std::string_view(d.payload).substr(body_at), index.get(), &why))
continue;
fetched = std::atoll(d.payload.substr(8, nl - 8).c_str());
status.updated = fetched;
@@ -489,10 +521,11 @@ void PopularityService::run(bool force, std::string url)
status.message = tr("Using the saved copy");
hub_.set_index(index);
cache_ok = true;
cache_current = saved_tag == config_.app_tag;
break;
}
const std::int64_t now = config_.now();
const bool fresh = cache_ok && fetched <= now && now - fetched < config_.ttl_s;
const bool fresh = cache_ok && cache_current && fetched <= now && now - fetched < config_.ttl_s;
if (fresh && !force)
{
status.busy = false;
@@ -521,7 +554,8 @@ void PopularityService::run(bool force, std::string url)
else
write_everywhere(config_.cache_paths,
hui::save::encode(hui::save::Kind::stats, 1,
"fetched=" + std::to_string(now) + "\n" + doc.body),
"fetched=" + std::to_string(now) + "\napp=" + config_.app_tag + "\n" +
doc.body),
nullptr);
}
}
+12 -3
View File
@@ -3,7 +3,7 @@
//
// The data is a small JSON file Wings fetches over https (docs/research/popularity.md):
// {schemaVersion:1, generatedAt, sources[], attribution{}, titles{titleId:{score, rating?,
// ratingCount?, sources[], confidence, fetchedAt}}}
// ratingCount?, sources[], confidence, hero?, heroLow?}}}
// On by default (the player can turn it off). Nothing here touches the network on its own: PopularityService asks a
// DocFetcher (main.cpp wires it to the cover-art HTTPS client) and only when the Wings
// preference is on. Everything else is platform neutral and tested on the host.
@@ -25,7 +25,7 @@
namespace orbit
{
constexpr std::size_t kPopularityMaxBytes = 400u * 1024u;
constexpr std::size_t kPopularityMaxBytes = 512u * 1024u;
constexpr double kPopularityMinConfidence = 0.6; // records below this are hidden
constexpr const char *kPopularityDefaultUrl = "https://larazadelsol.github.io/wings-app/popularity.v1.json";
@@ -36,7 +36,8 @@ struct PopRecord
long rating_count = -1;
double confidence = 0.0;
std::vector<std::string> sources;
std::string hero; // optional https:// art URL for the game page header ("" none; game_header.hpp)
std::string hero; // optional https:// art URL for the game page header ("" none; game_header.hpp)
std::string hero_low; // optional small, fast ~16:9 https:// URL shown until the full hero is there
};
class PopularityIndex
@@ -54,6 +55,8 @@ class PopularityIndex
// The hero art URL of a game: the first of its title ids whose record has one (confidence is not
// asked: art is not a ranking claim). "" when none does.
std::string hero(const std::vector<std::string> &title_ids) const;
// The same for the small "heroLow" image.
std::string hero_low(const std::vector<std::string> &title_ids) const;
std::size_t size() const { return records_.size(); }
const std::string &generated_at() const { return generated_at_; }
const std::vector<std::string> &attribution() const { return attribution_; } // one line per source
@@ -66,6 +69,8 @@ class PopularityIndex
// "Only https://": the source URL is refused otherwise (also credentials, spaces, > 300 bytes).
bool valid_popularity_url(std::string_view url);
// Writes `data` to every path (creating the folder); `written` counts the files that worked. Shared with editorial.cpp.
void write_everywhere(const std::vector<std::string> &paths, const std::string &data, int *written);
// 4300 -> "4.3k", 1200000 -> "1.2M", 950 -> "950".
std::string short_count(long n);
// "Popularity 87 · Rating 92 (4.3k)"; "" for a null record.
@@ -177,6 +182,10 @@ struct PopularityServiceConfig
{
std::vector<std::string> cache_paths; // first writable wins; the saved copy of the file
int ttl_s = 24 * 3600; // no ETag support in the client: re-fetch only when older
// Which build of the app this is ("00.001.001+20261012-203105"). The saved copy carries the tag of the build
// that fetched it; a copy from another build (or from before the tag existed) is fetched again at the first
// start, whatever its age, so updating the app always updates the data too. Empty: not asked (tests, preview).
std::string app_tag;
std::function<std::int64_t()> now; // unix seconds (default: the system clock)
};
+22 -1
View File
@@ -52,7 +52,10 @@ bool WingsLocalPrefs::operator==(const WingsLocalPrefs &other) const
prefer_debrid == other.prefer_debrid &&
popularity_enabled == other.popularity_enabled && popularity_url == other.popularity_url &&
tile_details == other.tile_details && pegasus_start == other.pegasus_start &&
after_download == other.after_download;
after_download == other.after_download && update_check == other.update_check &&
icon_source == other.icon_source && hero_source == other.hero_source &&
notes_enabled == other.notes_enabled && carousel_enabled == other.carousel_enabled &&
folder_confirmed == other.folder_confirmed;
}
bool valid_firmware(std::string_view value)
@@ -82,6 +85,12 @@ std::string encode_local_prefs(const WingsLocalPrefs &prefs)
out += std::string("tile_details=") + tile_details_key(prefs.tile_details) + "\n";
out += std::string("pegasus_start=") + pegasus_start_mode_key(prefs.pegasus_start) + "\n";
out += std::string("after_download=") + after_download_key(prefs.after_download) + "\n";
out += std::string("update_check=") + (prefs.update_check ? "1" : "0") + "\n";
out += std::string("icon_source=") + icon_source_key(prefs.icon_source) + "\n";
out += std::string("hero_source=") + hero_source_key(prefs.hero_source) + "\n";
out += std::string("notes=") + (prefs.notes_enabled ? "1" : "0") + "\n";
out += std::string("carousel=") + (prefs.carousel_enabled ? "1" : "0") + "\n";
out += std::string("folder_confirmed=") + (prefs.folder_confirmed ? "1" : "0") + "\n";
return out;
}
@@ -113,6 +122,12 @@ bool decode_local_prefs(std::string_view payload, WingsLocalPrefs *out)
prefs.prefer_debrid = value != "0";
else if (key == "popularity")
prefs.popularity_enabled = value == "1";
else if (key == "notes")
prefs.notes_enabled = value != "0";
else if (key == "carousel")
prefs.carousel_enabled = value != "0";
else if (key == "folder_confirmed")
prefs.folder_confirmed = value == "1";
else if (key == "popularity_url" && plain_line(value))
prefs.popularity_url = std::string(value);
else if (key == "tile_details")
@@ -121,6 +136,12 @@ bool decode_local_prefs(std::string_view payload, WingsLocalPrefs *out)
parse_pegasus_start_mode(value, &prefs.pegasus_start);
else if (key == "after_download")
parse_after_download(value, &prefs.after_download);
else if (key == "update_check")
prefs.update_check = value != "0";
else if (key == "icon_source")
parse_icon_source(value, &prefs.icon_source);
else if (key == "hero_source")
parse_hero_source(value, &prefs.hero_source);
}
*out = prefs;
return true;
+13
View File
@@ -9,6 +9,7 @@
#pragma once
#include "orbit/after_download_mode.hpp"
#include "orbit/art_sources.hpp"
#include "orbit/pegasus_start_mode.hpp"
#include "orbit/tile_details.hpp"
@@ -39,6 +40,18 @@ struct WingsLocalPrefs
PegasusStartMode pegasus_start = PegasusStartMode::ask;
// What Wings does when a download finishes: ask ShadowMount to scan (default) or nothing.
AfterDownload after_download = AfterDownload::scan;
// Look for a newer Wings release when the app starts (wings_update.hpp). On by default; files from before
// this choice have no key and load the default.
bool update_check = true;
// Where cover icons and the 16:9 game-page art come from (art_sources.hpp). Defaults: the catalogue's own
// covers, and every art source.
IconSource icon_source = IconSource::catalogue;
HeroSource hero_source = HeroSource::all;
// Editorial data (editorial.hpp): the short notes on game pages and the curated carousel on the store. On by
// default; folder_confirmed is set once the player has confirmed the games folder (files from before have no keys).
bool notes_enabled = true;
bool carousel_enabled = true;
bool folder_confirmed = false;
bool operator==(const WingsLocalPrefs &other) const;
};
+107 -4
View File
@@ -8,11 +8,13 @@
#include "orbit/screens.hpp"
#include "orbit/diagnostics.hpp"
#include "orbit/editorial.hpp"
#include "orbit/i18n.hpp"
#include "orbit/model.hpp"
#include "orbit/pegasus_manager.hpp"
#include "orbit/pegasus_version.hpp"
#include "orbit/title_export.hpp"
#include "orbit/wings_update.hpp"
#include <algorithm>
#include <cmath>
@@ -271,6 +273,8 @@ SettingsScreen::SettingsScreen(Context &context) : ListScreen(context)
{
load_local_prefs(ctx_.wings_prefs_paths, &prefs_);
popularity_hub().apply_prefs(prefs_.popularity_enabled, prefs_.popularity_url);
editorial_hub().apply_prefs(prefs_.notes_enabled, prefs_.carousel_enabled);
wings_update().set_enabled(prefs_.update_check);
}
}
@@ -279,6 +283,8 @@ void SettingsScreen::store_local_prefs()
if (!ctx_.wings_prefs_paths.empty())
save_local_prefs(ctx_.wings_prefs_paths, prefs_);
popularity_hub().apply_prefs(prefs_.popularity_enabled, prefs_.popularity_url);
editorial_hub().apply_prefs(prefs_.notes_enabled, prefs_.carousel_enabled);
wings_update().set_enabled(prefs_.update_check);
}
std::vector<ListScreen::Row> SettingsScreen::build(std::vector<Act> *acts) const
@@ -356,6 +362,19 @@ void SettingsScreen::build_wings(std::vector<Row> &out, std::vector<Act> *acts)
add("Retry failed covers", tr("Ask again for covers that could not be downloaded"),
std::string(), Act::retry_failed);
}
// Art sources: where cover icons and the 16:9 game-page art come from (art_sources.hpp).
begin("Art sources", theme::accent_cover_art);
add("Cover icons from",
tr("Where the covers on Store tiles come from. Prosperopatches uses each title's PS5 icon (needs Popularity "
"data on); a title it has no art for keeps its usual cover."),
prefs_.icon_source == IconSource::prospero ? std::string(tr("Prosperopatches")) : std::string(tr("Catalogue")),
Act::icon_source);
add("16:9 game art from",
tr("The wide picture behind a game's page. All sources: Prosperopatches first, then IGDB and Steam. "
"Prosperopatches only: the PS5's own background and nothing else (needs Popularity data on)."),
prefs_.hero_source == HeroSource::prospero ? std::string(tr("Prosperopatches only"))
: std::string(tr("All sources")),
Act::hero_source);
// Store grid defaults.
begin("Store grid", theme::accent_store_grid);
add("Reset Store grid", tr("Back to A to Z with no filters. Your favourites stay."),
@@ -407,11 +426,30 @@ void SettingsScreen::build_wings(std::vector<Row> &out, std::vector<Act> *acts)
prefs_.after_download == AfterDownload::scan ? std::string(tr("Ask ShadowMount to scan"))
: std::string(tr("Do nothing")),
Act::after_download);
// Updates: a read-only look at the project's latest release (wings_update.hpp). Wings never installs itself.
begin("Updates", theme::accent_about);
add("Check for Wings updates",
tr("When Wings starts it asks GitHub if a newer release exists (needs Cover art on). It only tells you: "
"it never downloads or installs anything."),
prefs_.update_check ? std::string(tr("On")) : std::string(tr("Off")), Act::update_check);
{
const WingsUpdateStatus up = wings_update().status();
const WingsUpdateText words = wings_update_text(up);
add("Wings update", words.detail, words.value, Act::wings_update, !up.attached);
}
// Editorial: short notes about games and the store carousel (editorial.hpp), both on by default.
begin("Store extras", theme::accent_store_grid);
add("Known-problem notes", tr("Show a short warning on a game's page when the project site lists one"),
prefs_.notes_enabled ? std::string(tr("On")) : std::string(tr("Off")), Act::notes);
add("Featured carousel", tr("Show the project's featured games above the Store grid"),
prefs_.carousel_enabled ? std::string(tr("On")) : std::string(tr("Off")), Act::carousel);
// Diagnostics and About: what Wings knows about itself and about Pegasus DL (read-only).
begin("About", theme::accent_about);
const bool known = state.online() && state.have_info;
add("Wings", tr("This app"), ctx_.app_version.empty() ? std::string("-") : ctx_.app_version,
Act::none);
{
const std::string &mine = ctx_.app_label.empty() ? ctx_.app_version : ctx_.app_label;
add("Wings", tr("This app"), mine.empty() ? std::string("-") : mine, Act::none);
}
add("Pegasus DL", state.endpoint,
known ? state.info.version : std::string(tr("Not connected")), Act::none);
add("Compatibility", tr("How well this version of Wings knows that Pegasus DL"),
@@ -491,8 +529,14 @@ void SettingsScreen::build_pegasus(std::vector<Row> &out, std::vector<Act> *acts
}
const SettingsView &s = state.settings;
begin("Downloads", theme::accent_downloads);
add("Download folder", s.download_root.empty() ? std::string("-") : s.download_root,
tr("Change"), Act::folder);
{
// Why no default folder could be set is a dim sentence after the path (empty: nothing to say).
const std::string &why = ctx_.store.default_folder_status();
std::string detail = s.download_root.empty() ? std::string("-") : s.download_root;
if (!why.empty())
detail = s.download_root.empty() ? why : detail + " · " + why;
add("Download folder", detail, tr("Change"), Act::folder);
}
add("Download connections",
tr("Parallel connections Pegasus DL opens for each download (1 to 64)"),
std::to_string(s.connections), Act::connections);
@@ -760,6 +804,65 @@ void SettingsScreen::on_confirm(int row, Feedback &feedback, Intent &intent)
prefs_.tile_details = next_tile_details(prefs_.tile_details);
store_local_prefs();
break;
case Act::icon_source:
prefs_.icon_source = prefs_.icon_source == IconSource::catalogue ? IconSource::prospero : IconSource::catalogue;
store_local_prefs();
intent.toast = prefs_.icon_source == IconSource::prospero ? tr("Covers now come from Prosperopatches")
: tr("Covers now come from the catalogue");
break;
case Act::hero_source:
prefs_.hero_source = prefs_.hero_source == HeroSource::all ? HeroSource::prospero : HeroSource::all;
store_local_prefs();
intent.toast = prefs_.hero_source == HeroSource::prospero ? tr("Game pages use Prosperopatches art only")
: tr("Game pages use every art source");
break;
case Act::update_check:
prefs_.update_check = !prefs_.update_check;
store_local_prefs();
intent.toast = prefs_.update_check ? tr("Wings will check for updates when it starts")
: tr("Wings will not check for updates");
break;
case Act::notes:
prefs_.notes_enabled = !prefs_.notes_enabled;
store_local_prefs();
intent.toast = prefs_.notes_enabled ? tr("Game pages show known-problem notes")
: tr("Game pages will not show notes");
break;
case Act::carousel:
prefs_.carousel_enabled = !prefs_.carousel_enabled;
store_local_prefs();
intent.toast = prefs_.carousel_enabled ? tr("The Store shows the featured carousel")
: tr("The Store will not show the carousel");
break;
case Act::wings_update:
{
const WingsUpdateStatus up = wings_update().status();
if (!up.attached || up.busy)
break;
if (up.advice == WingsAdvice::newer)
{
// How to install it by hand: a scrolling list (a confirm dialog would cut the steps off).
const WingsUpdateGuide guide = wings_update_guide(up);
std::vector<ListItem> items;
for (const WingsUpdateStep &step : guide.steps)
{
ListItem item;
item.label = step.label;
item.detail = step.detail;
items.push_back(std::move(item));
}
list_for_ = ListFor::wings_update;
list_.open(guide.title, guide.subtitle, std::move(items), ListDialog::Mode::single);
feedback.play(hui::audio::Cue::modal_open);
}
else
{
// The row says why when the network is not ready.
intent.toast = wings_update().check_now() ? tr("Checking for a newer Wings...")
: tr("Could not check for updates");
}
break;
}
case Act::pegasus_startup:
// Ask -> Auto-load -> I load it myself -> Ask.
prefs_.pegasus_start = prefs_.pegasus_start == PegasusStartMode::ask ? PegasusStartMode::autoload
+94
View File
@@ -13,10 +13,12 @@
#include "gfx/backdrop_spec.hpp"
#include "gfx/draw_list.hpp"
#include "orbit/art.hpp"
#include "orbit/carousel.hpp"
#include "orbit/catalogue_view.hpp"
#include "orbit/cover_art.hpp"
#include "orbit/destination_browser.hpp"
#include "orbit/download_all.hpp"
#include "orbit/download_folder.hpp"
#include "orbit/files_logic.hpp"
#include "orbit/game_header.hpp"
#include "orbit/game_view.hpp"
@@ -35,6 +37,7 @@
#include "ui/glyphs.hpp"
#include "ui/theme.hpp"
#include <map>
#include <memory>
#include <cstdint>
#include <functional>
@@ -82,14 +85,20 @@ struct Context
std::uint32_t mark = 0; // the Wings mark, 128 px
std::uint32_t loader_mark = 0; // the mark, dimmed outer strokes, 256 px
std::string app_version; // this app's contentVersion
// What the player reads for the version ("0.1.1", plus the build for a test build); app_version when empty.
std::string app_label;
// Opens Pegasus DL's web UI in the console browser; empty where impossible.
std::function<bool()> open_pegasus_ui;
// Settings > Cover art. Null where the HTTPS art module is not built in
// (the preview and the unit tests); the section then says so.
CoverArtControl *cover_art = nullptr;
// The poster resolver; the App tells it the cover icon source (Settings > Art sources). Null where there is none.
PosterIndex *posters = nullptr;
// The grid tells the art service which covers are just off screen (keys
// like "pkg:123"). Empty where there is no art service.
std::function<void(const std::vector<std::string> &)> prefetch_art;
// Fetches one 16:9 game-page picture ahead of time into the disk cache (the focused tile). Empty: no art service.
std::function<void(const std::string &url)> prefetch_hero;
// Where the Store grid keeps its sort, filters and favourites (the checked
// save container, first writable wins). Empty: not persisted (preview, tests).
std::vector<std::string> catalogue_paths;
@@ -98,6 +107,11 @@ struct Context
std::vector<std::string> wings_prefs_paths;
// Settings > Wings > Store grid: back to A to Z with no filters (the App sets it).
std::function<void()> reset_store_grid;
// The first-download "Download folder" question (docs B-13), shared by the Store and Downloads tabs. The App sets
// the two functions from Settings' prefs; empty (preview, tests): downloads are never held back.
FirstDownloadGate folder_gate;
std::function<bool()> folder_confirmed;
std::function<void()> confirm_folder; // sets and saves WingsLocalPrefs::folder_confirmed
float clock = 0.0f; // seconds since the app opened
// The first-run "load Pegasus DL" block is drawn under the waiting card (the App sets it every
// frame): the card then drops its "Need it? Settings > ..." line and the raw link error.
@@ -434,10 +448,18 @@ class StoreScreen final : public Screen
{
return list_;
}
bool folder_ask_open() const
{
return folder_ask_.is_open();
}
bool confirm_open() const
{
return confirm_.is_open();
}
const ConfirmDialog &confirm_dialog() const
{
return confirm_;
}
// The focused tile's position and what it shows (tests).
int focus_index() const
{
@@ -451,6 +473,27 @@ class StoreScreen final : public Screen
{
return panel_ != Panel::none;
}
// The carousel (tests, previews): shown, how many slides, the focus on it, its slide and button.
bool carousel_visible() const
{
return carousel_on_;
}
int carousel_count() const
{
return static_cast<int>(carousel_games_.size());
}
bool on_carousel() const
{
return in_carousel_;
}
int carousel_slide() const
{
return car_slide_;
}
int carousel_button() const
{
return car_button_;
}
// -1 / 0 / 1: unknown / not installed / installed, for the tile at position.
int tile_installed(int position) const;
bool tile_favourite(int position) const;
@@ -486,12 +529,33 @@ class StoreScreen final : public Screen
};
static constexpr int kColumns = 5;
// The carousel's picture addresses for one game.
struct CarouselArt
{
std::string key; // art key of the game's cover ("pkg:<id>"), shared with the tile and the page
std::string hero_key; // the key its 16:9 pictures are cached under (key, or key + "~b": art_sources.hpp)
std::string low_url; // the small 16:9 picture ("" none)
std::string hero_url; // the full 16:9 picture ("" none); drawn over the small one once it is there
};
ui::Rect tile_rect(int index) const;
// Extra height above grid row 0 while the carousel is shown (0 otherwise: the grid is exactly as it was).
float carousel_extra() const
{
return carousel_on_ ? kCarouselExtra : 0.0f;
}
void update_carousel(float dt, bool focused);
void update_carousel_input(const InputFrame &input, Feedback &feedback, Intent &intent);
bool toggle_carousel_favourite();
void draw_carousel(hui::gfx::DrawList &list) const;
// The slide's full / small picture if ready (asks for it otherwise). Null: none, or not yet.
const Artwork *carousel_full_art(const CarouselArt &art) const;
const Artwork *carousel_low_art(const CarouselArt &art) const;
void refresh_prefs();
void rebuild();
void read_facts(std::uint64_t serial, std::size_t from);
std::vector<std::string> source_order() const;
void prefetch_art();
void prefetch_hero(float dt, bool focused);
void persist();
void changed(bool reset_focus);
void open_search(Feedback &feedback);
@@ -556,8 +620,26 @@ class StoreScreen final : public Screen
bool reset_focus_ = false; // ... and put the focus back on the first tile
std::string focus_game_; // the focused game's id, kept across rebuilds
int prefetch_row_ = -1; // the grid row the last art prefetch was made for
HeroPrefetchGate hero_gate_; // when the focused tile's 16:9 picture is fetched ahead
std::uint64_t prefetch_serial_ = ~0ull;
bool on_button_ = false; // the focus is on the round sort/filter button
// The curated carousel above the grid (carousel.hpp). carousel_games_ are indexes into games_.
bool carousel_on_ = false; // shown (default grid view, switch on, at least one game found)
bool in_carousel_ = false; // the focus is on the carousel's buttons
int car_slide_ = 0; // index into carousel_games_
int car_button_ = 0; // kCarouselView / kCarouselFavourite
std::vector<int> carousel_games_;
std::vector<CarouselArt> carousel_art_; // per carousel game
CarouselClock car_clock_;
ui::Ease car_focus_; // the carousel has the focus
ui::Ease car_btn_[2]; // per button
ui::Ease car_in_; // a new slide fades in
float car_on_slide_s_ = 0.0f; // seconds the current slide has been up
std::vector<std::string> car_prefetched_; // picture addresses already sent to the disk cache
// Per game (its art key): the picture shown now is the IGDB one (true) or Prosperopatches' (false); a page
// that opens flips it for the next time (art_sources.hpp, pick_hero). Memory only.
std::map<std::string, bool> hero_second_;
std::uint64_t seen_editorial_ = ~0ull;
int index_ = 0;
ui::Ease scroll_;
ui::Ease button_focus_;
@@ -584,6 +666,7 @@ class StoreScreen final : public Screen
bool waiting_detail_ = false;
GameHeaderState header_; // the page header: focus, slide, hero (game_header.hpp)
// Panels and the flow they belong to.
FolderAsk folder_ask_; // the first-download folder question (start_single, start_batch)
TextPrompt prompt_;
ConfirmDialog confirm_;
ListDialog list_;
@@ -679,6 +762,7 @@ class DownloadsScreen final : public Screen
TextPrompt prompt_;
ConfirmDialog confirm_;
ListDialog list_;
FolderAsk folder_ask_; // the first-download folder question (queue a direct link)
Pending pending_ = Pending::none;
ListFor list_for_ = ListFor::none;
std::string menu_job_;
@@ -1224,6 +1308,12 @@ class SettingsScreen final : public ListScreen
pegasus_download,
pegasus_cancel,
pegasus_send,
update_check,
wings_update,
icon_source,
hero_source,
notes,
carousel,
};
enum class Pending : std::uint8_t
{
@@ -1245,6 +1335,7 @@ class SettingsScreen final : public ListScreen
region,
format,
providers,
wings_update,
};
enum class Typing : std::uint8_t
{
@@ -1453,6 +1544,9 @@ class App
int capture_button_ = 0; // 0 hide / close, 1 cancel the capture
ui::Ease capture_in_;
std::uint64_t capture_toasted_ = 0; // capture_serial whose completion was announced
// A newer Wings (wings_update.hpp): looked for when the network is ready, announced once per start.
float update_poll_ = 0.0f;
bool update_announced_ = false;
};
} // namespace orbit
+171 -5
View File
@@ -8,6 +8,7 @@
#include "orbit/store.hpp"
#include "orbit/i18n.hpp"
#include "orbit/link_retry.hpp"
#include <algorithm>
#include <memory>
@@ -34,6 +35,35 @@ enum Chan
chan_probe = 3,
chan_none = -1,
};
const char *kind_name(LinkResult::Kind kind)
{
switch (kind)
{
case LinkResult::Kind::dispatched:
return "dispatched";
case LinkResult::Kind::capture:
return "capture";
case LinkResult::Kind::select_files:
return "select_files";
case LinkResult::Kind::browser_opened:
return "browser_opened";
case LinkResult::Kind::blocked:
return "blocked";
}
return "?";
}
// [WINGS-PARTS] pkg=<id> link=<n> kind=<k> transient=<0|1> ms=<elapsed> err=<text, 80 chars at most>
std::string parts_line(const std::string &package_id, int link_index, const LinkStart &outcome,
double seconds)
{
std::string line = "[WINGS-PARTS] pkg=" + package_id + " link=" + std::to_string(link_index) +
" kind=" + kind_name(outcome.kind) + " transient=" + (outcome.transient ? "1" : "0") +
" ms=" + std::to_string(static_cast<long long>(seconds * 1000.0)) + " err=";
line += outcome.error.substr(0, 80);
return line;
}
} // namespace
const Package *Snapshot::package(std::string_view id) const
@@ -101,6 +131,43 @@ void Store::apply_settings(const SettingsView &value)
++state_.serial;
}
void Store::ensure_default_download_folder(const std::string &folder)
{
const FolderNeed need = folder_need(state_.have_settings, state_.settings.download_root,
default_folder_tried_);
if (!need.set_default)
return;
default_folder_tried_ = true;
submit(-1,
[this, folder]
{
Outcome out;
std::string failure;
const Result<bool> made = backend_.mkdir(folder);
// 409: the folder is already there, which is all we need.
if (!made.ok() && made.status != 409)
failure = made.error.empty() ? "The default download folder could not be created."
: made.error;
if (failure.empty())
{
SettingsPatch patch;
patch.has_root = true;
patch.root = folder;
Result<SettingsView> updated = backend_.settings_update(patch);
if (updated.ok())
{
inbox_.post([this, value = std::move(updated.value)]() mutable
{ apply_settings(value); });
return out;
}
failure = updated.error.empty() ? "The default download folder was refused."
: updated.error;
}
inbox_.post([this, failure] { default_folder_status_ = failure; });
return out;
});
}
void Store::apply_installed(const std::vector<InstalledGame> &value)
{
state_.installed = value;
@@ -238,22 +305,29 @@ void Store::poll_capture()
}
if (session.empty())
return;
poll_session(session, nullptr);
}
bool Store::poll_session(const std::string &session, CaptureView *seen)
{
Result<CaptureView> status = backend_.capture_status(session);
if (!status.ok())
{
// A missed poll is not a failed capture; try again next time.
if (status.unreachable())
post_failure(status.error, true);
return;
return false;
}
CaptureView view = std::move(status.value);
if (view.session_id.empty())
view.session_id = session;
const bool done = view.terminal();
if (done)
if (seen != nullptr)
*seen = view;
if (view.terminal())
{
Lock lock(mutex_);
capture_session_.clear();
if (capture_session_ == session)
capture_session_.clear();
}
if (view.state == "completed" && view.result.has_jobs)
post_jobs(view.result.jobs);
@@ -263,6 +337,7 @@ void Store::poll_capture()
poll_requested_ = true;
}
post_capture(std::move(view));
return true;
}
void Store::poll_once()
@@ -661,15 +736,30 @@ void Store::start_link(const std::string &package_id, int link_index, const std:
void Store::start_link(const std::string &package_id, int link_index, const std::string &service,
std::function<void(const LinkStart &)> done)
{
{
Lock lock(mutex_);
series_stop_ = false;
}
auto outcome = std::make_shared<LinkStart>();
submit(chan_link,
[this, package_id, link_index, service, outcome]
{
const double begun = now_seconds();
Result<LinkResult> result = backend_.start_link(package_id, link_index, service);
outcome->error = result.error;
outcome->kind = result.value.kind;
outcome->message = result.value.message;
return link_outcome(std::move(result));
outcome->transient =
!result.ok() && transient_link_error(result.status, result.error, result.unreachable());
const bool capture = result.ok() && result.value.kind == LinkResult::Kind::capture;
const CaptureView started = capture ? result.value.capture : CaptureView{};
Outcome out = link_outcome(std::move(result));
// After link_outcome(): the session is tracked and shown while it is waited for.
if (capture)
wait_capture(started, outcome.get());
if (this->log)
this->log(parts_line(package_id, link_index, *outcome, now_seconds() - begun));
return out;
});
// A second command posts after the first one's channel update: the callback
// sees the snapshot and link_action() as they are once this link ended.
@@ -689,6 +779,82 @@ void Store::debrid_select(const std::string &selection_id, const std::vector<int
{ return link_outcome(backend_.debrid_select(selection_id, indexes, service)); });
}
void Store::stop_series()
{
{
Lock lock(mutex_);
series_stop_ = true;
}
signal_.notify();
}
void Store::set_series_wait(double poll_seconds, double limit_seconds)
{
Lock lock(mutex_);
series_poll_ = poll_seconds;
series_limit_ = limit_seconds;
}
// Worker: waits for the automated capture a series start_link() began. The
// session stays tracked (track_session), so the capture UI and Downloads follow
// every read; a timed-out or stopped wait leaves it to the normal poll.
void Store::wait_capture(const CaptureView &started, LinkStart *outcome)
{
if (started.requires_browser)
{
outcome->error = "needs a check in the PS5 browser: open this part on its own";
outcome->transient = false;
return;
}
if (started.session_id.empty())
{
outcome->error = "Pegasus did not start a capture session";
outcome->transient = false;
return;
}
double poll = 1.0;
double limit = 120.0;
{
Lock lock(mutex_);
poll = series_poll_;
limit = series_limit_;
}
const double begun = now_seconds();
CaptureView view = started;
while (!view.terminal())
{
{
Lock lock(mutex_);
const double until = now_seconds() + poll;
while (!series_stop_ && !stopping_ && now_seconds() < until)
signal_.wait(mutex_, until - now_seconds());
if (series_stop_ || stopping_)
{
outcome->error = "stopped";
outcome->transient = false;
return;
}
}
poll_session(started.session_id, &view);
if (!view.terminal() && now_seconds() - begun >= limit)
{
outcome->error = "timed out waiting for the provider";
outcome->transient = true;
return;
}
}
outcome->message = view.message;
if (view.state == "completed")
{
outcome->error.clear();
outcome->kind = LinkResult::Kind::dispatched;
outcome->transient = false;
return;
}
outcome->error = view.message.empty() ? "capture " + view.state : view.message;
outcome->transient = transient_link_error(0, outcome->error, false);
}
bool Store::cancel_capture()
{
CaptureView view = state_.capture;
+44
View File
@@ -16,6 +16,7 @@
#pragma once
#include "orbit/backend.hpp"
#include "orbit/download_folder.hpp"
#include "orbit/model.hpp"
#include "orbit/thread.hpp"
@@ -103,6 +104,9 @@ struct LinkStart
std::string error; // empty: Pegasus took it
LinkResult::Kind kind = LinkResult::Kind::dispatched;
std::string message;
// The error is the kind that may pass by itself (timed out, Pegasus busy or not answering, a 5xx):
// asking again later is worth it. False for answers that will not change (not supported, not found).
bool transient = false;
};
// The outcome of the last player action of one kind.
@@ -201,6 +205,24 @@ class Store
}
void refresh_now();
// ---- default download folder (B-13) ----
// Frame thread, call once settings are known (every frame is fine): if
// Pegasus reported an EMPTY downloadRoot, create `folder` (mkdir; "already
// exists" counts as created) and make it the root. Never touches a
// non-empty root. At most once per run, success or failure: no retry loop.
// No busy state, no error toast; a failure shows in default_folder_status().
void ensure_default_download_folder(const std::string &folder = download_folder::default_path);
// "" while nothing failed; else the English reason the default was not set.
const std::string &default_folder_status() const
{
return default_folder_status_;
}
// True once ensure_default_download_folder() decided to act (this run).
bool default_folder_tried() const
{
return default_folder_tried_;
}
// ---- queue ----
void job_action(const std::string &job_id, const std::string &action);
void clear_finished();
@@ -213,8 +235,21 @@ class Store
void start_link(const std::string &package_id, int link_index, const std::string &service);
// The same call, and `done` on the frame thread once it ended (after the
// snapshot and link_action() show it), so a series can count its parts.
// An automated capture (no browser needed) is waited for on the worker:
// `done` comes once the session completed (queued), failed, was stopped
// (stop_series) or the wait limit passed (transient "timed out ..."). A
// capture that needs the PS5 browser is not waited for: it ends with an error.
void start_link(const std::string &package_id, int link_index, const std::string &service,
std::function<void(const LinkStart &)> done);
// Frame thread: a series start_link() waiting for a capture ends at its next
// poll with the error "stopped". Cleared by the next series start_link().
void stop_series();
// How often a series start_link() asks for the capture state, and for how
// long at most, in seconds (defaults 1 and 120; tests use 0).
void set_series_wait(double poll_seconds, double limit_seconds);
// Optional: one "[WINGS-PARTS] ..." line per series start_link(), from the worker.
// Set before start().
std::function<void(const std::string &)> log;
void debrid_select(const std::string &selection_id, const std::vector<int> &indexes,
const std::string &service);
// False (and nothing sent) while Pegasus is already resolving the link.
@@ -273,11 +308,17 @@ class Store
void post_tiers(Result<std::vector<std::uint8_t>> &&tiers);
void note_sources(const std::vector<Source> &sources);
void poll_capture();
// One status read of `session`, posted like poll_capture() does; true (and
// *seen set) when Pegasus answered.
bool poll_session(const std::string &session, CaptureView *seen);
void wait_capture(const CaptureView &started, LinkStart *outcome);
void track_session(const CaptureView &view);
Backend &backend_;
Inbox inbox_;
Snapshot state_;
bool default_folder_tried_ = false; // frame thread
std::string default_folder_status_; // frame thread
Channel channel_[7];
// Shared with the workers.
@@ -287,6 +328,9 @@ class Store
bool poll_requested_ = true;
bool stopping_ = false;
std::string capture_session_; // under mutex_: the session being watched
bool series_stop_ = false; // under mutex_: stop_series() was asked
double series_poll_ = 1.0; // under mutex_: seconds between capture reads of a series wait
double series_limit_ = 120.0; // under mutex_: longest series wait, seconds
std::atomic<bool> fast_{false};
std::atomic<bool> want_logs_{false};
std::atomic<bool> want_installed_{false};
+561 -50
View File
@@ -9,7 +9,9 @@
// and routes the controller.
#include "orbit/screens.hpp"
#include "orbit/editorial.hpp"
#include "orbit/filter_menu.hpp"
#include "orbit/game_hero.hpp"
#include "orbit/model.hpp"
#include "orbit/popularity.hpp"
@@ -169,19 +171,6 @@ int find_row(const GameView &view, PageRowKind kind)
return -1;
}
// The part number a link has inside its mirror (by position when unnumbered).
int part_number(const GamePage &page, int mirror, const LinkRef &ref)
{
const Mirror &m = page.mirrors[static_cast<std::size_t>(mirror)];
for (std::size_t i = 0; i < m.links.size(); ++i)
{
const GameLink &l = page.links[static_cast<std::size_t>(m.links[i])];
if (l.index == ref.link_index && page.packages[static_cast<std::size_t>(l.package)].id == ref.package_id)
return i < m.numbers.size() ? m.numbers[i] : static_cast<int>(i) + 1;
}
return 0;
}
ui::Color fit_color(FwFit fit)
{
switch (fit)
@@ -215,7 +204,8 @@ void StoreScreen::focus()
bool StoreScreen::modal() const
{
return prompt_.is_open() || confirm_.is_open() || list_.is_open() || panel_ != Panel::none ||
return prompt_.is_open() || confirm_.is_open() || list_.is_open() || folder_ask_.is_open() ||
panel_ != Panel::none || batch_.active ||
ctx_.store.link_action().busy;
}
@@ -238,7 +228,7 @@ ui::Rect StoreScreen::tile_rect(int index) const
const int col = index % kColumns;
const int row = index / kColumns;
return {kGridLeft + static_cast<float>(col) * kPitch,
kGridTop + static_cast<float>(row) * kPitch - scroll_.value, kTile, kTile};
kGridTop + carousel_extra() + static_cast<float>(row) * kPitch - scroll_.value, kTile, kTile};
}
// Settings > Wings > Downloads: the grid's tiers and the page's order follow
@@ -386,6 +376,78 @@ void StoreScreen::rebuild()
focus_game_.clear();
tile_focus_.assign(tiles_.size(), 0.0f);
prefetch_row_ = -1; // the covers to warm depend on the list
// The curated carousel: the featured games the player's own catalogue has, in the default view only.
{
EditorialHub &editorial = editorial_hub();
seen_editorial_ = editorial.serial();
std::vector<int> found;
if (editorial.carousel_enabled())
{
found = resolve_carousel(editorial.curated_ids(), games_.games);
if (found.empty())
{
// Nothing featured here: the most popular games of this catalogue instead (or its first ones).
std::vector<long> keys;
keys.reserve(games_.games.size());
for (const GameGroup &game : games_.games)
keys.push_back(popularity_sort_key(SortMode::popular, &pop_view_, game.title_ids));
found = pick_most_popular(keys, kCarouselFallbackItems);
}
}
const WingsLocalPrefs &prefs = prefs_source_ != nullptr ? *prefs_source_ : own_prefs_;
const HeroSource source = prefs.hero_source;
const bool was_on = carousel_on_;
carousel_on_ = carousel_shown(editorial.carousel_enabled(), found.size(), query_.empty(), tiles_.size());
// Keep the slide on the same game when the list only changed around it.
const int kept = car_slide_ >= 0 && car_slide_ < static_cast<int>(carousel_games_.size())
? carousel_games_[static_cast<std::size_t>(car_slide_)]
: -1;
carousel_games_.clear();
carousel_art_.clear();
if (carousel_on_)
{
carousel_games_ = found;
const Snapshot &catalogue = snapshot;
for (const int g : carousel_games_)
{
const GameGroup &game = games_.games[static_cast<std::size_t>(g)];
CarouselArt art;
art.key = "pkg:" + catalogue.packages[static_cast<std::size_t>(art_package(game, catalogue.packages))].id;
art.hero_key = art.key;
if (pop_index_ != nullptr)
{
// One picture of the two (art_sources.hpp): the one this game shows now, whole and sharp.
const auto shown = hero_second_.find(art.key);
const HeroPick pick = pick_hero(*pop_index_, game.title_ids, source,
shown != hero_second_.end() && shown->second);
art.hero_url = pick.full;
art.low_url = pick.low;
art.hero_key = hero_art_key(art.key, pick.second);
}
carousel_art_.push_back(std::move(art));
}
}
car_slide_ = 0;
for (std::size_t i = 0; i < carousel_games_.size(); ++i)
if (carousel_games_[i] == kept)
car_slide_ = static_cast<int>(i);
if (!carousel_on_)
{
in_carousel_ = false;
car_button_ = kCarouselView;
}
if (carousel_on_ != was_on)
{
// The hero is the first thing the Store shows: the focus starts on it. The grid moved by the
// carousel's height: no sliding from the old place.
in_carousel_ = carousel_on_;
car_button_ = kCarouselView;
scroll_.snap(0.0f);
car_clock_.restart();
car_on_slide_s_ = 0.0f;
}
}
dirty_ = false;
}
@@ -413,6 +475,34 @@ void StoreScreen::prefetch_art()
ctx_.prefetch_art(keys);
}
// The focused tile's 16:9 picture, fetched into the disk cache once focus has rested on it for a moment, so the
// page it opens finds the picture there (art_sources.hpp). Nothing is kept in memory.
void StoreScreen::prefetch_hero(float dt, bool focused)
{
(void)dt;
std::string game_id;
if (ctx_.prefetch_hero && focused && view_ == View::grid && !on_button_ && !in_carousel_ && pop_index_ != nullptr &&
index_ >= 0 &&
index_ < static_cast<int>(tiles_.size()))
game_id = games_.games[static_cast<std::size_t>(tiles_[static_cast<std::size_t>(index_)].game)].id;
if (!hero_gate_.update(game_id, static_cast<double>(ctx_.clock)))
return;
const GameGroup &g = games_.games[static_cast<std::size_t>(tiles_[static_cast<std::size_t>(index_)].game)];
const HeroSource source = prefs_source_ != nullptr ? prefs_source_->hero_source : HeroSource::all;
// The picture the page will show (the alternating choice, art_sources.hpp), not the other one.
const GameTile &tile = tiles_[static_cast<std::size_t>(index_)];
const Snapshot &snapshot = ctx_.store.state();
bool second = false;
if (tile.art_package >= 0 && tile.art_package < static_cast<int>(snapshot.packages.size()))
{
const auto shown = hero_second_.find("pkg:" + snapshot.packages[static_cast<std::size_t>(tile.art_package)].id);
second = shown != hero_second_.end() && shown->second;
}
const std::string url = pick_hero(*pop_index_, g.title_ids, source, second).full;
if (!url.empty())
ctx_.prefetch_hero(url);
}
void StoreScreen::persist()
{
if (!ctx_.catalogue_paths.empty())
@@ -961,6 +1051,11 @@ void StoreScreen::update_grid(const InputFrame &input, Feedback &feedback, Inten
feedback.play(hui::audio::Cue::modal_open);
return;
}
if (in_carousel_ && carousel_on_ && !carousel_games_.empty())
{
update_carousel_input(input, feedback, intent);
return;
}
if (on_button_)
{
if (input.nav == Direction::right)
@@ -977,7 +1072,17 @@ void StoreScreen::update_grid(const InputFrame &input, Feedback &feedback, Inten
}
else if (input.nav == Direction::up)
{
intent.kind = Intent::Kind::top_bar;
if (carousel_on_ && !carousel_games_.empty())
{
on_button_ = false;
in_carousel_ = true;
car_button_ = kCarouselView;
feedback.play(hui::audio::Cue::focus);
}
else
{
intent.kind = Intent::Kind::top_bar;
}
}
else if (input.nav != Direction::none)
{
@@ -1038,6 +1143,14 @@ void StoreScreen::update_grid(const InputFrame &input, Feedback &feedback, Inten
moved = true;
next = index_ - cols;
}
else if (carousel_on_ && !carousel_games_.empty())
{
// Up from the first row comes back to the hero (on View game).
in_carousel_ = true;
car_button_ = kCarouselView;
feedback.play(hui::audio::Cue::focus);
return;
}
else
{
intent.kind = Intent::Kind::top_bar;
@@ -1072,6 +1185,165 @@ void StoreScreen::update_grid(const InputFrame &input, Feedback &feedback, Inten
}
}
// ---- the carousel (carousel.hpp) ----
// Pad rules are documented in carousel.hpp. Cross on View game opens the game's page, exactly as Cross on its tile.
void StoreScreen::update_carousel_input(const InputFrame &input, Feedback &feedback, Intent &intent)
{
const int count = static_cast<int>(carousel_games_.size());
car_slide_ = std::clamp(car_slide_, 0, count - 1);
if (input.nav == Direction::up)
{
intent.kind = Intent::Kind::top_bar;
return;
}
if (input.nav == Direction::down)
{
in_carousel_ = false; // back to the grid's focused tile (always in the first row)
feedback.play(hui::audio::Cue::focus);
return;
}
if (input.nav == Direction::left || input.nav == Direction::right)
{
const CarouselStep step = carousel_step(car_slide_, car_button_, count, input.nav);
if (step.moved)
{
car_button_ = step.button;
if (step.slide_changed)
{
car_slide_ = step.slide;
car_in_.snap(0.0f);
car_on_slide_s_ = 0.0f;
}
feedback.play(hui::audio::Cue::focus);
}
else
{
refuse(feedback, input);
}
return;
}
if (input.is_pressed(Action::west))
{
const bool now = toggle_carousel_favourite();
feedback.play(hui::audio::Cue::select);
intent.toast = now ? std::string(tr("Added to favourites")) : std::string(tr("Removed from favourites"));
return;
}
if (input.is_pressed(Action::confirm))
{
if (car_button_ == kCarouselFavourite)
{
const bool now = toggle_carousel_favourite();
feedback.play(hui::audio::Cue::select);
intent.toast = now ? std::string(tr("Added to favourites")) : std::string(tr("Removed from favourites"));
}
else
{
open_game(carousel_games_[static_cast<std::size_t>(car_slide_)]);
feedback.play(hui::audio::Cue::open);
}
}
}
bool StoreScreen::toggle_carousel_favourite()
{
if (car_slide_ < 0 || car_slide_ >= static_cast<int>(carousel_games_.size()))
return false;
const int game = carousel_games_[static_cast<std::size_t>(car_slide_)];
const bool now = toggle_game_favourite(&state_, games_.games[static_cast<std::size_t>(game)],
ctx_.store.state().packages);
persist();
for (GameTile &tile : tiles_)
if (tile.game == game)
tile.favourite = now;
return now;
}
// Every frame: focus fades, the auto-advance clock, the slide's pictures. Art memory is bounded by the ArtCache's
// background budget (three textures, shared with the game page): the current slide's small and full pictures and
// the next slide's small one, and the next one only once the current full one is there. The slide after that goes
// to the disk cache only.
void StoreScreen::update_carousel(float dt, bool focused)
{
const bool on = carousel_on_ && view_ == View::grid && !carousel_games_.empty();
car_focus_.target = on && focused && in_carousel_ ? 1.0f : 0.0f;
car_focus_.update(dt, 16.0f);
for (int b = 0; b < 2; ++b)
{
car_btn_[b].target = b == car_button_ ? 1.0f : 0.0f;
car_btn_[b].update(dt, 18.0f);
}
car_in_.target = 1.0f;
car_in_.update(dt, 9.0f);
if (!on)
return;
const int count = static_cast<int>(carousel_games_.size());
car_slide_ = std::clamp(car_slide_, 0, count - 1);
car_on_slide_s_ += dt;
// Scrolled away (more than two thirds): nothing to advance or to load.
if (scroll_.value >= kCarouselExtra * 0.66f)
return;
// Auto-advance, paused while the carousel has the focus or a dialog or panel is open.
if (car_clock_.update(dt, in_carousel_ || modal(), count))
{
car_slide_ = carousel_wrap(car_slide_, 1, count);
car_in_.snap(0.0f);
car_on_slide_s_ = 0.0f;
}
const std::size_t cur = static_cast<std::size_t>(car_slide_);
const std::size_t next = static_cast<std::size_t>(carousel_wrap(car_slide_, 1, count));
const std::size_t after = static_cast<std::size_t>(carousel_wrap(car_slide_, 2, count));
// The addresses the art service resolves (cleared whenever a game page opens: set again here every frame).
// The current slide: the small picture, else the full one. The next slide: the small picture only.
HeroUrls &urls = hero_urls();
const auto announce = [&urls](const CarouselArt &art, bool allow_full)
{
if (!art.low_url.empty())
urls.set_low(art.hero_key, art.low_url);
if (allow_full && !art.hero_url.empty())
urls.set(art.hero_key, art.hero_url);
};
announce(carousel_art_[cur], true);
// The full picture is asked for first and the small one after it: the worker serves the newest request first,
// so the small one is up first and the full one fades in over it (as on the game page).
const Artwork *shown = carousel_full_art(carousel_art_[cur]);
if (shown == nullptr)
carousel_low_art(carousel_art_[cur]);
// The next slide's small picture is loaded once the current full one is up (or has had its time), so the
// worker never serves it first. Three textures in all: the cache keeps three background pictures and no more.
if (count > 1 && !carousel_art_[next].low_url.empty() && (shown != nullptr || car_on_slide_s_ > 3.0f))
{
announce(carousel_art_[next], false);
carousel_low_art(carousel_art_[next]);
}
// The slide after that is only fetched into the disk cache, once.
if (count > 2 && ctx_.prefetch_hero && next != after && after != cur)
{
const CarouselArt &art = carousel_art_[after];
const std::string &url = art.low_url.empty() ? art.hero_url : art.low_url;
if (!url.empty() && std::find(car_prefetched_.begin(), car_prefetched_.end(), url) == car_prefetched_.end())
{
if (car_prefetched_.size() >= 32)
car_prefetched_.clear();
car_prefetched_.push_back(url);
ctx_.prefetch_hero(url);
}
}
}
// The slide's full 16:9 picture if it is ready (asks for it otherwise). Null: none, or not yet.
const Artwork *StoreScreen::carousel_full_art(const CarouselArt &art) const
{
return art.hero_url.empty() ? nullptr : ctx_.art.get(art.hero_key, ArtKind::hero);
}
// The slide's small 16:9 picture if it is ready (asks for it otherwise). Null: none, or not yet.
const Artwork *StoreScreen::carousel_low_art(const CarouselArt &art) const
{
return art.low_url.empty() ? nullptr : ctx_.art.get(art.hero_key, ArtKind::hero_low);
}
// The existing "download this link" flow for one (packageId, linkIndex).
void StoreScreen::activate_ref(const LinkRef &ref, Feedback &feedback)
{
@@ -1162,6 +1434,8 @@ void StoreScreen::activate_ref(const LinkRef &ref, Feedback &feedback)
void StoreScreen::start_single(const std::string &service)
{
if (folder_ask_.guard(ctx_, [this, service] { start_single(service); }))
return; // the first download asks for the folder; it resumes here
ctx_.store.start_link(pending_ref_.package_id, pending_ref_.link_index, service);
popularity_hub().count_download(page_.title_ids); // "Your most downloaded"
single_ref_ = pending_ref_;
@@ -1255,12 +1529,34 @@ void StoreScreen::start_batch(const std::string &service)
if (parts_plan_.refs.empty() || parts_mirror_ < 0 ||
parts_mirror_ >= static_cast<int>(page_.page.mirrors.size()) || batch_.active)
return;
if (folder_ask_.guard(ctx_, [this, service] { start_batch(service); }))
return; // asked once for the whole set, not per part
popularity_hub().count_download(page_.title_ids);
std::vector<int> numbers;
for (const LinkRef &ref : parts_plan_.refs)
numbers.push_back(part_number(page_.page, parts_mirror_, ref));
std::vector<PartPlan> parts;
parts.reserve(parts_plan_.refs.size());
for (std::size_t i = 0; i < parts_plan_.refs.size(); ++i)
{
PartPlan part;
part.number = i < parts_plan_.numbers.size()
? parts_plan_.numbers[i]
: mirror_part_number(page_.page, parts_mirror_, parts_plan_.refs[i]);
part.refs.push_back(parts_plan_.refs[i]);
part.hosts.push_back(parts_plan_.host);
if (i < parts_plan_.alternates.size())
{
const std::vector<LinkRef> &others = parts_plan_.alternates[i];
const std::vector<std::string> *names =
i < parts_plan_.alternate_hosts.size() ? &parts_plan_.alternate_hosts[i] : nullptr;
for (std::size_t a = 0; a < others.size(); ++a)
{
part.refs.push_back(others[a]);
part.hosts.push_back(names != nullptr && a < names->size() ? (*names)[a] : std::string());
}
}
parts.push_back(std::move(part));
}
batch_.begin(
parts_plan_.refs, std::move(numbers), parts_plan_.host,
std::move(parts), parts_plan_.host,
[this, service](const LinkRef &ref, DownloadAll::Done done)
{ ctx_.store.start_link(ref.package_id, ref.link_index, service, std::move(done)); },
[this](const LinkRef &ref)
@@ -1547,7 +1843,7 @@ void StoreScreen::update_flow(Feedback &, Intent &intent)
}
}
Intent StoreScreen::update(const InputFrame &input, float dt, Feedback &feedback, bool focused)
Intent StoreScreen::update(const InputFrame &frame_input, float dt, Feedback &feedback, bool focused)
{
Intent intent;
const Snapshot &state = ctx_.store.state();
@@ -1556,24 +1852,41 @@ Intent StoreScreen::update(const InputFrame &input, float dt, Feedback &feedback
ctx_.store.want_installed(true);
refresh_prefs();
if (dirty_ || seen_catalog_ != state.catalog_serial || seen_tiers_ != state.tiers_serial ||
seen_installed_ != state.installed_serial || seen_pop_ != popularity_hub().serial())
seen_installed_ != state.installed_serial || seen_pop_ != popularity_hub().serial() ||
seen_editorial_ != editorial_hub().serial())
rebuild();
prefetch_art();
prefetch_hero(dt, focused);
if (!batch_toast_.empty())
{
intent.toast = batch_toast_;
batch_toast_.clear();
}
// Circle stops "Download all parts" after the part in flight.
if (batch_.active && input.is_pressed(Action::back))
// The first-download folder question takes the whole input while it is open.
if (folder_ask_.is_open())
{
folder_ask_.update(ctx_, frame_input, dt, feedback);
return intent;
}
// Circle stops "Download all parts": a wait between attempts ends at once, a
// capture the part waits for is let go. The press is used up here, so it
// neither closes the report cancel() may open nor leaves the page.
bool stop_pressed = false;
if (batch_.active && frame_input.is_pressed(Action::back))
{
batch_.cancel();
ctx_.store.stop_series();
stop_pressed = true;
}
const InputFrame &input = stop_pressed ? kIdle : frame_input;
// Panels: the one that is open takes the whole input.
const bool had_prompt = prompt_.is_open();
const bool had_confirm = confirm_.is_open();
const bool had_list = list_.is_open();
const bool had_panel = panel_ != Panel::none;
const bool busy = ctx_.store.link_action().busy;
const bool busy = ctx_.store.link_action().busy || batch_.active;
const TextPrompt::Result typed = prompt_.update(had_prompt ? input : kIdle, dt, feedback);
if (typed == TextPrompt::Result::accepted)
@@ -1704,6 +2017,9 @@ Intent StoreScreen::update(const InputFrame &input, float dt, Feedback &feedback
panel_in_.update(dt, 20.0f);
const bool panel = had_prompt || had_confirm || had_list || had_panel || busy;
// "Download all parts" counts its waits between attempts down every frame. After
// the dialogs: a report it opens is not answered by this frame's press.
batch_.tick(dt);
update_flow(feedback, intent);
if (!panel && focused && state.online())
{
@@ -1723,15 +2039,18 @@ Intent StoreScreen::update(const InputFrame &input, float dt, Feedback &feedback
for (int i = 0; i < count; ++i)
{
float &value = tile_focus_[static_cast<std::size_t>(i)];
const float target = focused && view_ == View::grid && !on_button_ && i == index_ ? 1.0f : 0.0f;
const float target = focused && view_ == View::grid && !on_button_ && !in_carousel_ && i == index_ ? 1.0f : 0.0f;
value += (target - value) * (1.0f - std::exp(-16.0f * dt));
}
button_focus_.target = focused && view_ == View::grid && on_button_ && !had_panel ? 1.0f : 0.0f;
button_focus_.update(dt, 16.0f);
// Row-wise: the focused row sits second from the top once the list scrolls.
// With the carousel above row 0 the grid is pushed down by its height (carousel.hpp); focus on the carousel
// itself shows the top.
const int row = index_ / kColumns;
scroll_.target = std::max(0.0f, static_cast<float>(row - 1) * kPitch);
scroll_.target = in_carousel_ ? 0.0f : grid_scroll_target(row, kPitch, carousel_extra());
scroll_.update(dt, 11.0f);
update_carousel(dt, focused && !had_panel);
const int rows = static_cast<int>(page_.rows.size());
row_focus_.resize(static_cast<std::size_t>(rows), 0.0f);
@@ -1750,9 +2069,22 @@ Intent StoreScreen::update(const InputFrame &input, float dt, Feedback &feedback
if (page_.loaded && !header_.hero_resolved && pop_index_ != nullptr)
{
header_.hero_resolved = true;
const std::string url = pop_index_->hero(page_.title_ids);
if (!url.empty() && hero_urls().set(page_.art_key, url))
header_.hero_url = url;
// Settings > Art sources: every source, or only the PS5's own background (Prosperopatches).
const HeroSource source = prefs_source_ != nullptr ? prefs_source_->hero_source : HeroSource::all;
// One picture of the two (art_sources.hpp): the one the Store's hero shot / this game showed last,
// then the other one the next time the page opens.
const bool second = hero_second_[page_.art_key];
const HeroPick pick = pick_hero(*pop_index_, page_.title_ids, source, second);
header_.hero_key = hero_art_key(page_.art_key, pick.second);
if (!pick.full.empty() && hero_urls().set(header_.hero_key, pick.full))
{
header_.hero_url = pick.full;
header_.hero_cached = hero_is_cached(pick.full);
}
if (!pick.low.empty() && hero_urls().set_low(header_.hero_key, pick.low))
header_.hero_low_url = pick.low;
if (pick.has_both)
hero_second_[page_.art_key] = !second;
}
// No record in the index: ask for the live Steam review summary (idempotent, one worker, cached).
if (page_.loaded && pop_index_ != nullptr && pop_index_->best(page_.title_ids) == nullptr)
@@ -1972,6 +2304,121 @@ void StoreScreen::draw_tile_cell(hui::gfx::DrawList &list, const GameTile &tile,
}
}
// The curated carousel, the Store's hero shot: the game's 16:9 picture over the whole width from the top of the
// screen (uniform scale, the overflow cropped, fading into the page at its foot so no edge shows), the title and
// View game / heart over it at the left, the slide dots under them. The page slides up as one piece (the header
// and the first row ride up behind it) and the hero fades out before it reaches them. Neutral chrome
// (DESIGN-colour.md): the only colour is the art.
void StoreScreen::draw_carousel(hui::gfx::DrawList &list) const
{
const ui::Type &type = ctx_.type;
const Snapshot &snapshot = ctx_.store.state();
const int count = static_cast<int>(carousel_games_.size());
const int slide = std::clamp(car_slide_, 0, count - 1);
const GameGroup &game = games_.games[static_cast<std::size_t>(carousel_games_[static_cast<std::size_t>(slide)])];
const CarouselArt &art = carousel_art_[static_cast<std::size_t>(slide)];
const float scroll = scroll_.value;
const float fade = carousel_fade(scroll);
if (fade < 0.01f)
return;
const float focus = std::clamp(car_focus_.value, 0.0f, 1.0f);
const auto fade_in = [](const Artwork &a)
{ return std::clamp(static_cast<float>((now_seconds() - a.ready_at) / 0.4), 0.0f, 1.0f); };
const ui::Color night = ui::color::night1;
const ui::Rect band{0.0f, 0.0f, 1920.0f, kCarouselArtH};
list.push_opacity(fade);
list.push_transform(1.0f, 0.0f, 0.0f, 0.0f, -scroll);
list.gradient_rect(band, 0.0f, ui::color::night2, night);
list.push_opacity(0.2f + 0.8f * std::clamp(car_in_.value, 0.0f, 1.0f));
// The full picture over the small one (until it is fully faded in), the cover's wash when there is neither.
float picture = 0.0f;
const Artwork *full = carousel_full_art(art);
const HeroPlacement full_at = full != nullptr ? place_hero(full->width, full->height, band) : HeroPlacement{};
const float full_alpha = full_at.usable ? fade_in(*full) : 0.0f;
if (full_alpha < 1.0f)
{
if (const Artwork *low = carousel_low_art(art))
{
const HeroPlacement at = place_hero(low->width, low->height, band);
if (at.usable)
{
picture = fade_in(*low);
list.image(low->texture, at.dest, at.uv, ui::Color::rgb(0xffffff, picture), 0.0f);
}
}
}
if (full_at.usable)
{
picture = std::max(picture, full_alpha);
list.image(full->texture, full_at.dest, full_at.uv, ui::Color::rgb(0xffffff, full_alpha), 0.0f);
}
if (picture < 1.0f)
{
// No picture (yet): the cover, scaled up as a blurred wash that fills the band, as on the game page.
if (const Artwork *cover = ctx_.art.get(art.key, ArtKind::cover))
{
const float a = (1.0f - picture) * fade_in(*cover);
if (cover->ambient != 0)
ui::image_cover(list, cover->ambient, cover->width, cover->height, band,
ui::Color::rgb(0xffffff, 0.95f * a));
ui::image_cover(list, cover->texture, cover->width, cover->height, band,
ui::Color::rgb(0xffffff, 0.30f * a), 0.0f, 0.5f, 0.3f);
}
}
// Veils: dark under the words (left), under the app bar (top), and into the page at the foot.
list.gradient_rect_h({0.0f, 0.0f, 1300.0f, kCarouselArtH}, 0.0f, ui::Color::rgb(0x000000, 0.72f),
ui::Color::rgb(0x000000, 0.0f));
list.gradient_rect({0.0f, 0.0f, 1920.0f, 170.0f}, 0.0f, ui::Color::rgb(0x000000, 0.65f),
ui::Color::rgb(0x000000, 0.0f));
list.gradient_rect({0.0f, kCarouselArtH - 340.0f, 1920.0f, 340.0f}, 0.0f, ui::Color::rgb(0x080808, 0.0f),
night);
// The words, bottom-aligned on the buttons: eyebrow, title (at most two lines), buttons, dots.
constexpr float kTitleSize = 73.0f;
constexpr float kTitleLine = 80.0f;
constexpr float kTitleWidth = 760.0f;
constexpr float kBtnH = 60.0f;
const float x0 = ui::kGutter;
const float by = 604.0f;
const int lines = std::clamp(static_cast<int>(type.semibold.font->wrap(game.title, kTitleSize, kTitleWidth).size()),
1, 2);
const float first = by - 48.0f - kTitleLine * static_cast<float>(lines - 1);
ui::text(list, type.medium, tr("FEATURED"), x0, first - kTitleSize - 20.0f, 16.0f, ui::color::text2);
ui::paragraph(list, type.semibold, game.title, x0, first, kTitleSize, kTitleWidth, kTitleLine,
ui::color::text, 2);
const std::string view = tr("View game");
const float view_w = std::max(234.0f, ui::button_width(type, view, 24.0f) + 40.0f);
ui::button(list, type, {x0, by, view_w, kBtnH}, view, ui::ButtonKind::light,
focus * car_btn_[kCarouselView].value, true, 24.0f);
// The heart: a round outline button, filled once the game is a favourite.
const bool fav = game_favourite(state_, game, snapshot.packages);
const ui::Rect heart{x0 + view_w + 18.0f, by, kBtnH, kBtnH};
ui::focus_ring(list, heart, kBtnH * 0.5f, focus * car_btn_[kCarouselFavourite].value);
list.bordered_rect(heart, kBtnH * 0.5f, night.with_alpha(0.45f), 1.5f, ui::color::text2.with_alpha(0.6f));
if (fav)
ui::icon_heart_filled(list, heart.cx(), heart.cy(), 25.0f, ui::color::text);
else
ui::icon_heart(list, heart.cx(), heart.cy(), 25.0f, ui::color::text);
list.pop_opacity();
if (count > 1)
{
for (int i = 0; i < count; ++i)
{
const float cx = x0 + 6.0f + static_cast<float>(i) * 24.0f;
const float cy = by + kBtnH + 40.0f;
if (i == slide)
list.circle(cx, cy, 6.0f, ui::color::text);
else
list.circle(cx, cy, 4.5f, ui::Color::rgb(0xffffff, 0.35f));
}
}
list.pop_transform();
list.pop_opacity();
}
void StoreScreen::draw_grid(Frame &frame, bool focused) const
{
hui::gfx::DrawList &list = frame.scene;
@@ -2016,14 +2463,20 @@ void StoreScreen::draw_grid(Frame &frame, bool focused) const
ui::button(list, type, go, tr("Open Sources"), ui::ButtonKind::primary, focused ? 1.0f : 0.0f);
return;
}
// The hero first (the header's words sit over its foot); with it the header and the round button ride up
// with the page until they reach their place, then stay there while the rows scroll under them.
if (carousel_on_ && !carousel_games_.empty() && !tiles_.empty())
draw_carousel(list);
list.push_transform(1.0f, 0.0f, 0.0f, 0.0f, std::max(0.0f, carousel_extra() - scroll_.value));
{
const WingsLocalPrefs &prefs = prefs_source_ != nullptr ? *prefs_source_ : own_prefs_;
const bool on_tile = focused && !on_button_ && index_ >= 0 &&
const bool on_tile = focused && !on_button_ && !in_carousel_ && index_ >= 0 &&
static_cast<std::size_t>(index_) < tile_focus_.size();
draw_header(list, tier_legend_alpha(prefs.tile_details,
on_tile ? tile_focus_[static_cast<std::size_t>(index_)] : 0.0f));
}
draw_utility_button(list, button_focus_.value);
list.pop_transform();
if (tiles_.empty())
{
ui::text(list, type.light, tr("No games match"), kGridLeft, kGridTop + 90.0f, 44.0f,
@@ -2486,8 +2939,10 @@ void StoreScreen::draw_detail(Frame &frame, bool focused) const
head.rating = header_rating(pop);
head.facts = header_facts(page_, link_prefs_, started_);
head.cards = build_header_cards(head.facts);
if (!header_.hero_url.empty())
head.hero_key = page_.art_key;
head.hero_full = !header_.hero_url.empty();
head.hero_low = !header_.hero_low_url.empty();
if (head.hero_full || head.hero_low)
head.hero_key = header_.hero_key.empty() ? page_.art_key : header_.hero_key;
if (pop != nullptr)
meta_line += " \xC2\xB7 " + popularity_line(pop);
else if (const SteamReviewService *live = steam_reviews())
@@ -2506,6 +2961,35 @@ void StoreScreen::draw_detail(Frame &frame, bool focused) const
if (!page_.loaded)
return;
// A known-problem note (editorial.hpp): under the title block, on the open header only, never focusable.
// Amber mark and word, the sentence itself in the quiet text colour (DESIGN-colour.md: semantic colours
// stay small and come with a word). Read from the hub every frame, so a refreshed file shows at once.
if (const std::string note = editorial_hub().note(page_.title_ids); !note.empty() && header_.slide.value < 0.99f)
{
const float fade = 1.0f - header_.slide.value;
const float nx = head_layout.text_x;
const float base = head_layout.meta_baseline + 54.0f;
const ui::Color amber = theme::semantic::caution;
// It is part of the header: it slides up with it (the same shift as draw_game_header), so it stays under
// the title block it belongs to instead of being left behind while that moves away.
const float shift = head_layout.list_top - 290.0f;
list.push_opacity(fade);
list.push_transform(1.0f, 0.0f, 0.0f, 0.0f, -header_.slide.value * shift);
list.rounded_rect({nx, base - 22.0f, 28.0f, 28.0f}, 14.0f, amber);
const float bang = type.semibold.measure("!", 21.0f);
ui::text(list, type.semibold, "!", nx + 14.0f - bang * 0.5f, base - 1.0f, 21.0f, ui::Color::rgb(0x181818));
ui::text(list, type.semibold, tr("Known problem"), nx + 40.0f, base - 1.0f, 21.0f, amber);
// Any length: the type shrinks (21 down to 14 px) until the note fits two lines, then it is cut with "...".
// Beside the facts panel, never under it: the column ends where the panel starts.
const float note_right = 1920.0f - ui::kGutter - hero::kPanelW - hero::kPanelGap;
const float note_w = std::max(320.0f, std::min(head_layout.text_w, note_right - nx) - 40.0f);
const float note_size = ui::fit_size(type.regular, note, 21.0f, 14.0f, note_w * 1.8f);
ui::paragraph(list, type.regular, note, nx + 40.0f, base + 32.0f, note_size, note_w, note_size * 1.43f,
ui::color::text2, 2);
list.pop_transform();
list.pop_opacity();
}
// The list's top and left move from under the header to the compact page's.
const float slide = header_.slide.value;
const float list_top = head_layout.list_top + (kPageListTop - head_layout.list_top) * slide;
@@ -2589,10 +3073,11 @@ void StoreScreen::draw_detail(Frame &frame, bool focused) const
draw_page_row(list, row, {x, ry, w, row.h}, f);
}
list.pop_clip();
// More below: a fade says so.
// More below: a fade says so. Full width and down to the screen edge, into the colour the hero band
// ends on, so it has no edges to show.
if (page_.height - detail_scroll_.value > kPageListBottom - list_top + 8.0f)
list.gradient_rect({x - 28.0f, kPageListBottom - 60.0f, w + 56.0f, 60.0f}, 0.0f,
ui::Color::rgb(0x000000, 0.0f), ui::Color::rgb(0x000000, 0.7f));
list.gradient_rect({0.0f, kPageListBottom - 90.0f, 1920.0f, 1080.0f - (kPageListBottom - 90.0f)}, 0.0f,
ui::Color::rgb(0x080808, 0.0f), ui::Color::rgb(0x080808, 0.92f));
}
void StoreScreen::draw(Frame &frame, bool focused) const
@@ -2603,26 +3088,43 @@ void StoreScreen::draw(Frame &frame, bool focused) const
draw_grid(frame, focused);
hui::gfx::DrawList &overlay = frame.overlay;
draw_panel(overlay);
if (ctx_.store.link_action().busy)
if (batch_.active)
{
if (batch_.active)
{
const std::string text =
tr("Queueing part {n} of {total} from {host}…",
{{"n", std::to_string(std::min(batch_.done + 1, batch_.total))},
{"total", std::to_string(batch_.total)},
{"host", batch_.host}}) +
" (" + tr("Circle: stop after this part") + ")";
draw_busy(ctx_, overlay, text.c_str());
}
else
draw_busy(ctx_, overlay, tr("Asking Pegasus DL\xE2\x80\xA6"));
// "Part 6 of 22 from VikingFile · try 2 of 3 · retrying in 6 s (Circle: stop)"
const int position = std::min(batch_.done + 1, batch_.total);
const int number = batch_.current > 0 ? batch_.current : position;
std::string text =
number == position
? tr("Part {n} of {total} from {host}", {{"n", std::to_string(number)},
{"total", std::to_string(batch_.total)},
{"host", batch_.host}})
: tr("Part {n} ({i} of {total}) from {host}", {{"n", std::to_string(number)},
{"i", std::to_string(position)},
{"total", std::to_string(batch_.total)},
{"host", batch_.host}});
if (batch_.attempt > 1)
text += " \xC2\xB7 " + tr("try {attempt} of {tries}", {{"attempt", std::to_string(batch_.attempt)},
{"tries", std::to_string(kPartAttempts)}});
if (!batch_.trying_host.empty() && batch_.trying_host != batch_.host)
text += " \xC2\xB7 " + tr("via {host}", {{"host", batch_.trying_host}});
if (batch_.waiting > 0.0f)
text += " \xC2\xB7 " + tr("retrying in {s} s",
{{"s", std::to_string(static_cast<int>(std::ceil(batch_.waiting)))}});
text += " (";
text += tr("Circle: stop");
text += ")";
draw_busy(ctx_, overlay, text.c_str());
}
else if (ctx_.store.link_action().busy)
{
draw_busy(ctx_, overlay, tr("Asking Pegasus DL\xE2\x80\xA6"));
}
// The const draw path of the panels needs a mutable frame: it is one.
Context &ctx = ctx_;
prompt_.draw(ctx, frame);
confirm_.draw(ctx, frame);
list_.draw(ctx, frame);
folder_ask_.draw(ctx, frame);
}
std::vector<hui::ui::Hint> StoreScreen::hints() const
@@ -2634,7 +3136,7 @@ std::vector<hui::ui::Hint> StoreScreen::hints() const
if (panel_ != Panel::none)
return {{hui::ui::Button::cross, tr("Choose")}, {hui::ui::Button::circle, tr("Back")}};
// A dialog over the page or the grid: only its own buttons work.
if (confirm_.is_open() || list_.is_open())
if (confirm_.is_open() || list_.is_open() || folder_ask_.is_open())
return {{hui::ui::Button::cross, tr("Choose")}, {hui::ui::Button::circle, tr("Back")}};
if (view_ == View::detail)
{
@@ -2665,6 +3167,15 @@ std::vector<hui::ui::Hint> StoreScreen::hints() const
items.push_back({hui::ui::Button::l1, tr("Switch Tabs"), hui::ui::Button::r1});
return items;
}
// On the carousel: Cross is the focused button, Square toggles the slide's game as a favourite.
if (in_carousel_ && carousel_on_)
{
return {{hui::ui::Button::cross, car_button_ == kCarouselFavourite ? tr("Favourite") : tr("View game")},
{hui::ui::Button::square, tr("Favourite")},
{hui::ui::Button::triangle, tr("Search")},
{hui::ui::Button::options, tr("Utilities")},
{hui::ui::Button::l1, tr("Switch Tabs"), hui::ui::Button::r1}};
}
// Nothing matches: Cross resets the search and the filters (as the screen says).
const bool none = tiles_.empty() && ctx_.store.state().have_catalog && !ctx_.store.state().packages.empty();
std::vector<hui::ui::Hint> items = {
+1 -1
View File
@@ -1,4 +1,4 @@
// Wings TV app - The colour system (docs/DESIGN-colour.md): place, section, meaning.
// Wings TV app - The colour system: place, section, meaning.
// SPDX-License-Identifier: GPL-3.0-or-later
//
// Colour answers one of four questions and nothing else. Place: one hue per tab, a soft wash
+1 -1
View File
@@ -1,7 +1,7 @@
// Wings TV app - Colours and geometry of the game page header (the hero band).
// SPDX-License-Identifier: GPL-3.0-or-later
//
// Colour comes from theme.hpp (docs/DESIGN-colour.md): the hero sits under a veil in the Store
// Colour comes from theme.hpp: the hero sits under a veil in the Store
// place hue (so every game page is in one family, whatever the art), the info cards are neutral
// glass, and semantic colours appear only on values (game_header.cpp). Text colours come from
// ui::color so they stay readable on any art (the veils keep the art dark under the words).
+1 -1
View File
@@ -1,7 +1,7 @@
// Wings TV app - Accent and compatibility folding for norm_title (generated).
// SPDX-License-Identifier: GPL-3.0-or-later
//
// Generated by tools/linkref/gen_fold_table.py: code point -> lower(NFKD) without
// Generated table: code point -> lower(NFKD) without
// combining marks, non-ASCII results written as spaces. Sorted by code point.
{0x00AA, "a"},
{0x00B2, "2"},
+2 -1
View File
@@ -108,7 +108,8 @@ void draw_heading(Context &ctx, hui::gfx::DrawList &list, const char *title,
void draw_busy(Context &ctx, hui::gfx::DrawList &list, const char *text)
{
const ui::Type &type = ctx.type;
const float width = std::max(520.0f, type.medium.measure(text, 26.0f) + 140.0f);
// The words get the box minus the dots (130 left) and a margin (30 right): sized so they fit whole.
const float width = std::max(520.0f, std::min(1840.0f, type.medium.measure(text, 26.0f) + 190.0f));
const ui::Rect r{960.0f - width * 0.5f, 470.0f, width, 120.0f};
ui::modal(list, r);
// Three dots, breathing in turn.
+371
View File
@@ -0,0 +1,371 @@
// Wings TV app - "Is there a newer Wings?" (see wings_update.hpp).
// SPDX-License-Identifier: GPL-3.0-or-later
#include "orbit/wings_update.hpp"
#include "core/save_file.hpp"
#include "core/version.hpp"
#include "orbit/i18n.hpp"
#include "orbit/json.hpp"
#include <chrono>
#include <cstdlib>
namespace orbit
{
namespace
{
std::int64_t system_now()
{
return std::chrono::duration_cast<std::chrono::seconds>(std::chrono::system_clock::now().time_since_epoch())
.count();
}
// "v0.1.2" -> "0.1.2"; "" for anything else.
std::string tag_text(std::string_view tag)
{
int v[3];
if (!hui::parse_release_tag(tag, v))
return {};
return std::to_string(v[0]) + "." + std::to_string(v[1]) + "." + std::to_string(v[2]);
}
} // namespace
// ---- pure rules ----
bool parse_wings_latest(std::string_view text, std::string *tag, std::string *error)
{
const auto fail = [&](const char *why)
{
if (error != nullptr)
*error = why;
return false;
};
json::Value root;
std::string why;
if (!json::parse(text, &root, &why) || !root.is_object())
return fail("not a release");
// "releases/latest" never answers with these; an answer that does is not one to act on.
if (root.flag("draft") || root.flag("prerelease"))
return fail("not a published release");
const std::string name = root.text("tag_name");
int v[3];
if (!hui::parse_release_tag(name, v))
return fail("the release tag is not a version");
if (tag != nullptr)
*tag = name;
return true;
}
bool wings_check_due(std::int64_t checked_at, std::int64_t now, bool force)
{
if (force || checked_at <= 0 || now < checked_at)
return true;
return now - checked_at >= kWingsCheckTtlS;
}
WingsAdvice wings_advice(std::string_view running, std::string_view latest_tag)
{
if (latest_tag.empty())
return WingsAdvice::not_checked;
int mine[3];
int latest[3];
if (!hui::parse_content_version(running, mine) || !hui::parse_release_tag(latest_tag, latest))
return WingsAdvice::unreadable;
const int order = hui::compare_versions(mine, latest);
return order < 0 ? WingsAdvice::newer : (order == 0 ? WingsAdvice::up_to_date : WingsAdvice::ahead);
}
std::string encode_wings_cache(std::int64_t checked_at, std::string_view tag)
{
return "checked=" + std::to_string(checked_at) + "\ntag=" + std::string(tag) + "\n";
}
bool decode_wings_cache(std::string_view payload, std::int64_t *checked_at, std::string *tag)
{
*checked_at = 0;
tag->clear();
bool have_time = false;
std::size_t pos = 0;
while (pos < payload.size())
{
std::size_t nl = payload.find('\n', pos);
if (nl == std::string_view::npos)
nl = payload.size();
const std::string_view line = payload.substr(pos, nl - pos);
pos = nl + 1;
if (line.substr(0, 8) == "checked=")
{
const std::string num(line.substr(8));
if (num.empty() || num.size() > 12 || num.find_first_not_of("0123456789") != std::string::npos)
return false;
*checked_at = std::atoll(num.c_str());
have_time = true;
}
else if (line.substr(0, 4) == "tag=")
{
int v[3];
if (hui::parse_release_tag(line.substr(4), v))
*tag = std::string(line.substr(4));
}
}
return have_time;
}
// ---- the service ----
WingsUpdate &wings_update()
{
static WingsUpdate update;
return update;
}
WingsUpdate::~WingsUpdate()
{
thread_.join();
}
bool WingsUpdate::gate(std::string *why) const
{
if (!config_.ready)
{
*why = tr("Not available in this build");
return false;
}
return config_.ready(why);
}
void WingsUpdate::refresh_advice_locked()
{
status_.running_text = hui::release_version_text(config_.running);
status_.latest_text = tag_text(status_.latest_tag);
status_.advice = wings_advice(config_.running, status_.latest_tag);
}
void WingsUpdate::attach(WingsUpdateConfig config)
{
thread_.join();
Lock l(mutex_);
config_ = std::move(config);
if (!config_.now)
config_.now = system_now;
status_ = WingsUpdateStatus();
status_.attached = true;
attempt_at_ = 0;
for (const std::string &path : config_.cache_paths)
{
std::string data;
if (!hui::save::read_file(path, &data, 4096))
continue;
const hui::save::Decoded d = hui::save::decode(hui::save::Kind::stats, data);
std::int64_t at = 0;
std::string tag;
if (d.ok && d.version == 1 && decode_wings_cache(d.payload, &at, &tag))
{
status_.checked_at = at;
status_.latest_tag = tag;
status_.have_latest = !tag.empty();
break;
}
}
refresh_advice_locked();
}
void WingsUpdate::set_enabled(bool on)
{
Lock l(mutex_);
status_.enabled = on;
}
WingsUpdateStatus WingsUpdate::status() const
{
Lock l(mutex_);
return status_;
}
void WingsUpdate::wait_idle()
{
thread_.join();
}
bool WingsUpdate::start_job()
{
{
Lock l(mutex_);
if (!status_.attached || status_.busy)
return false;
status_.busy = true;
status_.error.clear();
attempt_at_ = config_.now();
}
thread_.join();
if (!thread_.start([this] { run_check(); }, 256u << 10))
{
Lock l(mutex_);
status_.busy = false;
status_.error = tr("Could not start the background job");
return false;
}
return true;
}
void WingsUpdate::auto_check()
{
{
Lock l(mutex_);
if (!status_.attached || status_.busy || !status_.enabled)
return;
const std::int64_t now = config_.now();
if (!wings_check_due(status_.checked_at, now, false))
return;
// A failed attempt is not repeated every frame.
if (attempt_at_ != 0 && now >= attempt_at_ && now - attempt_at_ < kWingsRetryS)
return;
}
std::string why;
if (!gate(&why))
return; // the network is not up yet (or is off): try again on a later frame, say nothing
start_job();
}
bool WingsUpdate::check_now()
{
std::string why;
if (!gate(&why))
{
Lock l(mutex_);
status_.error = why;
return false;
}
return start_job();
}
void WingsUpdate::run_check()
{
DocResult r;
if (config_.docs != nullptr)
r = config_.docs->get(kWingsLatestReleaseUrl, kWingsReleaseCap);
else
r.error = "no network client";
std::string tag;
std::string error;
if (!r.error.empty())
error = r.error;
else if (r.status == 403 || r.status == 429)
error = tr("GitHub is limiting requests from this address. Try again later.");
else if (r.status == 404)
error = tr("No release has been published yet");
else if (r.status != 200)
error = tr("GitHub answered HTTP {status}", {{"status", std::to_string(r.status)}});
else
{
std::string why;
if (!parse_wings_latest(r.body, &tag, &why))
error = tr("The release information could not be read");
}
std::string data;
{
Lock l(mutex_);
status_.busy = false;
status_.error = error;
if (!error.empty())
return;
status_.checked_at = config_.now();
status_.latest_tag = tag;
status_.have_latest = true;
refresh_advice_locked();
data = hui::save::encode(hui::save::Kind::stats, 1, encode_wings_cache(status_.checked_at, tag));
}
// The saved answer is written outside the lock (the frame thread reads the status every frame).
for (const std::string &path : config_.cache_paths)
{
const std::size_t slash = path.rfind('/');
if (slash != std::string::npos && slash > 0)
hui::save::ensure_directory(path.substr(0, slash));
if (hui::save::write_atomic(path, data).empty())
break;
}
}
// ---- words ----
WingsUpdateText wings_update_text(const WingsUpdateStatus &s)
{
WingsUpdateText out;
const std::string where = kWingsReleasePage;
if (!s.attached)
{
out.value = "-";
out.detail = tr("Not available in this build");
return out;
}
if (s.busy)
{
out.value = tr("Checking...");
out.detail = tr("Asking {where} for the latest release", {{"where", where}});
return out;
}
if (s.advice == WingsAdvice::newer)
{
out.value = tr("{version} available", {{"version", s.latest_text}});
out.detail = tr("You have {mine}. Select shows how to install it. Wings never installs itself.",
{{"mine", s.running_text}});
return out;
}
if (!s.error.empty())
{
out.value = tr("Check");
out.detail = tr("Could not check: {why}", {{"why", s.error}});
return out;
}
switch (s.advice)
{
case WingsAdvice::up_to_date:
out.value = tr("Up to date");
out.detail = tr("Wings {mine} is the latest release. Select checks again.", {{"mine", s.running_text}});
break;
case WingsAdvice::ahead:
out.value = tr("Newer than the release");
out.detail = tr("This build ({mine}) is newer than the latest release ({latest}). Select checks again.",
{{"mine", s.running_text}, {"latest", s.latest_text}});
break;
case WingsAdvice::unreadable:
out.value = tr("Check");
out.detail = tr("The latest release is {latest}, but this app's own version could not be read.",
{{"latest", s.latest_text}});
break;
default:
out.value = tr("Check");
out.detail = s.enabled ? tr("Wings looks at {where} at most every 6 hours. Select checks now.",
{{"where", where}})
: tr("Checking at start-up is off. Select checks now.");
break;
}
return out;
}
WingsUpdateGuide wings_update_guide(const WingsUpdateStatus &s)
{
WingsUpdateGuide g;
g.title = tr("Update to Wings {latest}", {{"latest", s.latest_text}});
g.subtitle = tr("You have {mine}. Wings never installs itself.", {{"mine", s.running_text}});
g.steps = {
{tr("Download PPSA99178.ffpkg and check its SHA-256"),
tr("{where}, on a PC. The hash is on that page", {{"where", kWingsReleasePage}})},
{tr("Replace the old file"), tr("Copy it to /data/homebrew/ on this console, over the old one")},
{tr("Remove the staged copies"), tr("Delete the copies ShadowMountPlus made of the old package")},
{tr("Restart the jailbreak chain"), tr("So the new package is mounted. Then open Wings again")},
};
return g;
}
std::string wings_update_toast(const WingsUpdateStatus &s)
{
return tr("Wings {latest} is available. Settings > Wings > Updates has the steps.",
{{"latest", s.latest_text}});
}
} // namespace orbit
+142
View File
@@ -0,0 +1,142 @@
// Wings TV app - "Is there a newer Wings?": a read-only look at the project's latest release.
// SPDX-License-Identifier: GPL-3.0-or-later
//
// Wings never updates itself. A mounted package cannot be replaced while it runs, and ShadowMount only
// registers a new package after the old one is gone, so the player does that by hand (docs/guides/updating.md).
// This only tells them there is something to fetch:
// * once the network is ready, and then at most every 6 hours, it asks GitHub for the latest release of
// the Wings repository (one small JSON, no identifier sent beyond the fixed User-Agent) and remembers the tag;
// * the answer is compared with this app's own contentVersion (00.001.001 is release v0.1.1);
// * Settings > Wings > Updates shows the result and the steps, and a newer release is announced once per start.
// It is on by default and the player can turn it off (Settings); it uses the same network gate as cover art, so
// with cover downloads off Wings makes no connection at all.
//
// The pure parts (release JSON, tag, advice, cache time, saved copy) are free functions; the service is the one
// background job at a time that uses them.
#pragma once
#include "orbit/popularity.hpp" // DocFetcher
#include "orbit/thread.hpp"
#include <cstdint>
#include <functional>
#include <string>
#include <string_view>
#include <vector>
namespace orbit
{
inline constexpr const char *kWingsLatestReleaseUrl =
"https://api.github.com/repos/LaRazaDelSol/wings-app/releases/latest";
// Shown to the player (never fetched from the answer: nothing in the reply is trusted as an address).
inline constexpr const char *kWingsReleasePage = "github.com/LaRazaDelSol/wings-app/releases/latest";
inline constexpr std::size_t kWingsReleaseCap = 256u << 10;
inline constexpr int kWingsCheckTtlS = 6 * 3600;
inline constexpr int kWingsRetryS = 600; // a failed attempt is not repeated sooner
// ---- pure rules ----
// The tag of the latest published release from the "releases/latest" reply. False (with *error) when the
// reply is not a release object, is a draft or a pre-release, or its tag is not v<major>.<minor>.<patch>.
bool parse_wings_latest(std::string_view json, std::string *tag, std::string *error);
// True when the latest release should be asked for again (never checked, older than the TTL, or the clock
// went backwards), or when `force` (the player pressed Check).
bool wings_check_due(std::int64_t checked_at, std::int64_t now, bool force);
enum class WingsAdvice : std::uint8_t
{
not_checked, // no release is known
unreadable, // a release is known but this app's own version cannot be read
up_to_date,
newer, // a newer release exists
ahead // this build is newer than the latest release (a test build)
};
// running = this app's contentVersion ("00.001.001"); latest_tag "" means not checked.
WingsAdvice wings_advice(std::string_view running, std::string_view latest_tag);
// The saved answer (payload text, inside the checked save container on disk).
std::string encode_wings_cache(std::int64_t checked_at, std::string_view tag);
bool decode_wings_cache(std::string_view payload, std::int64_t *checked_at, std::string *tag);
// ---- the service ----
struct WingsUpdateConfig
{
DocFetcher *docs = nullptr; // the same fetcher as popularity
std::function<bool(std::string *)> ready; // the network gate; false with a sentence
std::vector<std::string> cache_paths; // the saved answer; first writable wins
std::string running; // this app's contentVersion
std::function<std::int64_t()> now; // unix seconds (default: the system clock)
};
struct WingsUpdateStatus
{
bool attached = false; // false in the preview and in tests that do not attach
bool enabled = true; // the player's switch
bool busy = false;
std::int64_t checked_at = 0; // unix time of the last successful check, 0 never
bool have_latest = false;
std::string latest_tag; // "v0.1.2"
std::string latest_text; // "0.1.2"
std::string running_text; // "0.1.1"
WingsAdvice advice = WingsAdvice::not_checked;
std::string error; // why the last check failed, "" when it did not
};
class WingsUpdate
{
public:
WingsUpdate() = default;
~WingsUpdate();
WingsUpdate(const WingsUpdate &) = delete;
WingsUpdate &operator=(const WingsUpdate &) = delete;
// Frame thread. Loads the saved answer.
void attach(WingsUpdateConfig config);
void set_enabled(bool on);
WingsUpdateStatus status() const; // any thread; a copy
// Frame thread; each starts the one background job unless it is busy or not allowed.
void auto_check(); // only when enabled, due (6 h) and the network is ready; nothing is reported when skipped
bool check_now(); // the player pressed Check (works while the switch is off too: it was asked for)
void wait_idle(); // tests and shutdown
private:
void run_check();
bool start_job();
bool gate(std::string *why) const;
void refresh_advice_locked();
WingsUpdateConfig config_;
mutable Mutex mutex_;
WingsUpdateStatus status_;
std::int64_t attempt_at_ = 0; // the last background check, so a failure is not retried every frame
Thread thread_;
};
WingsUpdate &wings_update();
// The words for Settings: the row's right-hand value and its detail line (translated).
struct WingsUpdateText
{
std::string value;
std::string detail;
};
WingsUpdateText wings_update_text(const WingsUpdateStatus &status);
// How to install a newer release by hand (translated): a title, a line under it and four steps. The dialog is
// a scrolling list (a confirm dialog would cut the text off).
struct WingsUpdateStep
{
std::string label;
std::string detail;
};
struct WingsUpdateGuide
{
std::string title;
std::string subtitle;
std::vector<WingsUpdateStep> steps;
};
WingsUpdateGuide wings_update_guide(const WingsUpdateStatus &status);
// The one-line announcement shown once per start when a newer release is known (translated).
std::string wings_update_toast(const WingsUpdateStatus &status);
} // namespace orbit
+1 -1
View File
@@ -1,5 +1,5 @@
// Wings TV app - Dumps the link parser, part sets and game grouping over a
// recorded catalogue as TSV, in the format of tools/linkref/dump_golden.py, so
// recorded catalogue as TSV, in the golden TSV format, so
// the C++ port can be diffed line by line against the Python reference.
// SPDX-License-Identifier: GPL-3.0-or-later
//
+256
View File
@@ -0,0 +1,256 @@
// Wings TV app - Art sources: the Prosperopatches address rules, the two Settings choices and what covers
// resolve to under each. No test uses the network.
// SPDX-License-Identifier: GPL-3.0-or-later
#include "orbit/art_sources.hpp"
#include "orbit/cover_art.hpp"
#include "orbit/popularity.hpp"
#include "orbit/prefs.hpp"
#include <gtest/gtest.h>
#include <memory>
#include <string>
#include <vector>
namespace orbit
{
namespace
{
const std::string kHash(64, 'a');
const std::string kPic0 = "https://cdn.prosperopatches.com/titles/PPSA01234_" + kHash + "/pic0.webp";
const std::string kIcon = "https://cdn.prosperopatches.com/titles/PPSA01234_" + kHash + "/icon0.webp";
const std::string kIgdb = "https://images.igdb.com/igdb/image/upload/t_1080p/abc.jpg";
std::shared_ptr<const PopularityIndex> index_with(const std::string &hero_a, const std::string &hero_b)
{
const auto record = [](const char *id, const std::string &hero)
{
return std::string("\"") + id + "\":{\"score\":60,\"confidence\":1.0,\"sources\":[\"igdb\"]" +
(hero.empty() ? "" : ",\"hero\":\"" + hero + "\"") + "}";
};
const std::string doc = "{\"schemaVersion\":1,\"generatedAt\":\"2026-10-12T00:00:00Z\",\"attribution\":{},\"titles\":{" +
record("PPSA01234", hero_a) + "," + record("PPSA05678", hero_b) + "}}";
auto index = std::make_shared<PopularityIndex>();
std::string error;
EXPECT_TRUE(PopularityIndex::parse(doc, index.get(), &error)) << error;
return index;
}
Package package(const char *id, const char *title_id, const char *poster)
{
Package p;
p.id = id;
p.title_id = title_id;
p.poster_url = poster;
return p;
}
TEST(ArtSources, OnlyTheExactProsperopatchesShapeIsAccepted)
{
EXPECT_TRUE(is_prospero_pic0_url(kPic0));
for (const std::string &bad : {
std::string(), kIcon, kIgdb,
"http://cdn.prosperopatches.com/titles/PPSA01234_" + kHash + "/pic0.webp",
"https://cdn.prosperopatches.com.evil.test/titles/PPSA01234_" + kHash + "/pic0.webp",
"https://evil.test/cdn.prosperopatches.com/titles/PPSA01234_" + kHash + "/pic0.webp",
"https://cdn.prosperopatches.com/titles/PPSA01234_" + kHash + "/pic0.webp?x=1",
"https://cdn.prosperopatches.com/titles/PPSA01234_" + kHash + "/pic0.webp/../icon0.webp",
"https://cdn.prosperopatches.com/titles/CUSA01234_" + kHash + "/pic0.webp",
"https://cdn.prosperopatches.com/titles/PPSA0123_" + kHash + "/pic0.webp",
"https://cdn.prosperopatches.com/titles/PPSA01234_" + std::string(64, 'A') + "/pic0.webp",
"https://cdn.prosperopatches.com/titles/PPSA01234_" + std::string(63, 'a') + "/pic0.webp",
"https://cdn.prosperopatches.com/titles/PPSA01234_" + std::string(64, 'g') + "/pic0.webp",
"https://cdn.prosperopatches.com/titles/PPSA01234_" + kHash + "/pic1.webp"})
EXPECT_FALSE(is_prospero_pic0_url(bad)) << bad;
}
TEST(ArtSources, TheIconIsDerivedNextToThePicture)
{
EXPECT_EQ(prospero_icon_url(kPic0), kIcon);
EXPECT_EQ(prospero_icon_url(kIgdb), "");
EXPECT_EQ(prospero_icon_url(kIcon), ""); // already an icon: not a picture address
EXPECT_EQ(prospero_icon_url(""), "");
}
TEST(ArtSources, HeroSourceFiltersByAddress)
{
EXPECT_TRUE(hero_url_allowed(kPic0, HeroSource::all));
EXPECT_TRUE(hero_url_allowed(kIgdb, HeroSource::all));
EXPECT_TRUE(hero_url_allowed(kPic0, HeroSource::prospero));
EXPECT_FALSE(hero_url_allowed(kIgdb, HeroSource::prospero));
EXPECT_FALSE(hero_url_allowed("https://cdn.cloudflare.steamstatic.com/x/library_hero.jpg", HeroSource::prospero));
}
TEST(ArtSources, ChoicesAreKeptAndDefaultToTodaysBehaviour)
{
WingsLocalPrefs prefs;
EXPECT_EQ(prefs.icon_source, IconSource::catalogue);
EXPECT_EQ(prefs.hero_source, HeroSource::all);
prefs.icon_source = IconSource::prospero;
prefs.hero_source = HeroSource::prospero;
WingsLocalPrefs back;
ASSERT_TRUE(decode_local_prefs(encode_local_prefs(prefs), &back));
EXPECT_EQ(back.icon_source, IconSource::prospero);
EXPECT_EQ(back.hero_source, HeroSource::prospero);
EXPECT_TRUE(back == prefs);
// Older files and unknown values keep the defaults.
WingsLocalPrefs old;
ASSERT_TRUE(decode_local_prefs("region=USA\n", &old));
EXPECT_EQ(old.icon_source, IconSource::catalogue);
EXPECT_EQ(old.hero_source, HeroSource::all);
ASSERT_TRUE(decode_local_prefs("icon_source=banana\nhero_source=\n", &old));
EXPECT_EQ(old.icon_source, IconSource::catalogue);
EXPECT_EQ(old.hero_source, HeroSource::all);
}
TEST(ArtSources, CoversResolveToTheCatalogueUnlessProsperopatchesIsChosenAndKnown)
{
PosterIndex posters;
const std::vector<Package> packages = {package("1", "PPSA01234", "https://img.example/one.jpg"),
package("2", "PPSA05678", "https://img.example/two.jpg"), // IGDB hero only
package("3", "", "https://img.example/three.jpg"), // no title id
package("4", "PPSA09999", "https://img.example/four.jpg")}; // not in the data
ASSERT_TRUE(posters.sync(1, packages));
const auto index = index_with(kPic0, kIgdb);
// Default: the catalogue, whatever the data says.
EXPECT_FALSE(posters.set_icon_source(false, index));
EXPECT_EQ(posters.resolve("pkg:1", ArtKind::cover), "https://img.example/one.jpg");
// Chosen, but the data is not loaded yet: still the catalogue, and nothing changed.
EXPECT_FALSE(posters.set_icon_source(true, nullptr));
EXPECT_EQ(posters.resolve("pkg:1", ArtKind::cover), "https://img.example/one.jpg");
// Chosen and loaded: covers change for titles with Prosperopatches art, not for the rest.
EXPECT_TRUE(posters.set_icon_source(true, index));
EXPECT_EQ(posters.resolve("pkg:1", ArtKind::cover), kIcon);
EXPECT_EQ(posters.resolve("pkg:2", ArtKind::cover), "https://img.example/two.jpg");
EXPECT_EQ(posters.resolve("pkg:3", ArtKind::cover), "https://img.example/three.jpg");
EXPECT_EQ(posters.resolve("pkg:4", ArtKind::cover), "https://img.example/four.jpg");
EXPECT_EQ(posters.resolve("pkg:99", ArtKind::cover), "");
// Asking again with the same data changes nothing; new data does.
EXPECT_FALSE(posters.set_icon_source(true, index));
EXPECT_TRUE(posters.set_icon_source(true, index_with(kPic0, kPic0)));
// Back to the catalogue.
EXPECT_TRUE(posters.set_icon_source(false, index));
EXPECT_EQ(posters.resolve("pkg:1", ArtKind::cover), "https://img.example/one.jpg");
// A catalogue that changes keeps the choice.
ASSERT_TRUE(posters.set_icon_source(true, index));
ASSERT_TRUE(posters.sync(2, packages));
EXPECT_EQ(posters.resolve("pkg:1", ArtKind::cover), kIcon);
}
TEST(ArtSources, ChooseHeroTakesTheFirstTitleIdWhoseArtTheSourceAllows)
{
const auto index = index_with(kIgdb, kPic0); // PPSA01234 has IGDB art, PPSA05678 has Prosperopatches art
EXPECT_EQ(choose_hero(*index, {"PPSA01234", "PPSA05678"}, HeroSource::all), kIgdb);
EXPECT_EQ(choose_hero(*index, {"PPSA01234", "PPSA05678"}, HeroSource::prospero), kPic0); // skips the IGDB one
EXPECT_EQ(choose_hero(*index, {"PPSA01234"}, HeroSource::prospero), "");
EXPECT_EQ(choose_hero(*index, {"PPSA09999"}, HeroSource::all), "");
EXPECT_EQ(choose_hero(*index, {}, HeroSource::all), "");
}
TEST(HeroPrefetchGate, FetchesOnlyAfterFocusHasRestedOnAGameAndOnlyOnce)
{
HeroPrefetchGate gate;
double t = 100.0;
EXPECT_FALSE(gate.update("g:a", t)); // focus lands
EXPECT_FALSE(gate.update("g:a", t += 0.2)); // not long enough
EXPECT_FALSE(gate.update("g:b", t += 0.2)); // moved on: the clock restarts
EXPECT_FALSE(gate.update("g:b", t += HeroPrefetchGate::kDwellS - 0.1));
EXPECT_TRUE(gate.update("g:b", t += 0.2)); // rested: fetch
EXPECT_FALSE(gate.update("g:b", t += 1.0)); // once per landing
EXPECT_FALSE(gate.update("g:b", t += 10.0));
}
TEST(HeroPrefetchGate, ScrollingPastFetchesNothingAndTheGapLimitsTheRate)
{
HeroPrefetchGate gate;
double t = 0.0;
int fetched = 0;
// ten games, 0.3 s each: never rests long enough
for (int i = 0; i < 10; ++i)
for (int f = 0; f < 18; ++f)
fetched += gate.update("g:" + std::to_string(i), t += 1.0 / 60.0) ? 1 : 0;
EXPECT_EQ(fetched, 0);
// rests on one: fetched; moves to the next and rests: held back until the gap has passed
for (int f = 0; f < 40; ++f)
fetched += gate.update("g:rest1", t += 1.0 / 60.0) ? 1 : 0;
EXPECT_EQ(fetched, 1);
for (int f = 0; f < 60; ++f) // one second on the next game
fetched += gate.update("g:rest2", t += 1.0 / 60.0) ? 1 : 0;
EXPECT_EQ(fetched, 1);
for (int f = 0; f < 600; ++f) // it stays focused: fetched once the gap is over
fetched += gate.update("g:rest2", t += 1.0 / 60.0) ? 1 : 0;
EXPECT_EQ(fetched, 2);
}
TEST(HeroPrefetchGate, LeavingTheGridAndComingBackAllowsAnotherLook)
{
HeroPrefetchGate gate;
double t = 0.0;
gate.update("g:a", t);
EXPECT_TRUE(gate.update("g:a", t += 1.0));
EXPECT_FALSE(gate.update("", t += 10.0)); // a page opened, or the tile lost focus
EXPECT_FALSE(gate.update("g:a", t += 0.1));
EXPECT_TRUE(gate.update("g:a", t += 1.0)); // back on it, after the gap
gate.reset();
EXPECT_FALSE(gate.update("g:a", t += 10.0)); // reset: the dwell starts again
EXPECT_TRUE(gate.update("g:a", t += 1.0));
}
TEST(ArtSources, TheIgdbFullAddressIsTheSamePictureAtFullSize)
{
EXPECT_EQ(igdb_full_url("https://images.igdb.com/igdb/image/upload/t_screenshot_big/abc.jpg"),
"https://images.igdb.com/igdb/image/upload/t_1080p/abc.jpg");
EXPECT_EQ(igdb_full_url("https://images.igdb.com/igdb/image/upload/t_1080p/abc.jpg"), "");
EXPECT_EQ(igdb_full_url("https://images.igdb.com/igdb/image/upload/t_screenshot_big/"), "");
EXPECT_EQ(igdb_full_url("https://evil.example/igdb/image/upload/t_screenshot_big/abc.jpg"), "");
EXPECT_EQ(igdb_full_url(""), "");
}
TEST(ArtSources, AGameWithTwoDifferentPicturesShowsOneWholeAndTheChoiceAlternates)
{
const std::string low = "https://images.igdb.com/igdb/image/upload/t_screenshot_big/abc.jpg";
const std::string doc = "{\"schemaVersion\":1,\"generatedAt\":\"2026-10-12T00:00:00Z\",\"attribution\":{},"
"\"titles\":{\"PPSA01234\":{\"score\":60,\"confidence\":1.0,\"sources\":[\"igdb\"],"
"\"hero\":\"" + kPic0 + "\",\"heroLow\":\"" + low + "\"},"
"\"PPSA05678\":{\"score\":60,\"confidence\":1.0,\"sources\":[\"igdb\"],"
"\"hero\":\"" + kIgdb + "\",\"heroLow\":\"" + low + "\"}}}";
PopularityIndex index;
std::string error;
ASSERT_TRUE(PopularityIndex::parse(doc, &index, &error)) << error;
// Both pictures exist: the first is Prosperopatches' alone (no small one flashed first) ...
HeroPick first = pick_hero(index, {"PPSA01234"}, HeroSource::all, false);
EXPECT_TRUE(first.has_both);
EXPECT_FALSE(first.second);
EXPECT_EQ(first.full, kPic0);
EXPECT_EQ(first.low, "");
// ... the other is IGDB's, at full size, its small stand-in being the same picture.
HeroPick second = pick_hero(index, {"PPSA01234"}, HeroSource::all, true);
EXPECT_TRUE(second.has_both);
EXPECT_TRUE(second.second);
EXPECT_EQ(second.full, "https://images.igdb.com/igdb/image/upload/t_1080p/abc.jpg");
EXPECT_EQ(second.low, low);
// Their cache keys differ, so they never share a texture.
EXPECT_NE(hero_art_key("pkg:7", false), hero_art_key("pkg:7", true));
EXPECT_EQ(hero_art_key("pkg:7", false), "pkg:7");
// An IGDB-only game keeps what it had: the picture and its own small stand-in, no alternation.
HeroPick igdb = pick_hero(index, {"PPSA05678"}, HeroSource::all, true);
EXPECT_FALSE(igdb.has_both);
EXPECT_FALSE(igdb.second);
EXPECT_EQ(igdb.full, kIgdb);
EXPECT_EQ(igdb.low, low);
// Prosperopatches only: the one picture, nothing from IGDB.
HeroPick only = pick_hero(index, {"PPSA01234"}, HeroSource::prospero, true);
EXPECT_FALSE(only.has_both);
EXPECT_EQ(only.full, kPic0);
EXPECT_EQ(only.low, "");
}
} // namespace
} // namespace orbit
+186
View File
@@ -0,0 +1,186 @@
// Wings TV app - The Store carousel: which games, when it shows, the pad rules, the timer, the grid offset.
// SPDX-License-Identifier: GPL-3.0-or-later
#include "orbit/carousel.hpp"
#include <gtest/gtest.h>
#include <algorithm>
namespace orbit
{
namespace
{
using hui::Direction;
GameGroup game(const char *title, std::vector<std::string> ids)
{
GameGroup g;
g.title = title;
g.title_ids = std::move(ids);
return g;
}
TEST(Carousel, ResolvesIdsInTheFilesOrderAgainstTheOwnCatalogue)
{
const std::vector<GameGroup> games = {game("A", {"PPSA00001"}), game("B", {"PPSA00002", "PPSA00022"}),
game("C", {"PPSA00003"})};
// Order follows the file; an id the player does not have is hidden.
EXPECT_EQ(resolve_carousel({"PPSA00003", "PPSA09999", "PPSA00001"}, games), (std::vector<int>{2, 0}));
// A game found through two ids is kept once.
EXPECT_EQ(resolve_carousel({"PPSA00022", "PPSA00002", "PPSA00001"}, games), (std::vector<int>{1, 0}));
EXPECT_TRUE(resolve_carousel({}, games).empty());
EXPECT_TRUE(resolve_carousel({"PPSA00001"}, {}).empty());
}
TEST(Carousel, KeepsAtMostEightGames)
{
std::vector<GameGroup> games;
std::vector<std::string> ids;
for (int i = 0; i < 12; ++i)
{
const std::string id = "PPSA0000" + std::to_string(i % 10) + std::to_string(i / 10);
games.push_back(game("G", {id}));
ids.push_back(id);
}
const std::vector<int> got = resolve_carousel(ids, games);
ASSERT_EQ(got.size(), kCarouselMaxItems);
EXPECT_EQ(got.front(), 0);
EXPECT_EQ(got.back(), 7);
}
TEST(Carousel, ShowsOnlyInTheDefaultGridView)
{
EXPECT_TRUE(carousel_shown(true, 3, true, 100));
EXPECT_FALSE(carousel_shown(false, 3, true, 100)); // switch off
EXPECT_FALSE(carousel_shown(true, 0, true, 100)); // nothing resolved
EXPECT_FALSE(carousel_shown(true, 3, false, 100)); // a search
EXPECT_FALSE(carousel_shown(true, 3, true, 0)); // empty grid
}
TEST(Carousel, FallsBackToTheMostPopularGames)
{
// Keys: -1 = no data. The three largest, largest first; ties keep the catalogue order.
EXPECT_EQ(pick_most_popular({10, -1, 70, 70, 5, 40}, 3), (std::vector<int>{2, 3, 5}));
// Nothing known: the first ones.
EXPECT_EQ(pick_most_popular({-1, -1, -1, -1}, 3), (std::vector<int>{0, 1, 2}));
EXPECT_EQ(pick_most_popular({4, 9}, 5), (std::vector<int>{1, 0}));
EXPECT_TRUE(pick_most_popular({}, 5).empty());
}
TEST(Carousel, WrapsSlides)
{
EXPECT_EQ(carousel_wrap(0, -1, 4), 3);
EXPECT_EQ(carousel_wrap(3, 1, 4), 0);
EXPECT_EQ(carousel_wrap(2, 1, 4), 3);
EXPECT_EQ(carousel_wrap(0, 1, 0), 0);
}
TEST(Carousel, ButtonsAreTheFocusStopsAndTheEdgesChangeSlide)
{
// Install -> Favourite on Right, back on Left.
CarouselStep s = carousel_step(1, kCarouselView, 3, Direction::right);
EXPECT_TRUE(s.moved);
EXPECT_FALSE(s.slide_changed);
EXPECT_EQ(s.button, kCarouselFavourite);
EXPECT_EQ(s.slide, 1);
s = carousel_step(1, kCarouselFavourite, 3, Direction::left);
EXPECT_EQ(s.button, kCarouselView);
EXPECT_EQ(s.slide, 1);
// The edge buttons change the slide and keep the button.
s = carousel_step(1, kCarouselFavourite, 3, Direction::right);
EXPECT_TRUE(s.slide_changed);
EXPECT_EQ(s.slide, 2);
EXPECT_EQ(s.button, kCarouselFavourite);
s = carousel_step(1, kCarouselView, 3, Direction::left);
EXPECT_EQ(s.slide, 0);
EXPECT_EQ(s.button, kCarouselView);
// Wraps both ways.
EXPECT_EQ(carousel_step(2, kCarouselFavourite, 3, Direction::right).slide, 0);
EXPECT_EQ(carousel_step(0, kCarouselView, 3, Direction::left).slide, 2);
}
TEST(Carousel, OneSlideRefusesTheEdgesAndOtherDirectionsDoNothing)
{
CarouselStep s = carousel_step(0, kCarouselFavourite, 1, Direction::right);
EXPECT_FALSE(s.moved);
EXPECT_EQ(s.slide, 0);
s = carousel_step(0, kCarouselView, 1, Direction::left);
EXPECT_FALSE(s.moved);
for (const Direction d : {Direction::up, Direction::down, Direction::none})
{
s = carousel_step(1, kCarouselView, 3, d);
EXPECT_FALSE(s.moved);
EXPECT_EQ(s.slide, 1);
EXPECT_EQ(s.button, kCarouselView);
}
}
TEST(Carousel, ClockAdvancesEveryEightSecondsAndPausesAndRestarts)
{
CarouselClock clock;
for (int i = 0; i < 7; ++i)
EXPECT_FALSE(clock.update(1.0f, false, 4));
EXPECT_TRUE(clock.update(1.0f, false, 4));
EXPECT_FLOAT_EQ(clock.elapsed(), 0.0f);
// Paused (focus, dialog, off screen): the count starts again afterwards.
EXPECT_FALSE(clock.update(5.0f, false, 4));
EXPECT_FALSE(clock.update(100.0f, true, 4));
EXPECT_FALSE(clock.update(5.0f, false, 4));
EXPECT_TRUE(clock.update(5.0f, false, 4));
// One slide never advances.
EXPECT_FALSE(clock.update(100.0f, false, 1));
clock.restart();
EXPECT_FLOAT_EQ(clock.elapsed(), 0.0f);
}
TEST(Carousel, GridScrollWithoutCarouselIsTheGridsOwnRule)
{
constexpr float pitch = 330.5f;
for (int row = 0; row < 40; ++row)
EXPECT_FLOAT_EQ(grid_scroll_target(row, pitch, 0.0f), std::max(0.0f, static_cast<float>(row - 1) * pitch));
}
TEST(Carousel, GridScrollWithCarouselKeepsTheFocusedRowOnScreen)
{
constexpr float pitch = 330.5f;
constexpr float top = 240.0f;
constexpr float bottom = 1000.0f;
// Row 0 scrolls the hero away: the header and the first row sit where they do without a carousel.
EXPECT_FLOAT_EQ(grid_scroll_target(0, pitch, kCarouselExtra), kCarouselExtra);
for (int row = 0; row < 40; ++row)
{
const float scroll = grid_scroll_target(row, pitch, kCarouselExtra);
const float y = top + kCarouselExtra + static_cast<float>(row) * pitch - scroll;
// The same place as without a carousel.
EXPECT_FLOAT_EQ(y, top + static_cast<float>(row) * pitch - grid_scroll_target(row, pitch, 0.0f)) << row;
EXPECT_GE(y, top - 1.0f) << row;
EXPECT_LE(y + 306.0f, bottom) << row;
}
}
TEST(Carousel, HeroAtRestShowsTheGridHeaderAndTheTopOfTheFirstRow)
{
// With the focus on the hero (scroll 0) the first row's tiles start under the words and well above the hint
// bar (it covers from 960), so a good part of them peeks in; the header sits just above them.
constexpr float top = 240.0f;
const float first_row = top + kCarouselExtra;
EXPECT_GT(first_row, 800.0f);
EXPECT_LT(first_row, 960.0f - 100.0f);
// The hero's picture is taller than Orbit's (which ends near 640) and ends before the tiles.
EXPECT_GT(kCarouselArtH, 700.0f);
EXPECT_LE(kCarouselArtH, first_row);
}
TEST(Carousel, HeroFadesAsThePageSlidesUp)
{
EXPECT_FLOAT_EQ(carousel_fade(0.0f), 1.0f);
EXPECT_FLOAT_EQ(carousel_fade(kCarouselExtra), 0.0f);
EXPECT_FLOAT_EQ(carousel_fade(kCarouselExtra * 3.0f), 0.0f);
EXPECT_FLOAT_EQ(carousel_fade(-5.0f), 1.0f);
EXPECT_GT(carousel_fade(kCarouselExtra * 0.2f), carousel_fade(kCarouselExtra * 0.4f));
}
} // namespace
} // namespace orbit
+78
View File
@@ -8,6 +8,7 @@
#include "fake_backend.hpp"
#include "orbit/catalogue_view.hpp"
#include "orbit/core_transport.hpp"
#include "orbit/editorial.hpp"
#include "orbit/pegasus_backend.hpp"
#include "orbit/screens.hpp"
#include "orbit/store.hpp"
@@ -429,6 +430,7 @@ struct GridHarness
if (!catalogue_path.empty())
context.catalogue_paths = {catalogue_path};
app = std::make_unique<App>(context);
context.folder_confirmed = [] { return true; };
store.poll_once();
store.load_catalog_once();
frame();
@@ -487,6 +489,81 @@ TEST(StoreGrid, DefaultIsAToZWithInstalledStatusFromTheGameList)
EXPECT_EQ(grid.tile_installed(at("pkg1")), 0);
}
// Puts the featured-carousel data in the shared editorial hub for one test and clears it afterwards.
struct CarouselData
{
explicit CarouselData(bool on)
{
auto data = std::make_shared<EditorialData>();
std::string why;
ok = EditorialData::parse_curated(
"{\"schemaVersion\":1,\"items\":[\"PPSA90002\",\"PPSA99999\",\"PPSA90001\"]}", data.get(), &why);
editorial_hub().set_data(data);
editorial_hub().set_enabled(false, on);
}
~CarouselData()
{
editorial_hub().set_enabled(false, false);
editorial_hub().set_data(nullptr);
}
bool ok = false;
};
TEST(StoreGrid, CarouselIsHiddenWhenTheSwitchIsOff)
{
CarouselData data(false);
ASSERT_TRUE(data.ok);
GridHarness h;
EXPECT_FALSE(h.grid().carousel_visible());
EXPECT_EQ(h.grid().shown_count(), 25);
h.move(Direction::up); // from the first row the focus leaves for the top bar, as before
EXPECT_FALSE(h.grid().on_carousel());
}
TEST(StoreGrid, CarouselShowsTheFeaturedGamesTheCatalogueHas)
{
CarouselData data(true);
ASSERT_TRUE(data.ok);
GridHarness h;
StoreScreen &grid = h.grid();
ASSERT_TRUE(grid.carousel_visible());
EXPECT_EQ(grid.carousel_count(), 2); // PPSA99999 is not in this catalogue
EXPECT_EQ(grid.shown_count(), 25); // the grid itself is unchanged
// The Store opens on the hero shot, on View game.
ASSERT_TRUE(grid.on_carousel());
EXPECT_EQ(grid.carousel_button(), kCarouselView);
EXPECT_EQ(grid.carousel_slide(), 0);
h.move(Direction::right);
EXPECT_EQ(grid.carousel_button(), kCarouselFavourite);
h.move(Direction::right); // the edge button changes the slide
EXPECT_EQ(grid.carousel_slide(), 1);
EXPECT_EQ(grid.carousel_button(), kCarouselFavourite);
h.move(Direction::left);
EXPECT_EQ(grid.carousel_button(), kCarouselView);
EXPECT_EQ(grid.carousel_slide(), 1);
// Down goes to the grid; Up from its first row comes back, and Cross on View game opens the slide's page.
h.move(Direction::down);
EXPECT_FALSE(grid.on_carousel());
h.move(Direction::up);
ASSERT_TRUE(grid.on_carousel());
EXPECT_EQ(grid.carousel_button(), kCarouselView);
h.press(hui::Action::confirm);
EXPECT_TRUE(grid.detail_open());
}
TEST(StoreGrid, CarouselGoesAwayWithASearch)
{
CarouselData data(true);
ASSERT_TRUE(data.ok);
GridHarness h;
StoreScreen &grid = h.grid();
ASSERT_TRUE(grid.carousel_visible());
grid.set_query("ember");
h.settle(3);
EXPECT_FALSE(grid.carousel_visible());
EXPECT_FALSE(grid.on_carousel());
}
TEST(StoreGrid, NothingIsClaimedWhileTheGameListHasNotLoaded)
{
host::FakeBackend backend;
@@ -496,6 +573,7 @@ TEST(StoreGrid, NothingIsClaimedWhileTheGameListHasNotLoaded)
ArtCache art(backend, sink);
Context context(fonts.type, fonts.fonts, store, art);
App app(context);
context.folder_confirmed = [] { return true; };
store.poll_once(); // no screen has asked for /appdb yet
store.load_catalog_once();
hui::InputFrame idle;
+1 -1
View File
@@ -1,4 +1,4 @@
// Wings TV app - tests for the colour system (docs/DESIGN-colour.md): stable hues, contrast of
// Wings TV app - tests for the colour system: stable hues, contrast of
// text over the worst wash, and a wash that only ever animates one hue.
// SPDX-License-Identifier: GPL-3.0-or-later
+435 -59
View File
@@ -1,4 +1,4 @@
// Wings TV app - tests of "Download all parts": sequence, skip, stop on failure, cancel, saved record.
// Wings TV app - tests of "Download all parts": sequence, retries, other hosts, skip and continue, cancel, record.
// SPDX-License-Identifier: GPL-3.0-or-later
#include "core/save_file.hpp"
@@ -9,8 +9,8 @@
#include <gtest/gtest.h>
#include <algorithm>
#include <cstdlib>
#include <deque>
#include <functional>
#include <string>
#include <vector>
@@ -78,10 +78,91 @@ struct Series
{
}
store.tick();
all.tick(0.5f);
}
}
};
LinkStart queued_outcome()
{
return LinkStart{};
}
LinkStart failure(const std::string &error, bool transient)
{
LinkStart outcome;
outcome.error = error;
outcome.transient = transient;
return outcome;
}
std::string key(const LinkRef &ref)
{
return ref.package_id + "#" + std::to_string(ref.link_index);
}
// `count` parts of "pkg25" on VikingFile; `alternate` adds the same part on
// Rapidgator ("pkg30", same index) after it.
std::vector<PartPlan> plans(int count, bool alternate = false)
{
std::vector<PartPlan> out;
for (int i = 0; i < count; ++i)
{
PartPlan part;
part.number = i + 1;
part.refs.push_back(LinkRef{"pkg25", i});
part.hosts.push_back("VikingFile");
if (alternate)
{
part.refs.push_back(LinkRef{"pkg30", i});
part.hosts.push_back("Rapidgator");
}
out.push_back(std::move(part));
}
return out;
}
// The series on a fake start: answers at once with `answer`, or (held) keeps
// every Done for the test to call.
struct Runner
{
DownloadAll all;
bool held = false;
std::function<LinkStart(const LinkRef &)> answer = [](const LinkRef &) { return queued_outcome(); };
std::vector<LinkRef> asked;
std::deque<DownloadAll::Done> pending; // a deque: calling one may add the next
std::vector<LinkRef> queued;
int finished_calls = 0;
void run(std::vector<PartPlan> parts)
{
all.begin(
std::move(parts), "VikingFile",
[this](const LinkRef &ref, DownloadAll::Done done)
{
asked.push_back(ref);
if (held)
pending.push_back(std::move(done));
else
done(answer(ref));
},
[this](const LinkRef &ref) { queued.push_back(ref); }, [this] { ++finished_calls; });
}
// Frames of `dt` seconds until the series ends (or the frames run out).
void frames(int count, float dt = 0.5f)
{
for (int i = 0; i < count && all.active; ++i)
all.tick(dt);
}
};
const std::string kContinue = "Choose \"Download all\" again to continue: parts already queued are left out.";
bool ends_with(const std::string &text, const std::string &tail)
{
return text.size() >= tail.size() && text.compare(text.size() - tail.size(), tail.size(), tail) == 0;
}
std::vector<PackageDetail> starfall_details(host::FakeBackend &backend)
{
std::vector<PackageDetail> out;
@@ -108,29 +189,360 @@ TEST(DownloadAll, QueuesEveryPartInOrderOneAtATime)
TEST(DownloadAll, NeverStartsTheNextPartBeforeThePreviousOneEnded)
{
Series s;
int in_flight = 0;
int most = 0;
s.on_start = [&](const LinkRef &)
Runner r;
r.held = true;
r.run(plans(3));
EXPECT_TRUE(r.all.active);
ASSERT_EQ(r.asked.size(), 1u); // a start that has not answered holds the series at part 1
EXPECT_EQ(r.all.current, 1);
EXPECT_EQ(r.all.attempt, 1);
EXPECT_EQ(r.all.trying_host, "VikingFile");
r.frames(100); // frames alone move nothing while a part is out
EXPECT_EQ(r.asked.size(), 1u);
r.pending[0](queued_outcome());
ASSERT_EQ(r.asked.size(), 2u);
EXPECT_EQ(r.all.done, 1);
EXPECT_EQ(r.all.current, 2);
r.pending[0](queued_outcome()); // a second Done of the same part is ignored
EXPECT_EQ(r.asked.size(), 2u);
EXPECT_EQ(r.all.ok, 1);
r.pending[1](queued_outcome());
r.pending[2](queued_outcome());
EXPECT_FALSE(r.all.active);
EXPECT_EQ(r.all.end, DownloadAll::End::finished);
EXPECT_EQ(r.all.ok, 3);
EXPECT_EQ(r.queued.size(), 3u);
EXPECT_EQ(r.finished_calls, 1);
}
TEST(DownloadAll, AllQueuedOnTheFakeStart)
{
Runner r;
r.run(plans(5));
EXPECT_FALSE(r.all.active);
EXPECT_EQ(r.all.end, DownloadAll::End::finished);
EXPECT_EQ(r.all.total, 5);
EXPECT_EQ(r.all.done, 5);
EXPECT_EQ(r.all.ok, 5);
EXPECT_TRUE(r.all.failed.empty());
EXPECT_EQ(r.all.via_alternate, 0);
EXPECT_EQ(r.asked.size(), 5u);
EXPECT_EQ(r.queued.size(), 5u);
EXPECT_EQ(r.finished_calls, 1);
EXPECT_EQ(r.all.toast(), "5 parts queued from VikingFile");
}
TEST(DownloadAll, ATimeoutIsAskedAgainAfterAWaitAndThenQueues)
{
Runner r;
int calls = 0;
r.answer = [&calls](const LinkRef &ref)
{
++in_flight;
most = std::max(most, in_flight);
if (ref.link_index == 1 && ++calls <= 2)
return failure("timed out", true);
return queued_outcome();
};
// The part counts as ended once the series moves on: start_link() of the
// next one is only reached from the previous one's callback.
s.run(s.parts(5));
EXPECT_EQ(s.all.ok, 5);
EXPECT_EQ(s.order.size(), 5u);
// While part k ran, no later part had been asked for.
EXPECT_EQ(in_flight, 5);
EXPECT_EQ(most, 5);
// Directly: a start that never answers holds the series at part 1.
DownloadAll held;
std::vector<LinkRef> asked;
held.begin(
s.parts(3), {1, 2, 3}, "H", [&](const LinkRef &ref, DownloadAll::Done) { asked.push_back(ref); }, {}, {});
EXPECT_TRUE(held.active);
EXPECT_EQ(asked.size(), 1u);
r.run(plans(3));
ASSERT_TRUE(r.all.active);
EXPECT_EQ(r.asked.size(), 2u);
EXPECT_EQ(r.all.current, 2);
EXPECT_EQ(r.all.attempt, 1);
EXPECT_FLOAT_EQ(r.all.waiting, kFirstBackoff);
r.all.tick(1.0f);
EXPECT_EQ(r.asked.size(), 2u); // still waiting
EXPECT_FLOAT_EQ(r.all.waiting, 1.0f);
r.all.tick(1.0f); // the wait is over: second attempt, timed out again
EXPECT_EQ(r.asked.size(), 3u);
EXPECT_EQ(r.all.attempt, 2);
EXPECT_FLOAT_EQ(r.all.waiting, kSecondBackoff);
r.all.tick(kSecondBackoff - 0.1f);
EXPECT_EQ(r.asked.size(), 3u);
r.all.tick(0.2f); // third attempt queues, part 3 follows at once
EXPECT_FALSE(r.all.active);
EXPECT_EQ(r.all.end, DownloadAll::End::finished);
EXPECT_EQ(r.all.ok, 3);
EXPECT_TRUE(r.all.failed.empty());
ASSERT_EQ(r.asked.size(), 5u);
EXPECT_EQ(key(r.asked[1]), "pkg25#1");
EXPECT_EQ(key(r.asked[2]), "pkg25#1");
EXPECT_EQ(key(r.asked[3]), "pkg25#1");
EXPECT_EQ(key(r.asked[4]), "pkg25#2");
}
TEST(DownloadAll, BackoffWaitsTwoThenSixSecondsThenGivesUpTheLink)
{
EXPECT_EQ(kPartAttempts, 3);
EXPECT_FLOAT_EQ(kFirstBackoff, 2.0f);
EXPECT_FLOAT_EQ(kSecondBackoff, 6.0f);
Runner r;
r.answer = [](const LinkRef &) { return failure("timed out", true); };
r.run(plans(1));
EXPECT_EQ(r.asked.size(), 1u);
r.all.tick(kFirstBackoff - 0.1f);
EXPECT_EQ(r.asked.size(), 1u);
r.all.tick(0.2f);
EXPECT_EQ(r.asked.size(), 2u);
r.all.tick(kSecondBackoff - 0.5f);
EXPECT_EQ(r.asked.size(), 2u);
EXPECT_NEAR(r.all.waiting, 0.5f, 1e-4f);
r.all.tick(0.5f);
EXPECT_EQ(r.asked.size(), 3u); // three attempts, then the part is given up
EXPECT_FALSE(r.all.active);
EXPECT_EQ(r.all.end, DownloadAll::End::finished_with_failures);
ASSERT_EQ(r.all.failed.size(), 1u);
EXPECT_EQ(r.all.failed[0].number, 1);
EXPECT_EQ(r.all.failed[0].why, "timed out");
EXPECT_EQ(r.all.ok, 0);
EXPECT_TRUE(r.queued.empty());
r.frames(100);
EXPECT_EQ(r.asked.size(), 3u); // nothing after the end
// A zero or negative frame time never ends a wait.
Runner z;
z.answer = [](const LinkRef &) { return failure("timed out", true); };
z.run(plans(1));
z.all.tick(0.0f);
z.all.tick(-5.0f);
EXPECT_EQ(z.asked.size(), 1u);
}
TEST(DownloadAll, AnAnswerThatWillNotChangeGoesStraightToTheOtherHost)
{
Runner r;
r.answer = [](const LinkRef &ref)
{
if (ref.package_id == "pkg25" && ref.link_index == 0)
return failure("not supported", false);
return queued_outcome();
};
r.run(plans(2, true));
EXPECT_FALSE(r.all.active);
EXPECT_EQ(r.all.end, DownloadAll::End::finished);
ASSERT_EQ(r.asked.size(), 3u); // no retry, no wait
EXPECT_EQ(key(r.asked[0]), "pkg25#0");
EXPECT_EQ(key(r.asked[1]), "pkg30#0");
EXPECT_EQ(key(r.asked[2]), "pkg25#1"); // the next part starts on the chosen host again
ASSERT_EQ(r.queued.size(), 2u);
EXPECT_EQ(key(r.queued[0]), "pkg30#0"); // the record keeps the link that worked
EXPECT_EQ(r.all.via_alternate, 1);
ASSERT_EQ(r.all.alternates.size(), 1u);
EXPECT_EQ(r.all.alternates[0].number, 1);
EXPECT_EQ(r.all.alternates[0].host, "Rapidgator");
EXPECT_EQ(r.all.toast(), "2 parts queued from VikingFile (1 from another host)");
}
TEST(DownloadAll, APartThatFailsEverywhereIsSkippedAndTheSeriesGoesOn)
{
Runner r;
r.answer = [](const LinkRef &ref)
{
if (ref.link_index == 1)
return failure("not found", false); // part 2, both hosts
if (ref.link_index == 2 && ref.package_id == "pkg25")
return failure("not supported", false); // part 3 comes from Rapidgator
return queued_outcome();
};
r.run(plans(5, true));
EXPECT_FALSE(r.all.active);
EXPECT_EQ(r.all.end, DownloadAll::End::finished_with_failures);
EXPECT_EQ(r.all.total, 5);
EXPECT_EQ(r.all.done, 5);
EXPECT_EQ(r.all.ok, 4);
EXPECT_EQ(r.asked.size(), 7u); // 1, 2, 2', 3, 3', 4, 5
ASSERT_EQ(r.all.failed.size(), 1u);
EXPECT_EQ(r.all.failed[0].number, 2);
EXPECT_EQ(r.all.failed[0].why, "Rapidgator: not found");
EXPECT_EQ(r.all.failed_number, 2);
EXPECT_EQ(r.queued.size(), 4u);
EXPECT_EQ(r.finished_calls, 1);
EXPECT_EQ(r.all.report_title(), "4 of 5 parts queued");
const std::string message = r.all.report_message();
EXPECT_NE(message.find("Part 2 did not queue: Rapidgator: not found."), std::string::npos) << message;
EXPECT_NE(message.find("4 parts were queued."), std::string::npos) << message;
EXPECT_NE(message.find("1 part came from another host: part 3 from Rapidgator."), std::string::npos) << message;
EXPECT_TRUE(ends_with(message, kContinue)) << message;
}
TEST(DownloadAll, FourFailedPartsInARowStopTheSeries)
{
EXPECT_EQ(kStopAfterFailedParts, 4);
Runner r;
r.answer = [](const LinkRef &ref)
{
if (ref.link_index >= 2)
return failure("Pegasus is not answering", false);
return queued_outcome();
};
r.run(plans(10));
EXPECT_FALSE(r.all.active);
EXPECT_EQ(r.all.end, DownloadAll::End::stopped);
EXPECT_EQ(r.all.ok, 2);
EXPECT_EQ(r.all.done, 6);
EXPECT_EQ(r.all.failed.size(), 4u);
EXPECT_EQ(r.asked.size(), 6u); // parts 7 to 10 were never tried
EXPECT_EQ(r.finished_calls, 1);
EXPECT_EQ(r.all.report_title(), "Stopped: 2 of 10 parts queued");
const std::string message = r.all.report_message();
EXPECT_NE(message.find("Pegasus seems stuck"), std::string::npos) << message;
EXPECT_NE(message.find("Parts 3, 4, 5 and 1 more did not queue."), std::string::npos) << message;
EXPECT_NE(message.find("2 parts were queued."), std::string::npos) << message;
EXPECT_TRUE(ends_with(message, kContinue)) << message;
}
TEST(DownloadAll, AQueuedPartBreaksTheRowOfFailures)
{
Runner r;
r.answer = [](const LinkRef &ref)
{
const int number = ref.link_index + 1;
if (number == 4 || number == 8)
return queued_outcome();
return failure("not found", false);
};
r.run(plans(9));
EXPECT_EQ(r.all.end, DownloadAll::End::finished_with_failures); // 3 in a row at most
EXPECT_EQ(r.all.ok, 2);
EXPECT_EQ(r.all.failed.size(), 7u);
EXPECT_EQ(r.asked.size(), 9u);
const std::string message = r.all.report_message();
EXPECT_NE(message.find("Parts 1, 2, 3 and 4 more did not queue. Last reason: not found."), std::string::npos)
<< message;
// The last part failing as the fourth in a row ends the series normally.
Runner tail;
tail.answer = [](const LinkRef &ref)
{ return ref.link_index == 0 ? queued_outcome() : failure("not found", false); };
tail.run(plans(5));
EXPECT_EQ(tail.all.end, DownloadAll::End::finished_with_failures);
EXPECT_EQ(tail.all.done, 5);
}
TEST(DownloadAll, CancelMidWayStopsAfterThePartInFlight)
{
Series s;
s.on_start = [&](const LinkRef &ref)
{
if (ref.link_index == 2)
s.all.cancel(); // Circle while part 3 is being queued
};
s.run(s.parts(10));
EXPECT_EQ(s.all.end, DownloadAll::End::cancelled);
EXPECT_EQ(s.all.ok, 3); // the part in flight still counts
EXPECT_EQ(s.order.size(), 3u);
EXPECT_EQ(s.queued.keys.size(), 3u);
EXPECT_EQ(s.finished_calls, 1);
EXPECT_EQ(s.all.report_title(), "Cancelled: 3 of 10 parts queued");
// Cancelling an idle series does nothing.
s.all.cancel();
EXPECT_FALSE(s.all.active);
EXPECT_EQ(s.finished_calls, 1);
}
TEST(DownloadAll, CancelWhileAPartIsOutWaitsForItsOutcome)
{
Runner r;
r.held = true;
r.run(plans(5));
r.pending[0](queued_outcome());
r.all.cancel();
EXPECT_TRUE(r.all.active); // part 2 is still out
EXPECT_EQ(r.finished_calls, 0);
r.pending[1](failure("timed out", true)); // it failed: no wait, no retry, the series ends
EXPECT_FALSE(r.all.active);
EXPECT_EQ(r.all.end, DownloadAll::End::cancelled);
EXPECT_EQ(r.all.ok, 1);
EXPECT_EQ(r.asked.size(), 2u);
EXPECT_TRUE(r.all.failed.empty());
EXPECT_EQ(r.finished_calls, 1);
const std::string message = r.all.report_message();
EXPECT_NE(message.find("Stopped on your request"), std::string::npos) << message;
EXPECT_TRUE(ends_with(message, kContinue)) << message;
}
TEST(DownloadAll, CancelDuringAWaitEndsAtOnce)
{
Runner r;
r.answer = [](const LinkRef &) { return failure("timed out", true); };
r.run(plans(4));
ASSERT_TRUE(r.all.active);
EXPECT_GT(r.all.waiting, 0.0f);
r.all.cancel();
EXPECT_FALSE(r.all.active);
EXPECT_EQ(r.all.end, DownloadAll::End::cancelled);
EXPECT_FLOAT_EQ(r.all.waiting, 0.0f);
EXPECT_EQ(r.finished_calls, 1);
r.frames(100, 1.0f);
EXPECT_EQ(r.asked.size(), 1u);
EXPECT_EQ(r.all.report_title(), "Cancelled: 0 of 4 parts queued");
}
TEST(DownloadAll, ACaptureThatStartedIsNotAQueuedPart)
{
LinkStart capture;
capture.kind = LinkResult::Kind::capture;
EXPECT_EQ(part_failure_reason(capture), "still resolving");
Runner r;
int calls = 0;
r.answer = [&calls, capture](const LinkRef &) { return ++calls == 1 ? capture : queued_outcome(); };
r.run(plans(1));
EXPECT_TRUE(r.all.active); // asked again after a wait, even though it was not marked transient
EXPECT_TRUE(r.queued.empty());
EXPECT_EQ(r.all.ok, 0);
EXPECT_FLOAT_EQ(r.all.waiting, kFirstBackoff);
r.frames(10);
EXPECT_EQ(r.all.end, DownloadAll::End::finished);
EXPECT_EQ(r.all.ok, 1);
EXPECT_EQ(r.queued.size(), 1u);
// Never resolving: the part fails with that reason.
Runner never;
never.answer = [capture](const LinkRef &) { return capture; };
never.run(plans(1));
never.frames(100);
EXPECT_EQ(never.asked.size(), 3u);
EXPECT_TRUE(never.queued.empty());
ASSERT_EQ(never.all.failed.size(), 1u);
EXPECT_EQ(never.all.failed[0].why, "still resolving");
}
TEST(DownloadAll, FailureReasonsOfTheOtherOutcomes)
{
EXPECT_EQ(part_failure_reason(queued_outcome()), "");
EXPECT_EQ(part_failure_reason(failure("timed out", true)), "timed out");
LinkStart blocked;
blocked.kind = LinkResult::Kind::blocked;
EXPECT_EQ(part_failure_reason(blocked), "not supported");
blocked.message = "host is down";
EXPECT_EQ(part_failure_reason(blocked), "host is down");
LinkStart select;
select.kind = LinkResult::Kind::select_files;
EXPECT_NE(part_failure_reason(select).find("which files"), std::string::npos);
LinkStart browser;
browser.kind = LinkResult::Kind::browser_opened;
EXPECT_NE(part_failure_reason(browser).find("PS5 browser"), std::string::npos);
}
TEST(DownloadAll, ANewRunIgnoresTheOutcomeOfTheOldOne)
{
Runner r;
r.held = true;
r.run(plans(3));
ASSERT_EQ(r.pending.size(), 1u);
DownloadAll::Done old = r.pending[0];
r.run(plans(2));
old(queued_outcome());
EXPECT_EQ(r.all.ok, 0);
EXPECT_TRUE(r.queued.empty());
EXPECT_EQ(r.all.total, 2);
r.pending[1](queued_outcome());
EXPECT_EQ(r.all.ok, 1);
// A part with no link at all is listed, not a crash.
Runner empty;
std::vector<PartPlan> parts = plans(2);
parts[0].refs.clear();
parts[0].hosts.clear();
empty.run(std::move(parts));
EXPECT_EQ(empty.all.end, DownloadAll::End::finished_with_failures);
EXPECT_EQ(empty.all.ok, 1);
ASSERT_EQ(empty.all.failed.size(), 1u);
EXPECT_EQ(empty.all.failed[0].number, 1);
}
TEST(DownloadAll, SecondPressSkipsWhatWasQueued)
@@ -161,42 +573,6 @@ TEST(DownloadAll, SecondPressSkipsWhatWasQueued)
EXPECT_EQ(plan_parts(v, viking, s.queued).refs.size(), 0u);
}
TEST(DownloadAll, FailureAtPartKStopsAndReportsKMinusOne)
{
Series s;
s.backend.set_failing_link("pkg25", 6); // part 7
s.run(s.parts(22));
EXPECT_EQ(s.all.end, DownloadAll::End::failed);
EXPECT_EQ(s.all.ok, 6);
EXPECT_EQ(s.all.failed_number, 7);
EXPECT_EQ(s.order.size(), 7u); // nothing after the failure was tried
EXPECT_EQ(s.queued.keys.size(), 6u);
EXPECT_EQ(s.finished_calls, 1);
EXPECT_EQ(s.all.report_title(), "6 of 22 parts queued");
EXPECT_NE(s.all.report_message().find("Stopped at part 7"), std::string::npos);
EXPECT_NE(s.all.report_message().find("6 parts were queued before it"), std::string::npos);
}
TEST(DownloadAll, CancelMidWayStopsAfterThePartInFlight)
{
Series s;
s.on_start = [&](const LinkRef &ref)
{
if (ref.link_index == 2)
s.all.cancel(); // Circle while part 3 is being queued
};
s.run(s.parts(10));
EXPECT_EQ(s.all.end, DownloadAll::End::cancelled);
EXPECT_EQ(s.all.ok, 3); // the part in flight still counts
EXPECT_EQ(s.order.size(), 3u);
EXPECT_EQ(s.queued.keys.size(), 3u);
EXPECT_EQ(s.finished_calls, 1);
EXPECT_EQ(s.all.report_title(), "Cancelled: 3 of 10 parts queued");
// Cancelling an idle series does nothing.
s.all.cancel();
EXPECT_FALSE(s.all.active);
}
TEST(DownloadAll, IncompleteAndRestartedSetsCarryTheirWarningInTheConfirmText)
{
host::FakeBackend backend;
+156
View File
@@ -0,0 +1,156 @@
// Wings TV app - tests of the default download folder and the first-download gate (B-13).
// SPDX-License-Identifier: GPL-3.0-or-later
#include "fake_backend.hpp"
#include "orbit/download_folder.hpp"
#include "orbit/store.hpp"
#include <gtest/gtest.h>
#include <string>
namespace orbit
{
namespace
{
struct Rig
{
host::FakeBackend backend;
Store store{backend};
void pump()
{
for (int i = 0; i < 4; ++i)
{
while (store.run_next_action())
{
}
store.tick();
}
}
// Reads the settings the way the poll does, then lets the frame see them.
void learn_settings()
{
store.poll_once();
pump();
}
};
const char *const fresh = "/data/homebrew/wings-downloads"; // parent exists, folder does not
TEST(DownloadFolderNeed, DecidesFromSettings)
{
EXPECT_TRUE(folder_need(true, "", false).set_default);
EXPECT_FALSE(folder_need(true, "/x", false).set_default);
EXPECT_FALSE(folder_need(true, "", true).set_default);
const FolderNeed unknown = folder_need(false, "", false);
EXPECT_FALSE(unknown.set_default);
EXPECT_TRUE(unknown.root_unknown);
}
TEST(DownloadFolderDefault, EmptyRootGetsTheDefaultOnce)
{
Rig r;
r.backend.set_download_root("");
r.learn_settings();
ASSERT_TRUE(r.store.state().have_settings);
ASSERT_TRUE(r.store.state().settings.download_root.empty());
r.store.ensure_default_download_folder(fresh);
r.store.ensure_default_download_folder(fresh); // same frame: no second command
r.pump();
r.store.ensure_default_download_folder(fresh); // later frames: still nothing
r.pump();
ASSERT_EQ(r.backend.mkdir_calls().size(), 1u);
EXPECT_EQ(r.backend.mkdir_calls()[0], fresh);
EXPECT_TRUE(r.backend.path_exists(fresh));
EXPECT_EQ(r.backend.settings_writes(), 1);
EXPECT_EQ(r.backend.last_root_written(), fresh);
EXPECT_EQ(r.store.state().settings.download_root, fresh);
EXPECT_TRUE(r.store.default_folder_status().empty());
}
TEST(DownloadFolderDefault, ConstantDefaultAlreadyExistingIsAccepted)
{
Rig r;
r.backend.set_download_root("");
r.learn_settings();
r.store.ensure_default_download_folder(); // fake already has this folder: mkdir says 409
r.pump();
ASSERT_EQ(r.backend.mkdir_calls().size(), 1u);
EXPECT_EQ(r.backend.mkdir_calls()[0], download_folder::default_path);
EXPECT_EQ(r.store.state().settings.download_root, download_folder::default_path);
}
TEST(DownloadFolderDefault, NonEmptyRootIsNeverTouched)
{
Rig r;
r.backend.set_download_root("/mnt/usb0/Backups");
r.learn_settings();
r.store.ensure_default_download_folder(fresh);
r.pump();
EXPECT_TRUE(r.backend.mkdir_calls().empty());
EXPECT_EQ(r.backend.settings_writes(), 0);
EXPECT_EQ(r.store.state().settings.download_root, "/mnt/usb0/Backups");
}
TEST(DownloadFolderDefault, UnknownSettingsDoNothing)
{
Rig r;
r.store.ensure_default_download_folder(fresh);
r.pump();
EXPECT_TRUE(r.backend.mkdir_calls().empty());
EXPECT_FALSE(r.store.default_folder_tried());
}
TEST(DownloadFolderDefault, MkdirFailureSetsNoRootAndDoesNotRepeat)
{
Rig r;
r.backend.set_download_root("");
r.backend.set_mkdir_fails(true);
r.learn_settings();
r.store.ensure_default_download_folder(fresh);
r.pump();
r.store.ensure_default_download_folder(fresh);
r.pump();
EXPECT_EQ(r.backend.mkdir_calls().size(), 1u);
EXPECT_EQ(r.backend.settings_writes(), 0);
EXPECT_FALSE(r.store.default_folder_status().empty());
EXPECT_TRUE(r.store.state().settings.download_root.empty());
EXPECT_FALSE(r.store.link_action().busy); // no popup / busy state
}
TEST(FirstDownloadGate, AsksOnceThenNever)
{
FirstDownloadGate gate;
EXPECT_EQ(gate.check(false, true), FirstDownloadGate::Verdict::ask);
EXPECT_EQ(gate.check(false, true), FirstDownloadGate::Verdict::ask); // dismissed: ask again
gate.answered();
EXPECT_EQ(gate.check(false, true), FirstDownloadGate::Verdict::proceed);
}
TEST(FirstDownloadGate, ConfirmedFlagOrUnknownSettingsProceed)
{
FirstDownloadGate gate;
EXPECT_EQ(gate.check(true, true), FirstDownloadGate::Verdict::proceed);
EXPECT_EQ(gate.check(false, false), FirstDownloadGate::Verdict::proceed);
EXPECT_FALSE(needs_first_confirm(false, false));
EXPECT_TRUE(needs_first_confirm(false, true));
}
TEST(FirstDownloadGate, DeferredDownloadResumes)
{
FirstDownloadGate gate;
int started = 0;
gate.defer([&started] { ++started; });
auto resume = gate.take_pending();
ASSERT_TRUE(static_cast<bool>(resume));
resume();
EXPECT_EQ(started, 1);
EXPECT_FALSE(static_cast<bool>(gate.take_pending()));
}
} // namespace
} // namespace orbit
+333
View File
@@ -0,0 +1,333 @@
// Wings TV app - Editorial data: the notes and carousel files, their checks, the hub and the fetch service.
// No test uses the network.
// SPDX-License-Identifier: GPL-3.0-or-later
#include "orbit/editorial.hpp"
#include "orbit/prefs.hpp"
#include <gtest/gtest.h>
#include <unistd.h>
#include <cstdio>
#include <map>
#include <memory>
#include <string>
namespace orbit
{
namespace
{
std::string tmp_dir()
{
char path[] = "/tmp/wings-edit-XXXXXX";
return mkdtemp(path) != nullptr ? std::string(path) : std::string("/tmp");
}
std::string notes_doc(const std::string &entries, const std::string &version = "1")
{
return "{\"schemaVersion\":" + version + ",\"notes\":{" + entries + "}}";
}
std::string curated_doc(const std::string &items, const std::string &version = "1")
{
return "{\"schemaVersion\":" + version + ",\"items\":[" + items + "]}";
}
struct FakeFetcher final : DocFetcher
{
int calls = 0;
std::map<std::string, DocResult> results; // by url
DocResult get(const std::string &url, std::size_t) override
{
++calls;
const auto it = results.find(url);
return it == results.end() ? DocResult{} : it->second;
}
void serve(const std::string &url, int status, const std::string &body = "", const std::string &error = "")
{
DocResult r;
r.status = status;
r.body = body;
r.error = error;
results[url] = r;
}
};
TEST(Editorial, NotesParseAndLookUpByAnyTitleId)
{
EditorialData data;
std::string why;
ASSERT_TRUE(EditorialData::parse_notes(
notes_doc("\"PPSA00001\":\"Great co-op.\",\"CUSA00002\":\"Needs the day-one patch.\""), &data, &why))
<< why;
EXPECT_EQ(data.note_count(), 2u);
EXPECT_EQ(data.note({"PPSA09999", "CUSA00002"}), "Needs the day-one patch.");
EXPECT_EQ(data.note({"PPSA00001", "CUSA00002"}), "Great co-op."); // the first id with a note wins
EXPECT_EQ(data.note({"PPSA09999"}), "");
EXPECT_EQ(data.note({}), "");
}
TEST(Editorial, BadNotesAreSkippedNotFatal)
{
const std::string long_text(161, 'x');
EditorialData data;
std::string why;
ASSERT_TRUE(EditorialData::parse_notes(notes_doc("\"PPSA00001\":\"ok\","
"\"PPSA1\":\"short id\","
"\"ABCD00003\":\"wrong prefix\","
"\"PPSA00004\":\"\","
"\"PPSA00005\":\"" +
long_text +
"\","
"\"PPSA00006\":\"tab\\there\","
"\"PPSA00007\":42,"
"\"PPSA00008\":\"also ok\""),
&data, &why))
<< why;
EXPECT_EQ(data.note_count(), 3u); // the long one is kept: no length limit
EXPECT_EQ(data.note({"PPSA00001"}), "ok");
EXPECT_EQ(data.note({"PPSA00008"}), "also ok");
std::string accents;
for (int i = 0; i < 160; ++i)
accents += "\xC3\xA9";
ASSERT_TRUE(EditorialData::parse_notes(notes_doc("\"PPSA00001\":\"" + accents + "\""), &data, &why));
EXPECT_EQ(data.note_count(), 1u);
// No length limit: the page scales the type.
ASSERT_TRUE(EditorialData::parse_notes(notes_doc("\"PPSA00001\":\"" + std::string(900, 'x') + "\""), &data, &why)) << why;
EXPECT_EQ(data.note_count(), 1u);
}
TEST(Editorial, SchemaAndSizeRejects)
{
EditorialData data;
std::string why;
EXPECT_FALSE(EditorialData::parse_notes("not json", &data, &why));
EXPECT_FALSE(EditorialData::parse_notes("{\"notes\":{}}", &data, &why));
EXPECT_FALSE(EditorialData::parse_notes(notes_doc("", "0"), &data, &why));
EXPECT_FALSE(EditorialData::parse_notes("{\"schemaVersion\":1}", &data, &why));
EXPECT_FALSE(EditorialData::parse_notes(notes_doc("", "2"), &data, &why));
EXPECT_NE(why.find("newer Wings"), std::string::npos);
EXPECT_FALSE(EditorialData::parse_curated(curated_doc("", "2"), &data, &why));
EXPECT_NE(why.find("newer Wings"), std::string::npos);
EXPECT_FALSE(EditorialData::parse_curated("{\"schemaVersion\":1,\"items\":5}", &data, &why));
// Oversize files are refused whole.
EXPECT_FALSE(EditorialData::parse_notes(notes_doc("\"PPSA00001\":\"" + std::string(kEditorialNotesMaxBytes, 'x') + "\""),
&data, &why));
EXPECT_NE(why.find("too large"), std::string::npos);
EXPECT_FALSE(EditorialData::parse_curated(curated_doc(std::string(kEditorialCuratedMaxBytes, ' ')), &data, &why));
// A failed parse leaves what was there.
ASSERT_TRUE(EditorialData::parse_notes(notes_doc("\"PPSA00001\":\"keep\""), &data, &why));
EXPECT_FALSE(EditorialData::parse_notes("nope", &data, &why));
EXPECT_EQ(data.note({"PPSA00001"}), "keep");
}
TEST(Editorial, NoteCountIsCapped)
{
std::string entries;
for (int i = 0; i < 700; ++i)
{
char id[16];
std::snprintf(id, sizeof id, "PPSA%05d", i);
entries += std::string(entries.empty() ? "" : ",") + "\"" + id + "\":\"n\"";
}
EditorialData data;
std::string why;
ASSERT_TRUE(EditorialData::parse_notes(notes_doc(entries), &data, &why)) << why;
EXPECT_EQ(data.note_count(), kEditorialMaxNotes);
}
TEST(Editorial, CuratedKeepsOrderDedupesAndCaps)
{
EditorialData data;
std::string why;
ASSERT_TRUE(EditorialData::parse_curated(
curated_doc("\"PPSA00003\",\"CUSA00001\",\"PPSA00003\",\"bogus\",7,\"PPSA00002\""), &data, &why))
<< why;
const std::vector<std::string> want = {"PPSA00003", "CUSA00001", "PPSA00002"};
EXPECT_EQ(data.curated_ids(), want);
std::string items;
for (int i = 0; i < 40; ++i)
{
char id[16];
std::snprintf(id, sizeof id, "PPSA%05d", i);
items += std::string(items.empty() ? "" : ",") + "\"" + id + "\"";
}
ASSERT_TRUE(EditorialData::parse_curated(curated_doc(items), &data, &why)) << why;
ASSERT_EQ(data.curated_ids().size(), kEditorialMaxCurated);
EXPECT_EQ(data.curated_ids().front(), "PPSA00000");
EXPECT_EQ(data.curated_ids().back(), "PPSA00023");
// Notes are untouched by a curated parse.
ASSERT_TRUE(EditorialData::parse_notes(notes_doc("\"PPSA00001\":\"hi\""), &data, &why));
EXPECT_EQ(data.curated_ids().size(), kEditorialMaxCurated);
}
TEST(Editorial, HubHonoursTheSwitches)
{
auto data = std::make_shared<EditorialData>();
std::string why;
ASSERT_TRUE(EditorialData::parse_notes(notes_doc("\"PPSA00001\":\"hi\""), data.get(), &why));
ASSERT_TRUE(EditorialData::parse_curated(curated_doc("\"PPSA00001\""), data.get(), &why));
EditorialHub hub;
hub.set_data(data);
EXPECT_EQ(hub.note({"PPSA00001"}), ""); // both off until the prefs say otherwise
EXPECT_TRUE(hub.curated_ids().empty());
const std::uint64_t before = hub.serial();
hub.apply_prefs(true, false);
EXPECT_NE(hub.serial(), before);
EXPECT_EQ(hub.note({"PPSA00001"}), "hi");
EXPECT_TRUE(hub.curated_ids().empty());
hub.apply_prefs(false, true);
EXPECT_EQ(hub.note({"PPSA00001"}), "");
EXPECT_EQ(hub.curated_ids().size(), 1u);
}
struct ServiceFixture
{
std::string dir = tmp_dir();
EditorialServiceConfig config;
std::int64_t now = 1000000;
FakeFetcher fetcher;
ServiceFixture()
{
config.cache_paths = {dir + "/ed.v1"};
config.app_tag = "build-1";
config.now = [this] { return now; };
fetcher.serve(kEditorialNotesUrl, 200, notes_doc("\"PPSA00001\":\"first\""));
fetcher.serve(kEditorialCuratedUrl, 200, curated_doc("\"PPSA00002\",\"PPSA00001\""));
}
// One app start: apply, wait, return what the hub holds.
std::shared_ptr<const EditorialData> start(bool notes = true, bool carousel = true)
{
EditorialHub hub;
EditorialService service(hub, fetcher, config);
service.apply(notes, carousel);
service.wait_idle();
last_status = hub.status();
return hub.data();
}
EditorialStatus last_status;
};
TEST(Editorial, OffMeansNoFetch)
{
ServiceFixture f;
EXPECT_EQ(f.start(false, false), nullptr);
EXPECT_EQ(f.fetcher.calls, 0);
}
TEST(Editorial, FetchesBothFilesOnceAndUsesTheFreshCopyNextTime)
{
ServiceFixture f;
auto data = f.start();
ASSERT_NE(data, nullptr);
EXPECT_EQ(f.fetcher.calls, 2);
EXPECT_EQ(data->note({"PPSA00001"}), "first");
EXPECT_EQ(data->curated_ids().size(), 2u);
EXPECT_EQ(f.last_status.updated, f.now);
EXPECT_FALSE(f.last_status.busy);
f.now += 3600;
data = f.start();
EXPECT_EQ(f.fetcher.calls, 2); // fresh: no fetch
ASSERT_NE(data, nullptr);
EXPECT_EQ(data->curated_ids().front(), "PPSA00002");
}
TEST(Editorial, StaleCopyOrAnotherBuildIsFetchedAgain)
{
ServiceFixture f;
f.start();
f.now += 25 * 3600;
f.fetcher.serve(kEditorialNotesUrl, 200, notes_doc("\"PPSA00001\":\"second\""));
auto data = f.start();
EXPECT_EQ(f.fetcher.calls, 4);
EXPECT_EQ(data->note({"PPSA00001"}), "second");
f.now += 60;
f.config.app_tag = "build-2";
f.start();
EXPECT_EQ(f.fetcher.calls, 6);
}
TEST(Editorial, FailureKeepsTheSavedCopy)
{
ServiceFixture f;
f.start();
f.now += 3 * 24 * 3600;
f.fetcher.serve(kEditorialNotesUrl, 0, "", "connection refused");
f.fetcher.serve(kEditorialCuratedUrl, 500);
auto data = f.start();
EXPECT_EQ(f.fetcher.calls, 4);
ASSERT_NE(data, nullptr);
EXPECT_EQ(data->note({"PPSA00001"}), "first");
EXPECT_EQ(data->curated_ids().size(), 2u);
EXPECT_NE(f.last_status.message.find("Could not refresh"), std::string::npos);
// The failure did not make the old copy look new: the next start tries again.
f.start();
EXPECT_EQ(f.fetcher.calls, 6);
}
TEST(Editorial, AMissingFileMeansNoneAndTheOtherStillWorks)
{
ServiceFixture f;
f.fetcher.serve(kEditorialCuratedUrl, 404);
auto data = f.start();
ASSERT_NE(data, nullptr);
EXPECT_EQ(data->note({"PPSA00001"}), "first");
EXPECT_TRUE(data->curated_ids().empty());
EXPECT_EQ(f.last_status.message, "Updated");
// A bad body keeps the saved half and does not stop the other file.
f.now += 2 * 24 * 3600;
f.fetcher.serve(kEditorialNotesUrl, 200, "garbage");
f.fetcher.serve(kEditorialCuratedUrl, 200, curated_doc("\"PPSA00009\""));
data = f.start();
EXPECT_EQ(data->note({"PPSA00001"}), "first");
EXPECT_EQ(data->curated_ids().size(), 1u);
}
TEST(Editorial, DamagedCacheAndNoNetworkGiveNoDataAndNoCrash)
{
ServiceFixture f;
f.start();
f.now += 3 * 24 * 3600;
f.fetcher.results.clear();
FILE *fp = std::fopen((f.dir + "/ed.v1").c_str(), "wb");
ASSERT_NE(fp, nullptr);
std::fputs("garbage", fp);
std::fclose(fp);
auto data = f.start();
ASSERT_NE(data, nullptr);
EXPECT_EQ(data->note_count(), 0u);
EXPECT_TRUE(data->curated_ids().empty());
}
TEST(Editorial, PrefsRoundTripAndOldFilesKeepTheDefaults)
{
WingsLocalPrefs prefs;
EXPECT_TRUE(prefs.notes_enabled);
EXPECT_TRUE(prefs.carousel_enabled);
EXPECT_FALSE(prefs.folder_confirmed);
prefs.notes_enabled = false;
prefs.carousel_enabled = false;
prefs.folder_confirmed = true;
WingsLocalPrefs back;
ASSERT_TRUE(decode_local_prefs(encode_local_prefs(prefs), &back));
EXPECT_FALSE(back.notes_enabled);
EXPECT_FALSE(back.carousel_enabled);
EXPECT_TRUE(back.folder_confirmed);
EXPECT_TRUE(back == prefs);
WingsLocalPrefs other = prefs;
other.folder_confirmed = false;
EXPECT_FALSE(other == prefs);
other = prefs;
other.notes_enabled = true;
EXPECT_FALSE(other == prefs);
WingsLocalPrefs old;
ASSERT_TRUE(decode_local_prefs("region=USA\n", &old));
EXPECT_TRUE(old.notes_enabled);
EXPECT_TRUE(old.carousel_enabled);
EXPECT_FALSE(old.folder_confirmed);
}
} // namespace
} // namespace orbit
+105
View File
@@ -66,11 +66,84 @@ TEST(HeroUrl, TheRegistryHoldsOnlyValidUrlsAndFeedsThePosterResolver)
EXPECT_EQ(posters.resolve("pkg:1", ArtKind::hero), "");
}
TEST(HeroUrl, TheSmallStandInHasItsOwnKindAndRegistryEntry)
{
hero_urls().clear();
PosterIndex posters;
EXPECT_EQ(posters.resolve("pkg:1", ArtKind::hero_low), "");
EXPECT_FALSE(hero_urls().set_low("pkg:1", "http://insecure.example/l.webp"));
EXPECT_FALSE(hero_urls().set_low("", "https://cdn.example.com/l.webp"));
EXPECT_TRUE(hero_urls().set_low("pkg:1", "https://cdn.example.com/l.webp"));
EXPECT_EQ(posters.resolve("pkg:1", ArtKind::hero_low), "https://cdn.example.com/l.webp");
// Only the small one is registered: the full kind and the cover resolve nothing.
EXPECT_EQ(posters.resolve("pkg:1", ArtKind::hero), "");
EXPECT_EQ(posters.resolve("pkg:1", ArtKind::cover), "");
EXPECT_TRUE(hero_urls().set("pkg:1", "https://cdn.example.com/h.webp"));
EXPECT_EQ(posters.resolve("pkg:1", ArtKind::hero), "https://cdn.example.com/h.webp");
EXPECT_EQ(posters.resolve("pkg:1", ArtKind::hero_low), "https://cdn.example.com/l.webp");
EXPECT_EQ(posters.resolve("pkg:2", ArtKind::hero_low), "");
EXPECT_EQ(posters.resolve("app:PPSA01234", ArtKind::hero_low), "");
hero_urls().clear();
EXPECT_EQ(posters.resolve("pkg:1", ArtKind::hero_low), "");
}
TEST(HeroPlacement, TheArtAlwaysFillsTheBandUniformlyAndCropsTheOverflow)
{
const ui::Rect band{0.0f, 0.0f, 1920.0f, 1080.0f};
// 16:9 fills exactly.
HeroPlacement p = place_hero(1920, 1080, band);
ASSERT_TRUE(p.usable);
EXPECT_FLOAT_EQ(p.dest.w, 1920.0f);
EXPECT_FLOAT_EQ(p.dest.h, 1080.0f);
EXPECT_FLOAT_EQ(p.uv.w, 1.0f);
EXPECT_FLOAT_EQ(p.uv.h, 1.0f);
// 1.9:1 (wider than the band): the sides are cropped a little.
p = place_hero(1900, 1000, band);
ASSERT_TRUE(p.usable);
EXPECT_FLOAT_EQ(p.dest.h, 1080.0f);
EXPECT_LT(p.uv.w, 1.0f);
EXPECT_GT(p.uv.w, 0.9f);
EXPECT_NEAR(p.uv.x, (1.0f - p.uv.w) * 0.5f, 1e-5f);
// 1.5:1 (taller than the band): the top and bottom are cropped a little.
p = place_hero(1500, 1000, band);
ASSERT_TRUE(p.usable);
EXPECT_LT(p.uv.h, 1.0f);
EXPECT_FLOAT_EQ(p.uv.w, 1.0f);
// Steam's 1920x620 (3.1:1): fills the band, the sides cropped, never black below it.
p = place_hero(1920, 620, band);
ASSERT_TRUE(p.usable);
EXPECT_FLOAT_EQ(p.dest.x, 0.0f);
EXPECT_FLOAT_EQ(p.dest.y, 0.0f);
EXPECT_FLOAT_EQ(p.dest.w, 1920.0f);
EXPECT_FLOAT_EQ(p.dest.h, 1080.0f);
EXPECT_FLOAT_EQ(p.uv.h, 1.0f);
EXPECT_NEAR(p.uv.w, (1920.0f / 1080.0f) / (1920.0f / 620.0f), 1e-4f);
// Alan Wake 2's 4.8:1 banner would have to zoom to a few letters: not used (the cover wash stays).
EXPECT_FALSE(place_hero(1920, 399, band).usable);
}
TEST(HeroPlacement, SquaresBannersAndEmptyImagesAreNotUsed)
{
const ui::Rect band{0.0f, 0.0f, 1920.0f, 1080.0f};
EXPECT_FALSE(place_hero(1000, 1000, band).usable); // a square logo
EXPECT_FALSE(place_hero(1400, 1000, band).usable);
EXPECT_TRUE(place_hero(1500, 1000, band).usable);
EXPECT_TRUE(place_hero(3300, 1000, band).usable);
EXPECT_FALSE(place_hero(3400, 1000, band).usable);
EXPECT_FALSE(place_hero(6000, 1000, band).usable); // a banner
EXPECT_FALSE(place_hero(0, 100, band).usable);
EXPECT_FALSE(place_hero(100, 0, band).usable);
EXPECT_FALSE(place_hero(1920, 1080, ui::Rect{0.0f, 0.0f, 0.0f, 0.0f}).usable);
}
TEST(HeroUrl, TheRegistryStaysSmall)
{
hero_urls().clear();
for (int i = 0; i < 40; ++i)
{
EXPECT_TRUE(hero_urls().set("pkg:" + std::to_string(i), "https://cdn.example.com/h.webp"));
EXPECT_TRUE(hero_urls().set_low("pkg:" + std::to_string(i), "https://cdn.example.com/l.webp"));
}
EXPECT_LE(hero_urls().size(), 8u);
hero_urls().clear();
}
@@ -294,6 +367,38 @@ TEST(HeaderState, ResetPutsTheFocusOnInstallWithTheHeaderShown)
EXPECT_FALSE(s.hero_resolved);
}
TEST(HeroWashHold, HoldsOnlyForACachedHeroInTheFirst300Ms)
{
EXPECT_TRUE(hold_cover_wash(true, true, 0.0f, false));
EXPECT_TRUE(hold_cover_wash(true, true, 0.29f, false));
EXPECT_FALSE(hold_cover_wash(true, true, 0.3f, false)); // the wash comes back after 300 ms
EXPECT_FALSE(hold_cover_wash(true, true, 2.0f, false));
EXPECT_FALSE(hold_cover_wash(false, true, 0.0f, false)); // no hero address
EXPECT_FALSE(hold_cover_wash(true, false, 0.0f, false)); // not on disk: the wash fills the wait
EXPECT_FALSE(hold_cover_wash(true, true, 0.0f, true)); // refused shape
}
TEST(HeroWashHold, StateResetAndHooks)
{
GameHeaderState s;
s.hero_cached = true;
s.hero_logged = true;
s.update(0.5f, false);
EXPECT_GT(s.open_s, 0.4f);
s.reset();
EXPECT_FALSE(s.hero_cached);
EXPECT_FALSE(s.hero_logged);
EXPECT_EQ(s.open_s, 0.0f);
hero_hooks() = HeroHooks{};
EXPECT_FALSE(hero_is_cached("https://cdn.example.com/h.webp")); // no hook
hero_hooks().cached = [](const std::string &url) { return url == "https://cdn.example.com/h.webp"; };
EXPECT_TRUE(hero_is_cached("https://cdn.example.com/h.webp"));
EXPECT_FALSE(hero_is_cached("https://cdn.example.com/other.webp"));
EXPECT_FALSE(hero_is_cached(""));
hero_hooks() = HeroHooks{};
}
TEST(HeaderState, LeavingTheHeaderSlidesTheListUp)
{
GameHeaderState s;
+91 -6
View File
@@ -51,6 +51,7 @@ struct Rig
if (!catalogue_path.empty())
context.catalogue_paths = {catalogue_path};
app = std::make_unique<App>(context);
context.folder_confirmed = [] { return true; }; // these tests are not about the first-download question
store.poll_once();
store.load_catalog_once();
frame();
@@ -88,6 +89,22 @@ struct Rig
frame(Direction::none, hui::action_bit(action));
frame();
}
// Runs "Download all parts" to its end: the queued work, and a second at a
// time through the waits between attempts (they count down in update()).
void run_batch()
{
for (int round = 0; round < 200 && screen().batch_running(); ++round)
{
work();
if (!screen().batch_running())
break;
hui::InputFrame input;
input.connected = true;
feedback.clear();
app->update(input, 1.0f, feedback);
}
work();
}
StoreScreen &screen()
{
return app->store_screen();
@@ -456,11 +473,15 @@ TEST(GamePageScreen, DownloadAllPartsConfirmsThenQueuesEachPartAndReportsFailure
EXPECT_EQ(r.backend.started_links(), 0); // nothing before the confirmation
r.press(hui::Action::confirm);
EXPECT_TRUE(s.batch_running());
r.work();
r.run_batch();
EXPECT_FALSE(s.batch_running());
EXPECT_EQ(r.backend.started_links(), 5); // one at a time: it stops at part 6
EXPECT_EQ(s.started_links().keys.size(), 5u);
EXPECT_TRUE(s.confirm_open()); // "5 of 22 parts queued", part 6 named
// One at a time; part 6 (a 502, asked three times, no other host carries it)
// is skipped and the series goes on to part 22.
EXPECT_EQ(r.backend.started_links(), 21);
EXPECT_EQ(s.started_links().keys.size(), 21u);
ASSERT_TRUE(s.confirm_open()); // the report: "21 of 22 parts queued", part 6 named
EXPECT_EQ(s.confirm_dialog().title(), "21 of 22 parts queued");
EXPECT_NE(s.confirm_dialog().message().find("Part 6"), std::string::npos) << s.confirm_dialog().message();
r.press(hui::Action::confirm);
EXPECT_FALSE(s.confirm_open());
// Again: only the parts from the one that failed.
@@ -471,7 +492,7 @@ TEST(GamePageScreen, DownloadAllPartsConfirmsThenQueuesEachPartAndReportsFailure
EXPECT_EQ(s.list_dialog().items()[6].right, "");
r.press(hui::Action::confirm);
r.press(hui::Action::confirm);
r.work();
r.run_batch();
EXPECT_EQ(r.backend.started_links(), 22);
EXPECT_EQ(s.started_links().keys.size(), 22u);
EXPECT_FALSE(s.confirm_open());
@@ -484,6 +505,42 @@ TEST(GamePageScreen, DownloadAllPartsConfirmsThenQueuesEachPartAndReportsFailure
EXPECT_FALSE(s.list_dialog().is_open());
}
// The first-download folder question, reached through a multi-part set that opens the PS5 browser: it must go
// away once answered and let the download carry on.
TEST(GamePageScreen, TheFirstDownloadQuestionClosesAfterABrowserPartAndTheDownloadGoesOn)
{
Rig r;
bool confirmed = false;
r.context.folder_confirmed = [&confirmed] { return confirmed; };
r.context.confirm_folder = [&confirmed] { confirmed = true; };
StoreScreen &s = r.screen();
s.open_package("pkg26");
r.work();
ASSERT_TRUE(s.detail_open());
const int akira = find_mirror(s.game_page(), "Akirabox", 4);
ASSERT_GE(akira, 0);
ASSERT_TRUE(s.focus_page_mirror(akira));
// Walk the dialogs (parts list, confirmations) until the folder question shows up.
int guard = 0;
while (!s.folder_ask_open() && guard++ < 8)
{
r.press(hui::Action::confirm);
r.work();
}
ASSERT_TRUE(s.folder_ask_open());
EXPECT_EQ(r.backend.started_links(), 0); // nothing before the answer
r.press(hui::Action::confirm); // "Keep this folder"
r.work();
EXPECT_TRUE(confirmed);
EXPECT_FALSE(s.folder_ask_open()); // gone
// ... and it did not come back by itself.
for (int i = 0; i < 5; ++i)
r.frame();
EXPECT_FALSE(s.folder_ask_open());
r.work();
EXPECT_GE(r.backend.started_links(), 1);
}
TEST(GamePageScreen, TheBestCardRunsTheExistingLinkFlowAndOptionsOpensTheFilters)
{
Rig r;
@@ -673,7 +730,7 @@ TEST(GameViewLibrary, EveryGameOfTheRecordedCatalogueBuildsThroughTheViewModel)
// Every game page: built, laid out, every card and every filter value.
const auto t_pages = std::chrono::steady_clock::now();
long pages = 0, cards = 0, sets = 0, best = 0, filter_steps = 0, empty_pages = 0;
long pages = 0, cards = 0, sets = 0, best = 0, filter_steps = 0, empty_pages = 0, alternate_sets = 0;
for (const GameGroup &g : index.games)
{
std::vector<PackageDetail> d;
@@ -708,6 +765,32 @@ TEST(GameViewLibrary, EveryGameOfTheRecordedCatalogueBuildsThroughTheViewModel)
EXPECT_EQ(plan.total, c.part_count);
EXPECT_EQ(plan.refs.size(), plan.one_at_a_time ? 1u : static_cast<std::size_t>(c.part_count));
EXPECT_FALSE(plan.action.empty());
// Each stand-in is the same part on another host of the same release.
ASSERT_EQ(plan.numbers.size(), plan.refs.size());
ASSERT_EQ(plan.alternates.size(), plan.refs.size());
ASSERT_EQ(plan.alternate_hosts.size(), plan.refs.size());
bool any = false;
for (std::size_t i = 0; i < plan.refs.size(); ++i)
{
ASSERT_EQ(plan.alternate_hosts[i].size(), plan.alternates[i].size());
for (std::size_t a = 0; a < plan.alternates[i].size(); ++a)
{
any = true;
EXPECT_NE(plan.alternate_hosts[i][a], plan.host) << g.title;
EXPECT_FALSE(plan.alternates[i][a] == plan.refs[i]);
int number = 0;
for (int o = 0; o < static_cast<int>(v.page.mirrors.size()) && number == 0; ++o)
{
const Mirror &om = v.page.mirrors[static_cast<std::size_t>(o)];
if (o != m && om.release == v.page.mirrors[static_cast<std::size_t>(m)].release &&
om.host == plan.alternate_hosts[i][a])
number = mirror_part_number(v.page, o, plan.alternates[i][a]);
}
EXPECT_EQ(number, plan.numbers[i]) << g.title;
EXPECT_GT(number, 0) << g.title;
}
}
alternate_sets += any ? 1 : 0;
}
}
// Every value of every filter row the panel would show.
@@ -735,8 +818,10 @@ TEST(GameViewLibrary, EveryGameOfTheRecordedCatalogueBuildsThroughTheViewModel)
"%ld with Best, %ld filter steps in %.2f s\n",
all.value.size(), facts_seconds, index.games.size(), runs, jpn, no_browser, regions.size(),
grid_seconds, pages, empty_pages, cards, sets, best, filter_steps, page_seconds);
std::printf("[game view] %ld of %ld part sets have the same parts on another host\n", alternate_sets, sets);
EXPECT_EQ(pages, static_cast<long>(index.games.size()));
EXPECT_GT(sets, 1000);
EXPECT_GT(alternate_sets, 0);
EXPECT_LT(grid_seconds, 10.0);
}
+199
View File
@@ -0,0 +1,199 @@
// Wings TV app - A series start_link() ("Download all parts") waits for an automated capture.
// SPDX-License-Identifier: GPL-3.0-or-later
#include "fake_backend.hpp"
#include "orbit/link_retry.hpp"
#include "orbit/store.hpp"
#include <gtest/gtest.h>
#include <string>
#include <vector>
namespace orbit
{
namespace
{
constexpr int kAutomated = 1; // pkg1 link 1: an automated provider (MediaFire in the fake)
// Starts one series link, runs the worker by hand and delivers the outcome.
LinkStart start_and_finish(Store &store, const std::string &package_id, int link_index, int *calls)
{
LinkStart seen;
store.start_link(package_id, link_index, "",
[&seen, calls](const LinkStart &outcome)
{
seen = outcome;
++*calls;
});
while (store.run_next_action())
{
}
store.tick();
return seen;
}
TEST(LinkRetry, TransientErrorsAreTheOnesThatMayPass)
{
EXPECT_TRUE(transient_link_error(0, "timed out", true));
EXPECT_TRUE(transient_link_error(0, "connection refused", true));
EXPECT_TRUE(transient_link_error(502, "The host did not answer.", false));
EXPECT_TRUE(transient_link_error(503, "", false));
EXPECT_TRUE(transient_link_error(408, "Request Timeout", false));
EXPECT_TRUE(transient_link_error(425, "", false));
EXPECT_TRUE(transient_link_error(429, "Too many requests", false));
EXPECT_TRUE(transient_link_error(409, "A capture session is already running", false));
EXPECT_TRUE(transient_link_error(409, "Pegasus is busy, try again", false));
EXPECT_TRUE(transient_link_error(200, "Provider timed out", false));
EXPECT_FALSE(transient_link_error(404, "Download link not found.", false));
EXPECT_FALSE(transient_link_error(400, "Choose a debrid service first.", false));
EXPECT_FALSE(transient_link_error(400, "That debrid service does not take this link.", false));
EXPECT_FALSE(transient_link_error(200, "Akirabox is not supported.", false));
EXPECT_FALSE(transient_link_error(410, "The file has expired", false));
EXPECT_FALSE(transient_link_error(502, "The file was removed by the host", false));
EXPECT_FALSE(transient_link_error(200, "", false));
}
TEST(LinkSeries, AnAutomatedCaptureEndsOnlyOnceItCompleted)
{
host::FakeBackend backend;
backend.set_capture_script({"priming", "queueing", "completed"});
Store store(backend);
store.set_series_wait(0.0, 1000.0);
std::vector<std::string> lines;
store.log = [&lines](const std::string &line) { lines.push_back(line); };
int calls = 0;
std::string state_when_done;
std::size_t jobs_when_done = 0;
store.start_link("pkg1", kAutomated, "",
[&](const LinkStart &outcome)
{
++calls;
EXPECT_TRUE(outcome.error.empty()) << outcome.error;
EXPECT_EQ(outcome.kind, LinkResult::Kind::dispatched);
EXPECT_FALSE(outcome.transient);
state_when_done = store.state().capture.state;
jobs_when_done = store.state().jobs.size();
});
ASSERT_TRUE(store.run_next_action()); // the start and the whole wait
EXPECT_EQ(calls, 0); // nothing reaches the frame thread before tick()
EXPECT_EQ(backend.capture_reads(), 3);
while (store.run_next_action())
{
}
store.tick();
EXPECT_EQ(calls, 1);
EXPECT_EQ(state_when_done, "completed"); // the snapshot shows the completion first
EXPECT_GT(jobs_when_done, 0u); // and the queued download
store.tick();
EXPECT_EQ(calls, 1);
ASSERT_EQ(lines.size(), 1u);
EXPECT_EQ(lines[0].rfind("[WINGS-PARTS] pkg=pkg1 link=1 kind=dispatched transient=0 ms=", 0), 0u)
<< lines[0];
}
TEST(LinkSeries, AFailedCaptureIsAnError)
{
host::FakeBackend backend;
backend.set_capture_script({"queueing", "failed"}, false, "The file was removed by the host");
Store store(backend);
store.set_series_wait(0.0, 1000.0);
int calls = 0;
LinkStart gone = start_and_finish(store, "pkg1", kAutomated, &calls);
EXPECT_EQ(calls, 1);
EXPECT_EQ(gone.error, "The file was removed by the host");
EXPECT_FALSE(gone.transient);
EXPECT_EQ(store.state().capture.state, "failed");
backend.set_capture_script({"queueing", "failed"}, false, "Provider timed out");
LinkStart slow = start_and_finish(store, "pkg1", kAutomated, &calls);
EXPECT_EQ(calls, 2);
EXPECT_EQ(slow.error, "Provider timed out");
EXPECT_TRUE(slow.transient);
}
TEST(LinkSeries, AManualCaptureIsNotWaitedFor)
{
host::FakeBackend backend;
backend.set_capture_script({"watching"}, true);
Store store(backend);
store.set_series_wait(0.0, 1000.0);
int calls = 0;
LinkStart manual = start_and_finish(store, "pkg1", kAutomated, &calls);
EXPECT_EQ(calls, 1);
EXPECT_EQ(manual.error, "needs a check in the PS5 browser: open this part on its own");
EXPECT_EQ(manual.kind, LinkResult::Kind::capture);
EXPECT_FALSE(manual.transient);
EXPECT_EQ(backend.capture_reads(), 0);
EXPECT_FALSE(store.state().capture.session_id.empty()); // still shown for the player
}
TEST(LinkSeries, StopSeriesEndsTheWait)
{
host::FakeBackend backend;
backend.set_capture_script({"queueing"});
Store store(backend);
store.set_series_wait(0.0, 1000.0);
backend.set_capture_hook(
[&store](int reads)
{
if (reads == 2)
store.stop_series();
});
int calls = 0;
LinkStart stopped = start_and_finish(store, "pkg1", kAutomated, &calls);
EXPECT_EQ(calls, 1);
EXPECT_EQ(stopped.error, "stopped");
EXPECT_FALSE(stopped.transient);
EXPECT_EQ(backend.capture_reads(), 2);
// The next series start clears the stop.
backend.set_capture_hook({});
backend.set_capture_script({"queueing", "completed"});
LinkStart next = start_and_finish(store, "pkg1", kAutomated, &calls);
EXPECT_EQ(calls, 2);
EXPECT_TRUE(next.error.empty()) << next.error;
}
TEST(LinkSeries, AWaitThatRunsOutIsTransientAndTheSessionStaysWatched)
{
host::FakeBackend backend;
backend.set_capture_script({"queueing"});
Store store(backend);
store.set_series_wait(0.0, 0.0);
int calls = 0;
LinkStart late = start_and_finish(store, "pkg1", kAutomated, &calls);
EXPECT_EQ(calls, 1);
EXPECT_EQ(late.error, "timed out waiting for the provider");
EXPECT_TRUE(late.transient);
EXPECT_EQ(store.state().capture.state, "queueing");
const int reads = backend.capture_reads();
store.poll_once(); // the normal poll keeps watching it
EXPECT_EQ(backend.capture_reads(), reads + 1);
}
TEST(LinkSeries, StartFailuresSayWhetherToAskAgain)
{
host::FakeBackend backend;
Store store(backend);
int calls = 0;
backend.set_failing_link("pkg1", 0);
LinkStart host_error = start_and_finish(store, "pkg1", 0, &calls);
EXPECT_FALSE(host_error.error.empty());
EXPECT_TRUE(host_error.transient); // a 502
LinkStart missing = start_and_finish(store, "pkg1", 99, &calls);
EXPECT_EQ(missing.error, "Download link not found.");
EXPECT_FALSE(missing.transient);
backend.set_mode(host::FakeMode::offline);
LinkStart offline = start_and_finish(store, "pkg1", 0, &calls);
EXPECT_FALSE(offline.error.empty());
EXPECT_TRUE(offline.transient); // Pegasus not answering
EXPECT_EQ(calls, 3);
}
} // namespace
} // namespace orbit
+2 -2
View File
@@ -3,11 +3,11 @@
//
// The parser is checked against the 75 test vectors of
// docs/research/links-grouping.md (expected values = the reference parser's
// output, generated by tools/linkref/gen_vectors.py) and against the whole
// output) and against the whole
// recorded catalogue (recon/samples/library.json: 35,559 links), whose numbers
// must equal the research's. The C++ port was also diffed field by field
// against the reference over every link, set, title and game
// (tests/tools/links_dump.cpp + tools/linkref/dump_golden.py: 0 differences).
// (tests/tools/links_dump.cpp: 0 differences).
#include "orbit/game_group.hpp"
#include "orbit/game_page.hpp"
+220
View File
@@ -0,0 +1,220 @@
// Wings TV app - "Download all parts": the same part on the release's other hosts.
// SPDX-License-Identifier: GPL-3.0-or-later
#include "orbit/game_view.hpp"
#include <gtest/gtest.h>
#include <string>
#include <vector>
namespace orbit
{
namespace
{
struct HostSpec
{
const char *label; // "USA 12.xx Base - 1fichier"
const char *host; // "1fichier.com"
LinkRoute route;
const char *provider;
std::vector<int> parts; // part numbers in catalogue order; empty: one file
};
// One package, each host's links one after the other, link indexes 0..N-1.
GameView page_of(const std::vector<HostSpec> &hosts)
{
PackageDetail d;
d.package.id = "p1";
d.package.title = "Game X";
d.package.source_name = "DLPS Catalog";
int index = 0;
const auto add = [&](const HostSpec &h, const std::string &name)
{
PackageLink l;
l.index = index;
l.name = name;
l.host = h.host;
l.route = h.route;
l.provider = h.provider;
l.url = "https://" + std::string(h.host) + "/f/" + std::to_string(index);
l.needs_browser = h.route == LinkRoute::capture || h.route == LinkRoute::browser_only;
d.links.push_back(l);
++index;
};
for (const HostSpec &h : hosts)
{
if (h.parts.empty())
add(h, h.label);
for (const int p : h.parts)
add(h, std::string(h.label) + " part " + std::to_string(p));
}
d.package.link_count = index;
GameView v;
load_game_view(&v, {d}, {}, LinkPrefs{});
return v;
}
int mirror_of(const GameView &v, int link_index)
{
for (std::size_t m = 0; m < v.page.mirrors.size(); ++m)
for (const int l : v.page.mirrors[m].links)
if (v.page.links[static_cast<std::size_t>(l)].index == link_index)
return static_cast<int>(m);
return -1;
}
std::string host_of(const GameView &v, int link_index)
{
const int m = mirror_of(v, link_index);
return m < 0 ? std::string() : v.page.mirrors[static_cast<std::size_t>(m)].host;
}
const std::vector<int> kSix = {1, 2, 3, 4, 5, 6};
// 1fichier (debrid) 0-5, Akirabox (automated) 6-11, DataNodes (automated) 12-17, Rootz (browser) 18-23.
GameView four_hosts()
{
return page_of({{"USA 12.xx Base - 1fichier", "1fichier.com", LinkRoute::debrid, "", kSix},
{"USA 12.xx Base - Akirabox", "akirabox.com", LinkRoute::automated, "Akirabox", kSix},
{"USA 12.xx Base - DataNodes", "datanodes.to", LinkRoute::automated, "DataNodes", kSix},
{"USA 12.xx Base - Rootz", "rootz.so", LinkRoute::capture, "Rootz", kSix}});
}
bool no_alternates(const PartsPlan &plan)
{
for (const std::vector<LinkRef> &a : plan.alternates)
if (!a.empty())
return false;
return plan.alternates.size() == plan.refs.size() && plan.alternate_hosts.size() == plan.refs.size() &&
plan.numbers.size() == plan.refs.size();
}
TEST(PartAlternates, AlignedHostsGiveEachPartItsCopiesBestTierFirst)
{
const GameView v = four_hosts();
const std::string fichier = host_of(v, 0);
const std::string akira = host_of(v, 6);
const std::string nodes = host_of(v, 12);
ASSERT_EQ(mirror_of(v, 0), mirror_of(v, 5)); // one card per host
ASSERT_NE(fichier, akira);
ASSERT_NE(akira, nodes);
ASSERT_LT(akira, nodes); // same tier: host A to Z
const StartedLinks none;
PartsPlan plan = plan_parts(v, mirror_of(v, 0), none);
ASSERT_EQ(plan.refs.size(), 6u);
ASSERT_EQ(plan.numbers, kSix);
ASSERT_EQ(plan.alternates.size(), 6u);
ASSERT_EQ(plan.alternate_hosts.size(), 6u);
for (int i = 0; i < 6; ++i)
{
const std::size_t at = static_cast<std::size_t>(i);
// Rootz opens the PS5 browser, 1fichier does not: never offered as its stand-in.
EXPECT_EQ(plan.alternates[at], (std::vector<LinkRef>{{"p1", 6 + i}, {"p1", 12 + i}}));
EXPECT_EQ(plan.alternate_hosts[at], (std::vector<std::string>{akira, nodes}));
}
EXPECT_NE(plan.message.find("If a part keeps failing, the same part is tried on " + akira + ", " + nodes + "."),
std::string::npos)
<< plan.message;
EXPECT_EQ(plan.title, "Download 6 parts from " + fichier + "?");
// From Akirabox: debrid (1fichier) before the other automated host.
plan = plan_parts(v, mirror_of(v, 6), none);
ASSERT_EQ(plan.alternates.size(), 6u);
EXPECT_EQ(plan.alternates[3], (std::vector<LinkRef>{{"p1", 3}, {"p1", 15}}));
EXPECT_EQ(plan.alternate_hosts[3], (std::vector<std::string>{fichier, nodes}));
// From Rootz (one part at a time): every other host may stand in.
plan = plan_parts(v, mirror_of(v, 18), none);
ASSERT_TRUE(plan.one_at_a_time);
ASSERT_EQ(plan.refs.size(), 1u);
ASSERT_EQ(plan.alternates.size(), 1u);
EXPECT_EQ(plan.numbers, (std::vector<int>{1}));
EXPECT_EQ(plan.alternates[0], (std::vector<LinkRef>{{"p1", 0}, {"p1", 6}, {"p1", 12}}));
}
TEST(PartAlternates, StartedLinksAreNeitherQueuedNorOfferedAgain)
{
const GameView v = four_hosts();
StartedLinks started;
started.add(LinkRef{"p1", 0}); // 1fichier part 1: left out of the plan
started.add(LinkRef{"p1", 8}); // Akirabox part 3: not a stand-in any more
const PartsPlan plan = plan_parts(v, mirror_of(v, 0), started);
ASSERT_EQ(plan.refs.size(), 5u);
EXPECT_EQ(plan.skipped, 1);
EXPECT_EQ(plan.numbers, (std::vector<int>{2, 3, 4, 5, 6}));
EXPECT_EQ(plan.alternates[0], (std::vector<LinkRef>{{"p1", 7}, {"p1", 13}}));
EXPECT_EQ(plan.alternates[1], (std::vector<LinkRef>{{"p1", 14}}));
EXPECT_EQ(plan.alternate_hosts[1], (std::vector<std::string>{host_of(v, 14)}));
}
TEST(PartAlternates, SetsThatDoNotLineUpExactlyAreNeverUsed)
{
// 1fichier 0-5; Akirabox 5 of 6 parts (6-10); DataNodes restarts (11-16);
// Gofile has a gap (17-22); Mega is not supported (23-28).
const GameView v =
page_of({{"USA 12.xx Base - 1fichier", "1fichier.com", LinkRoute::debrid, "", kSix},
{"USA 12.xx Base - Akirabox", "akirabox.com", LinkRoute::automated, "Akirabox", {1, 2, 3, 4, 5}},
{"USA 12.xx Base - DataNodes", "datanodes.to", LinkRoute::automated, "DataNodes", {1, 2, 3, 1, 2, 3}},
{"USA 12.xx Base - Gofile", "gofile.io", LinkRoute::automated, "Gofile", {1, 2, 3, 5, 6, 7}},
{"USA 12.xx Base - Mega", "mega.nz", LinkRoute::blocked, "", kSix}});
const StartedLinks none;
PartsPlan plan = plan_parts(v, mirror_of(v, 0), none);
ASSERT_EQ(plan.refs.size(), 6u);
EXPECT_EQ(plan.numbers, kSix);
EXPECT_TRUE(no_alternates(plan));
EXPECT_EQ(plan.message.find("keeps failing"), std::string::npos);
// A set whose numbering restarts gets none either, though 1fichier has six parts.
const int nodes = mirror_of(v, 11);
ASSERT_GE(nodes, 0);
ASSERT_EQ(v.page.mirrors[static_cast<std::size_t>(nodes)].status, PartStatus::restarts);
plan = plan_parts(v, nodes, none);
EXPECT_TRUE(no_alternates(plan));
}
TEST(PartAlternates, TwoReleasesNeverMix)
{
const GameView v = page_of({{"USA 12.xx Base - 1fichier", "1fichier.com", LinkRoute::debrid, "", kSix},
{"EUR 12.xx Base - Akirabox", "akirabox.com", LinkRoute::automated, "Akirabox", kSix}});
ASSERT_NE(v.page.mirrors[static_cast<std::size_t>(mirror_of(v, 0))].release,
v.page.mirrors[static_cast<std::size_t>(mirror_of(v, 6))].release);
const StartedLinks none;
EXPECT_TRUE(no_alternates(plan_parts(v, mirror_of(v, 0), none)));
EXPECT_TRUE(no_alternates(plan_parts(v, mirror_of(v, 6), none)));
}
TEST(PartAlternates, OneFileMirrorsNeitherGetNorGiveAlternates)
{
// 1fichier one file (0), Akirabox parts 1-6 (1-6), DataNodes parts 1-6 (7-12).
const GameView v =
page_of({{"USA 12.xx Base - 1fichier", "1fichier.com", LinkRoute::debrid, "", {}},
{"USA 12.xx Base - Akirabox", "akirabox.com", LinkRoute::automated, "Akirabox", kSix},
{"USA 12.xx Base - DataNodes", "datanodes.to", LinkRoute::automated, "DataNodes", kSix}});
const StartedLinks none;
const int single = mirror_of(v, 0);
ASSERT_FALSE(v.page.mirrors[static_cast<std::size_t>(single)].multipart());
PartsPlan plan = plan_parts(v, single, none);
ASSERT_EQ(plan.refs.size(), 1u);
EXPECT_EQ(plan.numbers, (std::vector<int>{1}));
EXPECT_TRUE(no_alternates(plan));
plan = plan_parts(v, mirror_of(v, 1), none);
ASSERT_EQ(plan.alternates.size(), 6u);
for (int i = 0; i < 6; ++i)
EXPECT_EQ(plan.alternates[static_cast<std::size_t>(i)], (std::vector<LinkRef>{{"p1", 7 + i}}));
}
TEST(PartAlternates, PartNumberOfALinkInsideItsMirror)
{
const GameView v = page_of({{"USA 12.xx Base - Akirabox", "akirabox.com", LinkRoute::automated, "Akirabox",
{3, 1, 2}}});
const int m = mirror_of(v, 0);
EXPECT_EQ(mirror_part_number(v.page, m, LinkRef{"p1", 0}), 3);
EXPECT_EQ(mirror_part_number(v.page, m, LinkRef{"p1", 1}), 1);
EXPECT_EQ(mirror_part_number(v.page, m, LinkRef{"p1", 9}), 0);
EXPECT_EQ(mirror_part_number(v.page, -1, LinkRef{"p1", 0}), 0);
}
} // namespace
} // namespace orbit
+652
View File
@@ -0,0 +1,652 @@
// Wings TV app - Experimental Pegasus DL ELF patcher: pattern matching, uniqueness, hash gating.
// SPDX-License-Identifier: GPL-3.0-or-later
//
// Uses small synthetic buffers, never the real pegasus_dl.elf (not a test fixture: it is the
// player's own downloaded, verified copy). docs/research/pegasus-dl-patch.md has the real anchor
// and the exact source hash the built-in catalogue targets.
#include "orbit/pegasus_patch.hpp"
#include "orbit/pegasus_manager.hpp" // sha256_hex
#include <gtest/gtest.h>
namespace orbit::pegasus_patch
{
namespace
{
std::vector<std::uint8_t> bytes(std::string_view hex)
{
std::vector<std::uint8_t> out;
EXPECT_TRUE(hex_decode(hex, &out));
return out;
}
std::string hash_of(const std::vector<std::uint8_t> &data)
{
return sha256_hex(std::string_view(reinterpret_cast<const char *>(data.data()), data.size()));
}
Patch make_patch(const std::string &source_sha256)
{
return Patch{"t0", "test patch", source_sha256, "deadbeef", "xxxx", "cafefeed"};
}
TEST(HexDecode, RoundTrips)
{
std::vector<std::uint8_t> out;
ASSERT_TRUE(hex_decode("deadBEEF", &out));
ASSERT_EQ(out.size(), 4u);
EXPECT_EQ(out[0], 0xde);
EXPECT_EQ(out[3], 0xef);
}
TEST(HexDecode, RejectsOddLengthOrBadChar)
{
std::vector<std::uint8_t> out;
EXPECT_FALSE(hex_decode("abc", &out));
EXPECT_FALSE(hex_decode("zz", &out));
}
TEST(PegasusPatchApply, AppliesAUniqueMatchOnlyWhenTheSourceHashMatches)
{
std::vector<std::uint8_t> data = bytes("00112233deadbeef44556677");
const std::string hash = hash_of(data);
Result r;
const std::vector<std::uint8_t> patched = apply(data, {make_patch(hash)}, &r);
EXPECT_TRUE(r.changed);
EXPECT_EQ(r.applied, std::vector<std::string>{"t0"});
EXPECT_EQ(patched, bytes("00112233cafefeed44556677"));
// The input vector itself is never mutated.
EXPECT_EQ(data, bytes("00112233deadbeef44556677"));
}
TEST(PegasusPatchApply, LeavesAWrongHashUntouched)
{
std::vector<std::uint8_t> data = bytes("00112233deadbeef44556677");
Result r;
const std::vector<std::uint8_t> patched = apply(data, {make_patch(std::string(64, '0'))}, &r);
EXPECT_FALSE(r.changed);
EXPECT_TRUE(r.applied.empty());
EXPECT_TRUE(r.error.empty());
EXPECT_EQ(patched, data);
}
TEST(PegasusPatchApply, SkipsSilentlyWhenTheAnchorIsAbsent)
{
std::vector<std::uint8_t> data = bytes("0011223344556677");
const std::string hash = hash_of(data);
Result r;
const std::vector<std::uint8_t> patched = apply(data, {make_patch(hash)}, &r);
EXPECT_FALSE(r.changed);
EXPECT_TRUE(r.error.empty()); // not an error: this build just does not have that byte shape
EXPECT_EQ(patched, data);
}
TEST(PegasusPatchApply, RefusesAnAnchorThatIsNotUniqueWhenRequireAllIsSet)
{
// "deadbeef" occurs twice.
std::vector<std::uint8_t> data = bytes("deadbeef0011deadbeef2233");
const std::string hash = hash_of(data);
Result r;
const std::vector<std::uint8_t> patched = apply(data, {make_patch(hash)}, &r, /*require_all=*/true);
EXPECT_FALSE(r.changed);
EXPECT_FALSE(r.error.empty());
EXPECT_EQ(patched, data); // refused outright, not a best-effort partial patch
}
TEST(PegasusPatchApply, SkipsANonUniqueAnchorSilentlyWhenRequireAllIsNotSet)
{
std::vector<std::uint8_t> data = bytes("deadbeef0011deadbeef2233");
const std::string hash = hash_of(data);
Result r;
const std::vector<std::uint8_t> patched = apply(data, {make_patch(hash)}, &r);
EXPECT_FALSE(r.changed);
EXPECT_TRUE(r.error.empty());
EXPECT_EQ(patched, data);
}
TEST(PegasusPatchApply, MaskCharacterSkipsThatByte)
{
std::vector<std::uint8_t> data = bytes("00dead00beef0011223300ff");
const std::string hash = hash_of(data);
// Prove the '?' wildcard itself: "dead??beef" (5 bytes) matches regardless of the 3rd byte.
Patch p{"t1", "wildcard", hash, "dead00beef", "xx?xx", "feed00face"};
Result r;
const std::vector<std::uint8_t> patched = apply(data, {p}, &r);
EXPECT_TRUE(r.changed);
EXPECT_EQ(patched, bytes("00feed00face0011223300ff"));
}
TEST(BuiltinCatalogue, EveryEntryIsWellFormed)
{
for (const Patch &p : builtin_catalogue())
{
EXPECT_FALSE(p.id.empty());
EXPECT_EQ(p.source_sha256.size(), 64u) << p.id;
std::vector<std::uint8_t> pattern, replacement;
ASSERT_TRUE(hex_decode(p.pattern_hex, &pattern)) << p.id;
ASSERT_TRUE(hex_decode(p.replacement_hex, &replacement)) << p.id;
EXPECT_EQ(pattern.size(), replacement.size()) << p.id;
EXPECT_EQ(pattern.size(), p.mask.size()) << p.id;
}
}
// ---- StructuralPatch (P1) ----
//
// Synthetic only - never the real pegasus_dl.elf, same rule as the Patch tests above. Builds a
// tiny buffer with just enough real ELF64 shape (one program header entry, one "FF 15 <disp32>"
// call site) to exercise try_apply_one()'s full path: overlay of the zero cave, program-header
// edit, call-site redirect. The real catalogue entry in pegasus_patch.cpp targets one exact 20 MB file
// by hash; this proves the *engine* is correct independent of that one file.
constexpr std::uint64_t kPOffset = 120; // right after a 64-byte header + one 56-byte phdr entry
constexpr std::uint64_t kPVaddr = 1000;
constexpr std::uint64_t kPFilesz = 10;
constexpr std::uint64_t kPMemsz = 10000;
// Inside e_ident (bytes 0-15), which this engine never reads, so it's safe scratch space clear of
// e_phoff/e_phentsize/e_phnum and the program header table at byte 64. text_p_offset=
// text_p_vaddr=0, so VA == file offset here.
constexpr std::uint64_t kCallSiteVa = 10;
constexpr std::uint64_t kOriginalTarget = 0x9999;
constexpr std::size_t kTotalSize = 200; // > p_offset + p_filesz + cave, so the tail is provable
constexpr std::uint64_t kCaveSize = 16; // all-zero bytes right after the segment's file content
std::vector<std::uint8_t> build_fake_elf(std::vector<std::uint8_t> *tail_marker_out = nullptr)
{
std::vector<std::uint8_t> d(kTotalSize, 0xAA); // filler, so "untouched" bytes are checkable
auto put64 = [&](std::size_t at, std::uint64_t v)
{
for (int i = 0; i < 8; ++i)
d[at + static_cast<std::size_t>(i)] = static_cast<std::uint8_t>(v >> (8 * i));
};
auto put32 = [&](std::size_t at, std::uint32_t v)
{
for (int i = 0; i < 4; ++i)
d[at + static_cast<std::size_t>(i)] = static_cast<std::uint8_t>(v >> (8 * i));
};
auto put16 = [&](std::size_t at, std::uint16_t v)
{
d[at] = static_cast<std::uint8_t>(v);
d[at + 1] = static_cast<std::uint8_t>(v >> 8);
};
// e_phoff (32,8)=64, e_phentsize (54,2)=56, e_phnum (56,2)=1 - every byte of each field is
// explicit (0xAA filler in a high byte would turn these into garbage 64/16-bit values).
put64(32, 64);
put16(54, 56);
put16(56, 1);
// One PT_LOAD program header entry at file offset 64.
const std::size_t ph = 64;
put32(ph + 0, 1); // p_type = PT_LOAD
put32(ph + 4, 6); // p_flags = PF_R|PF_W, no PF_X yet
put64(ph + 8, kPOffset);
put64(ph + 16, kPVaddr);
put64(ph + 32, kPFilesz);
put64(ph + 40, kPMemsz);
// The call site: FF 15 <disp32>, where disp32 makes it resolve to kOriginalTarget.
d[kCallSiteVa] = 0xFF;
d[kCallSiteVa + 1] = 0x15;
const std::int32_t disp =
static_cast<std::int32_t>(static_cast<std::int64_t>(kOriginalTarget) - static_cast<std::int64_t>(kCallSiteVa + 6));
for (int i = 0; i < 4; ++i)
d[kCallSiteVa + 2 + static_cast<std::size_t>(i)] = static_cast<std::uint8_t>(disp >> (8 * i));
// The segment's existing file content (bytes [p_offset, p_offset+p_filesz)) - distinctive, so
// we can tell it was left alone.
for (std::uint64_t i = 0; i < kPFilesz; ++i)
d[kPOffset + i] = 0xEE;
// The zero-filled file-backed cave right after the segment's content, where the blob lands.
for (std::uint64_t i = 0; i < kCaveSize; ++i)
d[kPOffset + kPFilesz + i] = 0x00;
if (tail_marker_out != nullptr)
*tail_marker_out = std::vector<std::uint8_t>(d.begin() + static_cast<std::ptrdiff_t>(kPOffset + kPFilesz), d.end());
return d;
}
StructuralPatch make_structural_patch(const std::string &hash)
{
StructuralPatch p;
p.id = "s0";
p.description = "test";
p.source_sha256 = hash;
p.phdr_index = 0;
p.expect_p_offset = kPOffset;
p.expect_p_vaddr = kPVaddr;
p.expect_p_filesz = kPFilesz;
p.expect_p_memsz = kPMemsz;
p.call_site_va = kCallSiteVa;
p.call_site_expected_target_va = kOriginalTarget;
p.text_p_offset = 0;
p.text_p_vaddr = 0;
p.blob_hex = "8877665544332211" "0000000000000000";
return p;
}
std::string hash_of_vec(const std::vector<std::uint8_t> &data)
{
return sha256_hex(std::string_view(reinterpret_cast<const char *>(data.data()), data.size()));
}
TEST(ApplyStructural, OverlaysTheCaveAndRedirectsTheCallSite)
{
const std::vector<std::uint8_t> original = build_fake_elf();
const StructuralPatch patch = make_structural_patch(hash_of_vec(original));
StructuralResult r;
const std::vector<std::uint8_t> out = apply_structural(original, {patch}, &r);
ASSERT_TRUE(r.changed);
EXPECT_EQ(r.id, "s0");
EXPECT_TRUE(r.error.empty());
// The file keeps its exact size: nothing is inserted, truncated or shifted.
ASSERT_EQ(out.size(), original.size());
// The segment's original content and everything past the cave are untouched.
for (std::uint64_t i = 0; i < kPFilesz; ++i)
EXPECT_EQ(out[kPOffset + i], 0xEE) << i;
for (std::size_t i = kPOffset + kPFilesz + kCaveSize; i < out.size(); ++i)
EXPECT_EQ(out[i], original[i]) << i;
// The blob overlaid the zero cave immediately after the segment's content, verbatim.
std::vector<std::uint8_t> blob;
ASSERT_TRUE(hex_decode(patch.blob_hex, &blob));
for (std::size_t i = 0; i < blob.size(); ++i)
EXPECT_EQ(out[kPOffset + kPFilesz + i], blob[i]) << i;
// p_filesz grew by exactly the blob's size; p_memsz and p_flags are unchanged.
auto read64 = [&](std::size_t at)
{
std::uint64_t v = 0;
for (int i = 7; i >= 0; --i)
v = (v << 8) | out[at + static_cast<std::size_t>(i)];
return v;
};
const std::size_t ph = 64;
EXPECT_EQ(read64(ph + 32), kPFilesz + blob.size());
EXPECT_EQ(read64(ph + 40), kPMemsz);
EXPECT_EQ(out[ph + 4], original[ph + 4]);
// The call site became `E8 rel32 90` (CALL + NOP, the same six bytes), targeting the blob's
// first byte at p_vaddr + p_filesz.
EXPECT_EQ(out[kCallSiteVa], 0xE8);
EXPECT_EQ(out[kCallSiteVa + 5], 0x90);
std::int32_t new_disp = 0;
for (int i = 3; i >= 0; --i)
new_disp = (new_disp << 8) | out[kCallSiteVa + 1 + static_cast<std::size_t>(i)];
const std::int64_t new_target = static_cast<std::int64_t>(kCallSiteVa) + 5 + new_disp;
EXPECT_EQ(new_target, static_cast<std::int64_t>(kPVaddr + kPFilesz));
// Section header fields in the ELF header are preserved, not zeroed.
EXPECT_EQ(read64(0x28), 0xAAAAAAAAAAAAAAAAull);
}
TEST(ApplyStructural, LeavesAWrongHashUntouched)
{
const std::vector<std::uint8_t> original = build_fake_elf();
const StructuralPatch patch = make_structural_patch(std::string(64, '0'));
StructuralResult r;
const std::vector<std::uint8_t> out = apply_structural(original, {patch}, &r);
EXPECT_FALSE(r.changed);
EXPECT_TRUE(r.error.empty());
EXPECT_EQ(out, original);
}
TEST(ApplyStructural, RefusesWhenTheProgramHeaderDoesNotMatchExactly)
{
const std::vector<std::uint8_t> original = build_fake_elf();
StructuralPatch patch = make_structural_patch(hash_of_vec(original));
patch.expect_p_filesz = kPFilesz + 1; // deliberately wrong
StructuralResult r;
const std::vector<std::uint8_t> out = apply_structural(original, {patch}, &r, /*require_all=*/true);
EXPECT_FALSE(r.changed);
EXPECT_FALSE(r.error.empty());
EXPECT_EQ(out, original);
}
TEST(ApplyStructural, RefusesWhenTheCallSiteNoLongerTargetsTheExpectedAddress)
{
const std::vector<std::uint8_t> original = build_fake_elf();
StructuralPatch patch = make_structural_patch(hash_of_vec(original));
patch.call_site_expected_target_va = kOriginalTarget + 1; // deliberately wrong
StructuralResult r;
const std::vector<std::uint8_t> out = apply_structural(original, {patch}, &r, /*require_all=*/true);
EXPECT_FALSE(r.changed);
EXPECT_FALSE(r.error.empty());
EXPECT_EQ(out, original);
}
TEST(ApplyStructural, RefusesWhenTheCaveIsNotAllZero)
{
std::vector<std::uint8_t> original = build_fake_elf();
original[kPOffset + kPFilesz + 3] = 0x01; // something already lives in the "cave"
const StructuralPatch patch = make_structural_patch(hash_of_vec(original));
StructuralResult r;
const std::vector<std::uint8_t> out = apply_structural(original, {patch}, &r, /*require_all=*/true);
EXPECT_FALSE(r.changed);
EXPECT_FALSE(r.error.empty());
EXPECT_EQ(out, original);
}
TEST(ApplyStructural, RefusesWhenTheBlobWouldRunPastTheEndOfTheFile)
{
std::vector<std::uint8_t> original = build_fake_elf();
original.resize(kPOffset + kPFilesz + 8); // cave truncated to 8 bytes; blob is 16
const StructuralPatch patch = make_structural_patch(hash_of_vec(original));
StructuralResult r;
const std::vector<std::uint8_t> out = apply_structural(original, {patch}, &r, /*require_all=*/true);
EXPECT_FALSE(r.changed);
EXPECT_FALSE(r.error.empty());
EXPECT_EQ(out, original);
}
// P0 companion anchor: P1 either applies the P0 bytes itself (stock input) or accepts a file that
// already carries them (P0-only hash), and refuses anything else.
constexpr std::size_t kP0Offset = 150; // in the untouched tail, clear of the segment and the cave
const std::string kP0Before(16, 'B');
const std::string kP0After(16, 'A');
std::string hex_of(const std::string &bytes)
{
static const char *digits = "0123456789abcdef";
std::string out;
for (const unsigned char c : bytes)
{
out.push_back(digits[c >> 4]);
out.push_back(digits[c & 15]);
}
return out;
}
std::vector<std::uint8_t> with_p0(std::vector<std::uint8_t> d, const std::string &bytes)
{
std::copy(bytes.begin(), bytes.end(), d.begin() + static_cast<std::ptrdiff_t>(kP0Offset));
return d;
}
StructuralPatch make_p0_companion_patch(const std::string &stock_hash, const std::string &p0_hash)
{
StructuralPatch p = make_structural_patch(stock_hash);
p.source_sha256_p0 = p0_hash;
p.p0_file_offset = kP0Offset;
p.p0_before_hex = hex_of(kP0Before);
p.p0_after_hex = hex_of(kP0After);
return p;
}
TEST(ApplyStructural, AppliesTheP0AnchorAtomicallyWithP1)
{
const std::vector<std::uint8_t> stock = with_p0(build_fake_elf(), kP0Before);
const std::vector<std::uint8_t> p0_only = with_p0(stock, kP0After);
const StructuralPatch patch = make_p0_companion_patch(hash_of_vec(stock), hash_of_vec(p0_only));
StructuralResult r;
const std::vector<std::uint8_t> out = apply_structural(stock, {patch}, &r, /*require_all=*/true);
ASSERT_TRUE(r.changed) << r.error;
EXPECT_EQ(std::string(out.begin() + kP0Offset, out.begin() + kP0Offset + 16), kP0After);
EXPECT_EQ(out[kCallSiteVa], 0xE8);
}
TEST(ApplyStructural, AcceptsAFileThatAlreadyHasP0)
{
const std::vector<std::uint8_t> stock = with_p0(build_fake_elf(), kP0Before);
const std::vector<std::uint8_t> p0_only = with_p0(stock, kP0After);
const StructuralPatch patch = make_p0_companion_patch(hash_of_vec(stock), hash_of_vec(p0_only));
StructuralResult r;
const std::vector<std::uint8_t> out = apply_structural(p0_only, {patch}, &r, /*require_all=*/true);
ASSERT_TRUE(r.changed) << r.error;
EXPECT_EQ(std::string(out.begin() + kP0Offset, out.begin() + kP0Offset + 16), kP0After);
EXPECT_EQ(out[kCallSiteVa], 0xE8);
}
TEST(ApplyStructural, RefusesWhenTheP0AnchorIsNotAtItsVerifiedOffset)
{
const std::vector<std::uint8_t> stock = with_p0(build_fake_elf(), kP0Before);
StructuralPatch patch = make_p0_companion_patch(hash_of_vec(stock), std::string(64, '1'));
patch.p0_file_offset = kP0Offset + 1; // deliberately wrong
StructuralResult r;
const std::vector<std::uint8_t> out = apply_structural(stock, {patch}, &r, /*require_all=*/true);
EXPECT_FALSE(r.changed);
EXPECT_FALSE(r.error.empty());
EXPECT_EQ(out, stock);
}
// ---- Format v2: direct-call redirects (P1's completion barrier) ----
//
// Two extra "E8 <rel32>" call sites, both calling the same original function, are repointed at an
// entry point inside the blob. In the synthetic file VA == file offset (text_p_offset == vaddr == 0);
// the sites sit in the ELF header's e_type..e_entry bytes (16..31), which the engine never reads.
constexpr std::uint64_t kSyncSiteA = 20;
constexpr std::uint64_t kSyncSiteB = 26;
constexpr std::uint64_t kSyncOriginal = 0x7777;
constexpr std::uint64_t kSyncEntry = 8; // byte offset of the barrier entry point inside the 16-byte blob
void put_e8_call(std::vector<std::uint8_t> &d, std::uint64_t site, std::uint64_t target)
{
d[site] = 0xE8;
const std::int32_t disp =
static_cast<std::int32_t>(static_cast<std::int64_t>(target) - static_cast<std::int64_t>(site + 5));
for (int i = 0; i < 4; ++i)
d[site + 1 + static_cast<std::size_t>(i)] = static_cast<std::uint8_t>(disp >> (8 * i));
}
std::vector<std::uint8_t> build_fake_elf_with_sync_sites()
{
std::vector<std::uint8_t> d = build_fake_elf();
put_e8_call(d, kSyncSiteA, kSyncOriginal);
put_e8_call(d, kSyncSiteB, kSyncOriginal);
return d;
}
StructuralPatch make_barrier_patch(const std::string &hash)
{
StructuralPatch p = make_structural_patch(hash);
p.direct_call_redirects = {{kSyncSiteA, kSyncOriginal, kSyncEntry}, {kSyncSiteB, kSyncOriginal, kSyncEntry}};
return p;
}
std::int64_t call_target(const std::vector<std::uint8_t> &d, std::uint64_t site)
{
std::int32_t disp = 0;
for (int i = 3; i >= 0; --i)
disp = static_cast<std::int32_t>((static_cast<std::uint32_t>(disp) << 8) | d[site + 1 + static_cast<std::size_t>(i)]);
return static_cast<std::int64_t>(site) + 5 + disp;
}
TEST(ApplyStructuralRedirects, RepointsBothSitesAtTheBlobEntryAndChangesNothingElse)
{
const std::vector<std::uint8_t> original = build_fake_elf_with_sync_sites();
const StructuralPatch patch = make_barrier_patch(hash_of_vec(original));
StructuralResult r;
const std::vector<std::uint8_t> out = apply_structural(original, {patch}, &r, /*require_all=*/true);
ASSERT_TRUE(r.changed) << r.error;
ASSERT_EQ(out.size(), original.size());
const std::int64_t entry = static_cast<std::int64_t>(kPVaddr + kPFilesz + kSyncEntry);
EXPECT_EQ(out[kSyncSiteA], 0xE8);
EXPECT_EQ(out[kSyncSiteB], 0xE8);
EXPECT_EQ(call_target(out, kSyncSiteA), entry);
EXPECT_EQ(call_target(out, kSyncSiteB), entry);
// The v1 pwrite redirect still goes to the blob's first byte.
EXPECT_EQ(out[kCallSiteVa], 0xE8);
EXPECT_EQ(call_target(out, kCallSiteVa), static_cast<std::int64_t>(kPVaddr + kPFilesz));
// Exactly these bytes differ: the pwrite site (6), both sync disp32s (4 each), the two
// program-header size fields (16), the 16-byte cave - nothing else, and not the E8 opcodes.
std::vector<std::size_t> diff;
for (std::size_t i = 0; i < out.size(); ++i)
if (out[i] != original[i])
diff.push_back(i);
for (const std::size_t i : diff)
{
const bool in_pwrite = i >= kCallSiteVa && i < kCallSiteVa + 6;
const bool in_sync_a = i >= kSyncSiteA + 1 && i < kSyncSiteA + 5;
const bool in_sync_b = i >= kSyncSiteB + 1 && i < kSyncSiteB + 5;
const bool in_phdr = i >= 64 + 32 && i < 64 + 48;
const bool in_cave = i >= kPOffset + kPFilesz && i < kPOffset + kPFilesz + 16;
EXPECT_TRUE(in_pwrite || in_sync_a || in_sync_b || in_phdr || in_cave) << "unexpected change at " << i;
}
}
TEST(ApplyStructuralRedirects, OneBadSiteRefusesTheWholePatchAtomically)
{
std::vector<std::uint8_t> original = build_fake_elf_with_sync_sites();
original[kSyncSiteB] = 0x90; // second site is no longer a call
const StructuralPatch patch = make_barrier_patch(hash_of_vec(original));
StructuralResult r;
const std::vector<std::uint8_t> out = apply_structural(original, {patch}, &r, /*require_all=*/true);
EXPECT_FALSE(r.changed);
EXPECT_FALSE(r.error.empty());
EXPECT_EQ(out, original); // the good first site and the pwrite site were not touched either
}
TEST(ApplyStructuralRedirects, RefusesASiteThatNoLongerTargetsTheExpectedFunction)
{
const std::vector<std::uint8_t> original = build_fake_elf_with_sync_sites();
StructuralPatch patch = make_barrier_patch(hash_of_vec(original));
patch.direct_call_redirects[1].expected_target_va = kSyncOriginal + 1;
StructuralResult r;
const std::vector<std::uint8_t> out = apply_structural(original, {patch}, &r, /*require_all=*/true);
EXPECT_FALSE(r.changed);
EXPECT_FALSE(r.error.empty());
EXPECT_EQ(out, original);
}
TEST(ApplyStructuralRedirects, RefusesAnEntryPointOutsideTheBlob)
{
const std::vector<std::uint8_t> original = build_fake_elf_with_sync_sites();
StructuralPatch patch = make_barrier_patch(hash_of_vec(original));
patch.direct_call_redirects[0].blob_entry_offset = 16; // blob is exactly 16 bytes
StructuralResult r;
const std::vector<std::uint8_t> out = apply_structural(original, {patch}, &r, /*require_all=*/true);
EXPECT_FALSE(r.changed);
EXPECT_FALSE(r.error.empty());
EXPECT_EQ(out, original);
}
TEST(ApplyStructuralRedirects, RefusesOverlappingOrDuplicateSites)
{
const std::vector<std::uint8_t> original = build_fake_elf_with_sync_sites();
StructuralPatch duplicate = make_barrier_patch(hash_of_vec(original));
duplicate.direct_call_redirects[1].va = kSyncSiteA; // same site twice
StructuralResult r;
EXPECT_EQ(apply_structural(original, {duplicate}, &r, true), original);
EXPECT_FALSE(r.changed);
EXPECT_FALSE(r.error.empty());
// Two sites that are each a valid E8 call with the expected target, but whose 5-byte ranges
// overlap (the second call starts inside the first one's rel32): refusal can only be the overlap.
std::vector<std::uint8_t> clash_elf = build_fake_elf();
put_e8_call(clash_elf, 20, kSyncOriginal);
put_e8_call(clash_elf, 22, kSyncOriginal);
StructuralPatch clash = make_structural_patch(hash_of_vec(clash_elf));
clash.direct_call_redirects = {{20, static_cast<std::uint64_t>(call_target(clash_elf, 20)), kSyncEntry},
{22, kSyncOriginal, kSyncEntry}};
ASSERT_GT(call_target(clash_elf, 20), 0);
StructuralResult r2;
EXPECT_EQ(apply_structural(clash_elf, {clash}, &r2, true), clash_elf);
EXPECT_FALSE(r2.changed);
EXPECT_NE(r2.error.find("overlaps"), std::string::npos) << r2.error;
}
TEST(ApplyStructuralRedirects, IsNotReappliedToItsOwnOutput)
{
const std::vector<std::uint8_t> original = build_fake_elf_with_sync_sites();
const StructuralPatch patch = make_barrier_patch(hash_of_vec(original));
StructuralResult first;
const std::vector<std::uint8_t> once = apply_structural(original, {patch}, &first, true);
ASSERT_TRUE(first.changed) << first.error;
StructuralResult second;
const std::vector<std::uint8_t> twice = apply_structural(once, {patch}, &second, true);
EXPECT_FALSE(second.changed); // different hash: the patched file is not the gated source
EXPECT_TRUE(second.error.empty());
EXPECT_EQ(twice, once);
// Even handed the patched bytes as if they were the source (hash forged to match), the
// engine's own structural checks refuse: the pwrite site is no longer FF 15.
StructuralPatch forged = patch;
forged.source_sha256 = hash_of_vec(once);
forged.expect_p_filesz = kPFilesz + 16;
StructuralResult third;
EXPECT_EQ(apply_structural(once, {forged}, &third, true), once);
EXPECT_FALSE(third.changed);
EXPECT_FALSE(third.error.empty());
}
TEST(ApplyStructuralRedirects, ComposesWithTheP0AnchorInEitherOrder)
{
const std::vector<std::uint8_t> stock = with_p0(build_fake_elf_with_sync_sites(), kP0Before);
const std::vector<std::uint8_t> p0_only = with_p0(stock, kP0After);
StructuralPatch patch = make_barrier_patch(hash_of_vec(stock));
patch.source_sha256_p0 = hash_of_vec(p0_only);
patch.p0_file_offset = kP0Offset;
patch.p0_before_hex = hex_of(kP0Before);
patch.p0_after_hex = hex_of(kP0After);
const std::int64_t entry = static_cast<std::int64_t>(kPVaddr + kPFilesz + kSyncEntry);
for (const std::vector<std::uint8_t> *input : {&stock, &p0_only})
{
StructuralResult r;
const std::vector<std::uint8_t> out = apply_structural(*input, {patch}, &r, true);
ASSERT_TRUE(r.changed) << r.error;
EXPECT_EQ(std::string(out.begin() + kP0Offset, out.begin() + kP0Offset + 16), kP0After);
EXPECT_EQ(call_target(out, kSyncSiteA), entry);
EXPECT_EQ(call_target(out, kSyncSiteB), entry);
}
}
TEST(BuiltinStructuralCatalogue, P1HasTheBarrierRedirectsAndARealBlob)
{
const StructuralPatch *p1 = nullptr;
for (const StructuralPatch &p : builtin_structural_catalogue())
if (p.id == "p1-writer-thread")
p1 = &p;
ASSERT_NE(p1, nullptr);
std::vector<std::uint8_t> blob;
ASSERT_TRUE(hex_decode(p1->blob_hex, &blob));
EXPECT_FALSE(blob.empty()) << "P1 must ship its verified blob";
EXPECT_EQ(p1->call_site_va, 0x4e22bu); // pwrite in segment_flush
EXPECT_EQ(p1->call_site_expected_target_va, 0x7ef838u);
// Both `call sync_segment_output` sites in download_segmented() go to one in-blob entry point,
// which is not the blob's first byte (that is w1_write).
ASSERT_EQ(p1->direct_call_redirects.size(), 2u);
EXPECT_EQ(p1->direct_call_redirects[0].va, 0x4c1aeu);
EXPECT_EQ(p1->direct_call_redirects[1].va, 0x4c24au);
for (const DirectCallRedirect &r : p1->direct_call_redirects)
{
EXPECT_EQ(r.expected_target_va, 0x4d870u);
EXPECT_GT(r.blob_entry_offset, 0u);
EXPECT_LT(r.blob_entry_offset, blob.size());
EXPECT_EQ(r.blob_entry_offset, p1->direct_call_redirects[0].blob_entry_offset);
}
}
TEST(BuiltinStructuralCatalogue, EveryEntryIsWellFormed)
{
for (const StructuralPatch &p : builtin_structural_catalogue())
{
EXPECT_FALSE(p.id.empty());
EXPECT_EQ(p.source_sha256.size(), 64u) << p.id;
std::vector<std::uint8_t> blob;
ASSERT_TRUE(hex_decode(p.blob_hex, &blob)) << p.id;
// An empty blob is the deliberate "disabled until rebuilt with the PS5 SDK" placeholder:
// the engine refuses it, so the entry is inert. A non-empty one must be real hex (above).
if (blob.empty())
{
StructuralPatch probe = p;
const std::vector<std::uint8_t> original = build_fake_elf();
probe.source_sha256 = hash_of_vec(original);
StructuralResult r;
const std::vector<std::uint8_t> out = apply_structural(original, {probe}, &r, /*require_all=*/true);
EXPECT_FALSE(r.changed) << p.id;
EXPECT_EQ(out, original) << p.id;
}
if (!p.p0_before_hex.empty() || !p.p0_after_hex.empty())
{
std::vector<std::uint8_t> before, after;
ASSERT_TRUE(hex_decode(p.p0_before_hex, &before)) << p.id;
ASSERT_TRUE(hex_decode(p.p0_after_hex, &after)) << p.id;
EXPECT_EQ(before.size(), after.size()) << p.id;
EXPECT_EQ(p.source_sha256_p0.size(), 64u) << p.id;
}
}
}
} // namespace
} // namespace orbit::pegasus_patch
+132 -3
View File
@@ -2,6 +2,7 @@
// (off => no call, cache fallback) and the local download counter.
// SPDX-License-Identifier: GPL-3.0-or-later
#include "core/save_file.hpp"
#include "orbit/game_view.hpp"
#include "orbit/popularity.hpp"
#include "support.hpp"
@@ -38,13 +39,17 @@ std::string doc(const std::string &titles, const std::string &version = "1")
titles + "}}";
}
std::string rec(const char *id, int score, double confidence, int rating = 80, int count = 100)
std::string rec(const char *id, int score, double confidence, int rating = 80, int count = 100,
const std::string &extra = "")
{
return std::string("\"") + id + "\":{\"score\":" + std::to_string(score) + ",\"rating\":" + std::to_string(rating) +
",\"ratingCount\":" + std::to_string(count) + ",\"confidence\":" + std::to_string(confidence) +
",\"sources\":[\"igdb\"],\"fetchedAt\":\"2026-10-12\"}";
",\"sources\":[\"igdb\"],\"fetchedAt\":\"2026-10-12\"" + extra + "}";
}
const char *const kHeroJson = ",\"hero\":\"https://cdn.example.com/h.webp\"";
const char *const kLowJson = ",\"heroLow\":\"https://cdn.example.com/l.webp\"";
struct FakeFetcher final : DocFetcher
{
int calls = 0;
@@ -78,6 +83,9 @@ TEST(Popularity, SchemaRejects)
PopularityIndex index;
std::string error;
EXPECT_FALSE(PopularityIndex::parse(doc(rec("PPSA00001", 50, 1.0), "2"), &index, &error)); // wrong version
EXPECT_NE(error.find("newer Wings"), std::string::npos); // a newer format says what to do
EXPECT_FALSE(PopularityIndex::parse(doc(rec("PPSA00001", 50, 1.0), "0"), &index, &error));
EXPECT_EQ(error, "unsupported data version");
EXPECT_FALSE(PopularityIndex::parse("{\"schemaVersion\":1}", &index, &error)); // no titles
EXPECT_FALSE(PopularityIndex::parse("not json", &index, &error));
EXPECT_FALSE(PopularityIndex::parse(doc(rec("PPSA00001", 500, 1.0)), &index, &error)); // no usable record
@@ -180,7 +188,7 @@ TEST(Popularity, FetchCachesAndFailureFallsBackToTheCache)
config.now = [&now] { return now; };
FakeFetcher fetcher;
fetcher.result.status = 200;
fetcher.result.body = fixture();
fetcher.result.body = doc(rec("PPSA00001", 60, 1.0, 70, 10, kHeroJson));
{
PopularityHub hub;
PopularityService service(hub, fetcher, config);
@@ -229,6 +237,127 @@ TEST(Popularity, FetchCachesAndFailureFallsBackToTheCache)
}
}
TEST(Popularity, HeroLowParsesWithTheSameChecksAndLooksUpByTitleId)
{
PopularityIndex index;
const std::string both = std::string(kHeroJson) + kLowJson;
const std::string bad = ",\"hero\":\"https://cdn.example.com/h.webp\",\"heroLow\":\"http://cdn.example.com/l.webp\"";
const std::string longer = ",\"heroLow\":\"https://cdn.example.com/" + std::string(300, 'a') + "\"";
const std::string old_style = kHeroJson;
ASSERT_TRUE(PopularityIndex::parse(doc(rec("PPSA00001", 60, 1.0, 70, 10, both) + "," +
rec("PPSA00002", 60, 1.0, 70, 10, bad) + "," +
rec("PPSA00003", 60, 1.0, 70, 10, longer) + "," +
rec("PPSA00004", 60, 1.0, 70, 10, old_style) + "," +
rec("PPSA00005", 60, 1.0, 70, 10, ",\"heroLow\":5")),
&index, nullptr));
EXPECT_EQ(index.hero({"PPSA00001"}), "https://cdn.example.com/h.webp");
EXPECT_EQ(index.hero_low({"PPSA00001"}), "https://cdn.example.com/l.webp");
// An insecure or oversize heroLow is dropped; the record and its hero stay.
EXPECT_EQ(index.hero_low({"PPSA00002"}), "");
EXPECT_EQ(index.hero({"PPSA00002"}), "https://cdn.example.com/h.webp");
EXPECT_EQ(index.hero_low({"PPSA00003"}), "");
EXPECT_EQ(index.hero_low({"PPSA00005"}), "");
// Old files (hero only) keep working.
EXPECT_EQ(index.hero({"PPSA00004"}), "https://cdn.example.com/h.webp");
EXPECT_EQ(index.hero_low({"PPSA00004"}), "");
// First title id that has one; unknown ids have none.
EXPECT_EQ(index.hero_low({"PPSA00004", "PPSA00001"}), "https://cdn.example.com/l.webp");
EXPECT_EQ(index.hero_low({"PPSA09999"}), "");
}
TEST(Popularity, ASavedCopyFromAnotherBuildIsRefreshedAtOnce)
{
const std::string dir = tmp_dir();
PopularityServiceConfig config;
config.cache_paths = {dir + "/pop.v1"};
config.app_tag = "00.001.001+20261012-100000";
std::int64_t now = 1000000;
config.now = [&now] { return now; };
FakeFetcher fetcher;
fetcher.result.status = 200;
fetcher.result.body = doc(rec("PPSA00001", 60, 1.0));
const auto start = [&](const PopularityServiceConfig &c)
{
PopularityHub hub;
PopularityService service(hub, fetcher, c);
service.apply(true, "");
service.wait_idle();
return hub.index() != nullptr;
};
ASSERT_TRUE(start(config));
EXPECT_EQ(fetcher.calls, 1);
// The same build, well within the TTL: the saved copy is used and nothing is fetched.
ASSERT_TRUE(start(config));
EXPECT_EQ(fetcher.calls, 1);
// A new build (the app was updated), still within the TTL: fetched again at the first start.
config.app_tag = "00.001.002+20261013-090000";
ASSERT_TRUE(start(config));
EXPECT_EQ(fetcher.calls, 2);
// ... and that copy now carries the new tag.
ASSERT_TRUE(start(config));
EXPECT_EQ(fetcher.calls, 2);
// A test build made from the same version is a different build too.
config.app_tag = "00.001.002+20261013-110000";
ASSERT_TRUE(start(config));
EXPECT_EQ(fetcher.calls, 3);
// A copy saved by an app that did not tag its copies (no app line) is fetched again once.
{
const std::string old_copy = "fetched=" + std::to_string(now) + "\n" + doc(rec("PPSA00001", 60, 1.0));
ASSERT_TRUE(hui::save::write_atomic(dir + "/pop.v1", hui::save::encode(hui::save::Kind::stats, 1, old_copy))
.empty());
}
ASSERT_TRUE(start(config));
EXPECT_EQ(fetcher.calls, 4);
ASSERT_TRUE(start(config));
EXPECT_EQ(fetcher.calls, 4);
// A copy from another build and a failing fetch: the copy is still used, and the next start tries again.
config.app_tag = "00.001.003+20261014-090000";
fetcher.result = DocResult{};
fetcher.result.error = "connection refused";
PopularityHub hub;
{
PopularityService service(hub, fetcher, config);
service.apply(true, "");
service.wait_idle();
}
EXPECT_EQ(fetcher.calls, 5);
ASSERT_NE(hub.index(), nullptr);
EXPECT_EQ(hub.index()->size(), 1u);
EXPECT_NE(hub.status().message.find("Could not refresh"), std::string::npos);
EXPECT_TRUE(start(config));
EXPECT_EQ(fetcher.calls, 6);
}
TEST(Popularity, ACopyWithoutHeroArtIsNotFetchedAgainWhileItIsFresh)
{
// A custom source may have no hero art at all; that must not mean a download on every start.
const std::string dir = tmp_dir();
PopularityServiceConfig config;
config.cache_paths = {dir + "/pop.v1"};
config.app_tag = "00.001.001+20261012-100000";
std::int64_t now = 1000000;
config.now = [&now] { return now; };
FakeFetcher fetcher;
fetcher.result.status = 200;
fetcher.result.body = doc(rec("PPSA00001", 60, 1.0));
for (int start = 0; start < 3; ++start)
{
PopularityHub hub;
PopularityService service(hub, fetcher, config);
service.apply(true, "");
service.wait_idle();
ASSERT_NE(hub.index(), nullptr);
}
EXPECT_EQ(fetcher.calls, 1);
// Past the TTL it is fetched again, as always.
now += 25 * 3600;
PopularityHub hub;
PopularityService service(hub, fetcher, config);
service.apply(true, "");
service.wait_idle();
EXPECT_EQ(fetcher.calls, 2);
}
TEST(Popularity, LocalDownloadsPersist)
{
const std::string path = tmp_dir() + "/downloads.v1";
+1
View File
@@ -463,6 +463,7 @@ struct SearchHarness
if (!path.empty())
context.catalogue_paths = {path};
app = std::make_unique<App>(context);
context.folder_confirmed = [] { return true; };
store.poll_once();
store.load_catalog_once();
frame();
+1
View File
@@ -10,6 +10,7 @@ src/audio/mixer.cpp
src/audio/wav.cpp
src/core/input.cpp
src/core/touch.cpp
src/core/version.cpp
src/core/save_file.cpp
src/gfx/draw_list.cpp
src/gfx/font.cpp
+111
View File
@@ -0,0 +1,111 @@
// Wings TV app - The app version and the build stamp: param.json, release tags, assets/build-info.json.
// SPDX-License-Identifier: GPL-3.0-or-later
#include "core/version.hpp"
#include <gtest/gtest.h>
#include <string>
namespace hui
{
namespace
{
TEST(Version, ReadsContentVersionFromParamJson)
{
EXPECT_EQ(content_version("{\n \"contentVersion\": \"00.001.001\",\n \"x\": 1\n}"), "00.001.001");
EXPECT_EQ(content_version("{\"contentVersion\":\"12.034.567\"}"), "12.034.567");
EXPECT_EQ(content_version("{\"masterVersion\": \"01.00\"}"), ""); // the key is missing
EXPECT_EQ(content_version("{\"contentVersion\": \"0.1.1\"}"), ""); // not NN.NNN.NNN
EXPECT_EQ(content_version("{\"contentVersion\": \"00.001.0x1\"}"), "");
EXPECT_EQ(content_version("{\"contentVersion\": \"00.001.001"), ""); // unterminated
EXPECT_EQ(content_version(""), "");
}
TEST(Version, ContentVersionAndReleaseTagAgree)
{
int content[3];
int tag[3];
ASSERT_TRUE(parse_content_version("00.001.001", content));
ASSERT_TRUE(parse_release_tag("v0.1.1", tag));
EXPECT_EQ(compare_versions(content, tag), 0);
EXPECT_EQ(content[0], 0);
EXPECT_EQ(content[1], 1);
EXPECT_EQ(content[2], 1);
ASSERT_TRUE(parse_content_version("01.020.300", content));
ASSERT_TRUE(parse_release_tag("v1.20.300", tag));
EXPECT_EQ(compare_versions(content, tag), 0);
EXPECT_EQ(release_version_text("00.001.001"), "0.1.1");
EXPECT_EQ(release_version_text("01.020.300"), "1.20.300");
EXPECT_EQ(release_version_text("garbage"), "");
}
TEST(Version, ReleaseTagMustBeExactlyThreeNumbers)
{
int v[3];
EXPECT_TRUE(parse_release_tag("v0.1.0", v));
EXPECT_TRUE(parse_release_tag("v10.200.999", v));
for (const char *bad : {"", "v", "0.1.1", "v1.2", "v1.2.3.4", "v1..2", "v1.2.", "v1.2.3-rc1", "V1.2.3",
"v1.2.3 ", "v1.2.x", "v1000.2.3", "v-1.2.3", "v1.2.3\n"})
EXPECT_FALSE(parse_release_tag(bad, v)) << bad;
}
TEST(Version, ComparesNumbersNotText)
{
int a[3];
int b[3];
ASSERT_TRUE(parse_release_tag("v0.9.0", a));
ASSERT_TRUE(parse_release_tag("v0.10.0", b));
EXPECT_EQ(compare_versions(a, b), -1); // 10 is more than 9
EXPECT_EQ(compare_versions(b, a), 1);
ASSERT_TRUE(parse_release_tag("v1.0.0", a));
EXPECT_EQ(compare_versions(a, b), 1);
EXPECT_EQ(compare_versions(a, a), 0);
}
TEST(BuildInfo, ParsesTheStampTheBuildWrites)
{
BuildInfo info = parse_build_info("{\"build\":\"20261012-203105\",\"channel\":\"test \"}\n");
EXPECT_TRUE(info.known);
EXPECT_FALSE(info.release);
EXPECT_EQ(info.build, "20261012-203105");
info = parse_build_info("{\"build\":\"20261012-203105\",\"channel\":\"release\"}\n");
EXPECT_TRUE(info.known);
EXPECT_TRUE(info.release);
}
TEST(BuildInfo, RejectsAnythingElse)
{
for (const char *bad : {"", "{}", "{\"build\":\"20261012-203105\"}", "{\"channel\":\"test \"}",
"{\"build\":\"2026-10-12\",\"channel\":\"test \"}",
"{\"build\":\"20261012-20310x\",\"channel\":\"test \"}",
"{\"build\":\"20261012-203105\",\"channel\":\"beta \"}",
"{\"build\":\"20261012-203105\",\"channel\":\"\"}",
"{\"build\":\"20261012-203105\",\"channel\":\"rele\"}"})
{
const BuildInfo info = parse_build_info(bad);
EXPECT_FALSE(info.known) << bad;
EXPECT_FALSE(info.release) << bad;
EXPECT_TRUE(info.build.empty()) << bad;
}
}
TEST(BuildInfo, LabelAndTag)
{
const BuildInfo test_build = parse_build_info("{\"build\":\"20261012-203105\",\"channel\":\"test \"}\n");
const BuildInfo release_build = parse_build_info("{\"build\":\"20261012-203105\",\"channel\":\"release\"}\n");
const BuildInfo none;
// A release build reads as the plain version; a test build says what it is.
EXPECT_EQ(version_label("00.001.001", release_build), "0.1.1");
EXPECT_EQ(version_label("00.001.001", test_build), "0.1.1 \xC2\xB7 test 20261012-203105");
EXPECT_EQ(version_label("00.001.001", none), "0.1.1");
EXPECT_EQ(version_label("", test_build), ""); // no version, nothing to label
// The tag is the same for the test build and the release made from it (nothing was compiled again).
EXPECT_EQ(build_tag("00.001.001", test_build), "00.001.001+20261012-203105");
EXPECT_EQ(build_tag("00.001.001", test_build), build_tag("00.001.001", release_build));
EXPECT_EQ(build_tag("00.001.001", none), "00.001.001");
}
} // namespace
} // namespace hui
+2
View File
@@ -364,6 +364,7 @@ struct AppHarness
explicit AppHarness(host::FakeMode mode = host::FakeMode::normal) : backend(mode)
{
context.folder_confirmed = [] { return true; };
context.app_version = "00.001.000";
store.poll_once();
store.load_catalog_once();
@@ -454,6 +455,7 @@ TEST(App, TheShellShowsBeforeTheCatalogueLoadsAndWhileOffline)
ArtCache art(backend, sink);
Context context(fonts.type, fonts.fonts, store, art);
App app(context);
context.folder_confirmed = [] { return true; };
Feedback feedback;
// Nothing polled yet: the shell (tabs, status pill) is already drawn.
app.update({}, 1.0f / 60.0f, feedback);
+391
View File
@@ -0,0 +1,391 @@
// Wings TV app - "Is there a newer Wings?": release JSON, tag rules, advice, saved answer, the check service
// (due time, retry window, the switch, the network gate) and the words Settings shows. No test uses the network.
// SPDX-License-Identifier: GPL-3.0-or-later
#include "orbit/i18n.hpp"
#include "orbit/prefs.hpp"
#include "orbit/wings_update.hpp"
#include <gtest/gtest.h>
#include <stdlib.h>
#include <unistd.h>
#include <cstdint>
#include <string>
namespace orbit
{
namespace
{
// The shape "releases/latest" answers with, trimmed to what we read plus a few fields we must ignore.
const char *const kLatest = R"({"url":"https://api.github.com/x","tag_name":"v0.1.2","name":"Wings 0.1.2",
"draft":false,"prerelease":false,"published_at":"2026-10-20T10:00:00Z",
"html_url":"https://example.invalid/not-used","assets":[{"name":"PPSA99178.ffpkg","size":54394880}],
"body":"Notes with \"quotes\" and unicode é."})";
std::string tmp_dir()
{
char path[] = "/tmp/wings-upd-XXXXXX";
return mkdtemp(path) != nullptr ? std::string(path) : std::string("/tmp");
}
struct FakeFetcher final : DocFetcher
{
int calls = 0;
std::string last_url;
DocResult result;
DocResult get(const std::string &url, std::size_t) override
{
++calls;
last_url = url;
return result;
}
};
struct Rig
{
explicit Rig(const std::string &running = "00.001.001")
{
fetcher.result.status = 200;
fetcher.result.body = kLatest;
dir = tmp_dir();
config.docs = &fetcher;
config.ready = [this](std::string *why)
{
if (!network)
*why = "The network is off.";
return network;
};
config.cache_paths = {dir + "/wings_update.v1"};
config.running = running;
config.now = [this] { return now; };
}
void attach(WingsUpdate &u)
{
u.attach(config);
}
FakeFetcher fetcher;
WingsUpdateConfig config;
std::string dir;
std::int64_t now = 2000000000;
bool network = true;
};
TEST(WingsUpdatePrefs, TheSwitchIsOnByDefaultAndIsKept)
{
WingsLocalPrefs prefs;
EXPECT_TRUE(prefs.update_check);
prefs.update_check = false;
WingsLocalPrefs back;
ASSERT_TRUE(decode_local_prefs(encode_local_prefs(prefs), &back));
EXPECT_FALSE(back.update_check);
EXPECT_TRUE(back == prefs);
prefs.update_check = true;
ASSERT_TRUE(decode_local_prefs(encode_local_prefs(prefs), &back));
EXPECT_TRUE(back.update_check);
// A file written before the switch existed has no key: the default (on) applies.
WingsLocalPrefs old;
ASSERT_TRUE(decode_local_prefs("region=USA\n", &old));
EXPECT_TRUE(old.update_check);
}
TEST(WingsUpdateRelease, ReadsTheLatestReleaseTag)
{
std::string tag;
std::string error;
ASSERT_TRUE(parse_wings_latest(kLatest, &tag, &error)) << error;
EXPECT_EQ(tag, "v0.1.2");
}
TEST(WingsUpdateRelease, RefusesAnythingThatIsNotAPublishedVersionedRelease)
{
std::string tag;
std::string error;
EXPECT_FALSE(parse_wings_latest("", &tag, &error));
EXPECT_FALSE(parse_wings_latest("not json", &tag, &error));
EXPECT_FALSE(parse_wings_latest("[]", &tag, &error)); // the list endpoint
EXPECT_FALSE(parse_wings_latest("{\"message\":\"Not Found\"}", &tag, &error)); // no tag
EXPECT_FALSE(parse_wings_latest("{\"tag_name\":\"v0.1.2\",\"draft\":true}", &tag, &error));
EXPECT_FALSE(parse_wings_latest("{\"tag_name\":\"v0.2.0\",\"prerelease\":true}", &tag, &error));
EXPECT_FALSE(parse_wings_latest("{\"tag_name\":\"latest\"}", &tag, &error));
EXPECT_FALSE(parse_wings_latest("{\"tag_name\":\"v0.1\"}", &tag, &error));
EXPECT_FALSE(parse_wings_latest("{\"tag_name\":\"v0.1.2-beta\"}", &tag, &error));
EXPECT_FALSE(parse_wings_latest("{\"tag_name\":5}", &tag, &error));
EXPECT_TRUE(tag.empty());
EXPECT_FALSE(error.empty());
}
TEST(WingsUpdateAdvice, ComparesTheAppWithTheLatestTag)
{
EXPECT_EQ(wings_advice("00.001.001", ""), WingsAdvice::not_checked);
EXPECT_EQ(wings_advice("00.001.001", "v0.1.2"), WingsAdvice::newer);
EXPECT_EQ(wings_advice("00.001.001", "v0.1.1"), WingsAdvice::up_to_date);
EXPECT_EQ(wings_advice("00.001.002", "v0.1.1"), WingsAdvice::ahead);
EXPECT_EQ(wings_advice("00.009.000", "v0.10.0"), WingsAdvice::newer); // numbers, not text
EXPECT_EQ(wings_advice("01.000.000", "v0.99.999"), WingsAdvice::ahead);
EXPECT_EQ(wings_advice("", "v0.1.2"), WingsAdvice::unreadable);
EXPECT_EQ(wings_advice("garbage", "v0.1.2"), WingsAdvice::unreadable);
EXPECT_EQ(wings_advice("00.001.001", "0.1.2"), WingsAdvice::unreadable);
}
TEST(WingsUpdateCache, DueTimeIsSixHoursAndSurvivesAClockThatWentBackwards)
{
EXPECT_TRUE(wings_check_due(0, 1000, false)); // never checked
EXPECT_FALSE(wings_check_due(1000, 1000 + kWingsCheckTtlS - 1, false));
EXPECT_TRUE(wings_check_due(1000, 1000 + kWingsCheckTtlS, false));
EXPECT_TRUE(wings_check_due(5000, 1000, false)); // the clock went backwards
EXPECT_TRUE(wings_check_due(1000, 1001, true)); // the player pressed Check
}
TEST(WingsUpdateCache, SavedAnswerRoundTripsAndRejectsDamage)
{
std::int64_t at = 0;
std::string tag;
ASSERT_TRUE(decode_wings_cache(encode_wings_cache(1234567890, "v0.1.2"), &at, &tag));
EXPECT_EQ(at, 1234567890);
EXPECT_EQ(tag, "v0.1.2");
EXPECT_FALSE(decode_wings_cache("", &at, &tag));
EXPECT_FALSE(decode_wings_cache("tag=v0.1.2\n", &at, &tag)); // no time
EXPECT_FALSE(decode_wings_cache("checked=12x\ntag=v0.1.2\n", &at, &tag));
EXPECT_FALSE(decode_wings_cache("checked=1234567890123456\n", &at, &tag));
// A tag that is not a version is dropped, the time is kept.
ASSERT_TRUE(decode_wings_cache("checked=99\ntag=../../etc\n", &at, &tag));
EXPECT_EQ(at, 99);
EXPECT_TRUE(tag.empty());
}
TEST(WingsUpdateService, ChecksOnceThenWaitsForTheTimeToPass)
{
Rig rig;
WingsUpdate update;
rig.attach(update);
EXPECT_TRUE(update.status().attached);
EXPECT_EQ(update.status().advice, WingsAdvice::not_checked);
update.auto_check();
update.wait_idle();
EXPECT_EQ(rig.fetcher.calls, 1);
EXPECT_EQ(rig.fetcher.last_url, kWingsLatestReleaseUrl);
WingsUpdateStatus s = update.status();
EXPECT_FALSE(s.busy);
EXPECT_TRUE(s.error.empty());
EXPECT_TRUE(s.have_latest);
EXPECT_EQ(s.latest_tag, "v0.1.2");
EXPECT_EQ(s.latest_text, "0.1.2");
EXPECT_EQ(s.running_text, "0.1.1");
EXPECT_EQ(s.advice, WingsAdvice::newer);
EXPECT_EQ(s.checked_at, rig.now);
// Within six hours nothing is asked again; after them it is.
rig.now += kWingsCheckTtlS - 1;
update.auto_check();
update.wait_idle();
EXPECT_EQ(rig.fetcher.calls, 1);
rig.now += 1;
update.auto_check();
update.wait_idle();
EXPECT_EQ(rig.fetcher.calls, 2);
}
TEST(WingsUpdateService, AnAppStartUsesTheSavedAnswerWithoutAsking)
{
Rig rig;
{
WingsUpdate first;
rig.attach(first);
first.auto_check();
first.wait_idle();
ASSERT_EQ(rig.fetcher.calls, 1);
}
// A later start, an hour on: the answer is known at once (the toast can use it) and nothing is fetched.
rig.now += 3600;
WingsUpdate second;
rig.attach(second);
const WingsUpdateStatus s = second.status();
EXPECT_EQ(s.latest_tag, "v0.1.2");
EXPECT_EQ(s.advice, WingsAdvice::newer);
second.auto_check();
second.wait_idle();
EXPECT_EQ(rig.fetcher.calls, 1);
// A new version of the app compares the same answer with its own number.
rig.config.running = "00.001.002";
WingsUpdate third;
rig.attach(third);
EXPECT_EQ(third.status().advice, WingsAdvice::up_to_date);
rig.config.running = "00.002.000";
WingsUpdate fourth;
rig.attach(fourth);
EXPECT_EQ(fourth.status().advice, WingsAdvice::ahead);
}
TEST(WingsUpdateService, TheSwitchStopsTheStartUpCheckButNotACheckTheLayerAskedFor)
{
Rig rig;
WingsUpdate update;
rig.attach(update);
update.set_enabled(false);
update.auto_check();
update.wait_idle();
EXPECT_EQ(rig.fetcher.calls, 0);
EXPECT_FALSE(update.status().enabled);
EXPECT_TRUE(update.check_now());
update.wait_idle();
EXPECT_EQ(rig.fetcher.calls, 1);
update.set_enabled(true);
EXPECT_TRUE(update.status().enabled);
}
TEST(WingsUpdateService, NothingIsAskedWhileTheNetworkIsNotReady)
{
Rig rig;
rig.network = false;
WingsUpdate update;
rig.attach(update);
update.auto_check();
update.wait_idle();
EXPECT_EQ(rig.fetcher.calls, 0);
EXPECT_TRUE(update.status().error.empty()); // the start-up check is silent about it
EXPECT_FALSE(update.check_now());
EXPECT_EQ(update.status().error, "The network is off."); // a check the player asked for says why
// The network comes up: the very next look goes ahead (no retry delay was spent on the closed gate).
rig.network = true;
update.auto_check();
update.wait_idle();
EXPECT_EQ(rig.fetcher.calls, 1);
EXPECT_TRUE(update.status().error.empty());
}
TEST(WingsUpdateService, AFailureKeepsTheOldAnswerAndIsNotRetriedEveryFrame)
{
Rig rig;
WingsUpdate update;
rig.attach(update);
update.auto_check();
update.wait_idle();
ASSERT_EQ(rig.fetcher.calls, 1);
rig.now += kWingsCheckTtlS;
rig.fetcher.result = DocResult{};
rig.fetcher.result.status = 403;
update.auto_check();
update.wait_idle();
EXPECT_EQ(rig.fetcher.calls, 2);
WingsUpdateStatus s = update.status();
EXPECT_NE(s.error.find("limiting"), std::string::npos);
EXPECT_EQ(s.latest_tag, "v0.1.2"); // the earlier answer stays
EXPECT_EQ(s.advice, WingsAdvice::newer);
update.auto_check(); // within the retry window
update.wait_idle();
EXPECT_EQ(rig.fetcher.calls, 2);
rig.now += kWingsRetryS;
rig.fetcher.result.status = 200;
rig.fetcher.result.body = "{\"tag_name\":\"v0.1.3\"}";
update.auto_check();
update.wait_idle();
EXPECT_EQ(rig.fetcher.calls, 3);
EXPECT_EQ(update.status().latest_tag, "v0.1.3");
EXPECT_TRUE(update.status().error.empty());
}
TEST(WingsUpdateService, EveryKindOfBadAnswerIsAnErrorAndNeverANewerRelease)
{
const struct
{
int status;
const char *body;
const char *error;
} cases[] = {
{404, "{\"message\":\"Not Found\"}", "No release"},
{500, "", "HTTP 500"},
{200, "<html>not json</html>", "could not be read"},
{200, "{\"tag_name\":\"v9.9.9\",\"draft\":true}", "could not be read"},
{200, "{\"tag_name\":\"junk\"}", "could not be read"},
};
for (const auto &c : cases)
{
Rig rig;
rig.fetcher.result.status = c.status;
rig.fetcher.result.body = c.body;
WingsUpdate update;
rig.attach(update);
update.auto_check();
update.wait_idle();
const WingsUpdateStatus s = update.status();
EXPECT_NE(s.error.find(c.error), std::string::npos) << c.body << " -> " << s.error;
EXPECT_FALSE(s.have_latest) << c.body;
EXPECT_EQ(s.advice, WingsAdvice::not_checked) << c.body;
}
// A transport failure carries its own sentence.
Rig rig;
rig.fetcher.result = DocResult{};
rig.fetcher.result.error = "connection refused";
WingsUpdate update;
rig.attach(update);
update.auto_check();
update.wait_idle();
EXPECT_EQ(update.status().error, "connection refused");
}
TEST(WingsUpdateService, NotAttachedDoesNothing)
{
WingsUpdate update;
EXPECT_FALSE(update.status().attached);
update.auto_check();
EXPECT_FALSE(update.check_now());
update.wait_idle();
const WingsUpdateText words = wings_update_text(update.status());
EXPECT_EQ(words.value, "-");
}
TEST(WingsUpdateWords, SayWhatIsKnown)
{
WingsUpdateStatus s;
s.attached = true;
s.running_text = "0.1.1";
EXPECT_EQ(wings_update_text(s).value, "Check"); // never checked
s.busy = true;
EXPECT_EQ(wings_update_text(s).value, "Checking...");
s.busy = false;
s.have_latest = true;
s.latest_tag = "v0.1.2";
s.latest_text = "0.1.2";
s.advice = WingsAdvice::newer;
EXPECT_EQ(wings_update_text(s).value, "0.1.2 available");
EXPECT_NE(wings_update_text(s).detail.find("0.1.1"), std::string::npos);
s.advice = WingsAdvice::up_to_date;
EXPECT_EQ(wings_update_text(s).value, "Up to date");
s.advice = WingsAdvice::ahead;
EXPECT_EQ(wings_update_text(s).value, "Newer than the release");
s.error = "connection refused";
EXPECT_EQ(wings_update_text(s).value, "Check");
EXPECT_NE(wings_update_text(s).detail.find("connection refused"), std::string::npos);
// A newer release is still the news when a later look failed.
s.advice = WingsAdvice::newer;
EXPECT_EQ(wings_update_text(s).value, "0.1.2 available");
}
TEST(WingsUpdateWords, TheGuideHasFourStepsThatFitTheDialog)
{
WingsUpdateStatus s;
s.attached = true;
s.running_text = "0.1.1";
s.latest_text = "0.1.2";
const WingsUpdateGuide g = wings_update_guide(s);
EXPECT_EQ(g.title, "Update to Wings 0.1.2");
EXPECT_NE(g.subtitle.find("0.1.1"), std::string::npos);
ASSERT_EQ(g.steps.size(), 4u);
for (const WingsUpdateStep &step : g.steps)
{
EXPECT_FALSE(step.label.empty());
EXPECT_LE(step.label.size(), 60u); // one line in the list dialog
EXPECT_LE(step.detail.size(), 90u);
}
EXPECT_NE(g.steps[0].detail.find(kWingsReleasePage), std::string::npos);
EXPECT_NE(wings_update_toast(s).find("0.1.2"), std::string::npos);
}
} // namespace
} // namespace orbit
+4
View File
@@ -260,6 +260,10 @@ for catalogue in "$root"/../i18n/*.json; do
[[ $(basename "$catalogue") == en.json ]] || cp "$catalogue" "$app/assets/i18n/"
done
shopt -u nullglob
# Wings: every build says which build it is (assets/build-info.json): the time it was made and its
# channel (test unless WINGS_CHANNEL=release). tools/mark-release-wsl.sh turns a test package into a
# release one later without compiling; see tools/wings-stamp.py.
python3 "$root/tools/wings-stamp.py" write "$app/assets/build-info.json"
# Directory listing returns nothing under /app0 on the console, so each
# audio folder carries an index of its files (read by save::list_files).
for dir in "$app/assets/audio/music" "$app"/assets/audio/sfx/*/; do
-77
View File
@@ -1,77 +0,0 @@
"""Reference 'Best' ranking for one game page section. Candidates are mirrors:
a single link, or one multipart set on one host (group_parts)."""
TIER_RANK = {"native_pkg": 0, "debrid": 1, "automated": 2, "browser": 3, "unknown": 4, "unsupported": 5}
TIER_TEXT = {"native_pkg": "PKG to PS5", "debrid": "Debrid", "automated": "Automatic",
"browser": "Browser", "unknown": "Unknown host", "unsupported": "Not supported"}
TIER_WHY = {"native_pkg": "goes straight to the PS5 downloads", "debrid": "debrid: fast, no captcha",
"automated": "Pegasus fetches it by itself", "browser": "needs the PS5 browser",
"unknown": "unknown host", "unsupported": "not supported"}
DEFAULT_PREFS = {"firmware": None, # e.g. "10.xx"; None = not set (fw is shown, not ranked)
"region": None, # "USA"/"EUR"/"JPN"/"ASIA"/...; None = any
"format": None, # "exFAT"/"FPKG"; None = any
"prefer_debrid": True, # False moves debrid to after automated
"prefer_single_file": True,
"hidden_hosts": set(),
"show_incompatible": True} # shown dimmed, never picked as Best
def fw_num(fw):
a, b = fw.split(".")
return int(a) + (0.99 if b == "xx" else int(b) / 100.0)
def fw_fit(cand_fw, user_fw):
"""0 runs, 1 unknown, 2 too new for this console."""
if not user_fw or not cand_fw:
return 1 if user_fw else 0
# '10.xx' needs at least 10.00; the console '10.xx' counts as 10.99
need = int(cand_fw.split(".")[0]) + (0 if cand_fw.endswith("xx") else int(cand_fw.split(".")[1]) / 100.0)
return 0 if need <= fw_num(user_fw) else 2
def ver_tuple(v):
return tuple(int(x) for x in v.split(".")) if v else ()
def rank_key(c, prefs):
"""c: dict(tier, fw (effective min fw), region, format, parts (0 = single), set_status,
size_known, version, source_order, index)."""
tier = TIER_RANK[c["tier"]]
if not prefs["prefer_debrid"] and c["tier"] == "debrid":
tier = 2.5
fit = fw_fit(c["fw"], prefs["firmware"])
region = 0 if not prefs["region"] or c["region"] == prefs["region"] else (1 if c["region"] else 0.5)
fmt = 0 if not prefs["format"] or c["format"] == prefs["format"] else 1
parts_class = {"single": 0, "complete": 1, "restarts": 2, "unnumbered": 2, "gaps": 3}[c["set_status"]]
parts = (parts_class, c["parts"]) if prefs["prefer_single_file"] else (parts_class >= 2, 0)
return (fit == 2, tier, fit, region, fmt, parts, not c["size_known"],
tuple(-x for x in ver_tuple(c["version"])), c["source_order"], c["index"])
def best(cands, prefs=DEFAULT_PREFS):
pool = [c for c in cands if c["host"] not in prefs["hidden_hosts"]]
if not pool:
return None, ""
b = min(pool, key=lambda c: rank_key(c, prefs))
chips = [TIER_TEXT[b["tier"]]]
if b["region"]:
chips.append(b["region"])
if b["fw"]:
chips.append(("BP " if b.get("backport") else "") + b["fw"])
if b["format"]:
chips.append(b["format"])
chips.append("1 file" if b["parts"] == 0 else f'{b["parts"]} parts')
why = [TIER_WHY[b["tier"]]]
fit = fw_fit(b["fw"], prefs["firmware"])
if prefs["firmware"]:
why.append({0: f'runs on your {prefs["firmware"]}', 1: "firmware not stated",
2: f'needs {b["fw"]}, newer than your {prefs["firmware"]} (no compatible link)'}[fit])
if prefs["region"] and b["region"] == prefs["region"]:
why.append("your region")
if b["parts"] == 0:
why.append("single file")
if b["version"]:
why.append("newest version" if all(ver_tuple(b["version"]) >= ver_tuple(c["version"]) for c in pool)
else f'v{b["version"]}')
return b, "Best: " + " · ".join(chips) + " — " + ", ".join(why)
-51
View File
@@ -1,51 +0,0 @@
"""Dump the reference parser's output over library.json as TSV, for diffing the C++ port.
Usage: python -I dump_golden.py <dir of linkparse.py/titlenorm.py> <library.json> <out dir>
Writes links.tsv, sets.tsv, titles.tsv, games.tsv (same format as tests/tools/links_dump.cpp).
"""
import json
import sys
sys.path.insert(0, sys.argv[1])
from linkparse import parse_link, group_parts # noqa: E402
from titlenorm import norm_title, loose_title, group_games # noqa: E402
lib = json.load(open(sys.argv[2], encoding="utf-8"))["packages"]
out = sys.argv[3]
def s(v):
if v is None:
return ""
if v is True:
return "1"
if v is False:
return "0"
return str(v).replace("\t", " ").replace("\n", " ")
with open(out + "/links.tsv", "w", encoding="utf-8", newline="\n") as fl, \
open(out + "/sets.tsv", "w", encoding="utf-8", newline="\n") as fs:
for p in lib:
for l in p["downloadLinks"]:
f = parse_link(l["name"], l["url"])
row = [p["id"], l["index"], f["region"], f["lang_from"], f["lang_to"], f["fw"], f["fw_plus"],
f["backport"], f["backport_target"], f["kind"], f["kind_inferred"], f["all_dlc"], f["format"],
f["build"], f["version"], f["release"], f["part"], f["part_unknown"], f["truncated"],
",".join(f["tags"]), f["packager"], f["host"], f["host_from"], f["size_text"], f["title_id"],
f["label"], f["residue"]]
fl.write("\t".join(s(x) for x in row) + "\n")
g = group_parts([(l["index"], l["name"], l["url"]) for l in p["downloadLinks"]])
for st in g["sets"]:
fs.write("\t".join([s(p["id"]), s(st["host"]), ",".join(map(str, st["links"])),
",".join(map(str, st["numbers"])), st["status"]]) + "\n")
games_in = [p for p in lib if p["downloadLinks"]]
with open(out + "/titles.tsv", "w", encoding="utf-8", newline="\n") as ft:
for p in lib:
ft.write("\t".join([s(p["id"]), norm_title(p["title"]), loose_title(p["title"])]) + "\n")
groups, _ = group_games(games_in)
lines = sorted(",".join(str(i) for i in sorted(p["id"] for p in g)) for g in groups)
with open(out + "/games.tsv", "w", encoding="utf-8", newline="\n") as fg:
fg.write("\n".join(lines) + "\n")
print("links", sum(len(p["downloadLinks"]) for p in lib), "games", len(groups))
-28
View File
@@ -1,28 +0,0 @@
import json, collections, sys
sys.path.insert(0, sys.argv[1])
from linkparse import parse_link, group_parts
d = json.load(open(sys.argv[2], encoding='utf-8'))['packages']
tot=0; resid=collections.Counter(); labels=collections.Counter(); cov=collections.Counter(); err=0
st=collections.Counter(); runs=collections.Counter(); hostsets=collections.Counter()
for p in d:
for l in p['downloadLinks']:
tot+=1
try: f=parse_link(l['name'],l['url'])
except Exception as e: err+=1; continue
if f['residue']: resid[l['name']]+=1
if f['label']: labels[f['label']]+=1
for k in ('host','region','kind','format','version'):
if f[k]: cov[k]+=1
if f['fw'] or f['backport_target']: cov['fw or bp target']+=1
if not f['kind_inferred']: cov['kind explicit']+=1
if f['part'] is not None or f['part_unknown']: cov['part marker']+=1
g=group_parts([(l['index'],l['name'],l['url']) for l in p['downloadLinks']])
for s in g['sets']:
st[s['status']]+=1; st['sets']+=1; st['parts']+=len(s['links'])
if s['status']=='restarts': runs[len(s['runs'])]+=1
if g['sets']: st['packages with sets']+=1
print('links',tot,'errors',err,'with residue',sum(resid.values()))
print(resid.most_common(10))
for k,v in cov.most_common(): print(f' {k}: {v} ({100*v/tot:.1f}%)')
print('labels', labels.most_common(40))
print(st, 'runs per restart set', runs)
-27
View File
@@ -1,27 +0,0 @@
"""Generate the accent/compatibility folding table used by game_group.cpp (norm_title).
Each row maps one code point to lower(NFKD(c) minus combining marks), with any
non-ASCII character of the result written as a space. Code points whose result
is blank are left out (the C++ side turns every unknown non-ASCII character into
a space, which is what the reference does at its step 7). U+2122 and U+00AE are
handled before the table (the reference replaces them with a space before NFKD).
Usage: python -I gen_fold_table.py > fold_table.inc
"""
import unicodedata
RANGES = [(0xA0, 0x250), (0x1E00, 0x1F00), (0x2000, 0x2190), (0x2460, 0x24EA),
(0xFB00, 0xFB07), (0xFF01, 0xFF5F)]
for lo, hi in RANGES:
for cp in range(lo, hi):
c = chr(cp)
if c in "™®":
continue
t = unicodedata.normalize("NFKD", c)
t = "".join(x for x in t if not unicodedata.combining(x)).lower()
if cp == 0x2019:
t = "'" # the reference maps the right single quote to an apostrophe
t = "".join(x if ord(x) < 128 else " " for x in t)
if t.strip() == "":
continue
esc = t.replace("\\", "\\\\").replace('"', '\\"')
print(' {0x%04X, "%s"},' % (cp, esc))
-90
View File
@@ -1,90 +0,0 @@
"""Emit the 75 research test vectors (docs/research/links-grouping.md 1.8) as C++ rows
for tests/unit/links_test.cpp, with the reference parser's full output.
Usage: python -I gen_vectors.py <library.json> > vectors.inc
"""
import json
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from linkparse import parse_link, host_of_url # noqa: E402
WANT = [
'USA 12.xx Base - Vikingfile part 5', 'EUR 9.xx Base - Akirabox', 'JPN 11.xx Base - Rootz part 3',
'ASIA 9.xx Base - Akirabox part 2', 'HK 10.xx Base - Rootz', 'EUR 5.00 Base - Mega',
'EUR 7.61 Base - 1fichier part 1', 'USA 13.60 Game (v01.010.000) (All DLC) - Vikingfile part 1',
'EUR 6.xx BackPort 4.xx - Vikingfile', 'USA 7.xx BackPort 4.xx - 1fichier part 3',
'USA 12.40 BackPort 4.xx - Rootz', 'EUR 9.xx BackPort 7.xx - Gofile', 'EUR BackPort 4.xx - Vikingfile',
'USA 11.xx BackPort - Akirabox', 'EUR 11.xx BackPort - Vikingfile part 1',
'EUR exFAT 01.000 Base - VIKI (exFAT)', 'USA exFAT 01.006 Base - FileK (exFAT)',
'JPN exFAT 01.060 Base - Vault (exFAT)', 'EUR exFAT 01.002 Base - AKIA (exFAT)',
'USA exFAT 01.001 Base - DATA (exFAT)', 'JPN to EUR Languages exFAT 01.000.300 Base - FileK Std (exFAT)',
'USA to EUR Languages exFAT 01.011 Base - AKIA (exFAT)',
'EUR exFAT 01.000 BackPort backport - AKIA Backport (exFAT)',
'USA to EUR Langs exFAT 01.008.001 BackPort backport - DATA Back',
'exFAT v01.052 Base - Vikingfile part 1', 'exFAT v01.000.003 Base - Vikingfile',
'EUR v0.2.5.3 Base - Filecrypt part 1', 'Release 1 Base - Akirabox',
'Release 2 DLCs (PKG – Psykzhack) - Akirabox part 1', 'USA 10.xx DLC (@Pkykzhack) - Vikingfile',
'USA 10.01 DLC (@Pkykzhack) - Akirabox', 'EUR 10.xx Only DLCs (PKG – DUPLEX) - Akirabox',
'USA 9.xx Only DLCs (EMU – Pippo) - 1fichier', 'USA 7.xx Game (v01.000)(DLC) - Rootz part 3',
'USA 12.xx Game (v01.055) (All DLCs) (APR-EMU) - Vikingfile part',
'USA 12.xx Game (v01.004.003) [APR-EMU] [ ALL DLC ] - Mediafire ',
'EUR 12.xx Game (v01.005.000) (DLC Deluxe Edition) - Akirabox pa',
'USA 12.xx Game (v17.661.002) (DLC Premium Edition) - Akirabox p', 'USA 12.xx DLC Multiplayer - Rootz part 1',
'USA 9.xx Update 01.003 - Akirabox', 'EUR DLC Unlocker - Vikingfile', 'USA 10.xx DLC 1 (@Pkykzhack) - Akirabox',
'4.xx PPSA26344 – v1.008.000 – DLC – 95.9 gb – 4.xx – ', 'DATA (exFAT)', 'Filecrypt',
'Gofile', 'AkiraBox', 'Fpkg - VikingFile', 'Fpkg Backport - DataNodes', 'Fpkg Standard - Filed',
'4.xx BackPort - DataNodes', '7.xx BackPort - Vault', 'Standard 9.xx - BuzzHeavier', 'Dump - AkiraBox',
'Fix - DataNodes', 'DLC - Filek', 'Viki', 'Archive', 'Akirabox - pt.7', 'Akirabox - 4.xx+ - pt.5',
'Viki - DLC', 'Viki (+Arabic Lang)', 'Viki - Backport - 4.xx+', '1fichier (Backport 4.xx)',
'Archive (Game + DLC)', 'Archive - 5xx (Game + DLC)', 'Archive - FW 10.xx - 10.xx+', '1fichier- 5.xx Backport',
'Data - DLC by Psykzhack - DLC', 'FULL GAME UNLOCK (DUPLEX – PKG) - DLC', 'BASE GAME - akirabox.com',
'BASE GAME + DLC - akirabox.to', 'FPKG BACKPORT - akirabox.to', 'Game - vik1ngfile.site',
'DLC - fuckingfast.net',
]
def c(s):
if s is None:
s = ""
out = '"'
for b in str(s).encode("utf-8"):
ch = chr(b)
if b >= 0x80:
out += "\\%03o" % b
elif ch in '"\\':
out += "\\" + ch
else:
out += ch
return out + '"'
def bl(v):
return "true" if v else "false"
KIND = {"base": "LinkKind::base", "game+dlc": "LinkKind::game_dlc", "dlc": "LinkKind::dlc",
"update": "LinkKind::update", "fix": "LinkKind::fix"}
FMT = {None: "LinkFormat::none", "exFAT": "LinkFormat::exfat", "FPKG": "LinkFormat::fpkg", "PKG": "LinkFormat::pkg",
"Dump": "LinkFormat::dump", "FFPFS": "LinkFormat::ffpfs"}
FROM = {None: "HostSource::none", "name": "HostSource::name", "url": "HostSource::url"}
lib = json.load(open(sys.argv[1], encoding="utf-8"))["packages"]
urls = {}
for p in lib:
for l in p["downloadLinks"]:
urls.setdefault(l["name"], l["url"])
n = 0
for w in WANT:
if w not in urls:
continue
n += 1
f = parse_link(w, urls[w])
print(" {%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %d, %d, %s, %s, %s, %s, %s, %s, %s, %s, %s}," % (
c(w), c(host_of_url(urls[w])), c(f["region"]), c(f["lang_from"]), c(f["lang_to"]), c(f["fw"]), bl(f["fw_plus"]),
bl(f["backport"]), c(f["backport_target"]), KIND[f["kind"]], bl(f["kind_inferred"]), bl(f["all_dlc"]),
FMT[f["format"]], bl(f["build"]), c(f["version"]), -1 if f["release"] is None else f["release"],
-1 if f["part"] is None else f["part"], bl(f["part_unknown"]), bl(f["truncated"]), c(",".join(f["tags"])),
c(f["packager"]), c(f["host"]), FROM[f["host_from"]], c(f["size_text"]), c(f["title_id"]), c(f["label"])))
print("// %d vectors" % n)
-379
View File
@@ -1,379 +0,0 @@
"""Reference link-name parser for Wings (phase 5). Pure functions, stdlib only.
parse_link(name, url) -> dict of fields; group_parts(links) -> multipart sets.
Tested over all 35,559 links of recon/samples/library.json (0 exceptions, 0 residue).
The C++ port mirrors it step by step. Note: std::regex has no lookbehind; the
lookbehind guards below become a manual "previous char is not alnum/'.'" check.
"""
import collections
import re
from urllib.parse import urlsplit
TRUNC_BYTES = 63 # Pegasus stores link names in 63 UTF-8 bytes; longer names are cut
# Host alias as written in names -> canonical host label. Keys are lower case.
HOST_ALIASES = {
"akirabox": "Akirabox", "akira": "Akirabox", "akia": "Akirabox",
"akirabox.com": "Akirabox", "akirabox.to": "Akirabox",
"vikingfile": "VikingFile", "viki": "VikingFile", "vikingfile.com": "VikingFile",
"vik1ngfile.site": "VikingFile",
"rootz": "Rootz", "1fichier": "1fichier", "mediafire": "MediaFire",
"buzzheavier": "BuzzHeavier", "buzz": "BuzzHeavier", "ts": "BuzzHeavier",
"rapidgator": "Rapidgator", "gofile": "Gofile", "gofile.io": "Gofile",
"filecrypt": "Filecrypt", "ranoz": "Ranoz", "transfer.it": "Transfer.it", "mega": "Mega",
"datanodes": "DataNodes", "datanodes.to": "DataNodes",
"data": "DataNodes", # DLPS "DATA (exFAT)"; PFS "Data - DLC" is datavaults (see URL rule)
"filek": "FileKeeper", "filekeeper": "FileKeeper",
"filed": "FileDitch", "fileditch": "FileDitch",
"vault": "DataVaults", "datavaults.co": "DataVaults",
"archive": "archive.org", "download": "",
"fuckingfast.net": "FuckingFast", "urlvanish.com": "urlvanish", "tinyurl.com": "tinyurl",
"1024terabox.com": "TeraBox", "link-vault.org": "link-vault",
}
# URL hosts that only wrap another host: the real host is the one named in the link.
WRAPPER_HOSTS = {"pippo26442999.github.io", "link-vault.org", "tinyurl.com", "urlvanish.com",
"filecrypt.cc"}
URL_HOST_LABEL = {
"akirabox.com": "Akirabox", "akirabox.to": "Akirabox", "vikingfile.com": "VikingFile",
"vik1ngfile.site": "VikingFile", "rootz.so": "Rootz", "1fichier.com": "1fichier",
"mediafire.com": "MediaFire", "buzzheavier.com": "BuzzHeavier", "ts.buzzheavier.com": "BuzzHeavier",
"rapidgator.net": "Rapidgator", "gofile.io": "Gofile", "filecrypt.cc": "Filecrypt",
"ranoz.gg": "Ranoz", "transfer.it": "Transfer.it", "mega.nz": "Mega",
"datanodes.to": "DataNodes", "datanodes.co": "DataNodes", "filekeeper.net": "FileKeeper",
"fileditchfiles.st": "FileDitch", "theditch.st": "FileDitch", "datavaults.co": "DataVaults",
"archive.org": "archive.org", "fuckingfast.net": "FuckingFast", "1024terabox.com": "TeraBox",
}
PACKAGER_ALIASES = {"pkykzhack": "Psykzhack", "psykzhack": "Psykzhack", "realromhunter": "realromhunter",
"romhunter": "realromhunter", "high-speed007": "High-Speed007",
"high-speed9": "High-Speed007"}
FW_RE = re.compile(r"(?<![\w.])(?:FW\s*)?([1-9]|1[0-9])\.(xx|\d\d)(\+)?(?![\w.])", re.I)
FW_COMPACT_RE = re.compile(r"(?<![\w.])([1-9])xx\b", re.I) # 4xx, 7xx, 5xx
VER_RE = re.compile(r"(?<![\w.])v(\d{1,2}(?:\.\d{1,3}){1,3})(?![\w.])", re.I)
PART_RE = re.compile(r"(?:\bpart\s*|\bpt\.?\s*)(\d{1,3})\s*$", re.I)
PART_TRUNC_RE = re.compile(r"\s(?:p|pa|par|part)\s*$", re.I)
TAG_RE = re.compile(r"[\[(]\s*([^\[\]()]*?)\s*[\])]")
def norm_space(s):
return re.sub(r"\s+", " ", s.replace("–", "-").replace("—", "-")).strip()
def split_top(s):
"""Split on ' - ' only outside brackets."""
out, depth, cur, i = [], 0, "", 0
while i < len(s):
c = s[i]
if c in "([":
depth += 1
elif c in ")]":
depth = max(0, depth - 1)
if depth == 0 and s.startswith(" - ", i):
out.append(cur); cur = ""; i += 3; continue
if depth == 0 and s.startswith("- ", i) and cur.endswith("fichier"): # '1fichier- 5.xx'
out.append(cur); cur = ""; i += 2; continue
cur += c; i += 1
out.append(cur)
return [x.strip() for x in out if x.strip() != ""] or [""]
def host_of_url(url):
try:
h = (urlsplit(url).hostname or "").lower()
except ValueError:
return ""
return h[4:] if h.startswith("www.") else h
def match_host_word(seg, allow_prefix):
"""Return (label, rest) if seg starts with a host alias."""
low = seg.lower()
best = None
for alias in sorted(HOST_ALIASES, key=len, reverse=True):
a = alias.strip()
if low == a or low.startswith(a + " ") or low.startswith(a + "(") or low.startswith(a + "-"):
best = (HOST_ALIASES[alias], seg[len(a):].strip(" -"))
break
if best is None and allow_prefix and len(low) >= 2:
first = low.split(" ")[0]
for alias in sorted(HOST_ALIASES, key=len, reverse=True):
if len(first) >= 4 and alias.startswith(first) and " " not in seg.strip():
return HOST_ALIASES[alias], ""
return best
def parse_link(name, url=""):
raw = name
f = {
"region": None, "lang_from": None, "lang_to": None,
"fw": None, "fw_plus": False, "backport": False, "backport_target": None,
"kind": None, "kind_inferred": False, "all_dlc": False,
"format": None, "build": None, "version": None, "release": None,
"part": None, "part_unknown": False, "truncated": False,
"tags": [], "packager": None, "host": None, "host_from": None,
"size_text": None, "title_id": None, "label": "", "residue": "",
}
f["truncated"] = len(raw.encode("utf-8")) >= TRUNC_BYTES
s = norm_space(raw)
# 1. multipart suffix (always last)
m = PART_RE.search(s)
if m:
f["part"] = int(m.group(1)); s = s[:m.start()].rstrip(" -")
elif f["truncated"] and PART_TRUNC_RE.search(s):
f["part_unknown"] = True; s = PART_TRUNC_RE.sub("", s)
# 2. host segment: last top-level segment, else first (PFS puts the host first)
segs = split_top(s)
host_seg = None
if len(segs) > 1:
hm = match_host_word(segs[-1], allow_prefix=f["truncated"])
if hm:
host_seg = len(segs) - 1
else:
hm = match_host_word(segs[0], allow_prefix=False)
if hm:
host_seg = 0
else:
hm = match_host_word(segs[0], allow_prefix=False)
if hm:
host_seg = 0
if host_seg is not None:
f["host"], hrest = hm
f["host_from"] = "name"
segs[host_seg] = hrest
desc = " ".join(x for x in segs if x)
# host from URL wins unless the URL host is a wrapper
uh = host_of_url(url)
if uh and uh not in WRAPPER_HOSTS and uh in URL_HOST_LABEL:
f["host"], f["host_from"] = URL_HOST_LABEL[uh], "url"
elif uh and not f["host"]:
f["host"], f["host_from"] = uh, "url"
work = " " + desc + " "
def take(pattern, flags=re.I):
nonlocal work
mm = re.search(pattern, work, flags)
if mm:
work = work[:mm.start()] + " " + work[mm.end():]
return mm
# 3. size / embedded title id (only the PPSA26344 family)
mm = take(r"(\d+(?:\.\d+)?)\s*(gb|mb|tb)\b")
if mm:
f["size_text"] = mm.group(1) + " " + mm.group(2).upper()
mm = take(r"\b([A-Z]{4}\d{5})\b", 0)
if mm:
f["title_id"] = mm.group(1)
# 4. tags in brackets: packagers, APR-EMU, languages, DLC markers
for mm in list(TAG_RE.finditer(work)):
inner = norm_space(mm.group(1))
low = inner.lower()
consumed = True
if low in ("apr-emu",):
f["tags"].append("APR-EMU")
elif "arabic" in low:
f["tags"].append("+Arabic")
elif low.startswith("v") and VER_RE.fullmatch(inner):
f["version"] = inner[1:]
elif re.fullmatch(r"(all\s+)?dlcs?(\s+merged)?", low) or re.fullmatch(r"dlcs?\s+merged", low) \
or low.startswith("dlc ") or low in ("dlcs", "bundle dlcs") or low == "game + dlc" \
or low.startswith("all dlc"):
f["kind"] = f["kind"] or "game+dlc"
if "all" in low or "merged" in low or "bundle" in low:
f["all_dlc"] = True
if low.startswith("dlc ") and low not in ("dlc merged",):
f["label"] = inner[4:]
elif low.startswith("backport"):
f["backport"] = True
fm = FW_RE.search(inner)
if fm:
f["backport_target"] = fm.group(1) + "." + fm.group(2).lower()
elif low == "exfat":
f["format"] = "exFAT"
elif low.startswith("only work"):
fm = FW_RE.search(inner)
if fm:
f["fw"] = fm.group(1) + "." + fm.group(2).lower()
elif FW_RE.fullmatch(inner) or FW_COMPACT_RE.fullmatch(inner):
fm = FW_RE.fullmatch(inner)
f["fw"] = fm.group(1) + ".xx" if fm else inner[0] + ".xx"
elif re.search(r"\b(pkg|emu)\b", low) or "@" in inner or low in ("duplex", "pkg"):
if re.search(r"\bpkg\b", low):
f["format"] = f["format"] or "PKG"
if re.search(r"\bemu\b", low):
f["tags"].append("EMU")
names = re.findall(r"@\s*([\w\-]+)", inner)
if not names:
names = [x for x in re.split(r"\s+-\s+|\s*&\s*|\s+", inner)
if x and x.lower() not in ("pkg", "emu", "")]
if names:
f["packager"] = PACKAGER_ALIASES.get(names[0].lower(), names[0])
else:
consumed = False
if consumed:
work = work.replace(mm.group(0), " ", 1)
# bare "by Psykzhack"
mm = take(r"\bby\s+@?([\w\-]+)")
if mm:
f["packager"] = PACKAGER_ALIASES.get(mm.group(1).lower(), mm.group(1))
# 5. region and "X to Y Languages"
mm = take(r"\b(USA|EUR|JPN|ASIA|HK|KOR)\s+to\s+(EUR|JPN|USA)\s+lang(?:uage)?s\b")
if mm:
f["region"] = mm.group(1).upper(); f["lang_from"] = mm.group(1).upper(); f["lang_to"] = mm.group(2).upper()
mm = take(r"\b(USA|EUR|JPN|ASIA|HK|KOR)\b", 0)
if mm and not f["region"]:
f["region"] = mm.group(1)
# 6. release number
mm = take(r"\brelease\s+(\d+)\b")
if mm:
f["release"] = int(mm.group(1))
# 7. format words
if take(r"\bexFAT\b"):
f["format"] = "exFAT"
if take(r"\bf?fpkg\b"):
f["format"] = "FPKG"
if take(r"\bffpfs\b"):
f["format"] = "FFPFS"
if take(r"\bpkg\b"):
f["format"] = f["format"] or "PKG"
if take(r"\bdump\b"):
f["format"] = f["format"] or "Dump"
# 8. backport (+ target), standard build
while True: # 'BackPort backport - AKIA Backport (exFAT)' says it twice
mm = take(r"\bback\s*port\b(?:\s+(?:([1-9]|1[0-9])\.(xx|\d\d)|([1-9])xx|backport))?")
if not mm:
break
f["backport"] = True
if mm.group(1):
f["backport_target"] = mm.group(1) + "." + mm.group(2).lower()
elif mm.group(3):
f["backport_target"] = mm.group(3) + ".xx"
if f["truncated"] and take(r"\s(?:b|ba|bac|back|backp|backpo|backpor)\s*$"):
f["backport"] = True # cut-off 'Backport' at the 63-byte limit
if take(r"\b(?:standard|std)\b"):
f["build"] = "standard"
# 9. firmware tokens (left of the backport keyword = original build fw)
fws = []
while True:
mm = take(r"(?<![\w.])(?:FW\s*)?([1-9]|1[0-9])\.(xx|\d\d)(\+)?(?![\w.])")
if not mm:
mm2 = take(r"(?<![\w.])([1-9])xx\b(\+)?")
if not mm2:
break
fws.append((mm2.group(1) + ".xx", bool(mm2.group(2))))
continue
fws.append((mm.group(1) + "." + mm.group(2).lower(), bool(mm.group(3))))
if fws:
f["fw"] = f["fw"] or fws[0][0]
f["fw_plus"] = any(p for _, p in fws)
if f["backport"] and not f["backport_target"] and not f["region"]:
# Pippo '4.xx BackPort', PFS '4.xx Backport', 'Backport - 4.xx+': the one fw IS the target.
# DLPS 'USA 12.xx BackPort' (region first) names the ORIGINAL build; target unknown.
f["backport_target"] = fws[-1][0]
if len(fws) == 1:
f["fw"] = None
# 10. version: vNN.NNN or bare NN.NNN(.NNN)
mm = take(r"(?<![\w.])v(\d{1,2}(?:\.\d{1,3}){1,3})(?![\w.])")
if mm:
f["version"] = f["version"] or mm.group(1)
mm = take(r"(?<![\w.])(\d{1,2}\.\d{2,4}(?:\.\d{1,3}){0,2})(?![\w.])")
if mm:
f["version"] = f["version"] or mm.group(1)
mm = take(r"\bupdate\b")
if mm:
f["kind"] = "update"
# 11. kind words
if take(r"\bfull\s+game\s+unlock\b"): # PFS 'FULL GAME UNLOCK (DUPLEX - PKG) - DLC'
f["label"] = "Full game unlock"
has_game = bool(take(r"\b(?:download\s+)?(?:base\s+)?game\b|\bbase\b"))
if has_game:
while take(r"\bbase\b|\bgame\b"):
pass
dlcm = take(r"(?:\+\s*)?(?:\bonly\s+)?(?:\ball\s+)?\bdlcs?\b(?:\s+merged)?(?:\s+unlocker)?")
while dlcm and take(r"\bdlcs?\b"):
pass # PFS repeats it: 'Data - DLC by Psykzhack - DLC'
if dlcm:
if "all" in dlcm.group(0).lower() or "merged" in dlcm.group(0).lower():
f["all_dlc"] = True
if f["kind"] == "update":
pass
elif has_game and (dlcm or f["kind"] == "game+dlc"):
f["kind"] = "game+dlc"
elif dlcm:
f["kind"] = "dlc"
elif f["kind"] == "game+dlc":
pass
elif has_game:
f["kind"] = "base"
elif take(r"\bfix\b"):
f["kind"] = "fix"
else:
f["kind"] = "base"; f["kind_inferred"] = True
# 12. leftovers: filler words, then whatever is left is a free label (e.g. DLC name)
take(r"\blink\b"); take(r"\bpre[\s-]?order\s+bonus\b|\bpre[\s-]?order\b")
left = norm_space(re.sub(r"[()\[\]+,]|(?<=\s)-(?=\s)", " ", work))
left = norm_space(re.sub(r"(?i)\b(?:by|the)\b$", "", left))
if left:
if f["kind"] in ("dlc", "game+dlc"):
f["label"] = (f["label"] + " " + left).strip() # a DLC's own name: parsed, shown as text
else:
f["residue"] = left
return f
def fw_key(fw):
"""'12.xx' -> 12.99 (upper bound of the family), '7.61' -> 7.61, None -> None."""
if not fw:
return None
a, b = fw.split(".")
return int(a) + (0.99 if b == "xx" else int(b) / 100.0)
def effective_min_fw(f):
"""The lowest firmware the link is said to run on."""
return f["backport_target"] or f["fw"]
# ----------------------------------------------------------------- multipart sets --
def release_key(f):
"""Everything that identifies a release except host and part."""
return (f["region"], f["lang_to"], f["fw"], f["backport"], f["backport_target"], f["kind"], f["all_dlc"],
f["format"], f["build"], f["version"], f["release"], f["packager"], f["label"], tuple(f["tags"]))
def group_parts(links):
"""links: [(index, name, url)] of ONE package, any order.
Returns {'singles': [(index, fields)], 'sets': [set]} where a set is
{'release': key, 'host': str, 'links': [index...] in download order, 'numbers': [...],
'status': 'complete'|'restarts'|'gaps'|'unnumbered', 'runs': [[n...], ...]}."""
parsed = [(i, n, u, parse_link(n, u)) for i, n, u in sorted(links)]
name_count = collections.Counter(n for _, n, _, _ in parsed)
singles, sets = [], collections.OrderedDict()
for i, n, u, f in parsed:
is_part = f["part"] is not None or f["part_unknown"] or (f["truncated"] and name_count[n] > 1)
if not is_part:
singles.append((i, f)); continue
key = (release_key(f), f["host"])
s = sets.setdefault(key, {"release": key[0], "host": key[1], "links": [], "raw": [], "trusted": True})
s["links"].append(i)
s["raw"].append(f["part"])
if f["truncated"] or f["part"] is None:
s["trusted"] = False # a 63-byte cut can turn 'part 12' into 'part 1'
out = []
for s in sets.values():
if s["trusted"]:
nums = s["raw"]
else:
nums = list(range(1, len(s["links"]) + 1)) # position in index order
runs, cur = [], []
for n in nums:
if cur and n <= cur[-1]:
runs.append(cur); cur = []
cur.append(n)
runs.append(cur)
gaps = any(r != list(range(r[0], r[0] + len(r))) or r[0] != 1 for r in runs)
status = ("unnumbered" if not s["trusted"] else "gaps" if gaps else
"restarts" if len(runs) > 1 else "complete")
# download order = catalogue index order (parts are listed ascending inside a run)
out.append({"release": s["release"], "host": s["host"], "links": s["links"],
"numbers": nums, "runs": runs, "status": status})
return {"singles": singles, "sets": out}
-98
View File
@@ -1,98 +0,0 @@
"""Reference title normalisation + game grouping for Wings (phase 5)."""
import re, unicodedata, collections
ROMAN = {'ii': 2, 'iii': 3, 'iv': 4, 'vi': 6, 'vii': 7, 'viii': 8, 'ix': 9, 'xi': 11, 'xii': 12,
'xiii': 13, 'xiv': 14, 'xv': 15, 'xvi': 16} # i, v, x are left alone (words)
TYPO_NUM = {'ll': '2', 'lll': '3'} # 'Little Nightmares lll'
RISK = {'bundle', 'trilogy', 'collection', 'demo', 'trial', 'soundtrack', 'ost', 'dlc', 'dlcs',
'season', 'vol', 'chapter', 'episode', 'unlock', 'pack', 'bonus'}
EDITION_TAIL = re.compile(
r"\s+(?:(?:the\s+)?(?:digital\s+deluxe|game of the year|complete|definitive)|[a-z0-9]+)\s+edition$"
r"|\s+(?:directors|final)\s+cut$|\s+(?:digital\s+)?deluxe$|\s+goty$")
def norm_title(t):
t = t.replace('™', ' ').replace('®', ' ') # before NFKD (TM -> 'TM')
t = unicodedata.normalize('NFKD', t)
t = ''.join(c for c in t if not unicodedata.combining(c)).lower()
t = re.sub(r"\[[^\]]*\]|\([^)]*\)", " ", t) # [exFAT], (DLC), (PS1)
t = re.sub(r"\bv?\d{1,2}\.\d{2,3}(?:\.\d{1,3})*\b", " ", t) # 'v01.005' (Pippo titles)
t = re.sub(r"(?<=[a-z])\.(?=[a-z])", "", t).replace('.', ' ') # P.I. -> pi
t = t.replace("&", " and ").replace("’", "'")
t = re.sub(r"'s\b", "s", t).replace("'", "")
t = re.sub(r"[^a-z0-9+]+", " ", t) # keep '+' (bundles: 'Kiwami 1 + 2')
t = re.sub(r"\bps[45]\b", " ", t)
toks = [str(ROMAN[w]) if w in ROMAN else TYPO_NUM.get(w, w) for w in t.split()]
return " ".join(toks)
def loose_title(t):
"""norm_title minus ONE trailing edition phrase ('deluxe edition', 'the complete edition')."""
n = norm_title(t)
m = EDITION_TAIL.search(n)
if m and m.start() > 0:
n = n[:m.start()].strip()
return n
def numbers(n):
return {int(w) for w in n.split() if w.isdigit() and not 1900 <= int(w) <= 2100}
def risk_words(n):
r = set(n.split()) & RISK
if '+' in n.split():
r.add('+')
return r
def same_title_id_compatible(a, b):
"""Two loose titles under ONE titleId: keep together unless there is evidence they differ."""
if numbers(a) != numbers(b):
return False # Chapter 4 / Chapter 5, MGS 2 / MGS 3, Kiwami / Kiwami 2
if risk_words(a) != risk_words(b):
return False # 'Turok' vs 'Turok Trilogy Bundle'
return bool(set(a.split()) & set(b.split())) # F1 25 vs 'No Sleep For Kaname Date': no overlap
def group_games(packages):
"""packages: list of dict(id, titleId, title). Returns list of lists of packages."""
parent = {}
def find(x):
parent.setdefault(x, x)
while parent[x] != x:
parent[x] = parent[parent[x]]
x = parent[x]
return x
def union(a, b):
parent[find(a)] = find(b)
by_tid = collections.defaultdict(list)
for p in packages:
find(p['id'])
by_tid[(p['titleId'] or '').upper() or ('notid', p['id'])].append(p)
# stage 1: titleId, split on evidence
for ps in by_tid.values():
for i in range(len(ps)):
for j in range(i + 1, len(ps)):
if same_title_id_compatible(loose_title(ps[i]['title']), loose_title(ps[j]['title'])):
union(ps[i]['id'], ps[j]['id'])
stage1 = collections.defaultdict(list)
for p in packages:
stage1[find(p['id'])].append(p)
# stage 2: join clusters (other titleIds = other regions/SKUs) whose canonical loose title is equal
by_canon = collections.defaultdict(list)
for root, ps in stage1.items():
c = collections.Counter(loose_title(p['title']) for p in ps)
canon = sorted(c.items(), key=lambda kv: (-kv[1], len(kv[0])))[0][0]
if canon:
by_canon[canon.replace(' ', '')].append(root)
for roots in by_canon.values():
for r in roots[1:]:
union(r, roots[0])
out = collections.defaultdict(list)
for p in packages:
out[find(p['id'])].append(p)
return list(out.values()), stage1
-54
View File
@@ -1,54 +0,0 @@
import json, sys, io
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
sys.path.insert(0, sys.argv[1])
from linkparse import parse_link
d = json.load(open(sys.argv[2], encoding='utf-8'))['packages']
names = {}
for p in d:
for l in p['downloadLinks']:
names.setdefault(l['name'], (p['sourceName'], l['url']))
want = [
'USA 12.xx Base - Vikingfile part 5', 'EUR 9.xx Base - Akirabox', 'JPN 11.xx Base - Rootz part 3', 'ASIA 9.xx Base - Akirabox part 2',
'HK 10.xx Base - Rootz', 'EUR 5.00 Base - Mega', 'EUR 7.61 Base - 1fichier part 1', 'USA 13.60 Game (v01.010.000) (All DLC) - Vikingfile part 1',
'EUR 6.xx BackPort 4.xx - Vikingfile', 'USA 7.xx BackPort 4.xx - 1fichier part 3', 'USA 9.xx BackPort 4.03 - Akirabox', 'USA 12.40 BackPort 4.xx - Rootz',
'EUR 9.xx BackPort 7.xx - Gofile', 'EUR BackPort 4.xx - Vikingfile', 'USA 11.xx BackPort - Akirabox', 'EUR 11.xx BackPort - Vikingfile part 1',
'EUR exFAT 01.000 Base - VIKI (exFAT)', 'USA exFAT 01.006 Base - FileK (exFAT)', 'JPN exFAT 01.060 Base - Vault (exFAT)', 'EUR exFAT 01.002 Base - AKIA (exFAT)',
'USA exFAT 01.001 Base - DATA (exFAT)', 'JPN to EUR Languages exFAT 01.000.300 Base - FileK Std (exFAT)', 'USA to EUR Languages exFAT 01.011 Base - AKIA (exFAT)',
'EUR exFAT 01.000 BackPort backport - AKIA Backport (exFAT)', 'USA to EUR Langs exFAT 01.008.001 BackPort backport - DATA Back',
'exFAT v01.052 Base - Vikingfile part 1', 'exFAT v01.000.003 Base - Vikingfile', 'EUR v0.2.5.3 Base - Filecrypt part 1', 'Release 1 Base - Akirabox', 'Release 2 DLCs (PKG \u2013 Psykzhack) - Akirabox part 1',
'USA 10.xx DLC (@Pkykzhack) - Vikingfile', 'USA 10.01 DLC (@Pkykzhack) - Akirabox', 'EUR 10.xx Only DLCs (PKG \u2013 DUPLEX) - Akirabox', 'USA 9.xx Only DLCs (EMU \u2013 Pippo) - 1fichier',
'USA 7.xx Game (v01.000)(DLC) - Rootz part 3', 'USA 12.xx Game (v01.055) (All DLCs) (APR-EMU) - Vikingfile part', 'USA 12.xx Game (v01.004.003) [APR-EMU] [ ALL DLC ] - Mediafire ',
'EUR 12.xx Game (v01.005.000) (DLC Deluxe Edition) - Akirabox pa', 'USA 12.xx Game (v17.661.002) (DLC Premium Edition) - Akirabox p', 'USA 12.xx DLC Multiplayer - Rootz part 1',
'USA 9.xx Update 01.003 - Akirabox', 'EUR DLC Unlocker - Vikingfile', 'USA 10.xx DLC 1 (@Pkykzhack) - Akirabox',
'4.xx PPSA26344 \u2013 v1.008.000 \u2013 DLC \u2013 95.9 gb \u2013 4.xx \u2013 ', 'DATA (exFAT)', 'Filecrypt', 'Gofile',
'AkiraBox', 'Fpkg - VikingFile', 'Fpkg Backport - DataNodes', 'Fpkg Standard - Filed', '4.xx BackPort - DataNodes', '7.xx BackPort - Vault', 'Standard 9.xx - BuzzHeavier', 'Dump - AkiraBox', 'Fix - DataNodes', 'DLC - Filek',
'Viki', 'Archive', 'Akirabox - pt.7', 'Akirabox - 4.xx+ - pt.5', 'Viki - DLC', 'Viki (+Arabic Lang)', 'Viki - Backport - 4.xx+', '1fichier (Backport 4.xx)', 'Archive (Game + DLC)', 'Archive - 5xx (Game + DLC)', 'Archive - FW 10.xx - 10.xx+', '1fichier- 5.xx Backport', 'Data - DLC by Psykzhack - DLC', 'FULL GAME UNLOCK (DUPLEX \u2013 PKG) - DLC',
'BASE GAME - akirabox.com', 'BASE GAME + DLC - akirabox.to', 'FPKG BACKPORT - akirabox.to', 'Game - vik1ngfile.site', 'DLC - fuckingfast.net',
]
missing=[w for w in want if w not in names]
print('missing', missing)
cols=['region','fw','backport_target','kind','format','version','part','host','extra']
print('| # | Source | Link name (exact) | region | fw | BP target | kind | format | version | part | host | other |')
print('|---|---|---|---|---|---|---|---|---|---|---|---|')
i=0
for w in want:
if w not in names: continue
i+=1
src,url=names[w]; f=parse_link(w,url)
part = str(f['part']) if f['part'] is not None else ('? (cut)' if f['part_unknown'] else '')
kind = f['kind'] + ('*' if f['kind_inferred'] else '') + (' all' if f['all_dlc'] else '')
extra=[]
if f['backport'] and not f['backport_target']: extra.append('backport (target ?)')
if f['build']: extra.append('build=standard')
if f['packager']: extra.append('@'+f['packager'])
if f['tags']: extra.append(','.join(f['tags']))
if f['lang_to']: extra.append('langs '+f['lang_from']+'->'+f['lang_to'])
if f['release']: extra.append('release '+str(f['release']))
if f['fw_plus']: extra.append('fw+')
if f['label']: extra.append('label "'+f['label']+'"')
if f['size_text']: extra.append('size '+f['size_text'])
if f['title_id']: extra.append(f['title_id'])
if f['truncated']: extra.append('truncated')
if f['host_from']=='name': extra.append('host from name')
nm=w
print(f"| {i} | {src.split()[0]} | `{nm}` | {f['region'] or ''} | {f['fw'] or ''} | {f['backport_target'] or ''} | {kind} | {f['format'] or ''} | {f['version'] or ''} | {part} | {f['host'] or ''} | {'; '.join(extra)} |")
+13 -4
View File
@@ -9,19 +9,22 @@ set -euo pipefail
root=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)
cd "$root"
title_id=$(python3 -c 'import json;print(json.load(open("sce_sys/param.json"))["titleId"])')
image=dist/$title_id.ffpkg
# WINGS_DIST: the folder holding the package (default dist; tools/mark-release-wsl.sh points it at build-out).
dist=${WINGS_DIST:-dist}
image=$dist/$title_id.ffpkg
runner=$(bash tools/setup-packaging-dependencies.sh ffpkg)
mkdir -p build
temporary=$(mktemp -d "$root/build/verify-package.XXXXXX")
trap 'rm -rf -- "$temporary" "$temporary.log"' EXIT
"$runner" extract "$image" "$temporary" >"$temporary.log"
python3 - "$temporary" "$title_id" <<'PY'
python3 - "$temporary" "$title_id" "$dist" <<'PY'
import hashlib, json, struct, sys
from pathlib import Path
extracted = Path(sys.argv[1])
title_id = sys.argv[2]
staged = Path('dist') / title_id
dist = Path(sys.argv[3])
staged = dist / title_id
def digest(path):
@@ -52,6 +55,11 @@ for name in ('inter-display-light', 'inter-regular', 'inter-medium', 'inter-semi
assert (extracted / 'assets/fonts' / f'{name}.huifont').is_file(), f'missing font {name}'
assert (extracted / 'assets/fonts/Inter-LICENSE.txt').is_file(), 'Inter licence must ship with the fonts'
assert (extracted / 'sce_module/libc.prx').is_file(), 'missing runtime libc.prx'
# The build stamp (tools/wings-stamp.py): which build this is, and whether it is a test or a release package.
import re as _stamp_re
_stamp = _stamp_re.fullmatch(r'\{"build":"(\d{8}-\d{6})","channel":"(test |release)"\}\n',
(extracted / 'assets/build-info.json').read_text())
assert _stamp, 'assets/build-info.json is missing or is not a valid build stamp'
# Nothing of Orbit's backend may ride along.
assert not (extracted / 'orbit').exists(), 'an orbit/ payload directory is in the package'
@@ -80,7 +88,8 @@ assert b'127.0.0.1:6970' in eboot or b'6970' in eboot, 'eboot.bin does not menti
record = {'titleId': title_id, 'contentId': param['contentId'],
'contentVersion': param['contentVersion'],
'imageSha256': digest(Path('dist') / f'{title_id}.ffpkg'),
'build': _stamp.group(1), 'channel': _stamp.group(2).strip(),
'imageSha256': digest(dist / f'{title_id}.ffpkg'),
'appSha256': digest(extracted / 'eboot.bin'), 'files': len(folder),
'caBundleCertificates': certificates}
Path('build/package-verification.json').write_text(json.dumps(record, indent=2) + '\n')
+177
View File
@@ -0,0 +1,177 @@
#!/usr/bin/env python3
"""Wings build identity: every package says which build it is, and a test build can be turned into a
release without compiling again.
wings-stamp.py write FILE write the stamp for the build in progress (tools/build.sh calls this)
wings-stamp.py release DIST turn the test package in DIST into a release package
wings-stamp.py show DIST print the stamp of the package in DIST
The stamp is assets/build-info.json inside the package: {"build":"20261012-203105","channel":"test "}
* build = UTC time of the build (YYYYMMDD-HHMMSS), so every build is different and they sort by age.
WINGS_BUILD_ID overrides it (the build scripts set it once so a whole run agrees).
* channel = "test " or "release", padded to the same length, so the flip changes bytes in place and
nothing in the file system image moves. WINGS_CHANNEL chooses it (default: test).
The app version users see is still param.json's contentVersion; the stamp only says which build of it this is.
`release` patches the app folder, rebuilds the zip, patches the .ffpkg in place (the old stamp text must be in
the image exactly once) and rewrites SHA256SUMS.txt. tools/verify-package.sh then extracts the image and checks
every file against the folder, so a bad patch cannot pass. Run it through tools/mark-release-wsl.sh.
SPDX-License-Identifier: GPL-3.0-or-later
"""
import datetime
import hashlib
import os
import re
import sys
import zipfile
from pathlib import Path
BUILD_RE = re.compile(r"\d{8}-\d{6}")
CHANNELS = {"test": "test ", "release": "release"}
STAMP_RE = re.compile(r'\{"build":"(\d{8}-\d{6})","channel":"(test |release)"\}\n')
STAMP_NAME = "assets/build-info.json"
def stamp_text(build, channel):
if not BUILD_RE.fullmatch(build):
raise SystemExit("build id must look like 20261012-203105, got %r" % build)
if channel not in CHANNELS:
raise SystemExit("channel must be test or release, got %r" % channel)
return '{"build":"%s","channel":"%s"}\n' % (build, CHANNELS[channel])
def new_build_id():
return os.environ.get("WINGS_BUILD_ID") or datetime.datetime.now(datetime.timezone.utc).strftime("%Y%m%d-%H%M%S")
def parse(text):
m = STAMP_RE.fullmatch(text)
if not m:
raise SystemExit("not a Wings build stamp: %r" % text[:80])
return m.group(1), m.group(2).strip()
def digest(path):
h = hashlib.sha256()
with open(path, "rb") as stream:
for chunk in iter(lambda: stream.read(1 << 20), b""):
h.update(chunk)
return h.hexdigest()
def find_package(dist):
"""(title id, folder, zip, ffpkg) of the one package in DIST."""
folders = sorted(p for p in dist.iterdir() if p.is_dir() and re.fullmatch(r"PPSA\d{5}", p.name))
if len(folders) != 1:
raise SystemExit("%s must hold exactly one PPSAxxxxx folder, found %d" % (dist, len(folders)))
title = folders[0].name
for name in (title + ".zip", title + ".ffpkg"):
if not (dist / name).is_file():
raise SystemExit("missing %s in %s" % (name, dist))
return title, folders[0], dist / (title + ".zip"), dist / (title + ".ffpkg")
def cmd_write(target):
text = stamp_text(new_build_id(), os.environ.get("WINGS_CHANNEL", "test"))
path = Path(target)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(text, encoding="ascii", newline="\n")
print("build stamp: %s" % text.strip())
def patch_image(image, old, new):
"""Replace OLD by NEW (same length) in the file IMAGE; OLD must occur exactly once."""
if len(old) != len(new):
raise SystemExit("the stamp length changed; refusing to patch")
data = image.read_bytes()
count = data.count(old)
if count != 1:
raise SystemExit("the old stamp occurs %d times in %s (expected 1); nothing was changed" % (count, image.name))
at = data.index(old)
temporary = image.with_name(image.name + ".patching")
temporary.write_bytes(data[:at] + new + data[at + len(old):])
os.replace(temporary, image)
def cmd_release(dist):
dist = Path(dist)
title, folder, archive, image = find_package(dist)
stamp = folder / STAMP_NAME
old_text = stamp.read_text(encoding="ascii")
build, channel = parse(old_text)
if channel == "release":
print("%s build %s is already a release package; nothing to do" % (title, build))
else:
new_text = stamp_text(build, "release")
patch_image(image, old_text.encode("ascii"), new_text.encode("ascii"))
stamp.write_text(new_text, encoding="ascii", newline="\n")
archive.unlink()
with zipfile.ZipFile(archive, "w", zipfile.ZIP_DEFLATED) as z:
# the same layout `python3 -m zipfile -c` makes in tools/build.sh: the folder, then what is in it
z.write(folder, title + "/")
for path in sorted(folder.rglob("*")):
name = "%s/%s" % (title, path.relative_to(folder).as_posix())
z.write(path, name + ("/" if path.is_dir() else ""))
print("%s build %s: test -> release" % (title, build))
(dist / "SHA256SUMS.txt").write_text(
"%s %s\n%s %s\n" % (digest(image), image.name, digest(archive), archive.name), encoding="ascii", newline="\n")
def cmd_show(dist):
title, folder, _archive, _image = find_package(Path(dist))
build, channel = parse((folder / STAMP_NAME).read_text(encoding="ascii"))
print("%s build %s %s" % (title, build, channel))
def selftest():
import tempfile
t = stamp_text("20261012-203105", "test")
assert t == '{"build":"20261012-203105","channel":"test "}\n' and parse(t) == ("20261012-203105", "test")
r = stamp_text("20261012-203105", "release")
assert len(r) == len(t) and parse(r) == ("20261012-203105", "release")
with tempfile.TemporaryDirectory() as tmp:
image = Path(tmp) / "x.ffpkg"
image.write_bytes(b"A" * 5000 + t.encode() + b"B" * 5000)
patch_image(image, t.encode(), r.encode())
assert image.read_bytes() == b"A" * 5000 + r.encode() + b"B" * 5000
image.write_bytes(t.encode() + b"." + t.encode()) # twice: refuse
try:
patch_image(image, t.encode(), r.encode())
except SystemExit:
pass
else:
raise AssertionError("a doubled stamp must be refused")
image.write_bytes(b"no stamp here") # none: refuse
try:
patch_image(image, t.encode(), r.encode())
except SystemExit:
pass
else:
raise AssertionError("a missing stamp must be refused")
# a whole fake package through `release`
dist = Path(tmp) / "dist"
(dist / "PPSA99178" / "assets").mkdir(parents=True)
(dist / "PPSA99178" / "assets" / "build-info.json").write_text(t, newline="\n")
(dist / "PPSA99178.ffpkg").write_bytes(b"\0" * 100 + t.encode() + b"\0" * 100)
(dist / "PPSA99178.zip").write_bytes(b"old")
cmd_release(dist)
assert parse((dist / "PPSA99178" / STAMP_NAME).read_text()) == ("20261012-203105", "release")
assert r.encode() in (dist / "PPSA99178.ffpkg").read_bytes()
with zipfile.ZipFile(dist / "PPSA99178.zip") as z:
assert z.read("PPSA99178/assets/build-info.json").decode() == r
sums = (dist / "SHA256SUMS.txt").read_text().splitlines()
assert len(sums) == 2 and sums[0].endswith(" PPSA99178.ffpkg") and sums[1].endswith(" PPSA99178.zip")
cmd_release(dist) # again: no change, still fine
print("wings-stamp selftest: ok")
def main(argv):
if len(argv) == 2 and argv[1] == "selftest":
return selftest()
if len(argv) != 3 or argv[1] not in ("write", "release", "show"):
raise SystemExit(__doc__)
{"write": cmd_write, "release": cmd_release, "show": cmd_show}[argv[1]](argv[2])
if __name__ == "__main__":
main(sys.argv)
+2
View File
@@ -181,6 +181,8 @@ class ArtService
// Queue background fetches for URLs that are not cached yet. Returns how many were queued.
std::size_t prefetch(const std::vector<std::string> &urls, Kind kind = Kind::cover,
Lane lane = Lane::prefetch);
// True when the bytes for this URL are already in the disk cache. Cheap, any thread.
bool is_cached(const std::string &url);
// Drops queued (not running) prefetch and idle-fill jobs, e.g. when the grid changes.
void cancel_background();
// Test/utility: request() until it is not pending, up to timeout_ms.
+15 -1
View File
@@ -16,6 +16,9 @@
// nothing, a sample of the index is stat()ed and trusted when files exist.
// * Eviction runs at open() and after every `evict_every_writes` puts, down
// to `budget_bytes` (default 96 MiB).
// * Game-page backgrounds are 5-10 times the size of a cover, so they have a budget of their own
// (`hero_budget_bytes`) and cannot push covers out. A background fetched ahead of time (the focused
// tile) is "speculative" until a page really reads it; one nobody opened for half an hour is evicted first.
// Thread-safe.
#pragma once
@@ -33,6 +36,8 @@ struct DiskCacheConfig
std::string root; // e.g. "/download0/wings/art"
std::uint64_t budget_bytes = 96ull << 20; // LRU eviction target
std::uint64_t max_entry_bytes = 8ull << 20; // refuse larger payloads
// Backgrounds (hero and speculative entries together) may hold this much; 0: no separate limit.
std::uint64_t hero_budget_bytes = 24ull << 20;
int evict_every_writes = 200;
int index_flush_every = 64; // index changes between index writes
const Clock *clock = nullptr; // default: system clock
@@ -44,6 +49,13 @@ enum class CacheGet : std::uint8_t
hit,
};
enum class EntryKind : std::uint8_t
{
normal, // a cover or anything else
hero, // a game-page background that was asked for by a page
speculative, // a background fetched ahead of time and not read yet (a read makes it `hero`)
};
class DiskCache
{
public:
@@ -59,7 +71,8 @@ class DiskCache
// A valid entry: bytes, and when it was fetched (unix seconds).
CacheGet get(const std::string &url, std::string *bytes, std::int64_t *fetched_at);
bool put(const std::string &url, const std::uint8_t *data, std::size_t size);
bool put(const std::string &url, const std::uint8_t *data, std::size_t size,
EntryKind kind = EntryKind::normal);
void remove(const std::string &url);
// Index lookup only (no file access): is a good entry recorded for url?
bool contains(const std::string &url);
@@ -90,6 +103,7 @@ class DiskCache
std::uint64_t size = 0;
std::int64_t last_used = 0;
bool negative = false;
EntryKind entry = EntryKind::normal;
std::int64_t expires = 0; // negative records only (unix seconds)
};
void evict_locked();
+11 -1
View File
@@ -586,7 +586,11 @@ struct ArtService::Impl
log(1, "image refused " + job.url + ": " + name(v));
return;
}
if (!disk_ok || !disk->put(job.url, r.body.data(), r.body.size()))
// A background fetched ahead of time (prefetch lanes) is speculative until a page reads it.
const EntryKind entry = job.kind != Kind::hero ? EntryKind::normal
: job.lane >= Lane::prefetch ? EntryKind::speculative
: EntryKind::hero;
if (!disk_ok || !disk->put(job.url, r.body.data(), r.body.size(), entry))
keep_in_memory(job.url, std::string(reinterpret_cast<const char *>(r.body.data()), r.body.size()));
}
@@ -611,6 +615,7 @@ ArtService::ArtService(ArtConfig config) : impl_(new Impl(std::move(config)))
DiskCacheConfig dc;
dc.root = impl_->cfg.cache_root;
dc.budget_bytes = impl_->cfg.cache_budget_bytes;
dc.hero_budget_bytes = impl_->cfg.cache_budget_bytes / 4; // backgrounds are big: a quarter at most
dc.clock = impl_->cfg.clock;
disk_.reset(new DiskCache(dc));
impl_->disk = disk_.get();
@@ -882,6 +887,11 @@ std::size_t ArtService::prefetch(const std::vector<std::string> &urls, Kind kind
return queued;
}
bool ArtService::is_cached(const std::string &url)
{
return !url.empty() && disk_->contains(url);
}
void ArtService::cancel_background()
{
Impl &s = *impl_;
+72 -18
View File
@@ -10,6 +10,7 @@
#include <cstdio>
#include <cstring>
#include <set>
#include <tuple>
#include <utility>
#include <vector>
@@ -133,12 +134,13 @@ bool DiskCache::open(std::string *error)
long long used = 0;
char kind = 0;
if (std::sscanf(line.c_str(), "%16llx %llu %lld %c %llu", &h, &size, &used, &kind, &expires) == 5 &&
(kind == 'a' || kind == 'n'))
(kind == 'a' || kind == 'n' || kind == 'h' || kind == 's'))
{
Item it;
it.size = size;
it.last_used = used;
it.negative = kind == 'n';
it.entry = kind == 'h' ? EntryKind::hero : (kind == 's' ? EntryKind::speculative : EntryKind::normal);
it.expires = static_cast<std::int64_t>(expires);
index_[h] = it;
}
@@ -171,6 +173,7 @@ bool DiskCache::open(std::string *error)
it.size = f.size;
it.last_used = known != index_.end() ? known->second.last_used : f.mtime;
it.negative = neg;
it.entry = known != index_.end() && !neg ? known->second.entry : EntryKind::normal;
it.expires = known != index_.end() ? known->second.expires : 0;
if (neg && it.expires == 0)
it.expires = clock_->unix_seconds() + 24 * 3600; // unknown: let it age out
@@ -249,6 +252,8 @@ CacheGet DiskCache::get(const std::string &url, std::string *bytes, std::int64_t
it.size = raw.size();
}
it.negative = false;
if (it.entry == EntryKind::speculative)
it.entry = EntryKind::hero; // a page read it: no longer a guess
it.last_used = clock_->unix_seconds();
note_change_locked();
++stats_.hits;
@@ -265,7 +270,7 @@ CacheGet DiskCache::get(const std::string &url, std::string *bytes, std::int64_t
return CacheGet::miss;
}
bool DiskCache::put(const std::string &url, const std::uint8_t *data, std::size_t size)
bool DiskCache::put(const std::string &url, const std::uint8_t *data, std::size_t size, EntryKind kind)
{
if (!open_ || size == 0 || size > config_.max_entry_bytes)
return false;
@@ -295,11 +300,16 @@ bool DiskCache::put(const std::string &url, const std::uint8_t *data, std::size_
Item it;
it.size = head.size() + size;
it.last_used = clock_->unix_seconds();
// A background that is already known as read stays read when it is fetched again ahead of time.
it.entry = kind;
if (kind == EntryKind::speculative && old != index_.end() && old->second.entry == EntryKind::hero)
it.entry = EntryKind::hero;
index_[h] = it;
total_ += it.size;
++stats_.writes;
++writes_since_evict_;
if (total_ > config_.budget_bytes || writes_since_evict_ >= config_.evict_every_writes)
if (total_ > config_.budget_bytes || kind != EntryKind::normal ||
writes_since_evict_ >= config_.evict_every_writes)
evict_locked();
note_change_locked();
return true;
@@ -426,29 +436,69 @@ void DiskCache::drop_locked(std::uint64_t h)
}
}
constexpr std::int64_t kStaleGuessSeconds = 30 * 60;
void DiskCache::evict_locked()
{
writes_since_evict_ = 0;
if (total_ <= config_.budget_bytes)
return;
// Evict to slightly under the budget so the next write does not evict again.
const std::uint64_t target = config_.budget_bytes - config_.budget_bytes / 32;
std::vector<std::pair<std::int64_t, std::uint64_t>> order; // last_used, hash
order.reserve(index_.size());
// Backgrounds first: they have a budget of their own, so a run of game pages cannot push covers out.
std::uint64_t hero_total = 0;
for (const auto &kv : index_)
order.emplace_back(kv.second.last_used, kv.first);
std::sort(order.begin(), order.end());
for (const auto &o : order)
if (!kv.second.negative && kv.second.entry != EntryKind::normal)
hero_total += kv.second.size;
const bool hero_over = config_.hero_budget_bytes != 0 && hero_total > config_.hero_budget_bytes;
if (total_ <= config_.budget_bytes && !hero_over)
return;
const auto drop = [this](std::uint64_t hash)
{
if (total_ <= target)
break;
auto it = index_.find(o.second);
auto it = index_.find(hash);
if (it == index_.end())
continue;
::unlink(path_for_hash(o.second, it->second.negative).c_str());
return;
::unlink(path_for_hash(hash, it->second.negative).c_str());
total_ -= it->second.size;
index_.erase(it);
++stats_.evicted;
};
// Oldest first, but a background fetched ahead of time that nobody opened for half an hour goes before
// one that was read. A fresh guess is kept like any other recent entry (it was fetched a moment ago).
using Order = std::tuple<int, std::int64_t, std::uint64_t>; // rank, last_used, hash
const std::int64_t now = clock_->unix_seconds();
const auto rank = [now](const Item &item)
{ return item.entry == EntryKind::speculative && now - item.last_used > kStaleGuessSeconds ? 0 : 1; };
if (hero_over)
{
std::vector<Order> order;
for (const auto &kv : index_)
if (!kv.second.negative && kv.second.entry != EntryKind::normal)
order.emplace_back(rank(kv.second), kv.second.last_used, kv.first);
std::sort(order.begin(), order.end());
const std::uint64_t target = config_.hero_budget_bytes - config_.hero_budget_bytes / 32;
for (const Order &o : order)
{
if (hero_total <= target)
break;
const auto it = index_.find(std::get<2>(o));
if (it == index_.end())
continue;
hero_total -= it->second.size;
drop(std::get<2>(o));
}
}
if (total_ > config_.budget_bytes)
{
// Evict to slightly under the budget so the next write does not evict again.
const std::uint64_t target = config_.budget_bytes - config_.budget_bytes / 32;
std::vector<Order> order;
order.reserve(index_.size());
for (const auto &kv : index_)
order.emplace_back(rank(kv.second), kv.second.last_used, kv.first);
std::sort(order.begin(), order.end());
for (const Order &o : order)
{
if (total_ <= target)
break;
drop(std::get<2>(o));
}
}
note_change_locked();
}
@@ -476,7 +526,11 @@ void DiskCache::flush_locked()
{
std::snprintf(line, sizeof line, "%016" PRIx64 " %" PRIu64 " %lld %c %lld\n", kv.first,
kv.second.size, static_cast<long long>(kv.second.last_used),
kv.second.negative ? 'n' : 'a', static_cast<long long>(kv.second.expires));
kv.second.negative ? 'n'
: kv.second.entry == EntryKind::hero ? 'h'
: kv.second.entry == EntryKind::speculative ? 's'
: 'a',
static_cast<long long>(kv.second.expires));
text += line;
}
fs::write_atomic(config_.root + "/index.v1", text.data(), text.size(), nullptr, 0);
+61
View File
@@ -411,6 +411,66 @@ void test_doh_wire()
CHECK(p.rejected_endpoints().size() == 3);
}
void test_disk_cache_backgrounds()
{
if (!begin("disk_cache: backgrounds have their own budget; stale unread guesses go first"))
return;
FakeClock clock;
DiskCacheConfig cfg;
cfg.root = tmpdir("dch") + "/art";
cfg.budget_bytes = 100000;
cfg.hero_budget_bytes = 30000; // room for two 12000-byte backgrounds
cfg.clock = &clock;
DiskCache c(cfg);
std::string err, out;
std::int64_t f = 0;
CHECK_MSG(c.open(&err), err);
const std::string big(12000, 'h'), small(1000, 'c');
for (int i = 0; i < 20; ++i) // covers
{
clock.advance(1);
CHECK(c.put("https://x.test/cover" + std::to_string(i), u8(small), small.size()));
}
// A. fresh entries are kept by age like anything else: the oldest background goes, no cover does
clock.advance(1);
CHECK(c.put("https://x.test/read-1", u8(big), big.size(), EntryKind::hero));
clock.advance(1);
CHECK(c.put("https://x.test/read-2", u8(big), big.size(), EntryKind::hero));
clock.advance(1);
CHECK(c.put("https://x.test/guess0", u8(big), big.size(), EntryKind::speculative)); // fresh guess survives
CHECK(!c.contains("https://x.test/read-1"));
CHECK(c.contains("https://x.test/read-2"));
CHECK(c.contains("https://x.test/guess0"));
for (int i = 0; i < 20; ++i)
CHECK(c.contains("https://x.test/cover" + std::to_string(i)));
// B. an hour on, the guess nobody opened goes before the background that was read
clock.advance(3600);
CHECK(c.put("https://x.test/read-3", u8(big), big.size(), EntryKind::hero));
CHECK(!c.contains("https://x.test/guess0"));
CHECK(c.contains("https://x.test/read-2"));
CHECK(c.contains("https://x.test/read-3"));
// C. reading a guess promotes it: it then lasts like a read background
clock.advance(1);
CHECK(c.put("https://x.test/guess1", u8(big), big.size(), EntryKind::speculative));
CHECK(c.get("https://x.test/guess1", &out, &f) == CacheGet::hit);
clock.advance(3600);
clock.advance(1);
CHECK(c.put("https://x.test/guess2", u8(big), big.size(), EntryKind::speculative));
CHECK(c.contains("https://x.test/guess1"));
CHECK(c.contains("https://x.test/guess2"));
CHECK(!c.contains("https://x.test/read-3"));
// the kinds survive a restart (index.v1)
c.flush();
DiskCache again(cfg);
CHECK(again.open(&err));
CHECK(again.get("https://x.test/guess1", &out, &f) == CacheGet::hit && out == big);
clock.advance(3600);
clock.advance(1);
CHECK(again.put("https://x.test/read-4", u8(big), big.size(), EntryKind::hero));
CHECK(again.contains("https://x.test/guess1")); // still counted as read after the restart
CHECK(!again.contains("https://x.test/guess2")); // the unread guess went
}
void test_disk_cache()
{
if (!begin("disk_cache: atomic write, header check, LRU, negatives, restart"))
@@ -1365,6 +1425,7 @@ int main(int argc, char **argv)
test_dns_cache();
test_doh_wire();
test_disk_cache();
test_disk_cache_backgrounds();
test_tls_client();
test_tls_safety();
test_service_basic();
-1
View File
@@ -25,7 +25,6 @@ UI -> model.hpp / logic.hpp -> IPegasus (pegasus.hpp) -> PegasusV1 (pegasus_v1.c
| `include/wings/json.hpp`, `src/json.cpp`, `json_writer.hpp` | Orbit's small JSON DOM reader (renamed, `\b`/`\f` quoting added) + a JSON.stringify-shaped body builder |
| `tests/` | Plain C++ asserts (`test_harness.hpp`), 55 tests / ~3900 checks (`test_stream.cpp`: array-wise catalogue reading) |
| `tools/smoke.cpp` | Read-only live smoke tool (GET only) |
| `parity.csv` | route,method,adapter_fn,test,status for every Pegasus route and UI-side rule |
## Build and test
+11
View File
@@ -23,6 +23,7 @@ struct Request
std::string body; // raw body (JSON text, or raw file bytes for upload)
std::string content_type; // sent only when a body is present; "" => application/json
std::size_t max_bytes = 0; // reply size limit; 0 = kDefaultLimit
int timeout_ms = 0; // socket time limit for this request; 0 = the transport's own
};
struct Response
@@ -42,6 +43,16 @@ struct Response
constexpr std::size_t kDefaultLimit = 8u << 20;
constexpr std::size_t kLibraryLimit = 128u << 20; // /api/library is ~7 MB today
// The POSTs that start a download make Pegasus resolve a provider or pre-create
// a large file before it answers, so they get this long; everything else keeps
// the transport's own (short) limit.
constexpr int kLinkStartTimeoutMs = 30000;
// The time limit the adapter asks for one request: kLinkStartTimeoutMs for
// POST /api/queue, /api/queue/direct, /api/provider/resolve, /api/browser/open,
// /api/debrid/queue and /api/resolve/probe (any query string ignored), else 0.
int timeout_for(std::string_view method, std::string_view path);
class Transport
{
public:
+1 -1
View File
@@ -48,7 +48,7 @@ class PackageSink
virtual void package(Package &&package) = 0;
};
// One method per Pegasus route or UI flow (see parity.csv). Every method is
// One method per Pegasus route or UI flow. Every method is
// synchronous and may block on the transport: call from a worker thread.
class IPegasus
{
-76
View File
@@ -1,76 +0,0 @@
route,method,adapter_fn,test,status
/api/health,GET,IPegasus::health + Pegasus connect(),sample_health; connect_*,done; live-read-verified; UI: wired in the Wings app (PegasusBackend; Store, Downloads, Sources tabs; unit-tested on the fake backend, not on hardware)
/api/settings,GET,IPegasus::getSettings,sample_settings_and_unknown_fields; tolerant_parsing_missing_and_mistyped_fields,done; live-read-verified; UI: wired in the Wings app (PegasusBackend; Store, Downloads, Sources tabs; unit-tested on the fake backend, not on hardware)
/api/settings,POST,IPegasus::updateSettings / setDownloadRoot / setDownloadConnections / setDebridToken / clearDebridToken,write_settings,done; request-shape-tested; NOT live-tested (changes real config); UI: wired in the Wings app phase 4 (Settings: download folder, connections, tokens; unit-tested on the fake backend and request-shape-tested through PegasusBackend, not on hardware)
/api/debrid/refresh,POST,IPegasus::refreshDebrid,write_debrid_refresh,done; request-shape-tested; NOT live-tested; UI: wired in the Wings app phase 4 (Settings: check token again; unit-tested on the fake backend and request-shape-tested through PegasusBackend, not on hardware)
/api/sources,GET,IPegasus::listSources,sample_sources_and_default_sources,done; live-read-verified; UI: wired in the Wings app (PegasusBackend; Store, Downloads, Sources tabs; unit-tested on the fake backend, not on hardware)
/api/sources/url,POST,IPegasus::addSourceUrl,write_sources,done; request-shape-tested; NOT live-tested; UI: wired in the Wings app (PegasusBackend; Store, Downloads, Sources tabs; unit-tested on the fake backend, not on hardware)
/api/sources/upload,POST,IPegasus::uploadSourceCatalog,write_sources; sample_mutating_responses,done; live-sandbox-verified (tools/pegasus_contract.py)
/api/sources/delete,POST,IPegasus::deleteSource,write_sources; sample_mutating_responses,done; live-sandbox-verified (throwaway source); UI: wired in the Wings app (PegasusBackend; Store, Downloads, Sources tabs; unit-tested on the fake backend, not on hardware)
/api/sources/refresh,POST,IPegasus::refreshSource,write_sources,done; request-shape-tested; NOT live-tested; UI: wired in the Wings app (PegasusBackend; Store, Downloads, Sources tabs; unit-tested on the fake backend, not on hardware)
/api/sources/enabled,POST,IPegasus::setSourceEnabled,write_sources; sample_mutating_responses,done; live-sandbox-verified (throwaway source); UI: wired in the Wings app (PegasusBackend; Store, Downloads, Sources tabs; unit-tested on the fake backend, not on hardware)
/api/default-sources,GET,IPegasus::getDefaultSources(false),sample_sources_and_default_sources,done; live-read-verified; UI: wired in the Wings app (PegasusBackend; Store, Downloads, Sources tabs; unit-tested on the fake backend, not on hardware)
/api/default-sources?refresh=1,GET,IPegasus::getDefaultSources(true),sample_sources_and_default_sources,done; request-shape-tested; UI: wired in the Wings app (PegasusBackend; Store, Downloads, Sources tabs; unit-tested on the fake backend, not on hardware)
/api/default-sources/apply,POST,IPegasus::applyDefaultSources,write_default_sources,done; request-shape-tested; NOT live-tested (changes sources); UI: wired in the Wings app (PegasusBackend; Store, Downloads, Sources tabs; unit-tested on the fake backend, not on hardware)
/api/default-sources/seen,POST,IPegasus::markDefaultSourcesSeen,write_default_sources,done; request-shape-tested; NOT live-tested; UI: wired in the Wings app (PegasusBackend; Store, Downloads, Sources tabs; unit-tested on the fake backend, not on hardware)
/api/library,GET,IPegasus::getLibrary,sample_library_7mb_parses_fast; provider_classification_over_real_library,done; live-read-verified (88 ms parse of 7 MB); UI: wired in the Wings app (PegasusBackend; Store, Downloads, Sources tabs; unit-tested on the fake backend, not on hardware)
/api/queue,GET,IPegasus::getQueue,sample_queue; queue_waiting_states_parse,done; live-read-verified; UI: wired in the Wings app (PegasusBackend; Store, Downloads, Sources tabs; unit-tested on the fake backend, not on hardware)
/api/queue,POST,IPegasus::enqueuePackage (native .pkg handoff),write_queue; dispatch_results,done; request-shape-tested; NOT live-tested (would start a PS5 download); UI: wired in the Wings app (PegasusBackend; Store, Downloads, Sources tabs; unit-tested on the fake backend, not on hardware)
/api/queue/direct,POST,IPegasus::queueDirect,write_queue; sample_queue_direct_dispatch,done; live-sandbox-verified (tiny file; contract --queue); UI: wired in the Wings app (PegasusBackend; Store, Downloads, Sources tabs; unit-tested on the fake backend, not on hardware)
/api/queue/pause,POST,IPegasus::pauseJob,write_queue; errors_surface_status_and_message; sample_mutating_responses,done; live error path verified (unknown job); UI: wired in the Wings app (PegasusBackend; Store, Downloads, Sources tabs; unit-tested on the fake backend, not on hardware)
/api/queue/resume,POST,IPegasus::resumeJob,write_queue,done; request-shape-tested; UI: wired in the Wings app (PegasusBackend; Store, Downloads, Sources tabs; unit-tested on the fake backend, not on hardware)
/api/queue/cancel,POST,IPegasus::cancelJob,write_queue,done; live-sandbox-verified (contract --queue); UI: wired in the Wings app (PegasusBackend; Store, Downloads, Sources tabs; unit-tested on the fake backend, not on hardware)
/api/queue/retry,POST,IPegasus::retryJob,write_queue,done; request-shape-tested; UI: wired in the Wings app (PegasusBackend; Store, Downloads, Sources tabs; unit-tested on the fake backend, not on hardware)
/api/queue/clear-finished,POST,IPegasus::clearFinished,write_queue,done; live-sandbox-verified (contract --queue); UI: wired in the Wings app (PegasusBackend; Store, Downloads, Sources tabs; unit-tested on the fake backend, not on hardware)
/api/queue/archive-password,POST,IPegasus::submitArchivePassword,write_queue; queue_waiting_states_parse,done; request-shape-tested; UI: wired in the Wings app (PegasusBackend; Store, Downloads, Sources tabs; unit-tested on the fake backend, not on hardware)
/api/queue/archive-choice,POST,IPegasus::submitArchiveChoice,write_queue; queue_waiting_states_parse,done; request-shape-tested; UI: wired in the Wings app (PegasusBackend; Store, Downloads, Sources tabs; unit-tested on the fake backend, not on hardware)
/api/resolve/probe,POST,IPegasus::resolveProbe,write_link_flows; sample_mutating_responses; probe_rejection_and_capture_text,done; live-verified (example.com); UI: wired in the Wings app (PegasusBackend; Store, Downloads, Sources tabs; unit-tested on the fake backend, not on hardware)
/api/debrid/queue,POST,IPegasus::debridQueue (packageId+linkIndex+service),write_link_flows,done; request-shape-tested; needsSelection reply parsed; NOT live-tested; UI: wired in the Wings app (PegasusBackend; Store, Downloads, Sources tabs; unit-tested on the fake backend, not on hardware)
/api/debrid/queue (selection),POST,IPegasus::debridQueueSelection (selectionId+indexes+service),write_link_flows,done; request-shape-tested; NOT live-tested; UI: wired in the Wings app (PegasusBackend; Store, Downloads, Sources tabs; unit-tested on the fake backend, not on hardware)
/api/provider/resolve,POST,IPegasus::providerResolve,write_link_flows,done; request-shape-tested; NOT live-tested; UI: wired in the Wings app (PegasusBackend; Store, Downloads, Sources tabs; unit-tested on the fake backend, not on hardware)
/api/browser/open,POST,IPegasus::browserOpen,write_link_flows,done; request-shape-tested; NOT live-tested (opens PS5 browser); UI: wired in the Wings app (PegasusBackend; Store, Downloads, Sources tabs; unit-tested on the fake backend, not on hardware)
/api/browser/capture/status,GET,IPegasus::captureStatus,write_link_flows,done; request-shape-tested; UI: wired in the Wings app (PegasusBackend; Store, Downloads, Sources tabs; unit-tested on the fake backend, not on hardware)
/api/browser/capture/active,GET,IPegasus::captureActive,sample_storage_files_logs_capture,done; live-read-verified; UI: wired in the Wings app (PegasusBackend; Store, Downloads, Sources tabs; unit-tested on the fake backend, not on hardware)
/api/browser/capture/cancel,POST,IPegasus::captureCancel,write_link_flows,done; request-shape-tested; UI: wired in the Wings app (PegasusBackend; Store, Downloads, Sources tabs; unit-tested on the fake backend, not on hardware)
/api/mounts,GET,IPegasus::getMounts,sample_storage_files_logs_capture,done; live-read-verified; UI: wired in the Wings app phase 4 (Settings and Files folder picker; unit-tested on the fake backend and request-shape-tested through PegasusBackend, not on hardware)
/api/browse,GET,IPegasus::browse,sample_storage_files_logs_capture; write_storage_logs,done; live-read-verified; UI: wired in the Wings app phase 4 (Settings and Files folder picker; unit-tested on the fake backend and request-shape-tested through PegasusBackend, not on hardware)
/api/mkdir,POST,IPegasus::mkdir,write_storage_logs,done; request-shape-tested; NOT live-tested; UI: wired in the Wings app phase 4 (folder picker New folder; unit-tested on the fake backend and request-shape-tested through PegasusBackend, not on hardware)
/api/logs,GET,IPegasus::getLogs,sample_storage_files_logs_capture; write_storage_logs,done; live-read-verified; UI: wired in the Wings app (PegasusBackend; Store, Downloads, Sources tabs; unit-tested on the fake backend, not on hardware)
/api/logs/clear,POST,IPegasus::clearLogs,write_storage_logs,done; request-shape-tested; NOT live-tested; UI: wired in the Wings app phase 4 (Logs: clear; unit-tested on the fake backend and request-shape-tested through PegasusBackend, not on hardware)
/api/client-log,POST,IPegasus::clientLog,write_storage_logs,done; request-shape-tested; NOT live-tested
/appdb,GET,IPegasus::getInstalledGames,sample_appdb,done; live-read-verified; UI: wired in the Wings app (PegasusBackend; Store, Downloads, Sources tabs; unit-tested on the fake backend, not on hardware)
/appdb/icon and /appdb/pic0,GET,IPegasus::fetchAppdbArt + logic::installedArtUrl,write_apr_and_installed_games; installed_game_helpers,done; request-shape-tested; UI: wired in the Wings app (PegasusBackend; Store, Downloads, Sources tabs; unit-tested on the fake backend, not on hardware)
/api/apr-emu/versions,GET,IPegasus::getAprVersions,sample_apr_versions_503_is_an_error_not_a_crash; connect_tested_version_full_probe,done; live 503 handled as capability degradation; UI: wired in the Wings app phase 4 (Library: reinstall list; 503 degrades to an explanation; unit-tested on the fake backend and request-shape-tested through PegasusBackend, not on hardware)
/api/apr-emu/update/start,POST,IPegasus::aprUpdateStart,write_apr_and_installed_games,done; request-shape-tested; NOT live-tested (modifies a game); UI: wired in the Wings app phase 4 (Library: APR update and reinstall; unit-tested on the fake backend and request-shape-tested through PegasusBackend, not on hardware)
/api/apr-emu/update/status,GET,IPegasus::aprUpdateStatus,write_apr_and_installed_games; connect_*,done; request-shape-tested; probe uses bogus titleId; UI: wired in the Wings app phase 4 (Library: job polling; unit-tested on the fake backend and request-shape-tested through PegasusBackend, not on hardware)
/api/apr-emu/restore/status,GET,IPegasus::aprRestoreStatus,write_apr_and_installed_games; connect_*,done; request-shape-tested; probe uses bogus titleId; UI: not wired (Wings shows remount-required text only)
/api/installed-games/delete,POST,IPegasus::installedGameDelete,write_apr_and_installed_games,done; request-shape-tested only; DESTRUCTIVE - never sent by tests or smoke tool; UI: wired in the Wings app phase 4 (Library: delete with two confirmations; unit-tested on the fake backend and request-shape-tested through PegasusBackend, not on hardware)
/api/installed-games/delete/status,GET,IPegasus::installedGameDeleteStatus,write_apr_and_installed_games; connect_*,done; request-shape-tested; probe uses bogus titleId; UI: wired in the Wings app phase 4 (Library: job polling; unit-tested on the fake backend and request-shape-tested through PegasusBackend, not on hardware)
/api/files/locations,GET,IPegasus::fileLocations,sample_storage_files_logs_capture; write_file_manager,done; live-read-verified (sample); UI: wired in the Wings app phase 4 (Files; unit-tested on the fake backend and request-shape-tested through PegasusBackend, not on hardware)
/api/files/list,GET,IPegasus::fileList,sample_mutating_responses; write_file_manager,done; live-read-verified; UI: wired in the Wings app phase 4 (Files; unit-tested on the fake backend and request-shape-tested through PegasusBackend, not on hardware)
/api/files/operations,GET,IPegasus::fileOperations,sample_storage_files_logs_capture; file_operation_states_parse,done; live-read-verified; UI: wired in the Wings app phase 4 (Files: operations panel; unit-tested on the fake backend and request-shape-tested through PegasusBackend, not on hardware)
/api/files/mkdir,POST,IPegasus::fileMkdir,sample_mutating_responses; write_file_manager,done; live-sandbox-verified (/data/wings-test); UI: wired in the Wings app phase 4 (Files; unit-tested on the fake backend and request-shape-tested through PegasusBackend, not on hardware)
/api/files/rename,POST,IPegasus::fileRename,sample_mutating_responses; write_file_manager,done; live-sandbox-verified (/data/wings-test); UI: wired in the Wings app phase 4 (Files; unit-tested on the fake backend and request-shape-tested through PegasusBackend, not on hardware)
/api/files/delete,POST,IPegasus::fileDelete,sample_mutating_responses; write_file_manager,done; live-sandbox-verified (/data/wings-test only); DESTRUCTIVE; UI: wired in the Wings app phase 4 (Files: confirmation; unit-tested on the fake backend and request-shape-tested through PegasusBackend, not on hardware)
/api/files/copy,POST,IPegasus::fileCopy,sample_mutating_responses; write_file_manager,done; live-sandbox-verified; UI: wired in the Wings app phase 4 (Files: destination and collision choice; unit-tested on the fake backend and request-shape-tested through PegasusBackend, not on hardware)
/api/files/move,POST,IPegasus::fileMove,sample_mutating_responses; write_file_manager,done; live-sandbox-verified; UI: wired in the Wings app phase 4 (Files: destination and collision choice; unit-tested on the fake backend and request-shape-tested through PegasusBackend, not on hardware)
/api/files/extract,POST,IPegasus::fileExtract,write_file_manager,done; request-shape-tested; NOT live-tested; UI: wired in the Wings app phase 4 (Files: RAR extract; unit-tested on the fake backend and request-shape-tested through PegasusBackend, not on hardware)
/api/files/extract/password,POST,IPegasus::fileExtractPassword,write_file_manager; file_operation_states_parse,done; request-shape-tested; NOT live-tested; UI: wired in the Wings app phase 4 (Files: password prompt; unit-tested on the fake backend and request-shape-tested through PegasusBackend, not on hardware)
/api/files/pkg/install,POST,IPegasus::filePkgInstall,write_file_manager,done; request-shape-tested; NOT live-tested (installs a PS5 package); UI: wired in the Wings app phase 4 (Files: .pkg install hand-off; unit-tested on the fake backend and request-shape-tested through PegasusBackend, not on hardware)
/api/files/operation/cancel,POST,IPegasus::fileOperationCancel,write_file_manager,done; request-shape-tested; UI: wired in the Wings app phase 4 (Files: operations panel; unit-tested on the fake backend and request-shape-tested through PegasusBackend, not on hardware)
/api/files/upload,PUT,IPegasus::fileUpload,sample_mutating_responses; write_file_manager,done; live-sandbox-verified (in-memory body; streaming uploads are a gap); UI: deliberately not wired (raw upload/download to a device is not meaningful on the console; restore status is only a Pegasus web UI auto-poll)
/api/files/download,GET,IPegasus::fileDownload + fileDownloadPath,sample_mutating_responses,done; live-sandbox-verified (in-memory body; streaming downloads are a gap); UI: deliberately not wired (raw upload/download to a device is not meaningful on the console; restore status is only a Pegasus web UI auto-poll)
/api/files/archive,GET,IPegasus::fileArchive + fileDownloadPath(dir),sample_mutating_responses,done; live-sandbox-verified (folder zip; POST variant is a 404 in 1.11.0); UI: deliberately not wired (raw upload/download to a device is not meaningful on the console; restore status is only a Pegasus web UI auto-poll)
(ui logic) downloadSupportForUrl,-,logic::downloadSupportForUrl,provider_classification; provider_classification_over_real_library,done; app.js:458; UI: wired in the Wings app (PegasusBackend; Store, Downloads, Sources tabs; unit-tested on the fake backend, not on hardware)
(ui logic) debridServicesForUrl,-,logic::debridServicesForUrl / debridReady,debrid_matching,done; app.js:438; UI: wired in the Wings app (PegasusBackend; Store, Downloads, Sources tabs; unit-tested on the fake backend, not on hardware)
(ui logic) queueDownloadLink decision,-,logic::decideLinkAction,link_action_decision,done; app.js:4850; UI: wired in the Wings app (PegasusBackend; Store, Downloads, Sources tabs; unit-tested on the fake backend, not on hardware)
(ui logic) .pkg native handoff rule,-,logic::urlHasPkgExtension,url_pkg_extension_rule,done; app.js:346; UI: wired in the Wings app (PegasusBackend; Store, Downloads, Sources tabs; unit-tested on the fake backend, not on hardware)
(ui logic) base64 download URL,-,logic::downloadLinkUrl / decodeBase64DownloadUrl,url_base64_decoding,done; app.js:356-374; UI: wired in the Wings app (PegasusBackend; Store, Downloads, Sources tabs; unit-tested on the fake backend, not on hardware)
(ui logic) dispatch handling,-,logic::nativeDispatchResults / queuedDispatchResults / finishDispatchResponse,dispatch_results; sample_queue_direct_dispatch,done; app.js:3429-3521; UI: wired in the Wings app (PegasusBackend; Store, Downloads, Sources tabs; unit-tested on the fake backend, not on hardware)
(ui logic) manual link validation,-,logic::probeRejection,probe_rejection_and_capture_text,done; app.js:3551; UI: wired in the Wings app (PegasusBackend; Store, Downloads, Sources tabs; unit-tested on the fake backend, not on hardware)
(ui logic) capture status text,-,logic::captureStatusText,probe_rejection_and_capture_text,done; app.js:4006; UI: wired in the Wings app (PegasusBackend; Store, Downloads, Sources tabs; unit-tested on the fake backend, not on hardware)
(ui logic) APR state/version order,-,logic::aprEmuState / sortedAprVersions,apr_logic,done; app.js:851-1041
(ui logic) firmware text,-,logic::psFirmwareVersion,installed_game_helpers,done; app.js:806
(ui logic) file-manager path/name rules,-,logic::normalizeFilePath / uniquePaths / validateName / isPkgEntry / isExtractableRarEntry,file_manager_helpers,done; fm.js:80-335
(capability) version + feature probe,-,connect() / probeCapabilities(),connect_*,done; UI: wired in the Wings app (PegasusBackend; Store, Downloads, Sources tabs; unit-tested on the fake backend, not on hardware)
/api/library (streamed),GET,IPegasus::streamLibrary (PackageSink; json::for_each_array_element),stream_library_matches_get_library_on_the_real_sample; stream_library_errors_and_edge_replies; scan_*,done; used by the Wings app: array-wise parse, peak heap about 16 MiB for the 7.2 MB sample (wings/app/tools/library-peak.sh); UI: wired in the Wings app (PegasusBackend; Store, Downloads, Sources tabs; unit-tested on the fake backend, not on hardware)
1 route,method,adapter_fn,test,status
2 /api/health,GET,IPegasus::health + Pegasus connect(),sample_health; connect_*,done; live-read-verified; UI: wired in the Wings app (PegasusBackend; Store, Downloads, Sources tabs; unit-tested on the fake backend, not on hardware)
3 /api/settings,GET,IPegasus::getSettings,sample_settings_and_unknown_fields; tolerant_parsing_missing_and_mistyped_fields,done; live-read-verified; UI: wired in the Wings app (PegasusBackend; Store, Downloads, Sources tabs; unit-tested on the fake backend, not on hardware)
4 /api/settings,POST,IPegasus::updateSettings / setDownloadRoot / setDownloadConnections / setDebridToken / clearDebridToken,write_settings,done; request-shape-tested; NOT live-tested (changes real config); UI: wired in the Wings app phase 4 (Settings: download folder, connections, tokens; unit-tested on the fake backend and request-shape-tested through PegasusBackend, not on hardware)
5 /api/debrid/refresh,POST,IPegasus::refreshDebrid,write_debrid_refresh,done; request-shape-tested; NOT live-tested; UI: wired in the Wings app phase 4 (Settings: check token again; unit-tested on the fake backend and request-shape-tested through PegasusBackend, not on hardware)
6 /api/sources,GET,IPegasus::listSources,sample_sources_and_default_sources,done; live-read-verified; UI: wired in the Wings app (PegasusBackend; Store, Downloads, Sources tabs; unit-tested on the fake backend, not on hardware)
7 /api/sources/url,POST,IPegasus::addSourceUrl,write_sources,done; request-shape-tested; NOT live-tested; UI: wired in the Wings app (PegasusBackend; Store, Downloads, Sources tabs; unit-tested on the fake backend, not on hardware)
8 /api/sources/upload,POST,IPegasus::uploadSourceCatalog,write_sources; sample_mutating_responses,done; live-sandbox-verified (tools/pegasus_contract.py)
9 /api/sources/delete,POST,IPegasus::deleteSource,write_sources; sample_mutating_responses,done; live-sandbox-verified (throwaway source); UI: wired in the Wings app (PegasusBackend; Store, Downloads, Sources tabs; unit-tested on the fake backend, not on hardware)
10 /api/sources/refresh,POST,IPegasus::refreshSource,write_sources,done; request-shape-tested; NOT live-tested; UI: wired in the Wings app (PegasusBackend; Store, Downloads, Sources tabs; unit-tested on the fake backend, not on hardware)
11 /api/sources/enabled,POST,IPegasus::setSourceEnabled,write_sources; sample_mutating_responses,done; live-sandbox-verified (throwaway source); UI: wired in the Wings app (PegasusBackend; Store, Downloads, Sources tabs; unit-tested on the fake backend, not on hardware)
12 /api/default-sources,GET,IPegasus::getDefaultSources(false),sample_sources_and_default_sources,done; live-read-verified; UI: wired in the Wings app (PegasusBackend; Store, Downloads, Sources tabs; unit-tested on the fake backend, not on hardware)
13 /api/default-sources?refresh=1,GET,IPegasus::getDefaultSources(true),sample_sources_and_default_sources,done; request-shape-tested; UI: wired in the Wings app (PegasusBackend; Store, Downloads, Sources tabs; unit-tested on the fake backend, not on hardware)
14 /api/default-sources/apply,POST,IPegasus::applyDefaultSources,write_default_sources,done; request-shape-tested; NOT live-tested (changes sources); UI: wired in the Wings app (PegasusBackend; Store, Downloads, Sources tabs; unit-tested on the fake backend, not on hardware)
15 /api/default-sources/seen,POST,IPegasus::markDefaultSourcesSeen,write_default_sources,done; request-shape-tested; NOT live-tested; UI: wired in the Wings app (PegasusBackend; Store, Downloads, Sources tabs; unit-tested on the fake backend, not on hardware)
16 /api/library,GET,IPegasus::getLibrary,sample_library_7mb_parses_fast; provider_classification_over_real_library,done; live-read-verified (88 ms parse of 7 MB); UI: wired in the Wings app (PegasusBackend; Store, Downloads, Sources tabs; unit-tested on the fake backend, not on hardware)
17 /api/queue,GET,IPegasus::getQueue,sample_queue; queue_waiting_states_parse,done; live-read-verified; UI: wired in the Wings app (PegasusBackend; Store, Downloads, Sources tabs; unit-tested on the fake backend, not on hardware)
18 /api/queue,POST,IPegasus::enqueuePackage (native .pkg handoff),write_queue; dispatch_results,done; request-shape-tested; NOT live-tested (would start a PS5 download); UI: wired in the Wings app (PegasusBackend; Store, Downloads, Sources tabs; unit-tested on the fake backend, not on hardware)
19 /api/queue/direct,POST,IPegasus::queueDirect,write_queue; sample_queue_direct_dispatch,done; live-sandbox-verified (tiny file; contract --queue); UI: wired in the Wings app (PegasusBackend; Store, Downloads, Sources tabs; unit-tested on the fake backend, not on hardware)
20 /api/queue/pause,POST,IPegasus::pauseJob,write_queue; errors_surface_status_and_message; sample_mutating_responses,done; live error path verified (unknown job); UI: wired in the Wings app (PegasusBackend; Store, Downloads, Sources tabs; unit-tested on the fake backend, not on hardware)
21 /api/queue/resume,POST,IPegasus::resumeJob,write_queue,done; request-shape-tested; UI: wired in the Wings app (PegasusBackend; Store, Downloads, Sources tabs; unit-tested on the fake backend, not on hardware)
22 /api/queue/cancel,POST,IPegasus::cancelJob,write_queue,done; live-sandbox-verified (contract --queue); UI: wired in the Wings app (PegasusBackend; Store, Downloads, Sources tabs; unit-tested on the fake backend, not on hardware)
23 /api/queue/retry,POST,IPegasus::retryJob,write_queue,done; request-shape-tested; UI: wired in the Wings app (PegasusBackend; Store, Downloads, Sources tabs; unit-tested on the fake backend, not on hardware)
24 /api/queue/clear-finished,POST,IPegasus::clearFinished,write_queue,done; live-sandbox-verified (contract --queue); UI: wired in the Wings app (PegasusBackend; Store, Downloads, Sources tabs; unit-tested on the fake backend, not on hardware)
25 /api/queue/archive-password,POST,IPegasus::submitArchivePassword,write_queue; queue_waiting_states_parse,done; request-shape-tested; UI: wired in the Wings app (PegasusBackend; Store, Downloads, Sources tabs; unit-tested on the fake backend, not on hardware)
26 /api/queue/archive-choice,POST,IPegasus::submitArchiveChoice,write_queue; queue_waiting_states_parse,done; request-shape-tested; UI: wired in the Wings app (PegasusBackend; Store, Downloads, Sources tabs; unit-tested on the fake backend, not on hardware)
27 /api/resolve/probe,POST,IPegasus::resolveProbe,write_link_flows; sample_mutating_responses; probe_rejection_and_capture_text,done; live-verified (example.com); UI: wired in the Wings app (PegasusBackend; Store, Downloads, Sources tabs; unit-tested on the fake backend, not on hardware)
28 /api/debrid/queue,POST,IPegasus::debridQueue (packageId+linkIndex+service),write_link_flows,done; request-shape-tested; needsSelection reply parsed; NOT live-tested; UI: wired in the Wings app (PegasusBackend; Store, Downloads, Sources tabs; unit-tested on the fake backend, not on hardware)
29 /api/debrid/queue (selection),POST,IPegasus::debridQueueSelection (selectionId+indexes+service),write_link_flows,done; request-shape-tested; NOT live-tested; UI: wired in the Wings app (PegasusBackend; Store, Downloads, Sources tabs; unit-tested on the fake backend, not on hardware)
30 /api/provider/resolve,POST,IPegasus::providerResolve,write_link_flows,done; request-shape-tested; NOT live-tested; UI: wired in the Wings app (PegasusBackend; Store, Downloads, Sources tabs; unit-tested on the fake backend, not on hardware)
31 /api/browser/open,POST,IPegasus::browserOpen,write_link_flows,done; request-shape-tested; NOT live-tested (opens PS5 browser); UI: wired in the Wings app (PegasusBackend; Store, Downloads, Sources tabs; unit-tested on the fake backend, not on hardware)
32 /api/browser/capture/status,GET,IPegasus::captureStatus,write_link_flows,done; request-shape-tested; UI: wired in the Wings app (PegasusBackend; Store, Downloads, Sources tabs; unit-tested on the fake backend, not on hardware)
33 /api/browser/capture/active,GET,IPegasus::captureActive,sample_storage_files_logs_capture,done; live-read-verified; UI: wired in the Wings app (PegasusBackend; Store, Downloads, Sources tabs; unit-tested on the fake backend, not on hardware)
34 /api/browser/capture/cancel,POST,IPegasus::captureCancel,write_link_flows,done; request-shape-tested; UI: wired in the Wings app (PegasusBackend; Store, Downloads, Sources tabs; unit-tested on the fake backend, not on hardware)
35 /api/mounts,GET,IPegasus::getMounts,sample_storage_files_logs_capture,done; live-read-verified; UI: wired in the Wings app phase 4 (Settings and Files folder picker; unit-tested on the fake backend and request-shape-tested through PegasusBackend, not on hardware)
36 /api/browse,GET,IPegasus::browse,sample_storage_files_logs_capture; write_storage_logs,done; live-read-verified; UI: wired in the Wings app phase 4 (Settings and Files folder picker; unit-tested on the fake backend and request-shape-tested through PegasusBackend, not on hardware)
37 /api/mkdir,POST,IPegasus::mkdir,write_storage_logs,done; request-shape-tested; NOT live-tested; UI: wired in the Wings app phase 4 (folder picker New folder; unit-tested on the fake backend and request-shape-tested through PegasusBackend, not on hardware)
38 /api/logs,GET,IPegasus::getLogs,sample_storage_files_logs_capture; write_storage_logs,done; live-read-verified; UI: wired in the Wings app (PegasusBackend; Store, Downloads, Sources tabs; unit-tested on the fake backend, not on hardware)
39 /api/logs/clear,POST,IPegasus::clearLogs,write_storage_logs,done; request-shape-tested; NOT live-tested; UI: wired in the Wings app phase 4 (Logs: clear; unit-tested on the fake backend and request-shape-tested through PegasusBackend, not on hardware)
40 /api/client-log,POST,IPegasus::clientLog,write_storage_logs,done; request-shape-tested; NOT live-tested
41 /appdb,GET,IPegasus::getInstalledGames,sample_appdb,done; live-read-verified; UI: wired in the Wings app (PegasusBackend; Store, Downloads, Sources tabs; unit-tested on the fake backend, not on hardware)
42 /appdb/icon and /appdb/pic0,GET,IPegasus::fetchAppdbArt + logic::installedArtUrl,write_apr_and_installed_games; installed_game_helpers,done; request-shape-tested; UI: wired in the Wings app (PegasusBackend; Store, Downloads, Sources tabs; unit-tested on the fake backend, not on hardware)
43 /api/apr-emu/versions,GET,IPegasus::getAprVersions,sample_apr_versions_503_is_an_error_not_a_crash; connect_tested_version_full_probe,done; live 503 handled as capability degradation; UI: wired in the Wings app phase 4 (Library: reinstall list; 503 degrades to an explanation; unit-tested on the fake backend and request-shape-tested through PegasusBackend, not on hardware)
44 /api/apr-emu/update/start,POST,IPegasus::aprUpdateStart,write_apr_and_installed_games,done; request-shape-tested; NOT live-tested (modifies a game); UI: wired in the Wings app phase 4 (Library: APR update and reinstall; unit-tested on the fake backend and request-shape-tested through PegasusBackend, not on hardware)
45 /api/apr-emu/update/status,GET,IPegasus::aprUpdateStatus,write_apr_and_installed_games; connect_*,done; request-shape-tested; probe uses bogus titleId; UI: wired in the Wings app phase 4 (Library: job polling; unit-tested on the fake backend and request-shape-tested through PegasusBackend, not on hardware)
46 /api/apr-emu/restore/status,GET,IPegasus::aprRestoreStatus,write_apr_and_installed_games; connect_*,done; request-shape-tested; probe uses bogus titleId; UI: not wired (Wings shows remount-required text only)
47 /api/installed-games/delete,POST,IPegasus::installedGameDelete,write_apr_and_installed_games,done; request-shape-tested only; DESTRUCTIVE - never sent by tests or smoke tool; UI: wired in the Wings app phase 4 (Library: delete with two confirmations; unit-tested on the fake backend and request-shape-tested through PegasusBackend, not on hardware)
48 /api/installed-games/delete/status,GET,IPegasus::installedGameDeleteStatus,write_apr_and_installed_games; connect_*,done; request-shape-tested; probe uses bogus titleId; UI: wired in the Wings app phase 4 (Library: job polling; unit-tested on the fake backend and request-shape-tested through PegasusBackend, not on hardware)
49 /api/files/locations,GET,IPegasus::fileLocations,sample_storage_files_logs_capture; write_file_manager,done; live-read-verified (sample); UI: wired in the Wings app phase 4 (Files; unit-tested on the fake backend and request-shape-tested through PegasusBackend, not on hardware)
50 /api/files/list,GET,IPegasus::fileList,sample_mutating_responses; write_file_manager,done; live-read-verified; UI: wired in the Wings app phase 4 (Files; unit-tested on the fake backend and request-shape-tested through PegasusBackend, not on hardware)
51 /api/files/operations,GET,IPegasus::fileOperations,sample_storage_files_logs_capture; file_operation_states_parse,done; live-read-verified; UI: wired in the Wings app phase 4 (Files: operations panel; unit-tested on the fake backend and request-shape-tested through PegasusBackend, not on hardware)
52 /api/files/mkdir,POST,IPegasus::fileMkdir,sample_mutating_responses; write_file_manager,done; live-sandbox-verified (/data/wings-test); UI: wired in the Wings app phase 4 (Files; unit-tested on the fake backend and request-shape-tested through PegasusBackend, not on hardware)
53 /api/files/rename,POST,IPegasus::fileRename,sample_mutating_responses; write_file_manager,done; live-sandbox-verified (/data/wings-test); UI: wired in the Wings app phase 4 (Files; unit-tested on the fake backend and request-shape-tested through PegasusBackend, not on hardware)
54 /api/files/delete,POST,IPegasus::fileDelete,sample_mutating_responses; write_file_manager,done; live-sandbox-verified (/data/wings-test only); DESTRUCTIVE; UI: wired in the Wings app phase 4 (Files: confirmation; unit-tested on the fake backend and request-shape-tested through PegasusBackend, not on hardware)
55 /api/files/copy,POST,IPegasus::fileCopy,sample_mutating_responses; write_file_manager,done; live-sandbox-verified; UI: wired in the Wings app phase 4 (Files: destination and collision choice; unit-tested on the fake backend and request-shape-tested through PegasusBackend, not on hardware)
56 /api/files/move,POST,IPegasus::fileMove,sample_mutating_responses; write_file_manager,done; live-sandbox-verified; UI: wired in the Wings app phase 4 (Files: destination and collision choice; unit-tested on the fake backend and request-shape-tested through PegasusBackend, not on hardware)
57 /api/files/extract,POST,IPegasus::fileExtract,write_file_manager,done; request-shape-tested; NOT live-tested; UI: wired in the Wings app phase 4 (Files: RAR extract; unit-tested on the fake backend and request-shape-tested through PegasusBackend, not on hardware)
58 /api/files/extract/password,POST,IPegasus::fileExtractPassword,write_file_manager; file_operation_states_parse,done; request-shape-tested; NOT live-tested; UI: wired in the Wings app phase 4 (Files: password prompt; unit-tested on the fake backend and request-shape-tested through PegasusBackend, not on hardware)
59 /api/files/pkg/install,POST,IPegasus::filePkgInstall,write_file_manager,done; request-shape-tested; NOT live-tested (installs a PS5 package); UI: wired in the Wings app phase 4 (Files: .pkg install hand-off; unit-tested on the fake backend and request-shape-tested through PegasusBackend, not on hardware)
60 /api/files/operation/cancel,POST,IPegasus::fileOperationCancel,write_file_manager,done; request-shape-tested; UI: wired in the Wings app phase 4 (Files: operations panel; unit-tested on the fake backend and request-shape-tested through PegasusBackend, not on hardware)
61 /api/files/upload,PUT,IPegasus::fileUpload,sample_mutating_responses; write_file_manager,done; live-sandbox-verified (in-memory body; streaming uploads are a gap); UI: deliberately not wired (raw upload/download to a device is not meaningful on the console; restore status is only a Pegasus web UI auto-poll)
62 /api/files/download,GET,IPegasus::fileDownload + fileDownloadPath,sample_mutating_responses,done; live-sandbox-verified (in-memory body; streaming downloads are a gap); UI: deliberately not wired (raw upload/download to a device is not meaningful on the console; restore status is only a Pegasus web UI auto-poll)
63 /api/files/archive,GET,IPegasus::fileArchive + fileDownloadPath(dir),sample_mutating_responses,done; live-sandbox-verified (folder zip; POST variant is a 404 in 1.11.0); UI: deliberately not wired (raw upload/download to a device is not meaningful on the console; restore status is only a Pegasus web UI auto-poll)
64 (ui logic) downloadSupportForUrl,-,logic::downloadSupportForUrl,provider_classification; provider_classification_over_real_library,done; app.js:458; UI: wired in the Wings app (PegasusBackend; Store, Downloads, Sources tabs; unit-tested on the fake backend, not on hardware)
65 (ui logic) debridServicesForUrl,-,logic::debridServicesForUrl / debridReady,debrid_matching,done; app.js:438; UI: wired in the Wings app (PegasusBackend; Store, Downloads, Sources tabs; unit-tested on the fake backend, not on hardware)
66 (ui logic) queueDownloadLink decision,-,logic::decideLinkAction,link_action_decision,done; app.js:4850; UI: wired in the Wings app (PegasusBackend; Store, Downloads, Sources tabs; unit-tested on the fake backend, not on hardware)
67 (ui logic) .pkg native handoff rule,-,logic::urlHasPkgExtension,url_pkg_extension_rule,done; app.js:346; UI: wired in the Wings app (PegasusBackend; Store, Downloads, Sources tabs; unit-tested on the fake backend, not on hardware)
68 (ui logic) base64 download URL,-,logic::downloadLinkUrl / decodeBase64DownloadUrl,url_base64_decoding,done; app.js:356-374; UI: wired in the Wings app (PegasusBackend; Store, Downloads, Sources tabs; unit-tested on the fake backend, not on hardware)
69 (ui logic) dispatch handling,-,logic::nativeDispatchResults / queuedDispatchResults / finishDispatchResponse,dispatch_results; sample_queue_direct_dispatch,done; app.js:3429-3521; UI: wired in the Wings app (PegasusBackend; Store, Downloads, Sources tabs; unit-tested on the fake backend, not on hardware)
70 (ui logic) manual link validation,-,logic::probeRejection,probe_rejection_and_capture_text,done; app.js:3551; UI: wired in the Wings app (PegasusBackend; Store, Downloads, Sources tabs; unit-tested on the fake backend, not on hardware)
71 (ui logic) capture status text,-,logic::captureStatusText,probe_rejection_and_capture_text,done; app.js:4006; UI: wired in the Wings app (PegasusBackend; Store, Downloads, Sources tabs; unit-tested on the fake backend, not on hardware)
72 (ui logic) APR state/version order,-,logic::aprEmuState / sortedAprVersions,apr_logic,done; app.js:851-1041
73 (ui logic) firmware text,-,logic::psFirmwareVersion,installed_game_helpers,done; app.js:806
74 (ui logic) file-manager path/name rules,-,logic::normalizeFilePath / uniquePaths / validateName / isPkgEntry / isExtractableRarEntry,file_manager_helpers,done; fm.js:80-335
75 (capability) version + feature probe,-,connect() / probeCapabilities(),connect_*,done; UI: wired in the Wings app (PegasusBackend; Store, Downloads, Sources tabs; unit-tested on the fake backend, not on hardware)
76 /api/library (streamed),GET,IPegasus::streamLibrary (PackageSink; json::for_each_array_element),stream_library_matches_get_library_on_the_real_sample; stream_library_errors_and_edge_replies; scan_*,done; used by the Wings app: array-wise parse, peak heap about 16 MiB for the 7.2 MB sample (wings/app/tools/library-peak.sh); UI: wired in the Wings app (PegasusBackend; Store, Downloads, Sources tabs; unit-tested on the fake backend, not on hardware)
+22 -3
View File
@@ -155,6 +155,24 @@ bool dechunk(std::string_view body, std::string *out)
} // namespace
int timeout_for(std::string_view method, std::string_view path)
{
if (method != "POST")
return 0;
const std::size_t query = path.find('?');
if (query != std::string_view::npos)
path = path.substr(0, query);
static constexpr std::string_view kLinkStarts[] = {"/api/queue", "/api/queue/direct",
"/api/provider/resolve", "/api/browser/open",
"/api/debrid/queue", "/api/resolve/probe"};
for (const std::string_view start : kLinkStarts)
{
if (path == start)
return kLinkStartTimeoutMs;
}
return 0;
}
SocketTransport::SocketTransport(std::string host, std::uint16_t port, int timeout_ms)
: host_(std::move(host)), port_(port), timeout_ms_(timeout_ms)
{
@@ -209,14 +227,15 @@ Response SocketTransport::send(const Request &request)
response.error = "socket failed";
return response;
}
const int limit_ms = request.timeout_ms > 0 ? request.timeout_ms : timeout_ms_;
#ifdef _WIN32
const DWORD timeout = static_cast<DWORD>(timeout_ms_);
const DWORD timeout = static_cast<DWORD>(limit_ms);
setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, reinterpret_cast<const char *>(&timeout), sizeof(timeout));
setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, reinterpret_cast<const char *>(&timeout), sizeof(timeout));
#else
timeval timeout{};
timeout.tv_sec = timeout_ms_ / 1000;
timeout.tv_usec = (timeout_ms_ % 1000) * 1000;
timeout.tv_sec = limit_ms / 1000;
timeout.tv_usec = (limit_ms % 1000) * 1000;
setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &timeout, sizeof(timeout));
setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, &timeout, sizeof(timeout));
#endif
Loaded 100 of 127 files, more files were not shown because too many files have changed in this diff. Show more