Compare commits

...
3 Commits
Author SHA1 Message Date
ManosandDevin 79def2a46f README: trophy ban disclaimer + feature warnings
Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-08 02:53:05 +03:00
ManosandDevin e5d3728d6c v7.2.3: Android parity, Trophy lock, Prospero Store, macOS .app packaging
- Android client: full feature parity — new Files/Games/Saves/Trophies/
  Prospero pages, responsive WrapPanel layouts, modal dialogs, protocol
  trophy commands + MountGameAsync
- Trophies: re-lock support (rewrites TRPTITLE.DAT bitmasks in place),
  game-running validation dialogs
- Prospero Store: homebrew.page catalog, ZIP install with SHA-256 verify,
  parallel upload, auto-mount
- macOS releases ship as PS5Suite.app inside zip (preserves exec bit)
- README: coffee/X links, trophy + prospero docs, drop disc-dump mention

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-08 02:46:10 +03:00
Manos 6cf0fdd114 Remove vendored lib-prospero-pkg source tree
The client consumes the compiled LibProsperoPkg.dll; the full library
source doesn't belong in this repo.
2026-10-06 11:51:59 +03:00
186 changed files with 6728 additions and 41995 deletions

No files matched your search

+3
View File
@@ -47,3 +47,6 @@ client-avalonia-android/
# dev scratch: homebrew pkg builds, extracted binaries — not for the repo
homebrew/
# vendored library source — the client uses the compiled dll (client-avalonia/libs/)
lib-prospero-pkg/
+38 -6
View File
@@ -2,7 +2,18 @@
**By Manos**
All-in-one management suite for jailbroken PS5 consoles. One app — on **Windows, Linux, macOS and Android** — that does everything: high-speed file transfers, game mount/launch, decrypted save backup/restore, screenshots, live hardware monitoring, PKG streaming installs, full FPKG building on the PC, an integrated Homebrew Store, memory tools, fan control, kernel log, a remote shell — plus console LED control and DualSense lightbar support.
All-in-one management suite for jailbroken PS5 consoles. One app — on **Windows, Linux, macOS and Android** — that does everything: high-speed file transfers, game mount/launch, **trophy unlock & re-lock**, decrypted save backup/restore, screenshots, live hardware monitoring, PKG streaming installs, full FPKG building on the PC, an integrated Homebrew Store + Prospero Store, memory tools, fan control, kernel log, a remote shell — plus console LED control and DualSense lightbar support.
<div align="center">
### ☕ Enjoying PS5 Suite?
**This project is built for the PS5 scene, for free — if it saved you time or you just like what we do, consider buying us a coffee. Every cup goes straight back into new features, fixes and payload wizardry.** 🍻
[![Buy Me A Coffee](https://img.shields.io/badge/Buy%20Me%20a%20Coffee-FFDD00?style=for-the-badge&logo=buy-me-a-coffee&logoColor=black)](https://buymeacoffee.com/manos555555)
[![Follow on X](https://img.shields.io/badge/Follow%20on%20X-000000?style=for-the-badge&logo=x&logoColor=white)](https://x.com/manos_moyrtzis)
</div>
![PS5 Suite — File Transfer](screenshots/file_transfer.png)
@@ -26,8 +37,8 @@ All-in-one management suite for jailbroken PS5 consoles. One app — on **Window
| `PS5Suite-Windows-x64.exe` | Windows 10/11 64-bit — self-contained, no .NET install needed |
| `PS5Suite-Linux-x64` | Linux 64-bit — `chmod +x` and run |
| `PS5Suite-Linux-ARM64` | ARM64 Linux (Raspberry Pi, etc.) |
| `PS5Suite-macOS-x64` | Intel Macs — `chmod +x`, Gatekeeper: right-click → Open |
| `PS5Suite-macOS-ARM64` | Apple Silicon (M1/M2/M3/M4) |
| `PS5Suite-macOS-x64.zip` | Intel Macs — unzip → double-click `PS5Suite.app` (Gatekeeper: right-click → Open) |
| `PS5Suite-macOS-ARM64.zip` | Apple Silicon (M1/M2/M3/M4) — same, exec permissions preserved |
| `PS5Suite.apk` | **Android** — signed APK, full feature parity with desktop |
| `ps5_suite_server.elf` | **Required** on-console server component |
@@ -100,6 +111,26 @@ Two side-by-side panes: your PC on the left, the PS5 on the right.
---
### 🌐 GAMES → Prospero Store
- Built-in **homebrew.page** catalog: homebrew apps, games and tools with covers, categories and search.
- **📥 Install** — downloads the app ZIP, verifies SHA-256, extracts, uploads to /data/homebrew/ with parallel connections and auto-mounts it on the PS5 home screen.
---
### 🏆 TROPHIES
- Lists every trophy set on the console (per game) with real data parsed from TRPTITLE.DAT — names, descriptions, grades (bronze/silver/gold/platinum), icons, hidden status and earned timestamps.
- **🔓 Unlock** — awards a trophy live through the running game (the matching title must be running — the app tells you if it isn't).
- **🔒 Lock** — re-locks a trophy by rewriting the set's unlock bitmasks in place (group masks, row flags and timestamps cleared) — as if it was never earned.
- **📥 Unlock All** — batch-unlock every trophy in the set in one click.
- Search/filter across sets, live refresh after every change.
> ⚠️ **Warning:** unlocking many trophies with unrealistically short/identical timestamps can look suspicious if the console ever syncs to PSN. **I take no responsibility for any ban on your account or console** — use this feature offline and at your own risk.
---
### �💾 SAVES & MEDIA → Saves
- Enumerates all save data on the console (per user, per title) with size/type badges.
@@ -152,8 +183,7 @@ Everything is read **live from the console** — nothing hardcoded; values the c
- **🎮 Controller (DualSense)** — live pad state + controller info read through a remote bridge into `SceShellUI` (the payload gets no pad session of its own, so it resolves the real logged-in user remotely — nothing hardcoded). **Lightbar**: set any RGB color.
- **💡 Console LED** — real `/dev/icc_indicator` interface with three hardware channels (blue `0x01` / white `0x11` / amber `0x21` — verified on hardware): pick a color, run effects (breathe, sunrise, blink, chase…), `auto` hands control back to the system.
- **🔔 Notify** — push a custom notification to the PS5 screen.
- **📀 Disc Dump** — dump the inserted BD disc to `/user/disc` with live progress + cancel.
- **🔊 Beeper** — console beep / mute.
- ** Beeper** — console beep / mute.
---
@@ -242,7 +272,7 @@ Everything is read **live from the console** — nothing hardcoded; values the c
## 📱 Android app
- Same Avalonia UI/protocol as desktop — every feature above works on Android: transfers, games, store, saves, system info, tools, devices, debug log.
- Same Avalonia UI/protocol as desktop — every feature above works on Android: transfers, games (mount/launch/stop), both stores, trophies, saves, system info, tools, devices, debug log.
- Side **☰ hamburger menu** navigation, signed APK, version synced with desktop releases.
- Install → enter PS5 IP → Connect. Same payload, same ports.
@@ -258,4 +288,6 @@ Please report it in the **[Issues](https://github.com/manos555555/PS5-Suite/issu
Requires a jailbroken PS5 with a payload loader. Save backup/restore and memory write features modify console state — **use at your own risk**. For personal/educational use; no copyrighted content included.
**🏆 Trophy features:** unlocking trophies — especially many at once or with unrealistic timestamps — may look suspicious to Sony if your console or account ever syncs with PSN. **The author takes no responsibility for any ban of your account or console.** Use offline, at your own risk.
**Enjoy. — Manos**
@@ -13,6 +13,17 @@ namespace PS5SuiteAndroid.Android;
ConfigurationChanges = ConfigChanges.Orientation | ConfigChanges.ScreenSize | ConfigChanges.UiMode)]
public class MainActivity : AvaloniaMainActivity<App>
{
protected override void OnCreate(global::Android.OS.Bundle? savedInstanceState)
{
// Expose user-browsable writable dirs to the shared library before
// Avalonia starts (Android/data/<pkg>/files — no permission needed).
PS5SuiteAndroid.AppPaths.DownloadsDirProvider =
() => GetExternalFilesDir(null)?.AbsolutePath ?? FilesDir!.AbsolutePath;
PS5SuiteAndroid.AppPaths.CacheDirProvider =
() => CacheDir!.AbsolutePath;
base.OnCreate(savedInstanceState);
}
protected override AppBuilder CustomizeAppBuilder(AppBuilder builder)
{
return base.CustomizeAppBuilder(builder)
@@ -0,0 +1,18 @@
using System;
namespace PS5SuiteAndroid;
// Platform-provided writable locations. The Android head fills these in
// during startup (external files dir needs no storage permission and is
// browsable by the user at Android/data/<pkg>/files).
public static class AppPaths
{
public static Func<string>? DownloadsDirProvider;
public static Func<string>? CacheDirProvider;
public static string DownloadsDir =>
DownloadsDirProvider?.Invoke() ?? Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments);
public static string CacheDir =>
CacheDirProvider?.Invoke() ?? System.IO.Path.GetTempPath();
}
+224 -4
View File
@@ -85,11 +85,13 @@ namespace PS5Upload
PowerAction = 0x77,
UsbList = 0x78,
PadInfo = 0x79,
DiscDump = 0x7A,
Screenshot = 0x7B,
Notify = 0x7C,
PadAction = 0x7D,
IccControl = 0x7E,
TrophyList = 0x80,
TrophyIcon = 0x81,
TrophyUnlock = 0x82,
Shutdown = 0xFF
}
@@ -671,6 +673,133 @@ namespace PS5Upload
}
}
/// <summary>Mount a single game by title id (worker pipeline, filtered).</summary>
public async Task<string?> MountGameAsync(string titleId, Action<string>? onProgress = null)
{
await _commandLock.WaitAsync();
try
{
await SendCommandAsync(Command.MountGame, Encoding.UTF8.GetBytes(titleId));
while (true)
{
var (response, data) = await ReceiveResponseAsync();
if (response == Response.Progress)
{
string msg = Encoding.UTF8.GetString(data).TrimEnd('\0');
if (!string.IsNullOrEmpty(msg)) onProgress?.Invoke(msg);
}
else if (response == Response.Ok) return Encoding.UTF8.GetString(data);
else if (response == Response.Error) return "ERROR: " + Encoding.UTF8.GetString(data);
else return null;
}
}
catch (Exception ex) { LastError = $"MountGameAsync: {ex.Message}"; return null; }
finally { _commandLock.Release(); }
}
// ============ Trophy viewer (NpTrophy V2) ============
// Returns every registered trophy set with raw tropconf/tropmeta JSON
// payloads and the per-user TRPTITLE.DAT blob (empty when the user
// never earned anything in that set).
public async Task<List<PS5TrophySet>> GetTrophyListAsync()
{
var sets = new List<PS5TrophySet>();
await _commandLock.WaitAsync();
try
{
await SendCommandAsync(Command.TrophyList);
var (response, data) = await ReceiveResponseAsync(180000);
if (response != Response.Data || data == null || data.Length < 2)
return sets;
int off = 0;
ushort count = BitConverter.ToUInt16(data, off); off += 2;
string rdStr()
{
int l = data[off]; off++;
string s = Encoding.UTF8.GetString(data, off, l); off += l;
return s;
}
byte[] rdBlob()
{
uint l = BitConverter.ToUInt32(data, off); off += 4;
var b = new byte[l];
Array.Copy(data, off, b, 0, l); off += (int)l;
return b;
}
for (int i = 0; i < count; i++)
{
sets.Add(new PS5TrophySet
{
NpCommunicationId = rdStr(),
TitleId = rdStr(),
UserId = rdStr(),
TropConfJson = rdBlob(),
TropMetaJson = rdBlob(),
TrpTitleData = rdBlob()
});
}
}
catch (Exception ex)
{
LastError = $"GetTrophyListAsync: {ex.GetType().Name}: {ex.Message}";
}
finally
{
_commandLock.Release();
}
return sets;
}
// Fetch a trophy PNG ("trop0000.png", "icon0_en-US.png", …) from a set's UCP.
public async Task<byte[]?> GetTrophyIconAsync(string npwr, string entry)
{
await _commandLock.WaitAsync();
try
{
byte[] req = Encoding.UTF8.GetBytes($"{npwr}|{entry}");
await SendCommandAsync(Command.TrophyIcon, req);
var (response, data) = await ReceiveResponseAsync();
if (response != Response.Data || data == null || data.Length == 0)
return null;
return data;
}
catch
{
return null;
}
finally
{
_commandLock.Release();
}
}
// Trophy unlock/lock — "unlock:<id|all>" or "lock:<npwr>:<id>".
// Unlock goes through the running game's trophy pipeline; lock is a
// direct TRPTITLE.DAT rewrite done by the payload itself.
public async Task<(bool ok, string msg)> TrophyUnlockAsync(string spec)
{
await _commandLock.WaitAsync();
try
{
await SendCommandAsync(Command.TrophyUnlock, Encoding.UTF8.GetBytes(spec + "\0"));
var (response, data) = await ReceiveResponseAsync(120000);
var msg = Encoding.UTF8.GetString(data ?? Array.Empty<byte>());
return (response == Response.Data || response == Response.Ok, msg);
}
catch (Exception ex)
{
return (false, $"TrophyUnlockAsync: {ex.GetType().Name}: {ex.Message}");
}
finally
{
_commandLock.Release();
}
}
// NEW: Launch browser with URL
public async Task<(bool success, string message)> LaunchBrowserAsync(string url)
{
@@ -2494,9 +2623,6 @@ namespace PS5Upload
}
}
public Task<(bool success, string message)> DiscDumpAsync(string action)
=> SendTextCommandAsync(Command.DiscDump, action);
public async Task<(bool success, string message)> CaptureScreenshotAsync()
{
await _commandLock.WaitAsync();
@@ -3264,4 +3390,98 @@ namespace PS5Upload
public string ShortUserId => UserId.Length > 8 ? UserId.Substring(0, 8) + "…" : UserId;
}
// ============ Trophy viewer (NpTrophy V2) ============
// One registered trophy set: raw UCP payloads + per-user TRPTITLE.DAT.
public class PS5TrophySet : System.ComponentModel.INotifyPropertyChanged
{
public string NpCommunicationId { get; set; } = "";
public string TitleId { get; set; } = "";
public string UserId { get; set; } = "";
public byte[] TropConfJson { get; set; } = Array.Empty<byte>();
public byte[] TropMetaJson { get; set; } = Array.Empty<byte>();
public byte[] TrpTitleData { get; set; } = Array.Empty<byte>();
public List<PS5Trophy> Trophies { get; } = new();
public byte[] UnlockMask { get; set; } = Array.Empty<byte>();
public bool StateKnown { get; set; }
public int EarnedFallback { get; set; } = -1;
private string _gameName = "";
public string GameName
{
get => _gameName;
set { _gameName = value; PropertyChanged?.Invoke(this, new System.ComponentModel.PropertyChangedEventArgs(nameof(GameName))); }
}
private Avalonia.Media.IImage? _icon;
public Avalonia.Media.IImage? Icon
{
get => _icon;
set { _icon = value; PropertyChanged?.Invoke(this, new System.ComponentModel.PropertyChangedEventArgs(nameof(Icon))); }
}
public int EarnedCount => StateKnown
? Trophies.Count(t => t.IsUnlocked)
: EarnedFallback >= 0 ? EarnedFallback : Trophies.Count(t => t.IsUnlocked);
public int TotalCount => Trophies.Count;
public string ProgressDisplay => TotalCount == 0
? "—"
: (StateKnown || EarnedFallback >= 0)
? $"{EarnedCount}/{TotalCount}"
: $"—/{TotalCount}";
public event System.ComponentModel.PropertyChangedEventHandler? PropertyChanged;
}
// One trophy definition merged with (optional) per-user unlock state.
public class PS5Trophy : System.ComponentModel.INotifyPropertyChanged
{
public int Id { get; set; }
public string Name { get; set; } = "";
public string Detail { get; set; } = "";
public string Grade { get; set; } = "";
public bool Hidden { get; set; }
public string GroupId { get; set; } = "";
public bool IsUnlocked { get; set; }
public bool StateKnown { get; set; }
public DateTime? UnlockedTime { get; set; }
public string GradeDisplay => Grade switch
{
"P" => "Platinum",
"G" => "Gold",
"S" => "Silver",
"B" => "Bronze",
_ => "?"
};
public Avalonia.Media.IBrush GradeBrush => Grade switch
{
"P" => new Avalonia.Media.SolidColorBrush(Avalonia.Media.Color.Parse("#7FD4FF")),
"G" => new Avalonia.Media.SolidColorBrush(Avalonia.Media.Color.Parse("#FFD24A")),
"S" => new Avalonia.Media.SolidColorBrush(Avalonia.Media.Color.Parse("#C0C0C0")),
"B" => new Avalonia.Media.SolidColorBrush(Avalonia.Media.Color.Parse("#CD7F32")),
_ => new Avalonia.Media.SolidColorBrush(Avalonia.Media.Color.Parse("#6C757D"))
};
public string StateDisplay => !StateKnown
? "—"
: IsUnlocked
? (UnlockedTime?.ToString("yyyy-MM-dd HH:mm") ?? "Unlocked")
: "Locked";
public string HiddenDisplay => Hidden ? "🙈" : "";
public string GroupDisplay => string.IsNullOrEmpty(GroupId) || GroupId == "default" ? "Base" : GroupId;
public bool CanUnlock => StateKnown && !IsUnlocked;
public bool CanLock => StateKnown && IsUnlocked;
private Avalonia.Media.IImage? _icon;
public Avalonia.Media.IImage? Icon
{
get => _icon;
set { _icon = value; PropertyChanged?.Invoke(this, new System.ComponentModel.PropertyChangedEventArgs(nameof(Icon))); }
}
public event System.ComponentModel.PropertyChangedEventHandler? PropertyChanged;
}
}
@@ -0,0 +1,135 @@
using System;
using System.IO;
using System.Threading.Tasks;
using Avalonia.Controls;
using Avalonia.Interactivity;
using Avalonia.Platform.Storage;
using PS5Upload;
namespace PS5SuiteAndroid.Views;
// File Transfer actions — upload / download / mkdir / rename / delete.
// Downloads land in AppPaths.DownloadsDir (Android/data/<pkg>/files on
// Android, user-browsable without permissions).
public partial class MainView
{
private async void FilesRefresh_Click(object? sender, RoutedEventArgs e)
=> await RefreshFilesAsync();
private async void FileUpload_Click(object? sender, RoutedEventArgs e)
{
if (!RequireConnection()) return;
var top = TopLevel.GetTopLevel(this);
if (top == null) return;
var picked = await top.StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions
{
Title = "Select file to upload",
AllowMultiple = false
});
if (picked.Count == 0) return;
var file = picked[0];
string remotePath = _currentPath.TrimEnd('/') + "/" + file.Name;
UpdateStatus($"Uploading {file.Name}...");
// On Android the picker may return a content:// stream without a
// local path — stage it through the cache dir.
string? localPath = file.TryGetLocalPath();
string? staged = null;
try
{
if (localPath == null || !File.Exists(localPath))
{
staged = Path.Combine(AppPaths.CacheDir, file.Name);
await using var src = await file.OpenReadAsync();
await using var dst = File.Create(staged);
await src.CopyToAsync(dst);
localPath = staged;
}
var progress = new Progress<UploadProgress>(p =>
{
if (p.TotalBytes > 0)
UpdateStatus($"Uploading {file.Name}: {p.BytesSent * 100 / p.TotalBytes}%");
});
bool ok = await _protocol!.UploadFileAsync(localPath, remotePath, progress);
UpdateStatus(ok ? $"Uploaded: {remotePath}" : $"Upload failed: {_protocol.LastError}");
if (ok) await RefreshFilesAsync();
}
catch (Exception ex) { UpdateStatus($"Upload error: {ex.Message}"); }
finally { if (staged != null) try { File.Delete(staged); } catch { } }
}
private async void FileDownload_Click(object? sender, RoutedEventArgs e)
{
if (!RequireConnection()) return;
if (FilesListBox.SelectedItem is not FileItem item) { UpdateStatus("Select a file first"); return; }
if (item.IsDirectory) { UpdateStatus("Folder download: use the Backup flow in Saves"); return; }
string localDir = Path.Combine(AppPaths.DownloadsDir, "PS5Suite");
Directory.CreateDirectory(localDir);
string localPath = Path.Combine(localDir, item.Name);
UpdateStatus($"Downloading {item.Name}...");
try
{
var progress = new Progress<UploadProgress>(p =>
{
if (p.TotalBytes > 0)
UpdateStatus($"Downloading {item.Name}: {p.BytesSent * 100 / p.TotalBytes}%");
});
bool ok = await _protocol!.DownloadFileAsync(item.FullPath, localPath, progress);
UpdateStatus(ok ? $"Saved to {localPath}" : $"Download failed: {_protocol.LastError}");
}
catch (Exception ex) { UpdateStatus($"Download error: {ex.Message}"); }
}
private async void FileNewFolder_Click(object? sender, RoutedEventArgs e)
{
if (!RequireConnection()) return;
var name = await ShowInputAsync("New Folder", $"Create folder inside {_currentPath}");
if (string.IsNullOrWhiteSpace(name)) return;
string path = _currentPath.TrimEnd('/') + "/" + name.Trim();
try
{
bool ok = await _protocol!.CreateDirAsync(path);
UpdateStatus(ok ? $"Created {path}" : $"Failed: {_protocol.LastError}");
if (ok) await RefreshFilesAsync();
}
catch (Exception ex) { UpdateStatus($"Mkdir error: {ex.Message}"); }
}
private async void FileRename_Click(object? sender, RoutedEventArgs e)
{
if (!RequireConnection()) return;
if (FilesListBox.SelectedItem is not FileItem item) { UpdateStatus("Select an item first"); return; }
var name = await ShowInputAsync("Rename", $"Rename \"{item.Name}\" to:", item.Name);
if (string.IsNullOrWhiteSpace(name) || name == item.Name) return;
string newPath = _currentPath.TrimEnd('/') + "/" + name.Trim();
try
{
bool ok = await _protocol!.RenameAsync(item.FullPath, newPath);
UpdateStatus(ok ? $"Renamed to {name}" : $"Rename failed: {_protocol.LastError}");
if (ok) await RefreshFilesAsync();
}
catch (Exception ex) { UpdateStatus($"Rename error: {ex.Message}"); }
}
private async void FileDelete_Click(object? sender, RoutedEventArgs e)
{
if (!RequireConnection()) return;
if (FilesListBox.SelectedItem is not FileItem item) { UpdateStatus("Select an item first"); return; }
string kind = item.IsDirectory ? "folder (recursively)" : "file";
if (!await ShowConfirmAsync($"Delete {kind} \"{item.Name}\"?\n\n{item.FullPath}", "Confirm delete")) return;
try
{
bool ok = item.IsDirectory
? await _protocol!.DeleteDirAsync(item.FullPath)
: await _protocol!.DeleteFileAsync(item.FullPath);
UpdateStatus(ok ? $"Deleted {item.Name}" : $"Delete failed: {_protocol.LastError}");
if (ok) await RefreshFilesAsync();
}
catch (Exception ex) { UpdateStatus($"Delete error: {ex.Message}"); }
}
}
@@ -0,0 +1,129 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using Avalonia.Controls;
using Avalonia.Interactivity;
using Avalonia.Media.Imaging;
using Avalonia.Threading;
using PS5Upload;
namespace PS5SuiteAndroid.Views;
// Games — search/sort, per-game mount, stop running title, icons.
public partial class MainView
{
private readonly List<GameItem> _allGames = new();
private void GameSearch_TextChanged(object? sender, TextChangedEventArgs e) => ApplyGameFilter();
private void GameSort_SelectionChanged(object? sender, SelectionChangedEventArgs e) => ApplyGameFilter();
private void ApplyGameFilter()
{
if (GamesListBox == null || GameSearchBox == null || GameSortCombo == null) return;
var prev = (GamesListBox.SelectedItem as GameItem)?.TitleId;
var q = GameSearchBox.Text?.Trim() ?? "";
IEnumerable<GameItem> view = string.IsNullOrEmpty(q)
? _allGames
: _allGames.Where(g =>
g.Name.Contains(q, StringComparison.OrdinalIgnoreCase) ||
g.TitleId.Contains(q, StringComparison.OrdinalIgnoreCase));
view = GameSortCombo.SelectedIndex switch
{
1 => view.OrderByDescending(g => g.Name, StringComparer.OrdinalIgnoreCase),
2 => view.OrderBy(g => g.Size),
3 => view.OrderByDescending(g => g.Size),
_ => view.OrderBy(g => g.Name, StringComparer.OrdinalIgnoreCase),
};
var list = view.ToList();
_games.Clear();
foreach (var g in list) _games.Add(g);
if (GameCountText != null)
GameCountText.Text = string.IsNullOrEmpty(q) || list.Count == _allGames.Count
? $"Games ({_allGames.Count})"
: $"Games ({list.Count}/{_allGames.Count})";
if (prev != null)
GamesListBox.SelectedItem = list.FirstOrDefault(g => g.TitleId == prev);
}
private async void MountGameButton_Click(object? sender, RoutedEventArgs e)
{
if (_protocol == null || !_protocol.IsConnected) { UpdateStatus("Not connected"); return; }
if (GamesListBox.SelectedItem is not GameItem game) { UpdateStatus("Select a game first"); return; }
UpdateStatus($"Mounting {game.TitleId}...");
try
{
var result = await _protocol.MountGameAsync(game.TitleId, m => UpdateStatus(m));
bool failed = result == null || result.StartsWith("ERROR");
UpdateStatus(failed ? $"Mount failed: {result ?? _protocol.LastError}" : $"Mounted {game.TitleId}");
if (!failed) await RefreshGamesAsync();
}
catch (Exception ex) { UpdateStatus($"Mount error: {ex.Message}"); }
}
// Stop = kill the running app whose title id matches the selected game.
private async void StopGameButton_Click(object? sender, RoutedEventArgs e)
{
if (_protocol == null || !_protocol.IsConnected) { UpdateStatus("Not connected"); return; }
if (GamesListBox.SelectedItem is not GameItem game) { UpdateStatus("Select a game first"); return; }
try
{
var running = await _protocol.GetRunningAppsAsync();
var match = running.FirstOrDefault(a =>
string.Equals(a.TitleId, game.TitleId, StringComparison.OrdinalIgnoreCase));
if (match == null)
{
UpdateStatus($"{game.Name} is not running");
await ShowMessageAsync($"\"{game.Name}\" is not currently running on the PS5.", "Not running");
return;
}
if (!await ShowConfirmAsync($"Stop \"{game.Name}\" on the PS5?", "Stop game")) return;
var (ok, msg) = await _protocol.KillAppAsync(game.TitleId);
UpdateStatus(ok ? $"Stopped {game.Name}" : $"Stop failed: {msg}");
await RefreshGamesAsync();
}
catch (Exception ex) { UpdateStatus($"Stop error: {ex.Message}"); }
}
// Mark running titles and lazy-load icons on a side connection.
private async Task LoadGameExtrasAsync()
{
try
{
var running = await _protocol!.GetRunningAppsAsync();
var runningIds = new HashSet<string>(running.Select(a => a.TitleId), StringComparer.OrdinalIgnoreCase);
await Dispatcher.UIThread.InvokeAsync(() =>
{
foreach (var g in _allGames)
g.IsRunning = runningIds.Contains(g.TitleId);
if (RunningAppsText != null)
{
RunningAppsText.IsVisible = running.Count > 0;
RunningAppsText.Text = running.Count == 0 ? ""
: "Running: " + string.Join(", ", running.Select(a => string.IsNullOrEmpty(a.Name) ? a.TitleId : a.Name));
}
});
string? ip = _ps5IpAddress;
if (ip == null) return;
using var iconProto = new PS5Protocol();
if (!await iconProto.ConnectAsync(ip)) return;
foreach (var g in _allGames)
{
if (g.Icon != null) continue;
var bytes = await iconProto.GetGameIconAsync(g.TitleId);
if (bytes == null || bytes.Length == 0) continue;
await Dispatcher.UIThread.InvokeAsync(() =>
{
try { g.Icon = new Bitmap(new MemoryStream(bytes)); } catch { }
});
if (_protocol?.IsConnected != true) break;
}
}
catch { }
}
}
@@ -0,0 +1,454 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.IO.Compression;
using System.Linq;
using System.Net.Http;
using System.Security.Cryptography;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Threading;
using System.Threading.Tasks;
using Avalonia.Controls;
using Avalonia.Interactivity;
using Avalonia.Media.Imaging;
using Avalonia.Threading;
using PS5Upload;
namespace PS5SuiteAndroid.Views;
// Prospero Store — homebrew.page/api/v1 catalog (same as desktop).
// zip artifacts → download → SHA-256 verify → extract → upload tree to
// /data/homebrew/<TITLEID>/ → MountGame registers it on the home screen.
public partial class MainView
{
public class ProsperoItem : System.ComponentModel.INotifyPropertyChanged
{
public string TitleId { get; set; } = "";
public string Name { get; set; } = "";
public string Author { get; set; } = "";
public string Version { get; set; } = "";
public string Kind { get; set; } = "";
public string Format { get; set; } = "";
public long Size { get; set; }
public string Status { get; set; } = "";
public string IconSmall { get; set; } = "";
public string Updated { get; set; } = "";
[JsonIgnore]
private Bitmap? _icon;
[JsonIgnore]
public Bitmap? IconImage
{
get => _icon;
set { _icon = value; PropertyChanged?.Invoke(this, new System.ComponentModel.PropertyChangedEventArgs(nameof(IconImage))); }
}
[JsonIgnore]
public bool CanInstall => Status == "available" && Format == "zip";
[JsonIgnore]
public string ButtonLabel => CanInstall ? "📥" : "—";
[JsonIgnore]
public string MetaLine
{
get
{
string kind = Kind switch { "game" => "🎮", "tool" => "🔧", "app" => "🧩", _ => "📦" };
string size = Size > 0 ? $" · {Size / 1048576.0:0.0} MB" : "";
string st = Status == "coming_soon" ? " · coming soon"
: Format == "ffpfsc" ? " · ffpfsc (unsupported)" : "";
return $"{kind} {Version}{size} · {Author}{st}";
}
}
public event System.ComponentModel.PropertyChangedEventHandler? PropertyChanged;
}
private const string ProsperoApi = "https://homebrew.page/api/v1";
private readonly List<ProsperoItem> _prosperoItems = new();
private bool _prosperoFetching;
private bool _prosperoInstalling;
private static readonly HttpClient _prosperoHttp = new() { Timeout = TimeSpan.FromSeconds(30) };
private static readonly HttpClient _prosperoDl = new() { Timeout = TimeSpan.FromMinutes(15) };
private string ProsperoCacheFile => Path.Combine(AppPaths.DownloadsDir, "PS5Suite", "prospero_cache.json");
private string ProsperoIconDir => Path.Combine(AppPaths.CacheDir, "prospero_icons");
private string ProsperoDlDir => Path.Combine(AppPaths.CacheDir, "prospero_dl");
private static List<ProsperoItem> ParseProsperoIndex(string json)
{
var list = new List<ProsperoItem>();
using var doc = JsonDocument.Parse(json);
if (!doc.RootElement.TryGetProperty("apps", out var apps)) return list;
foreach (var a in apps.EnumerateArray())
{
static string S(JsonElement e, string k) =>
e.TryGetProperty(k, out var v) && v.ValueKind == JsonValueKind.String
? v.GetString() ?? "" : "";
list.Add(new ProsperoItem
{
TitleId = S(a, "titleid"),
Name = S(a, "name") is { Length: > 0 } n ? n : S(a, "titleid"),
Author = S(a, "author"),
Version = S(a, "version"),
Kind = S(a, "kind"),
Format = S(a, "format"),
Status = S(a, "status"),
IconSmall = S(a, "icon_small"),
Updated = S(a, "updated"),
Size = a.TryGetProperty("size", out var sz) && sz.ValueKind == JsonValueKind.Number ? sz.GetInt64() : 0,
});
}
return list;
}
private async void ProsperoRefresh_Click(object? sender, RoutedEventArgs e) => await RefreshProsperoAsync();
private void ProsperoSearch_TextChanged(object? sender, TextChangedEventArgs e) => ApplyProsperoFilter();
private void ProsperoCategory_SelectionChanged(object? sender, SelectionChangedEventArgs e) => ApplyProsperoFilter();
private async Task RefreshProsperoAsync()
{
if (_prosperoFetching) return;
_prosperoFetching = true;
try
{
// show cache first if present
try
{
if (File.Exists(ProsperoCacheFile))
{
var items = JsonSerializer.Deserialize<List<ProsperoItem>>(File.ReadAllText(ProsperoCacheFile));
if (items is { Count: > 0 })
{
_prosperoItems.Clear();
_prosperoItems.AddRange(items);
ApplyProsperoFilter();
ProsperoStatusText.Text = $"{items.Count} apps (cached) — refreshing…";
}
}
}
catch { }
UpdateStatus("Fetching Prospero catalog...");
var fresh = ParseProsperoIndex(await _prosperoHttp.GetStringAsync($"{ProsperoApi}/index.json"));
if (fresh.Count == 0) throw new Exception("catalog returned no apps");
_prosperoItems.Clear();
_prosperoItems.AddRange(fresh);
ApplyProsperoFilter();
int avail = fresh.Count(i => i.CanInstall);
ProsperoStatusText.Text = $"{fresh.Count} apps ({avail} installable)";
UpdateStatus($"Prospero catalog: {fresh.Count} apps ({avail} installable)");
try
{
Directory.CreateDirectory(Path.GetDirectoryName(ProsperoCacheFile)!);
File.WriteAllText(ProsperoCacheFile, JsonSerializer.Serialize(fresh));
}
catch { }
}
catch (Exception ex)
{
ProsperoStatusText.Text = _prosperoItems.Count > 0
? $"{_prosperoItems.Count} apps (cached — catalog unreachable)"
: "Fetch failed";
UpdateStatus($"Prospero catalog error: {ex.Message}");
}
finally
{
_prosperoFetching = false;
if (_prosperoItems.Count > 0) _ = Task.Run(FetchProsperoIconsAsync);
}
}
private void ApplyProsperoFilter()
{
if (ProsperoListBox == null || ProsperoSearchBox == null || ProsperoCategoryCombo == null) return;
IEnumerable<ProsperoItem> view = ProsperoCategoryCombo.SelectedIndex switch
{
1 => _prosperoItems.Where(i => i.Kind == "app"),
2 => _prosperoItems.Where(i => i.Kind == "game"),
3 => _prosperoItems.Where(i => i.Kind == "tool"),
4 => _prosperoItems.Where(i => i.Status == "coming_soon"),
_ => _prosperoItems,
};
string q = ProsperoSearchBox.Text?.Trim() ?? "";
if (!string.IsNullOrEmpty(q))
view = view.Where(i => i.Name.Contains(q, StringComparison.OrdinalIgnoreCase)
|| i.TitleId.Contains(q, StringComparison.OrdinalIgnoreCase)
|| i.Author.Contains(q, StringComparison.OrdinalIgnoreCase));
var list = view.OrderBy(i => i.Name, StringComparer.OrdinalIgnoreCase).ToList();
ProsperoListBox.ItemsSource = null;
ProsperoListBox.ItemsSource = list;
if (_prosperoItems.Count > 0)
ProsperoStatusText.Text = string.IsNullOrEmpty(q) && ProsperoCategoryCombo.SelectedIndex == 0
? $"{_prosperoItems.Count} apps ({_prosperoItems.Count(i => i.CanInstall)} installable)"
: $"{list.Count}/{_prosperoItems.Count} apps";
}
private async Task FetchProsperoIconsAsync()
{
try { Directory.CreateDirectory(ProsperoIconDir); } catch { }
var items = _prosperoItems.ToList();
await Task.WhenAll(items.Select(async it =>
{
if (it.IconImage != null) return;
string cached = Path.Combine(ProsperoIconDir, it.TitleId + ".png");
try
{
if (File.Exists(cached))
{
var bmp = new Bitmap(cached);
await Dispatcher.UIThread.InvokeAsync(() => { it.IconImage = bmp; });
return;
}
}
catch { }
string url = !string.IsNullOrEmpty(it.IconSmall)
? it.IconSmall
: $"{ProsperoApi}/icons/{it.TitleId}-256.png";
for (int a = 0; a < 3 && it.IconImage == null; a++)
{
try
{
var bytes = await _prosperoHttp.GetByteArrayAsync(url);
if (bytes.Length < 100 || bytes[0] != 0x89 || bytes[1] != 0x50) break;
using var ms = new MemoryStream(bytes);
var bmp = new Bitmap(ms);
await Dispatcher.UIThread.InvokeAsync(() => { it.IconImage = bmp; });
try { File.WriteAllBytes(cached, bytes); } catch { }
}
catch { await Task.Delay(500 * (a + 1)); }
}
}));
}
// ---------- install ----------
private record ProsperoDetail(string Url, string Sha256, string Format, string Status, long Size);
private static ProsperoDetail ParseProsperoDetail(string json)
{
using var doc = JsonDocument.Parse(json);
var r = doc.RootElement;
static string S(JsonElement e, string k) =>
e.TryGetProperty(k, out var v) && v.ValueKind == JsonValueKind.String
? v.GetString() ?? "" : "";
return new ProsperoDetail(
S(r, "artifact_url"), S(r, "sha256"), S(r, "format"), S(r, "status"),
r.TryGetProperty("size", out var sz) && sz.ValueKind == JsonValueKind.Number ? sz.GetInt64() : 0);
}
private void ProsperoUi(string text)
=> Dispatcher.UIThread.Post(() =>
{
ProsperoStatusText.Text = text;
UpdateStatus(text);
});
private async Task<bool> DownloadProsperoZipAsync(string url, string name, long expected, string destPath)
{
for (int attempt = 0; attempt < 3; attempt++)
{
try
{
using var cts = new CancellationTokenSource(TimeSpan.FromMinutes(15));
using var resp = await _prosperoDl.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, cts.Token);
resp.EnsureSuccessStatusCode();
long total = resp.Content.Headers.ContentLength ?? expected;
await using var src = await resp.Content.ReadAsStreamAsync(cts.Token);
await using var dst = new FileStream(destPath, FileMode.Create, FileAccess.Write, FileShare.None, 1 << 20);
var buf = new byte[512 * 1024];
long done = 0; int rd; int lastBucket = -1;
while ((rd = await src.ReadAsync(buf, cts.Token)) > 0)
{
await dst.WriteAsync(buf.AsMemory(0, rd), cts.Token);
done += rd;
if (total > 0)
{
int bucket = (int)(done * 10 / total);
if (bucket != lastBucket)
{
lastBucket = bucket;
int pct = (int)(done * 100 / total);
ProsperoUi($"Downloading {name} — {done / 1048576}/{total / 1048576} MB");
Dispatcher.UIThread.Post(() => { ProsperoProgress.Value = pct; });
}
}
}
if (total > 0 && done < total) throw new IOException($"short read {done}/{total}");
// sanity: must be a zip
await using (var chk = new FileStream(destPath, FileMode.Open, FileAccess.Read))
{
var magic = new byte[4];
if (await chk.ReadAsync(magic) < 4 || magic[0] != 'P' || magic[1] != 'K')
throw new IOException("downloaded file is not a ZIP");
}
return true;
}
catch (Exception ex)
{
try { File.Delete(destPath); } catch { }
ProsperoUi($"⚠️ Download failed ({ex.Message}){(attempt < 2 ? $" — retry {attempt + 2}/3" : "")}");
if (attempt < 2) await Task.Delay(1200);
}
}
return false;
}
private static string? FindAppFolder(string extRoot, string titleId)
{
var cand = Path.Combine(extRoot, titleId);
if (Directory.Exists(cand) && File.Exists(Path.Combine(cand, "eboot.bin")))
return cand;
foreach (var dir in Directory.EnumerateDirectories(extRoot))
if (File.Exists(Path.Combine(dir, "eboot.bin")))
return dir;
return null;
}
private async void ProsperoInstall_Click(object? sender, RoutedEventArgs e)
{
if (_prosperoInstalling) return;
if (_protocol?.IsConnected != true)
{ await ShowMessageAsync("Not connected to PS5", "Error"); return; }
string? tid = (sender as Control)?.Tag as string;
if (string.IsNullOrEmpty(tid)) return;
var item = _prosperoItems.FirstOrDefault(i => i.TitleId == tid);
if (item == null || !item.CanInstall) return;
string name = item.Name;
if (!await ShowConfirmAsync(
$"Install \"{name}\" ({tid}) on the PS5?\n\n" +
$"The app folder goes to /data/homebrew/{tid} and is mounted to the home screen.",
"Install")) return;
_prosperoInstalling = true;
string? zipPath = null, extDir = null;
try
{
ProsperoProgress.IsVisible = true;
ProsperoProgress.Value = 0;
// 1. detail record
ProsperoUi($"Resolving {name}...");
var det = ParseProsperoDetail(await _prosperoHttp.GetStringAsync($"{ProsperoApi}/apps/{tid}.json"));
if (det.Format != "zip" || det.Status != "available" || det.Url.Length == 0)
{ ProsperoUi($"❌ {name}: not installable ({det.Format}/{det.Status})"); return; }
// 2. download
Directory.CreateDirectory(ProsperoDlDir);
zipPath = Path.Combine(ProsperoDlDir, tid + ".zip");
ProsperoUi($"📥 {name}: downloading {(det.Size > 0 ? det.Size : item.Size) / 1048576.0:0.0} MB...");
if (!await DownloadProsperoZipAsync(det.Url, name, det.Size, zipPath))
{ ProsperoUi("download failed"); return; }
// 3. SHA-256 verify (thread pool)
if (det.Sha256.Length == 64)
{
ProsperoUi($"Verifying {name}...");
string hex = await Task.Run(async () =>
Convert.ToHexString(await SHA256.HashDataAsync(File.OpenRead(zipPath))));
if (!hex.Equals(det.Sha256, StringComparison.OrdinalIgnoreCase))
{ ProsperoUi($"❌ {name}: SHA-256 mismatch — rejected"); return; }
}
// 4. extract (thread pool)
string? appDir = null;
await Task.Run(() =>
{
extDir = Path.Combine(ProsperoDlDir, tid + "_ext");
if (Directory.Exists(extDir)) Directory.Delete(extDir, true);
ZipFile.ExtractToDirectory(zipPath, extDir);
appDir = FindAppFolder(extDir, tid);
});
if (appDir == null)
{ ProsperoUi($"❌ {name}: no eboot.bin inside the zip"); return; }
string folderName = Path.GetFileName(appDir);
// 5. upload — parallel connections like the desktop lanes
var files = Directory.GetFiles(appDir, "*", SearchOption.AllDirectories);
string remoteBase = $"/data/homebrew/{folderName}";
ProsperoUi($"📤 {name}: uploading {files.Length} files...");
ProsperoProgress.Value = 0;
var rels = files.Select(f => $"{remoteBase}/{Path.GetRelativePath(appDir, f).Replace('\\', '/')}").ToArray();
Exception? uploadError = null;
int doneFiles = 0;
await Task.Run(async () =>
{
await _protocol.CreateDirAsync(remoteBase);
foreach (var dir in Directory.GetDirectories(appDir, "*", SearchOption.AllDirectories))
{
string rel = Path.GetRelativePath(appDir, dir).Replace('\\', '/');
await _protocol.CreateDirAsync($"{remoteBase}/{rel}");
}
int lanes = Math.Min(8, files.Length);
int nextFile = -1;
long lastUi = 0;
var laneTasks = Enumerable.Range(0, lanes).Select(async _ =>
{
PS5Protocol? lane = null;
try
{
if (_ps5IpAddress == null) throw new Exception("no PS5 IP");
lane = new PS5Protocol();
if (!await lane.ConnectAsync(_ps5IpAddress)) throw new Exception("lane connect failed");
while (Volatile.Read(ref uploadError) == null)
{
int i = Interlocked.Increment(ref nextFile);
if (i >= files.Length) break;
if (!await lane.UploadFileAsync(files[i], rels[i]))
{
Interlocked.CompareExchange(ref uploadError,
new Exception($"upload failed: {rels[i]} — {lane.LastError}"), null);
break;
}
int d = Interlocked.Increment(ref doneFiles);
long now = Environment.TickCount64;
if (d == files.Length || now - lastUi > 150)
{
Interlocked.Exchange(ref lastUi, now);
ProsperoUi($"Uploading {name} — {d}/{files.Length} files");
int pct = d * 100 / files.Length;
Dispatcher.UIThread.Post(() => { ProsperoProgress.Value = pct; });
}
}
}
finally { lane?.Dispose(); }
}).ToList();
await Task.WhenAll(laneTasks);
});
if (uploadError != null)
{ ProsperoUi($"❌ {name}: {uploadError.Message}"); return; }
// clean temp — nothing stored twice
try { File.Delete(zipPath); zipPath = null; } catch { }
try { if (extDir != null) { Directory.Delete(extDir, true); extDir = null; } } catch { }
// 6. mount
ProsperoUi($"🗂️ {name}: mounting to home screen...");
var mountRes = await _protocol.MountGameAsync(folderName, msg =>
ProsperoUi($"Mounting {name} — {msg}"));
ProsperoUi(mountRes == null
? $"{name} installed (use Mount All in Games to show it)"
: $"✅ {name} installed — on the home screen");
}
catch (Exception ex)
{
ProsperoUi($"❌ Install error: {ex.Message}");
}
finally
{
_prosperoInstalling = false;
Dispatcher.UIThread.Post(() => { ProsperoProgress.IsVisible = false; ProsperoProgress.Value = 0; });
try { if (zipPath != null) File.Delete(zipPath); } catch { }
try { if (extDir != null && Directory.Exists(extDir)) Directory.Delete(extDir, true); } catch { }
}
}
}
@@ -0,0 +1,250 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using Avalonia.Controls;
using Avalonia.Interactivity;
using Avalonia.Platform.Storage;
using PS5Upload;
namespace PS5SuiteAndroid.Views;
// Saves: browse mounted, backup (decrypted + raw), restore. Screenshots:
// download to the app's downloads dir.
public partial class MainView
{
// ---------- Browse mounted ----------
private async void SaveBrowseButton_Click(object? sender, RoutedEventArgs e)
{
if (!RequireConnection()) return;
var (mounted, _, _, _) = await _protocol!.SaveMountStatusAsync();
if (!mounted)
{
await ShowMessageAsync("No save is mounted.\nMount a save image first.", "Saves");
return;
}
_currentPath = PS5Protocol.SaveMountPoint;
NavigateTo(1); // File Transfer page
await RefreshFilesAsync();
}
// ---------- Mount helper ----------
private async Task<(bool mountedOk, bool weMounted)> EnsureSaveMountedAsync(PS5SaveFile save)
{
var (mounted, src, _, _) = await _protocol!.SaveMountStatusAsync();
if (mounted && string.Equals(src, save.Path, StringComparison.OrdinalIgnoreCase))
return (true, false);
if (mounted)
await _protocol.SaveUnmountAsync(m => UpdateStatus(m));
UpdateStatus($"Mounting {save.SaveName}...");
var (ok, msg) = await _protocol.SaveMountAsync(save.Path, m => UpdateStatus(m));
if (!ok) UpdateStatus($"Mount failed: {msg}");
return (ok, ok);
}
private async Task UnmountSaveAsync()
{
var (ok, msg) = await _protocol!.SaveUnmountAsync(m => UpdateStatus(m));
UpdateStatus(ok ? "Save unmounted — image written back" : $"Unmount failed: {msg}");
if (ok) SaveMountStatusText.Text = "No save mounted";
}
// ---------- Backup (decrypted) ----------
private async void SaveBackupButton_Click(object? sender, RoutedEventArgs e)
{
if (!RequireConnection()) return;
if (SavesListBox.SelectedItem is not PS5SaveFile save)
{ UpdateStatus("Select a save first"); return; }
string backupRoot = Path.Combine(AppPaths.DownloadsDir, "PS5Suite", "save_backups");
string backupDir = Path.Combine(backupRoot, $"{save.SaveName}_{save.TitleId}");
Directory.CreateDirectory(backupDir);
if (!await ShowConfirmAsync(
$"Backup decrypted contents of {save.SaveName}?\n\nDestination:\n{backupDir}",
"Backup save")) return;
var (mountedOk, weMounted) = await EnsureSaveMountedAsync(save);
if (!mountedOk) return;
try
{
UpdateStatus("Downloading decrypted save contents...");
var progress = new Progress<DownloadFolderProgress>(p =>
{
if (p.TotalFiles > 0)
UpdateStatus($"Backup {save.SaveName}: {p.FilesCompleted}/{p.TotalFiles} files");
});
var result = await _protocol!.DownloadFolderAsync(
PS5Protocol.SaveMountPoint, backupDir, _ps5IpAddress ?? "",
progress, System.Threading.CancellationToken.None);
await ShowMessageAsync(
$"Backup complete!\n\nFiles: {result.filesDownloaded} (failed: {result.filesFailed})\nSize: {FormatSize(result.totalBytes)}\n\nLocation:\n{backupDir}",
"Backup complete");
}
catch (Exception ex) { UpdateStatus($"Backup error: {ex.Message}"); }
if (weMounted) await UnmountSaveAsync();
}
// ---------- Backup (raw image) ----------
private async void SaveBackupRaw_Click(object? sender, RoutedEventArgs e)
{
if (!RequireConnection()) return;
if (SavesListBox.SelectedItem is not PS5SaveFile save)
{ UpdateStatus("Select a save first"); return; }
string dir = Path.Combine(AppPaths.DownloadsDir, "PS5Suite", "save_backups_raw");
Directory.CreateDirectory(dir);
string local = Path.Combine(dir, Path.GetFileName(save.Path));
if (!await ShowConfirmAsync(
$"Download the raw (encrypted) save image?\n\n{save.SaveName}\n→ {local}",
"Raw backup")) return;
UpdateStatus($"Downloading {save.SaveName} (raw)...");
try
{
bool ok = await _protocol!.DownloadFileAsync(save.Path, local,
new Progress<UploadProgress>(p =>
{
if (p.TotalBytes > 0)
UpdateStatus($"Downloading: {p.BytesSent * 100 / p.TotalBytes}%");
}));
UpdateStatus(ok ? $"Raw image saved: {local}" : $"Download failed: {_protocol.LastError}");
}
catch (Exception ex) { UpdateStatus($"Raw backup error: {ex.Message}"); }
}
// ---------- Restore (decrypted) ----------
private async void SaveRestoreButton_Click(object? sender, RoutedEventArgs e)
{
if (!RequireConnection()) return;
if (SavesListBox.SelectedItem is not PS5SaveFile save)
{ UpdateStatus("Select a save first"); return; }
var top = TopLevel.GetTopLevel(this);
if (top == null) return;
var folders = await top.StorageProvider.OpenFolderPickerAsync(new FolderPickerOpenOptions
{
Title = "Select backup folder containing decrypted save files"
});
if (folders.Count == 0) return;
string stageRoot;
string? localPath = folders[0].TryGetLocalPath();
if (localPath != null && Directory.Exists(localPath))
{
stageRoot = localPath;
}
else
{
// SAF folder — stage it through the cache so we can upload by path.
stageRoot = Path.Combine(AppPaths.CacheDir, "save_restore_stage");
if (Directory.Exists(stageRoot)) Directory.Delete(stageRoot, true);
Directory.CreateDirectory(stageRoot);
UpdateStatus("Copying backup folder to staging...");
try { await StageFolderAsync(folders[0], stageRoot); }
catch (Exception ex) { UpdateStatus($"Stage failed: {ex.Message}"); return; }
}
if (!await ShowConfirmAsync(
$"Restore decrypted files into {save.SaveName} ({save.TitleId})?\n\nFrom: {stageRoot}\n\nThe save is mounted, files are overwritten, then the image is written back.",
"Restore save")) return;
var (mountedOk, weMounted) = await EnsureSaveMountedAsync(save);
if (!mountedOk) return;
string mnt = PS5Protocol.SaveMountPoint;
int uploaded = 0, failed = 0; long totalBytes = 0;
try
{
var remoteDirs = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var files = Directory.EnumerateFiles(stageRoot, "*", SearchOption.AllDirectories).ToList();
foreach (var localFile in files)
{
string rel = Path.GetRelativePath(stageRoot, localFile).Replace('\\', '/');
string? remoteDir = Path.GetDirectoryName(rel)?.Replace('\\', '/');
if (string.IsNullOrEmpty(remoteDir)) continue;
string cur = mnt;
foreach (var seg in remoteDir.Split('/', StringSplitOptions.RemoveEmptyEntries))
{
cur += "/" + seg;
if (remoteDirs.Add(cur))
try { await _protocol!.CreateDirAsync(cur); } catch { }
}
}
foreach (var localFile in files)
{
string rel = Path.GetRelativePath(stageRoot, localFile).Replace('\\', '/');
string remote = mnt + "/" + rel;
try
{
if (await _protocol!.UploadFileAsync(localFile, remote))
{ uploaded++; totalBytes += new FileInfo(localFile).Length; }
else failed++;
}
catch { failed++; }
UpdateStatus($"Restoring: {uploaded + failed}/{files.Count} files");
}
UpdateStatus($"Restore upload done: {uploaded} ok, {failed} failed, {FormatSize(totalBytes)} — writing back...");
}
catch (Exception ex) { UpdateStatus($"Restore error: {ex.Message}"); }
if (weMounted) await UnmountSaveAsync();
}
// Recursively copy a picked SAF folder to a real local dir.
private static async Task StageFolderAsync(IStorageFolder folder, string localDir)
{
Directory.CreateDirectory(localDir);
await foreach (var item in folder.GetItemsAsync())
{
if (item is IStorageFile f)
{
string dest = Path.Combine(localDir, f.Name);
await using var src = await f.OpenReadAsync();
await using var dst = File.Create(dest);
await src.CopyToAsync(dst);
}
else if (item is IStorageFolder sub)
{
await StageFolderAsync(sub, Path.Combine(localDir, sub.Name));
}
}
}
// ---------- Screenshot download ----------
private async void ScreenshotDownload_Click(object? sender, RoutedEventArgs e)
{
if (!RequireConnection()) return;
if (ScreenshotsListBox.SelectedItem is not PS5Screenshot shot)
{ UpdateStatus("Select a screenshot first"); return; }
string dir = Path.Combine(AppPaths.DownloadsDir, "PS5Suite", "screenshots");
Directory.CreateDirectory(dir);
string local = Path.Combine(dir, shot.FileName);
try
{
bool ok = await _protocol!.DownloadFileAsync(shot.FullPath, local);
UpdateStatus(ok ? $"Saved to {local}" : $"Download failed: {_protocol.LastError}");
}
catch (Exception ex) { UpdateStatus($"Download error: {ex.Message}"); }
}
// ---------- Power extras ----------
private async void RestartUi_Click(object? sender, RoutedEventArgs e)
{
if (!RequireConnection()) return;
if (!await ShowConfirmAsync("Restart the PS5 shell UI (SceShellUI)?", "Restart UI")) return;
var (ok, msg) = await _protocol!.SendTextCommandAsync(PS5Upload.Command.RestartUI, "");
UpdateStatus(ok ? "Shell UI restarted" : $"Restart failed: {msg}");
}
}
@@ -0,0 +1,449 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Text.Json;
using System.Threading.Tasks;
using Avalonia.Controls;
using Avalonia.Interactivity;
using Avalonia.Media.Imaging;
using Avalonia.Threading;
using PS5Upload;
namespace PS5SuiteAndroid.Views;
// Trophies — two-level view: trophy sets list → per-set trophy list.
// Parsing logic is a direct port of the desktop client (verified T2PD parse).
public partial class MainView
{
private List<PS5TrophySet> _trophySets = new();
private PS5TrophySet? _selectedTrophySet;
private bool _trophyLoading;
private void TrophySearch_TextChanged(object? sender, TextChangedEventArgs e) => ApplyTrophySetFilter();
private void ApplyTrophySetFilter()
{
if (TrophySetsListBox == null || TrophySearchBox == null) return;
var prevNpwr = _selectedTrophySet?.NpCommunicationId;
var q = TrophySearchBox.Text?.Trim() ?? "";
var view = string.IsNullOrEmpty(q)
? _trophySets
: _trophySets.Where(s =>
s.GameName.Contains(q, StringComparison.OrdinalIgnoreCase) ||
s.NpCommunicationId.Contains(q, StringComparison.OrdinalIgnoreCase) ||
s.TitleId.Contains(q, StringComparison.OrdinalIgnoreCase));
var list = view.OrderBy(s => s.GameName, StringComparer.OrdinalIgnoreCase).ToList();
TrophySetsListBox.ItemsSource = null;
TrophySetsListBox.ItemsSource = list;
if (prevNpwr != null && TrophySetsListBox.IsVisible)
TrophySetsListBox.SelectedItem = list.FirstOrDefault(s => s.NpCommunicationId == prevNpwr);
}
private async void TrophyRefresh_Click(object? sender, RoutedEventArgs e)
=> await RefreshTrophiesAsync();
private async Task RefreshTrophiesAsync()
{
if (_trophyLoading) return;
if (_protocol == null || !_protocol.IsConnected) { UpdateStatus("Not connected"); return; }
_trophyLoading = true;
TrophyStatusText.Text = "loading…";
UpdateStatus("Loading trophy sets...");
try
{
var sets = await _protocol.GetTrophyListAsync();
foreach (var set in sets)
ParseTrophySet(set);
// Real game names from tropmeta; fall back to mounted-games list.
var mounted = await _protocol.GetGameListAsync();
var nameByTitle = mounted.ToDictionary(g => g.TitleId, g => g.Name, StringComparer.OrdinalIgnoreCase);
foreach (var set in sets)
{
if (string.IsNullOrEmpty(set.GameName))
set.GameName = !string.IsNullOrEmpty(set.TitleId)
? (nameByTitle.TryGetValue(set.TitleId, out var gn) ? gn : set.TitleId)
: set.NpCommunicationId;
}
_trophySets = sets;
await Dispatcher.UIThread.InvokeAsync(() => ApplyTrophySetFilter());
TrophyStatusText.Text = $"{sets.Count} sets, {sets.Sum(s => s.TotalCount)} trophies";
UpdateStatus($"🏆 {sets.Count} trophy sets, {sets.Sum(s => s.TotalCount)} trophies");
_ = Task.Run(() => LoadTrophySetIconsAsync(sets));
}
catch (Exception ex)
{
TrophyStatusText.Text = "error";
UpdateStatus($"Trophy load failed: {ex.Message}");
}
finally { _trophyLoading = false; }
}
private void TrophySet_SelectionChanged(object? sender, SelectionChangedEventArgs e)
{
if (TrophySetsListBox.SelectedItem is not PS5TrophySet set) return;
_selectedTrophySet = set;
TrophyItemsListBox.ItemsSource = null;
TrophyItemsListBox.ItemsSource = set.Trophies;
TrophySetsListBox.IsVisible = false;
TrophyItemsListBox.IsVisible = true;
TrophyBackBar.IsVisible = true;
TrophyStatusText.Text = $"{set.GameName} — {set.TotalCount} trophies";
if (set.Trophies.Any(t => t.Icon == null))
_ = Task.Run(() => LoadTrophyItemIconsAsync(set));
}
private void TrophyBack_Click(object? sender, RoutedEventArgs e)
{
TrophyItemsListBox.IsVisible = false;
TrophyBackBar.IsVisible = false;
TrophySetsListBox.IsVisible = true;
_selectedTrophySet = null;
if (TrophySetsListBox.ItemsSource == null) ApplyTrophySetFilter();
TrophyStatusText.Text = $"{_trophySets.Count} sets, {_trophySets.Sum(s => s.TotalCount)} trophies";
}
// ---------- parsing (ported 1:1 from desktop MainWindow.Trophies.cs) ----------
private static void ParseTrophySet(PS5TrophySet set)
{
try
{
var confGrades = new Dictionary<string, (string grade, bool hidden, string group)>();
using (var doc = JsonDocument.Parse(set.TropConfJson))
{
var root = doc.RootElement;
if (root.TryGetProperty("trophies", out var trs))
foreach (var t in trs.EnumerateArray())
{
string id = t.TryGetProperty("id", out var idp) ? idp.GetString() ?? "" : "";
if (id.Length == 0) continue;
string grade = t.TryGetProperty("grade", out var g) ? g.GetString() ?? "" : "";
bool hidden = t.TryGetProperty("hidden", out var h) && h.GetBoolean();
string group = t.TryGetProperty("groupId", out var gr) ? gr.GetString() ?? "" : "";
confGrades[id] = (grade, hidden, group);
}
}
var names = new Dictionary<string, (string name, string detail)>();
var groupNames = new Dictionary<string, string>();
string? titleName = null;
if (set.TropMetaJson.Length > 0)
{
using var doc = JsonDocument.Parse(set.TropMetaJson);
if (doc.RootElement.TryGetProperty("metadata", out var meta))
{
if (meta.TryGetProperty("titleMetadata", out var tm) &&
tm.TryGetProperty("name", out var tn))
titleName = tn.GetString();
if (meta.TryGetProperty("groupMetadata", out var gms))
foreach (var g in gms.EnumerateArray())
{
string id = g.TryGetProperty("id", out var i) ? i.GetString() ?? "" : "";
string name = g.TryGetProperty("name", out var n) ? n.GetString() ?? "" : "";
if (id.Length > 0) groupNames[id] = name;
}
if (meta.TryGetProperty("trophyMetadata", out var tms))
foreach (var t in tms.EnumerateArray())
{
string id = t.TryGetProperty("id", out var i) ? i.GetString() ?? "" : "";
string name = t.TryGetProperty("name", out var n) ? n.GetString() ?? "" : "";
string det = t.TryGetProperty("detail", out var d) ? d.GetString() ?? "" : "";
if (id.Length > 0) names[id] = (name, det);
}
}
}
if (!string.IsNullOrEmpty(titleName)) set.GameName = titleName;
set.Trophies.Clear();
foreach (var kv in confGrades.OrderBy(k => k.Key, StringComparer.Ordinal))
{
var (grade, hidden, group) = kv.Value;
names.TryGetValue(kv.Key, out var nd);
set.Trophies.Add(new PS5Trophy
{
Id = int.TryParse(kv.Key, out int idNum) ? idNum : 0,
Name = string.IsNullOrEmpty(nd.name) ? $"Trophy {kv.Key}" : nd.name,
Detail = nd.detail ?? "",
Grade = grade,
Hidden = hidden,
GroupId = groupNames.TryGetValue(group, out var gn) ? gn : group,
StateKnown = false
});
}
ApplyTrpTitleState(set);
}
catch (Exception ex)
{
Console.WriteLine($"[Trophy] parse failed for {set.NpCommunicationId}: {ex.Message}");
}
}
// TRPTITLE.DAT (T2PD) per-trophy state — verified layout, same as desktop.
private static void ApplyTrpTitleState(PS5TrophySet set)
{
var d = set.TrpTitleData;
if (d.Length < 0x200 || d[0] != 'T' || d[1] != '2' || d[2] != 'P' || d[3] != 'D')
return;
static bool IsRec(byte[] d, int o, byte typeHi, byte szLo)
=> o + 8 <= d.Length && d[o] == 0 && d[o + 1] == 0 &&
d[o + 2] == typeHi && d[o + 3] == 0 &&
d[o + 4] == 0 && d[o + 5] == 0 && d[o + 6] == 0 && d[o + 7] == szLo;
int maxId = -1;
for (int o = 0x200; o + 0x14 <= d.Length; o += 4)
{
if (!IsRec(d, o, 5, 0xC0)) continue;
int id = (d[o + 0x10] << 24) | (d[o + 0x11] << 16) | (d[o + 0x12] << 8) | d[o + 0x13];
if (id < 512 && id > maxId) maxId = id;
}
if (maxId < 0) return;
int nbits = maxId + 1, nb = (nbits + 7) / 8;
var unionMask = new byte[nb];
int groupRecs = 0;
for (int o = 0x800; o + 0x10 + 0x30 + nb <= d.Length; o += 4)
{
if (!IsRec(d, o, 7, 0xB0)) continue;
groupRecs++;
int mo = o + 0x10 + 0x30;
for (int j = 0; j < nb; j++) unionMask[j] |= d[mo + j];
}
if (groupRecs == 0) return;
var rowState = new Dictionary<int, (bool unlocked, DateTime when)>();
var epoch = new DateTime(1, 1, 1, 0, 0, 0, DateTimeKind.Utc);
for (int o = 0x800; o + 0x10 + 0x18 <= d.Length; o += 4)
{
if (!IsRec(d, o, 8, 0x50)) continue;
int id = (d[o + 0x10] << 24) | (d[o + 0x11] << 16) | (d[o + 0x12] << 8) | d[o + 0x13];
if (id < 0 || id >= nbits) continue;
int fl = (d[o + 0x14] << 24) | (d[o + 0x15] << 16) | (d[o + 0x16] << 8) | d[o + 0x17];
long ts = 0;
for (int j = 0; j < 8; j++) ts = (ts << 8) | d[o + 0x20 + j];
bool unlocked = (fl & 1) != 0;
DateTime when = default;
if (unlocked && ts > 0)
{
try { when = epoch + TimeSpan.FromTicks(ts * 10); } catch { }
}
rowState[id] = (unlocked, when);
}
int unionPop = 0;
for (int i = 0; i < nb * 8; i++)
{
if (((unionMask[i >> 3] >> (i & 7)) & 1) == 0) continue;
if (i >= nbits) return;
unionPop++;
}
byte[] mask;
if (unionPop == 0)
{
mask = new byte[nb];
}
else
{
byte[]? found = null;
for (int o = 0x200; o + nb + 4 <= d.Length && found == null; o += 8)
{
int pop = 0; bool ok = true;
for (int j = 0; j < nb; j++)
{
int b = d[o + j];
for (int bit = 0; bit < 8; bit++)
if (((b >> bit) & 1) != 0)
{
if (j * 8 + bit >= nbits) { ok = false; break; }
pop++;
}
if (!ok) break;
}
if (!ok || pop != unionPop) continue;
if (d[o + nb] != 0 || d[o + nb + 1] != 0 || d[o + nb + 2] != 0 || d[o + nb + 3] != 0)
continue;
found = new byte[nb];
Array.Copy(d, o, found, 0, nb);
}
if (found == null)
{
set.EarnedFallback = unionPop;
if (rowState.Count == 0) return;
foreach (var t in set.Trophies)
if (rowState.TryGetValue(t.Id, out var rs))
{
t.StateKnown = true;
t.IsUnlocked = rs.unlocked;
t.UnlockedTime = rs.when == default ? null : rs.when;
}
return;
}
mask = found;
}
set.UnlockMask = mask;
set.StateKnown = true;
foreach (var t in set.Trophies)
{
if (t.Id < 0 || t.Id >= nbits) continue;
bool unlocked = ((mask[t.Id >> 3] >> (t.Id & 7)) & 1) != 0;
DateTime? when = null;
if (rowState.TryGetValue(t.Id, out var rs))
{
unlocked |= rs.unlocked;
if (rs.when != default) when = rs.when;
}
t.StateKnown = true;
t.IsUnlocked = unlocked;
t.UnlockedTime = when;
}
}
// ---------- unlock / lock ----------
private async Task<bool> EnsureTrophyGameRunningAsync(PS5TrophySet set)
{
var apps = await _protocol!.GetRunningAppsAsync();
if (!string.IsNullOrEmpty(set.TitleId) &&
apps.Any(a => string.Equals(a.TitleId, set.TitleId, StringComparison.OrdinalIgnoreCase)))
return true;
if (apps.Count == 0)
{
UpdateStatus($"⚠️ No game running — launch '{set.GameName}' first");
await ShowMessageAsync(
$"The game is not running on the PS5.\n\nLaunch \"{set.GameName}\" first — trophies can only be unlocked while the matching game is running.",
"Launch the game first");
}
else
{
UpdateStatus($"⚠️ Running title is '{apps[0].Name}', need '{set.TitleId}'");
await ShowMessageAsync(
$"A different title is running: \"{apps[0].Name}\".\n\nLaunch \"{set.GameName}\" ({set.TitleId}) first.",
"Launch the game first");
}
return false;
}
private async Task RunTrophyUnlockAsync(string spec)
{
var sw = Stopwatch.StartNew();
var (ok, msg) = await _protocol!.TrophyUnlockAsync(spec);
if (ok)
{
UpdateStatus($"🏆 {msg} ({sw.ElapsedMilliseconds} ms)");
await Task.Delay(1200);
await RefreshTrophiesAsync();
}
else
{
UpdateStatus($"❌ Trophy unlock failed: {msg}");
await ShowMessageAsync(msg, "Unlock failed");
}
}
private async void TrophyUnlock_Click(object? sender, RoutedEventArgs e)
{
if (sender is not Button { DataContext: PS5Trophy t }) return;
if (_selectedTrophySet is not PS5TrophySet set) return;
if (!await EnsureTrophyGameRunningAsync(set)) return;
if (!await ShowConfirmAsync(
$"Unlock trophy \"{t.Name}\" ({t.GradeDisplay}) on the PS5?\n\n" +
"The unlock goes through the running game's trophy pipeline — it is permanent and fires a real notification.",
"Unlock trophy")) return;
await RunTrophyUnlockAsync($"unlock:{t.Id}");
}
private async void TrophyLock_Click(object? sender, RoutedEventArgs e)
{
if (sender is not Button { DataContext: PS5Trophy t }) return;
if (_selectedTrophySet is not PS5TrophySet set) return;
if (_protocol?.IsConnected != true) { UpdateStatus("Not connected"); return; }
if (!await ShowConfirmAsync(
$"Re-lock trophy \"{t.Name}\" on the PS5?\n\n" +
"This rewrites TRPTITLE.DAT on the console — the unlock bit and timestamp are cleared.\n\n" +
"Close the game first if it's running, so the trophy daemon doesn't overwrite the edit.",
"Re-lock trophy")) return;
var (ok, msg) = await _protocol.TrophyUnlockAsync($"lock:{set.NpCommunicationId}:{t.Id}");
if (ok)
{
UpdateStatus($"🔒 {msg}");
await Task.Delay(600);
await RefreshTrophiesAsync();
// stay inside the set's trophy list after refresh
if (_selectedTrophySet != null && TrophyItemsListBox.IsVisible)
{
var same = _trophySets.FirstOrDefault(s => s.NpCommunicationId == _selectedTrophySet.NpCommunicationId);
if (same != null)
{
_selectedTrophySet = same;
TrophyItemsListBox.ItemsSource = same.Trophies;
}
}
}
else
{
UpdateStatus($"❌ Trophy lock failed: {msg}");
await ShowMessageAsync(msg, "Lock failed");
}
}
// ---------- icons (side connection) ----------
private async Task LoadTrophySetIconsAsync(List<PS5TrophySet> sets)
{
try
{
if (_ps5IpAddress == null) return;
using var proto = new PS5Protocol();
if (!await proto.ConnectAsync(_ps5IpAddress)) return;
foreach (var set in sets)
{
foreach (var cand in new[] { "icon0_en-US.png", "icon0.png" })
{
var bytes = await proto.GetTrophyIconAsync(set.NpCommunicationId, cand);
if (bytes == null || bytes.Length == 0) continue;
await Dispatcher.UIThread.InvokeAsync(() =>
{
try { set.Icon = new Bitmap(new MemoryStream(bytes)); } catch { }
});
break;
}
}
}
catch { }
}
private async Task LoadTrophyItemIconsAsync(PS5TrophySet set)
{
try
{
if (_ps5IpAddress == null) return;
using var proto = new PS5Protocol();
if (!await proto.ConnectAsync(_ps5IpAddress)) return;
foreach (var t in set.Trophies)
{
if (t.Icon != null) continue;
var bytes = await proto.GetTrophyIconAsync(set.NpCommunicationId, $"trop{t.Id:0000}.png");
if (bytes == null || bytes.Length == 0) continue;
await Dispatcher.UIThread.InvokeAsync(() =>
{
try { t.Icon = new Bitmap(new MemoryStream(bytes)); } catch { }
});
if (_protocol?.IsConnected != true) break;
}
}
catch { }
}
}
@@ -51,6 +51,8 @@
<ListBoxItem>📁 File Transfer</ListBoxItem>
<ListBoxItem>🎮 Games</ListBoxItem>
<ListBoxItem>🛒 Homebrew Store</ListBoxItem>
<ListBoxItem>🌐 Prospero Store</ListBoxItem>
<ListBoxItem>🏆 Trophies</ListBoxItem>
<ListBoxItem>💾 Saves &amp; Media</ListBoxItem>
<ListBoxItem>🖥️ System Info</ListBoxItem>
<ListBoxItem>🛠️ Tools</ListBoxItem>
@@ -93,7 +95,7 @@
<!-- Files Tab -->
<Panel x:Name="PageFiles" IsVisible="False">
<Grid RowDefinitions="Auto,*">
<Grid RowDefinitions="Auto,Auto,*">
<Border Grid.Row="0" Background="#252526" Padding="10">
<Grid ColumnDefinitions="*,Auto">
<TextBlock x:Name="CurrentPathText" Text="/data" Foreground="#A0A0A0"
@@ -101,7 +103,15 @@
<Button Grid.Column="1" Content="⬆️" Background="#3E3E42" Padding="10" Click="GoUpButton_Click"/>
</Grid>
</Border>
<ListBox Grid.Row="1" x:Name="FilesListBox" Background="#1E1E1E"
<WrapPanel Grid.Row="1" Margin="10,6" Orientation="Horizontal">
<Button Content="🔄 Refresh" Background="#3E3E42" Foreground="White" Padding="10" Margin="0,4,6,4" Click="FilesRefresh_Click"/>
<Button Content="📤 Upload" Background="#007ACC" Foreground="White" Padding="10" Margin="0,4,6,4" Click="FileUpload_Click"/>
<Button Content="📁 New Folder" Background="#3E3E42" Foreground="White" Padding="10" Margin="0,4,6,4" Click="FileNewFolder_Click"/>
<Button Content="⬇️ Download" Background="#3E3E42" Foreground="White" Padding="10" Margin="0,4,6,4" Click="FileDownload_Click"/>
<Button Content="📝 Rename" Background="#3E3E42" Foreground="White" Padding="10" Margin="0,4,6,4" Click="FileRename_Click"/>
<Button Content="🗑️ Delete" Background="#DC3545" Foreground="White" Padding="10" Margin="0,4,6,4" Click="FileDelete_Click"/>
</WrapPanel>
<ListBox Grid.Row="2" x:Name="FilesListBox" Background="#1E1E1E"
SelectionMode="Single" DoubleTapped="FilesListBox_DoubleTapped">
<ListBox.ItemTemplate>
<DataTemplate>
@@ -124,30 +134,52 @@
<!-- Games Tab -->
<Panel x:Name="PageGames" IsVisible="False">
<Grid RowDefinitions="Auto,Auto,*">
<Grid RowDefinitions="Auto,Auto,Auto,*">
<Border Grid.Row="0" Background="#252526" Padding="10">
<Grid ColumnDefinitions="*,Auto">
<TextBlock Text="Mounted Games" Foreground="#0E9CFF" FontWeight="Bold" FontSize="14" VerticalAlignment="Center"/>
<Button Grid.Column="1" Content="🔄" Background="#3E3E42" Padding="10" Click="RefreshGamesButton_Click"/>
</Grid>
<StackPanel Spacing="8">
<Grid ColumnDefinitions="*,Auto">
<TextBox x:Name="GameSearchBox" Watermark="Search games..."
Background="#2D2D30" BorderBrush="#3E3E42" Padding="10"
TextChanged="GameSearch_TextChanged"/>
<Button Grid.Column="1" Content="🔄" Background="#3E3E42" Padding="10" Click="RefreshGamesButton_Click"/>
</Grid>
<Grid ColumnDefinitions="*,Auto">
<TextBlock x:Name="GameCountText" Text="Games" Foreground="#0E9CFF" FontWeight="Bold" FontSize="13" VerticalAlignment="Center"/>
<ComboBox x:Name="GameSortCombo" Grid.Column="1" Background="#2D2D30" SelectedIndex="0"
SelectionChanged="GameSort_SelectionChanged">
<ComboBoxItem>Name A→Z</ComboBoxItem>
<ComboBoxItem>Name Z→A</ComboBoxItem>
<ComboBoxItem>Size ↑</ComboBoxItem>
<ComboBoxItem>Size ↓</ComboBoxItem>
</ComboBox>
</Grid>
</StackPanel>
</Border>
<StackPanel Grid.Row="1" Orientation="Horizontal" Spacing="8" Margin="10,6">
<Button Content="🎮 Mount All" Background="#007ACC" Foreground="White" Padding="12" Click="MountGamesButton_Click"/>
<Button Content="▶️ Launch" Background="#3E3E42" Foreground="White" Padding="12" Click="LaunchGameButton_Click"/>
<Button Content="🗑️ Unmount" Background="#3E3E42" Foreground="White" Padding="12" Click="UnmountGameButton_Click"/>
</StackPanel>
<ListBox Grid.Row="2" x:Name="GamesListBox" Background="#1E1E1E">
<WrapPanel Grid.Row="1" Margin="10,6" Orientation="Horizontal">
<Button Content="🎮 Mount All" Background="#007ACC" Foreground="White" Padding="10" Margin="0,4,6,4" Click="MountGamesButton_Click"/>
<Button Content="📥 Mount" Background="#3E3E42" Foreground="White" Padding="10" Margin="0,4,6,4" Click="MountGameButton_Click"/>
<Button Content="▶️ Launch" Background="#3E3E42" Foreground="White" Padding="10" Margin="0,4,6,4" Click="LaunchGameButton_Click"/>
<Button Content="⏹ Stop" Background="#DC3545" Foreground="White" Padding="10" Margin="0,4,6,4" Click="StopGameButton_Click"/>
<Button Content="🗑️ Unmount" Background="#3E3E42" Foreground="White" Padding="10" Margin="0,4,6,4" Click="UnmountGameButton_Click"/>
</WrapPanel>
<TextBlock Grid.Row="2" x:Name="RunningAppsText" Text="" Foreground="#28A745" FontSize="11" Margin="12,0" TextWrapping="Wrap" IsVisible="False"/>
<ListBox Grid.Row="3" x:Name="GamesListBox" Background="#1E1E1E">
<ListBox.ItemTemplate>
<DataTemplate>
<Border Background="#2D2D30" CornerRadius="8" Padding="12" Margin="4">
<Grid ColumnDefinitions="Auto,*">
<Border Grid.Column="0" Width="60" Height="60" Background="#3E3E42"
CornerRadius="8" Margin="0,0,12,0">
<TextBlock Text="🎮" FontSize="28" HorizontalAlignment="Center" VerticalAlignment="Center"/>
CornerRadius="8" Margin="0,0,12,0" ClipToBounds="True">
<Panel>
<TextBlock Text="🎮" FontSize="28" HorizontalAlignment="Center" VerticalAlignment="Center"/>
<Image Source="{Binding Icon}" Stretch="UniformToFill"/>
</Panel>
</Border>
<StackPanel Grid.Column="1" VerticalAlignment="Center">
<TextBlock Text="{Binding Name}" FontSize="14" FontWeight="Bold" TextTrimming="CharacterEllipsis"/>
<TextBlock Text="{Binding TitleId}" FontSize="12" Foreground="#0E9CFF"/>
<TextBlock FontSize="12" Foreground="#0E9CFF">
<Run Text="{Binding TitleId}"/><Run Text="{Binding RunningBadge}" Foreground="#28A745"/>
</TextBlock>
<TextBlock Text="{Binding SizeText}" FontSize="11" Foreground="#808080"/>
</StackPanel>
</Grid>
@@ -201,6 +233,140 @@
</Grid>
</Panel>
<!-- Prospero Store Tab (homebrew.page native PS5 apps) -->
<Panel x:Name="PageProspero" IsVisible="False">
<Grid RowDefinitions="Auto,Auto,*">
<Border Grid.Row="0" Background="#252526" Padding="10">
<StackPanel Spacing="8">
<Grid ColumnDefinitions="*,Auto">
<TextBox x:Name="ProsperoSearchBox" Watermark="Search Prospero store..."
Background="#2D2D30" BorderBrush="#3E3E42" Padding="10"
TextChanged="ProsperoSearch_TextChanged"/>
<Button Grid.Column="1" Content="🔄" Background="#3E3E42" Padding="10" Click="ProsperoRefresh_Click"/>
</Grid>
<ComboBox x:Name="ProsperoCategoryCombo" Background="#2D2D30" SelectedIndex="0"
HorizontalAlignment="Stretch" SelectionChanged="ProsperoCategory_SelectionChanged">
<ComboBoxItem>📦 All</ComboBoxItem>
<ComboBoxItem>🧩 Apps</ComboBoxItem>
<ComboBoxItem>🎮 Games</ComboBoxItem>
<ComboBoxItem>🔧 Tools</ComboBoxItem>
<ComboBoxItem>⏳ Coming soon</ComboBoxItem>
</ComboBox>
<TextBlock x:Name="ProsperoStatusText" Text="Tap 🔄 to fetch homebrew.page catalog" Foreground="#808080" FontSize="11"/>
</StackPanel>
</Border>
<ProgressBar Grid.Row="1" x:Name="ProsperoProgress" Height="6" Minimum="0" Maximum="100" Value="0"
Background="#1E1E1E" Foreground="#0E9CFF" IsVisible="False"/>
<ListBox Grid.Row="2" x:Name="ProsperoListBox" Background="#1E1E1E">
<ListBox.ItemTemplate>
<DataTemplate>
<Border Background="#2D2D30" CornerRadius="8" Padding="12" Margin="4">
<Grid ColumnDefinitions="Auto,*,Auto">
<Border Grid.Column="0" Width="52" Height="52" Background="#3E3E42"
CornerRadius="8" Margin="0,0,12,0" ClipToBounds="True">
<Panel>
<TextBlock Text="📦" FontSize="24" HorizontalAlignment="Center" VerticalAlignment="Center"/>
<Image Source="{Binding IconImage}" Stretch="UniformToFill"/>
</Panel>
</Border>
<StackPanel Grid.Column="1" VerticalAlignment="Center" Margin="0,0,8,0">
<TextBlock Text="{Binding Name}" FontSize="13" FontWeight="Bold" TextTrimming="CharacterEllipsis"/>
<TextBlock Text="{Binding TitleId}" FontSize="11" Foreground="#0E9CFF"/>
<TextBlock FontSize="10" Foreground="#808080">
<Run Text="{Binding MetaLine}"/>
</TextBlock>
</StackPanel>
<Button Grid.Column="2" Content="{Binding ButtonLabel}" Background="#007ACC" Foreground="White"
Padding="8" VerticalAlignment="Center" Tag="{Binding TitleId}"
IsEnabled="{Binding CanInstall}" Click="ProsperoInstall_Click"/>
</Grid>
</Border>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</Grid>
</Panel>
<!-- Trophies Tab -->
<Panel x:Name="PageTrophies" IsVisible="False">
<Grid RowDefinitions="Auto,Auto,*">
<Border Grid.Row="0" Background="#252526" Padding="10">
<StackPanel Spacing="8">
<Grid ColumnDefinitions="*,Auto">
<TextBox x:Name="TrophySearchBox" Watermark="Search trophy sets..."
Background="#2D2D30" BorderBrush="#3E3E42" Padding="10"
TextChanged="TrophySearch_TextChanged"/>
<Button Grid.Column="1" Content="🔄" Background="#3E3E42" Padding="10" Click="TrophyRefresh_Click"/>
</Grid>
<TextBlock x:Name="TrophyStatusText" Text="Tap 🔄 to load trophy sets" Foreground="#808080" FontSize="11"/>
</StackPanel>
</Border>
<Border Grid.Row="1" Background="#252526" Padding="6,0" x:Name="TrophyBackBar" IsVisible="False">
<Button Content="← Back to sets" Background="Transparent" Foreground="#0E9CFF"
Padding="10" Click="TrophyBack_Click"/>
</Border>
<ListBox Grid.Row="2" x:Name="TrophySetsListBox" Background="#1E1E1E"
SelectionChanged="TrophySet_SelectionChanged">
<ListBox.ItemTemplate>
<DataTemplate>
<Border Background="#2D2D30" CornerRadius="8" Padding="12" Margin="4">
<Grid ColumnDefinitions="Auto,*">
<Border Grid.Column="0" Width="52" Height="52" Background="#3E3E42"
CornerRadius="8" Margin="0,0,12,0" ClipToBounds="True">
<Panel>
<TextBlock Text="🏆" FontSize="24" HorizontalAlignment="Center" VerticalAlignment="Center"/>
<Image Source="{Binding Icon}" Stretch="UniformToFill"/>
</Panel>
</Border>
<StackPanel Grid.Column="1" VerticalAlignment="Center">
<TextBlock Text="{Binding GameName}" FontSize="14" FontWeight="Bold" TextTrimming="CharacterEllipsis"/>
<TextBlock Text="{Binding NpCommunicationId}" FontSize="11" Foreground="#0E9CFF"/>
<TextBlock FontSize="11" Foreground="#808080">
<Run Text="{Binding ProgressDisplay}"/><Run Text=" earned"/>
</TextBlock>
</StackPanel>
</Grid>
</Border>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<ListBox Grid.Row="2" x:Name="TrophyItemsListBox" Background="#1E1E1E" IsVisible="False">
<ListBox.ItemTemplate>
<DataTemplate>
<Border Background="#2D2D30" CornerRadius="8" Padding="10" Margin="4">
<Grid ColumnDefinitions="Auto,*,Auto">
<Border Grid.Column="0" Width="44" Height="44" Background="#3E3E42"
CornerRadius="6" Margin="0,0,10,0" ClipToBounds="True">
<Panel>
<TextBlock Text="🏆" FontSize="20" HorizontalAlignment="Center" VerticalAlignment="Center"/>
<Image Source="{Binding Icon}" Stretch="UniformToFill"/>
</Panel>
</Border>
<StackPanel Grid.Column="1" VerticalAlignment="Center" Margin="0,0,8,0">
<StackPanel Orientation="Horizontal" Spacing="6">
<TextBlock Text="{Binding Name}" FontSize="13" FontWeight="SemiBold" TextTrimming="CharacterEllipsis"/>
<TextBlock Text="{Binding HiddenDisplay}" FontSize="11"/>
</StackPanel>
<StackPanel Orientation="Horizontal" Spacing="8">
<TextBlock Text="{Binding GradeDisplay}" FontSize="10" FontWeight="Bold" Foreground="{Binding GradeBrush}"/>
<TextBlock Text="{Binding StateDisplay}" FontSize="10" Foreground="#808080"/>
</StackPanel>
<TextBlock Text="{Binding Detail}" FontSize="10" Foreground="#606060" TextTrimming="CharacterEllipsis"/>
</StackPanel>
<StackPanel Grid.Column="2" VerticalAlignment="Center" Spacing="4">
<Button Content="🔓" Background="#28A745" Foreground="White" Padding="8"
IsVisible="{Binding CanUnlock}" Click="TrophyUnlock_Click"/>
<Button Content="🔒" Background="#DC3545" Foreground="White" Padding="8"
IsVisible="{Binding CanLock}" Click="TrophyLock_Click"/>
</StackPanel>
</Grid>
</Border>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</Grid>
</Panel>
<!-- Saves & Media Tab -->
<Panel x:Name="PageSaves" IsVisible="False">
<ScrollViewer>
@@ -229,11 +395,15 @@
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<StackPanel Orientation="Horizontal" Spacing="8">
<Button Content="🔓 Mount" Background="#007ACC" Foreground="White" Padding="10" Click="SaveMountButton_Click"/>
<Button Content="🔒 Unmount" Background="#3E3E42" Foreground="White" Padding="10" Click="SaveUnmountButton_Click"/>
<Button Content="🗑️ Delete" Background="#DC3545" Foreground="White" Padding="10" Click="SaveDeleteButton_Click"/>
</StackPanel>
<WrapPanel Orientation="Horizontal">
<Button Content="🔓 Mount" Background="#007ACC" Foreground="White" Padding="10" Margin="0,4,6,4" Click="SaveMountButton_Click"/>
<Button Content="🔒 Unmount" Background="#3E3E42" Foreground="White" Padding="10" Margin="0,4,6,4" Click="SaveUnmountButton_Click"/>
<Button Content="📂 Browse" Background="#3E3E42" Foreground="White" Padding="10" Margin="0,4,6,4" Click="SaveBrowseButton_Click"/>
<Button Content="� Backup" Background="#28A745" Foreground="White" Padding="10" Margin="0,4,6,4" Click="SaveBackupButton_Click"/>
<Button Content="📥 Raw" Background="#3E3E42" Foreground="White" Padding="10" Margin="0,4,6,4" Click="SaveBackupRaw_Click"/>
<Button Content="📤 Restore" Background="#FFC107" Foreground="#1E1E1E" Padding="10" Margin="0,4,6,4" Click="SaveRestoreButton_Click"/>
<Button Content="�🗑️ Delete" Background="#DC3545" Foreground="White" Padding="10" Margin="0,4,6,4" Click="SaveDeleteButton_Click"/>
</WrapPanel>
<TextBlock Text="Mounted saves are browsable at /data/save_mnt" Foreground="#606060" FontSize="10"/>
</StackPanel>
</Border>
@@ -251,18 +421,23 @@
<ListBox.ItemTemplate>
<DataTemplate>
<Border Background="#2D2D30" CornerRadius="4" Padding="10" Margin="3">
<StackPanel>
<TextBlock Text="{Binding FileName}" FontSize="12" FontWeight="SemiBold"/>
<TextBlock FontSize="10" Foreground="#808080">
<Run Text="{Binding DateDisplay}"/><Run Text=" · "/><Run Text="{Binding SizeDisplay}"/>
</TextBlock>
</StackPanel>
<Grid ColumnDefinitions="*,Auto">
<StackPanel Grid.Column="0">
<TextBlock Text="{Binding FileName}" FontSize="12" FontWeight="SemiBold"/>
<TextBlock FontSize="10" Foreground="#808080">
<Run Text="{Binding DateDisplay}"/><Run Text=" · "/><Run Text="{Binding SizeDisplay}"/>
</TextBlock>
</StackPanel>
<Image Grid.Column="1" Source="{Binding Thumbnail}" Width="72" Height="40" Stretch="Uniform"/>
</Grid>
</Border>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<Button Content="🗑️ Delete Selected" Background="#DC3545" Foreground="White"
Padding="10" HorizontalAlignment="Stretch" Click="ScreenshotDeleteButton_Click"/>
<WrapPanel Orientation="Horizontal">
<Button Content="⬇️ Download" Background="#3E3E42" Foreground="White" Padding="10" Margin="0,4,6,4" Click="ScreenshotDownload_Click"/>
<Button Content="🗑️ Delete Selected" Background="#DC3545" Foreground="White" Padding="10" Margin="0,4,6,4" Click="ScreenshotDeleteButton_Click"/>
</WrapPanel>
</StackPanel>
</Border>
</StackPanel>
@@ -490,12 +665,12 @@
<Slider x:Name="FanSlider" Minimum="30" Maximum="90" Value="60"
TickFrequency="1" IsSnapToTickEnabled="True"
ValueChanged="FanSlider_ValueChanged"/>
<StackPanel Orientation="Horizontal" HorizontalAlignment="Center" Spacing="8">
<Button Content="Cool 50°" Background="#3E3E42" Padding="10" Click="FanPreset_Click"/>
<WrapPanel Orientation="Horizontal" HorizontalAlignment="Center">
<Button Content="Cool 50°" Background="#3E3E42" Foreground="White" Padding="10" Margin="0,4,6,4" Click="FanPreset_Click"/>
<TextBlock x:Name="FanSliderText" Text="60°C" FontSize="18" FontWeight="Bold"
Foreground="#0E9CFF" VerticalAlignment="Center" MinWidth="60"/>
<Button Content="Quiet 70°" Background="#3E3E42" Padding="10" Click="FanPreset_Click"/>
</StackPanel>
Foreground="#0E9CFF" VerticalAlignment="Center" MinWidth="60" Margin="0,4"/>
<Button Content="Quiet 70°" Background="#3E3E42" Foreground="White" Padding="10" Margin="0,4,6,4" Click="FanPreset_Click"/>
</WrapPanel>
<Button Content="🌀 Set Threshold" Background="#007ACC" Foreground="White"
Padding="12" HorizontalAlignment="Stretch" Click="FanSetButton_Click"/>
</StackPanel>
@@ -543,18 +718,18 @@
<Border Background="#252526" CornerRadius="8" Padding="15">
<StackPanel Spacing="10">
<TextBlock Text="🧠 Memory" Foreground="#0E9CFF" FontWeight="Bold" FontSize="14"/>
<StackPanel Orientation="Horizontal" Spacing="8">
<TextBox x:Name="MemPidBox" Watermark="pid" Width="100"
<WrapPanel Orientation="Horizontal">
<TextBox x:Name="MemPidBox" Watermark="pid" Width="100" Margin="0,4,6,4"
Background="#2D2D30" BorderBrush="#3E3E42" Padding="10" FontSize="12"/>
<Button Content="📋 Regions" Background="#3E3E42" Padding="8" Click="MemRegions_Click"/>
</StackPanel>
<StackPanel Orientation="Horizontal" Spacing="8">
<TextBox x:Name="MemAddrBox" Watermark="0xaddr" Width="150"
<Button Content="📋 Regions" Background="#3E3E42" Foreground="White" Padding="8" Margin="0,4,6,4" Click="MemRegions_Click"/>
</WrapPanel>
<WrapPanel Orientation="Horizontal">
<TextBox x:Name="MemAddrBox" Watermark="0xaddr" Width="150" Margin="0,4,6,4"
Background="#2D2D30" BorderBrush="#3E3E42" Padding="10" FontSize="12"/>
<TextBox x:Name="MemLenBox" Watermark="len" Width="80"
<TextBox x:Name="MemLenBox" Watermark="len" Width="80" Margin="0,4,6,4"
Background="#2D2D30" BorderBrush="#3E3E42" Padding="10" FontSize="12"/>
<Button Content="📖 Read" Background="#007ACC" Padding="8" Click="MemRead_Click"/>
</StackPanel>
<Button Content="📖 Read" Background="#007ACC" Foreground="White" Padding="8" Margin="0,4,6,4" Click="MemRead_Click"/>
</WrapPanel>
<TextBox x:Name="MemOutputBox" IsReadOnly="True" Height="160" FontFamily="Consolas" FontSize="10"
Background="#0D0D0D" BorderBrush="#3E3E42" Padding="8" TextWrapping="Wrap"/>
</StackPanel>
@@ -584,11 +759,11 @@
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<StackPanel Orientation="Horizontal" Spacing="8" HorizontalAlignment="Center">
<Button Content="⏸ Suspend" Background="#3E3E42" Padding="8" Click="AppSuspend_Click"/>
<Button Content="▶ Resume" Background="#3E3E42" Padding="8" Click="AppResume_Click"/>
<Button Content="🗑 Kill" Background="#DC3545" Foreground="White" Padding="8" Click="AppKill_Click"/>
</StackPanel>
<WrapPanel Orientation="Horizontal" HorizontalAlignment="Center">
<Button Content="⏸ Suspend" Background="#3E3E42" Foreground="White" Padding="10" Margin="0,4,6,4" Click="AppSuspend_Click"/>
<Button Content="▶ Resume" Background="#3E3E42" Foreground="White" Padding="10" Margin="0,4,6,4" Click="AppResume_Click"/>
<Button Content="⏹ Kill" Background="#DC3545" Foreground="White" Padding="10" Margin="0,4,6,4" Click="AppKill_Click"/>
</WrapPanel>
</StackPanel>
</Border>
@@ -615,10 +790,11 @@
<Border Background="#252526" CornerRadius="8" Padding="15">
<StackPanel Spacing="10">
<TextBlock Text="⚡ Power" Foreground="#0E9CFF" FontWeight="Bold" FontSize="14"/>
<StackPanel Orientation="Horizontal" Spacing="8">
<Button Content="🔄 Reboot" Background="#3E3E42" Foreground="White" Padding="10" Click="PowerReboot_Click"/>
<Button Content="⏻ Shutdown" Background="#DC3545" Foreground="White" Padding="10" Click="PowerShutdown_Click"/>
</StackPanel>
<WrapPanel Orientation="Horizontal">
<Button Content="🔄 Reboot" Background="#3E3E42" Foreground="White" Padding="10" Margin="0,4,6,4" Click="PowerReboot_Click"/>
<Button Content="⏻ Shutdown" Background="#DC3545" Foreground="White" Padding="10" Margin="0,4,6,4" Click="PowerShutdown_Click"/>
<Button Content="📺 Restart Shell UI" Background="#3E3E42" Foreground="White" Padding="10" Margin="0,4,6,4" Click="RestartUi_Click"/>
</WrapPanel>
</StackPanel>
</Border>
@@ -657,11 +833,11 @@
<Border Background="#252526" CornerRadius="8" Padding="15">
<StackPanel Spacing="10">
<TextBlock Text="💡 Console LED" Foreground="#0E9CFF" FontWeight="Bold" FontSize="14"/>
<StackPanel Orientation="Horizontal" Spacing="8">
<Button Content="Bright" Background="#3E3E42" Padding="8" Click="LedDim_Click" Tag="0"/>
<Button Content="Medium" Background="#3E3E42" Padding="8" Click="LedDim_Click" Tag="1"/>
<Button Content="Dim" Background="#3E3E42" Padding="8" Click="LedDim_Click" Tag="2"/>
</StackPanel>
<WrapPanel Orientation="Horizontal">
<Button Content="Bright" Background="#3E3E42" Foreground="White" Padding="8" Margin="0,4,6,4" Click="LedDim_Click" Tag="0"/>
<Button Content="Medium" Background="#3E3E42" Foreground="White" Padding="8" Margin="0,4,6,4" Click="LedDim_Click" Tag="1"/>
<Button Content="Dim" Background="#3E3E42" Foreground="White" Padding="8" Margin="0,4,6,4" Click="LedDim_Click" Tag="2"/>
</WrapPanel>
<Grid ColumnDefinitions="*,Auto">
<ComboBox x:Name="LedEffectCombo" Grid.Column="0" Background="#2D2D30" SelectedIndex="1">
<ComboBoxItem>off</ComboBoxItem>
@@ -709,21 +885,6 @@
Padding="10" HorizontalAlignment="Stretch" Click="NotifySend_Click"/>
</StackPanel>
</Border>
<!-- Disc Dump Card -->
<Border Background="#252526" CornerRadius="8" Padding="15">
<StackPanel Spacing="10">
<TextBlock Text="💿 Disc Dump" Foreground="#0E9CFF" FontWeight="Bold" FontSize="14"/>
<StackPanel Orientation="Horizontal" Spacing="8">
<Button Content="▶ Start" Background="#007ACC" Foreground="White" Padding="10" Click="DiscDumpStart_Click"/>
<Button Content="⏹ Cancel" Background="#DC3545" Foreground="White" Padding="10" Click="DiscDumpCancel_Click"/>
</StackPanel>
<ProgressBar x:Name="DiscDumpBar" Height="8" Minimum="0" Maximum="100" Value="0"
Background="#1E1E1E" Foreground="#0E9CFF"/>
<TextBlock x:Name="DiscDumpStatus" Text="Idle — dumps /mnt/disc to /user/data/disc_dumps"
Foreground="#808080" FontSize="11" TextWrapping="Wrap"/>
</StackPanel>
</Border>
</StackPanel>
</ScrollViewer>
</Panel>
@@ -750,5 +911,28 @@
<Border Grid.Row="2" Background="#252526" Padding="10">
<TextBlock x:Name="StatusText" Text="Ready" Foreground="#808080" FontSize="11" HorizontalAlignment="Center"/>
</Border>
<!-- Modal dialog overlay (message / confirm / input) -->
<Grid Grid.Row="0" Grid.RowSpan="3" x:Name="DialogOverlay" IsVisible="False"
Background="#AA000000">
<Border Background="#252526" CornerRadius="12" Padding="20" Margin="24"
MaxWidth="420" VerticalAlignment="Center" HorizontalAlignment="Center"
BorderBrush="#3E3E42" BorderThickness="1">
<StackPanel Spacing="14">
<TextBlock x:Name="DialogTitle" Text="Title" FontSize="16" FontWeight="Bold" Foreground="#0E9CFF"/>
<ScrollViewer MaxHeight="300">
<TextBlock x:Name="DialogMessage" Text="" FontSize="13" Foreground="#E0E0E0" TextWrapping="Wrap"/>
</ScrollViewer>
<TextBox x:Name="DialogInput" Watermark="" Background="#2D2D30" BorderBrush="#3E3E42"
Padding="10" FontSize="14" IsVisible="False"/>
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right" Spacing="10">
<Button x:Name="DialogCancelButton" Content="Cancel" Background="#3E3E42" Foreground="White"
Padding="16,10" Click="DialogCancel_Click"/>
<Button x:Name="DialogOkButton" Content="OK" Background="#007ACC" Foreground="White"
Padding="16,10" Click="DialogOk_Click"/>
</StackPanel>
</StackPanel>
</Border>
</Grid>
</Grid>
</UserControl>
@@ -18,6 +18,7 @@ namespace PS5SuiteAndroid.Views;
public partial class MainView : UserControl
{
private PS5Protocol? _protocol;
private string? _ps5IpAddress;
private string _currentPath = "/data";
private ObservableCollection<FileItem> _files = new();
private ObservableCollection<GameItem> _games = new();
@@ -44,14 +45,68 @@ public partial class MainView : UserControl
{
if (NavList.SelectedIndex < 0 || PageConnect == null) return;
var pages = new Control[] {
PageConnect, PageFiles, PageGames, PageStore, PageSaves,
PageSystem, PageTools, PageDevices, PageLog
PageConnect, PageFiles, PageGames, PageStore, PageProspero,
PageTrophies, PageSaves, PageSystem, PageTools, PageDevices, PageLog
};
for (int i = 0; i < pages.Length; i++)
if (pages[i] != null) pages[i].IsVisible = i == NavList.SelectedIndex;
NavDrawer.IsPaneOpen = false;
// Auto-load pages that need the PS5 the first time they open
if (_protocol?.IsConnected == true)
{
switch (NavList.SelectedIndex)
{
case 2: if (!_gamesLoadedOnce) { _gamesLoadedOnce = true; _ = RefreshGamesAsync(); } break;
case 4: if (!_prosperoLoadedOnce) { _prosperoLoadedOnce = true; _ = RefreshProsperoAsync(); } break;
case 5: if (!_trophyLoadedOnce) { _trophyLoadedOnce = true; _ = RefreshTrophiesAsync(); } break;
}
}
}
private bool _gamesLoadedOnce;
private bool _prosperoLoadedOnce;
private bool _trophyLoadedOnce;
// ============================================================
// MODAL DIALOG OVERLAY (message / confirm / input / pick)
// ============================================================
private TaskCompletionSource<object?>? _dialogTcs;
private void DialogShow(string title, string message, bool showInput, bool showCancel, string inputText = "")
{
DialogTitle.Text = title;
DialogMessage.Text = message;
DialogInput.IsVisible = showInput;
DialogInput.Text = inputText;
DialogCancelButton.IsVisible = showCancel;
DialogOverlay.IsVisible = true;
if (showInput) DialogInput.Focus();
}
private void DialogOk_Click(object? sender, RoutedEventArgs e)
=> _dialogTcs?.TrySetResult(DialogInput.IsVisible ? DialogInput.Text : (object?)true);
private void DialogCancel_Click(object? sender, RoutedEventArgs e)
=> _dialogTcs?.TrySetResult(null);
private Task<object?> RunDialogAsync(string title, string message, bool showInput, bool showCancel, string inputText = "")
{
_dialogTcs = new TaskCompletionSource<object?>();
DialogShow(title, message, showInput, showCancel, inputText);
return _dialogTcs.Task.ContinueWith(t => { DialogOverlay.IsVisible = false; return t.Result; },
TaskScheduler.FromCurrentSynchronizationContext());
}
private Task ShowMessageAsync(string message, string title = "PS5 Suite")
=> RunDialogAsync(title, message, false, false);
private async Task<bool> ShowConfirmAsync(string message, string title = "Confirm")
=> await RunDialogAsync(title, message, false, true) is true;
private async Task<string?> ShowInputAsync(string title, string message, string initial = "")
=> await RunDialogAsync(title, message, true, true, initial) as string;
private void UpdateStatus(string message)
{
Dispatcher.UIThread.Post(() =>
@@ -105,6 +160,7 @@ public partial class MainView : UserControl
if (success)
{
_ps5IpAddress = ip;
UpdateConnectionStatus(true);
UpdateStatus("Connected successfully!");
}
@@ -127,21 +183,23 @@ public partial class MainView : UserControl
}
}
private void NavigateTo(int index) => NavList.SelectedIndex = index;
private void BrowseFilesButton_Click(object? sender, RoutedEventArgs e)
{
// Switch to Files tab - find parent TabControl
var tabControl = this.FindControl<TabControl>("TabControl");
// For now just refresh files
NavigateTo(1);
_ = RefreshFilesAsync();
}
private void ViewGamesButton_Click(object? sender, RoutedEventArgs e)
{
NavigateTo(2);
_ = RefreshGamesAsync();
}
private void SystemInfoButton_Click(object? sender, RoutedEventArgs e)
{
NavigateTo(7);
_ = RefreshSystemInfoAsync();
}
@@ -220,20 +278,23 @@ public partial class MainView : UserControl
try
{
var games = await _protocol.GetGameListAsync();
_games.Clear();
_allGames.Clear();
foreach (var game in games)
{
_games.Add(new GameItem
_allGames.Add(new GameItem
{
Name = game.Name,
TitleId = game.TitleId,
Path = game.Path,
SizeText = "—"
Size = game.Size,
SizeText = game.Size > 0 ? FormatSize((long)game.Size) : "—"
});
}
ApplyGameFilter();
UpdateStatus($"Found {games.Count} games");
_ = Task.Run(LoadGameExtrasAsync);
}
catch (Exception ex)
{
@@ -702,54 +763,6 @@ public partial class MainView : UserControl
catch (Exception ex) { UpdateStatus($"Notify error: {ex.Message}"); }
}
private async void DiscDumpStart_Click(object? sender, RoutedEventArgs e)
{
if (!RequireConnection()) return;
UpdateStatus("Starting disc dump...");
try
{
var (ok, msg) = await _protocol!.DiscDumpAsync("start");
UpdateStatus(ok ? "Disc dump started" : $"Dump failed: {msg}");
if (ok) _ = PollDiscDumpAsync();
}
catch (Exception ex) { UpdateStatus($"Dump error: {ex.Message}"); }
}
private async Task PollDiscDumpAsync()
{
for (int i = 0; i < 600; i++)
{
await Task.Delay(2000);
if (_protocol == null || !_protocol.IsConnected) return;
try
{
var (ok, msg) = await _protocol.DiscDumpAsync("status");
Dispatcher.UIThread.Post(() =>
{
DiscDumpStatus.Text = msg;
var m = System.Text.RegularExpressions.Regex.Match(msg, @"(\d+)%");
if (m.Success) DiscDumpBar.Value = int.Parse(m.Groups[1].Value);
});
if (!ok || msg.Contains("done", StringComparison.OrdinalIgnoreCase)
|| msg.Contains("idle", StringComparison.OrdinalIgnoreCase)
|| msg.Contains("error", StringComparison.OrdinalIgnoreCase))
return;
}
catch { return; }
}
}
private async void DiscDumpCancel_Click(object? sender, RoutedEventArgs e)
{
if (!RequireConnection()) return;
try
{
var (ok, msg) = await _protocol!.DiscDumpAsync("cancel");
UpdateStatus(ok ? "Dump cancelled" : $"Cancel failed: {msg}");
}
catch (Exception ex) { UpdateStatus($"Cancel error: {ex.Message}"); }
}
// ============================================================
// HOMEBREW STORE (pkg-zone.com catalog)
// ============================================================
@@ -1186,10 +1199,22 @@ public class FileItem
public string SizeText { get; set; } = "";
}
public class GameItem
public class GameItem : System.ComponentModel.INotifyPropertyChanged
{
public string Name { get; set; } = "";
public string TitleId { get; set; } = "";
public string Path { get; set; } = "";
public string SizeText { get; set; } = "";
public ulong Size { get; set; }
public bool IsRunning { get; set; }
public string RunningBadge => IsRunning ? " ▶ RUNNING" : "";
private Avalonia.Media.IImage? _icon;
public Avalonia.Media.IImage? Icon
{
get => _icon;
set { _icon = value; PropertyChanged?.Invoke(this, new System.ComponentModel.PropertyChangedEventArgs(nameof(Icon))); }
}
public event System.ComponentModel.PropertyChangedEventHandler? PropertyChanged;
}
+242 -119
View File
@@ -607,7 +607,7 @@ namespace PS5Upload
{
try
{
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo { FileName = "https://buymeacoffee.com/manos555554", UseShellExecute = true });
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo { FileName = "https://buymeacoffee.com/manos555555", UseShellExecute = true });
Log("☕ Thank you for your support!");
}
catch (Exception ex) { Log($"❌ Failed to open link: {ex.Message}"); }
@@ -1637,96 +1637,6 @@ namespace PS5Upload
catch (Exception ex) { Log($"❌ Beeper mute error: {ex.Message}"); }
}
// ============================================================
// DISC DUMP
// ============================================================
private DispatcherTimer? _discDumpTimer;
private async void DiscDumpStart_Click(object? sender, RoutedEventArgs e)
{
if (!_protocol.IsConnected) { await ShowMessageAsync("Not connected to PS5", "Error"); return; }
try
{
var (success, message) = await _protocol.DiscDumpAsync("start");
if (success)
{
Log($"💿 Disc dump started → {message}");
DiscDumpStatusText.Text = $"Dumping to {message}...";
_discDumpTimer ??= new DispatcherTimer { Interval = TimeSpan.FromSeconds(2) };
_discDumpTimer.Tick -= DiscDumpTimer_Tick;
_discDumpTimer.Tick += DiscDumpTimer_Tick;
_discDumpTimer.Start();
}
else
{
DiscDumpStatusText.Text = $"❌ {message}";
Log($"❌ Disc dump: {message}");
}
}
catch (Exception ex) { Log($"❌ Disc dump error: {ex.Message}"); }
}
private async void DiscDumpTimer_Tick(object? sender, EventArgs e)
{
try
{
var (success, message) = await _protocol.DiscDumpAsync("status");
if (!success && string.IsNullOrEmpty(message)) return;
var kv = message.Split('|')
.Select(l => l.Split('=', 2))
.Where(p => p.Length == 2)
.ToDictionary(p => p[0], p => p[1]);
bool active = kv.GetValueOrDefault("active", "0") == "1";
ulong done = ulong.TryParse(kv.GetValueOrDefault("done", "0"), out var d) ? d : 0;
ulong total = ulong.TryParse(kv.GetValueOrDefault("total", "0"), out var t) ? t : 0;
string file = kv.GetValueOrDefault("file", "");
string err = kv.GetValueOrDefault("err", "");
if (total > 0)
{
DiscDumpProgress.Value = Math.Min(100, (double)done * 100.0 / total);
DiscDumpStatusText.Text = $"{done / 1048576:N0} / {total / 1048576:N0} MB — {file}";
}
else
{
DiscDumpStatusText.Text = $"{done / 1048576:N0} MB — {file}";
}
if (!active)
{
_discDumpTimer?.Stop();
DiscDumpStatusText.Text = string.IsNullOrEmpty(err)
? $"✅ Done — {done / 1048576:N0} MB copied to {kv.GetValueOrDefault("dest", "?")}"
: $"⚠️ Stopped: {err}";
Log($"💿 {DiscDumpStatusText.Text}");
}
}
catch { /* transient read errors — keep polling */ }
}
private async void DiscDumpStatus_Click(object? sender, RoutedEventArgs e)
{
if (!_protocol.IsConnected) { await ShowMessageAsync("Not connected to PS5", "Error"); return; }
try
{
var (success, message) = await _protocol.DiscDumpAsync("status");
DiscDumpStatusText.Text = message;
Log($"💿 Status: {message}");
}
catch (Exception ex) { Log($"❌ Disc dump error: {ex.Message}"); }
}
private async void DiscDumpCancel_Click(object? sender, RoutedEventArgs e)
{
if (!_protocol.IsConnected) { await ShowMessageAsync("Not connected to PS5", "Error"); return; }
try
{
var (success, message) = await _protocol.DiscDumpAsync("cancel");
Log(success ? "💿 Dump cancelled" : $"❌ Cancel: {message}");
}
catch (Exception ex) { Log($"❌ Disc dump error: {ex.Message}"); }
}
// ============================================================
// HOMEBREW STORE (pkg-zone.com catalog)
// ============================================================
@@ -1738,6 +1648,7 @@ namespace PS5Upload
public string Author { get; set; } = "";
private Bitmap? _cover;
[System.Text.Json.Serialization.JsonIgnore]
public Bitmap? Cover
{
get => _cover;
@@ -1748,16 +1659,131 @@ namespace PS5Upload
}
private readonly List<StoreItem> _storeItems = new();
private static readonly HttpClient _storeHttp = new() { Timeout = TimeSpan.FromSeconds(20) };
// pkg-zone.com is extremely flaky — roughly half of all requests either
// time out at the edge or hit a Laravel 500. Short timeout + per-request
// retry is the only way a fetch reliably lands; a single long timeout
// just makes the app look frozen.
private static readonly HttpClient _storeHttp = CreateStoreHttp();
private static HttpClient CreateStoreHttp()
{
var h = new HttpClient { Timeout = TimeSpan.FromSeconds(20) };
h.DefaultRequestHeaders.UserAgent.ParseAdd(
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36");
h.DefaultRequestHeaders.Accept.ParseAdd(
"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8");
h.DefaultRequestHeaders.AcceptLanguage.ParseAdd("en-US,en;q=0.9");
return h;
}
// Retry wrapper for the flaky backend. Returns null after `attempts`
// failures so the caller can decide whether to abort or degrade.
private static async Task<string?> FetchStorePageAsync(string url, int attempts)
{
Exception? lastEx = null;
for (int a = 0; a < attempts; a++)
{
try { return await _storeHttp.GetStringAsync(url); }
catch (Exception ex)
{
lastEx = ex;
if (a + 1 < attempts) await Task.Delay(800 * (a + 1));
}
}
_ = lastEx;
return null;
}
private static string StoreClean(string s)
=> System.Net.WebUtility.HtmlDecode(s).Trim();
private async void StoreRefresh_Click(object? sender, RoutedEventArgs e)
// The catalog is tiny and rarely changes — cache it next to the exe so
// the store paints instantly even when pkg-zone.com is having a bad day
// (it 500s roughly half of all requests).
private const string StoreCacheFile = "pkgzone_cache.json";
private const string StoreCoverDir = "pkgzone_covers";
private int LoadStoreCache()
{
try
{
StoreStatusText.Text = "Fetching pkg-zone.com catalog...";
if (!File.Exists(StoreCacheFile)) return 0;
var items = JsonSerializer.Deserialize<List<StoreItem>>(File.ReadAllText(StoreCacheFile));
if (items == null || items.Count == 0) return 0;
_storeItems.Clear();
_storeItems.AddRange(items);
ApplyStoreFilter();
StoreStatusText.Text = $"{items.Count} PS5 packages (cached)";
foreach (var it in items) TryLoadCachedCover(it);
return items.Count;
}
catch { return 0; }
}
private static void TryLoadCachedCover(StoreItem it)
{
try
{
string p = Path.Combine(StoreCoverDir, it.Id + ".png");
if (File.Exists(p))
it.Cover = new Bitmap(p);
}
catch { }
}
private static void SaveCoverToCache(StoreItem it, byte[] png)
{
try
{
Directory.CreateDirectory(StoreCoverDir);
File.WriteAllBytes(Path.Combine(StoreCoverDir, it.Id + ".png"), png);
}
catch { }
}
// Fetches every missing cover in parallel, writes successful ones to the
// disk cache. Runs after every refresh attempt — success OR failure —
// so cached catalogs still get their icons whenever the site responds.
private void FetchStoreCoversAsync()
{
var items = _storeItems.ToList();
_ = Task.Run(async () =>
{
await Task.WhenAll(items.Select(async it =>
{
if (it.Cover != null) return;
TryLoadCachedCover(it);
if (it.Cover != null) return;
for (int a = 0; a < 5 && it.Cover == null; a++)
{
try
{
var bytes = await _storeHttp.GetByteArrayAsync($"https://pkg-zone.com/images/{it.Id}/cover.png");
// Site outages return HTML error pages — reject
// anything that isn't actually a PNG.
if (bytes.Length < 100 || bytes[0] != 0x89 || bytes[1] != 0x50)
throw new Exception("not a PNG");
using var ms = new MemoryStream(bytes);
var bmp = new Bitmap(ms);
await Dispatcher.UIThread.InvokeAsync(() => { it.Cover = bmp; });
SaveCoverToCache(it, bytes);
}
catch { await Task.Delay(800 * (a + 1)); }
}
}));
});
}
private bool _storeFetching;
private async void StoreRefresh_Click(object? sender, RoutedEventArgs e)
{
if (_storeFetching) return; // rapid clicks pile up overlapping fetches
_storeFetching = true;
try
{
int cached = LoadStoreCache();
StoreStatusText.Text = cached > 0
? $"{cached} PS5 packages (cached) — refreshing…"
: "Fetching pkg-zone.com catalog...";
Log("🛒 Fetching homebrew catalog...");
var items = new List<StoreItem>();
@@ -1774,17 +1800,22 @@ namespace PS5Upload
@"<div class=""dark:text-gray-300"">([^<]*)</div>", System.Text.RegularExpressions.RegexOptions.Compiled);
var seen = new HashSet<string>();
int consecutiveFailures = 0;
for (int page = 1; page <= 15; page++)
{
string url = $"https://pkg-zone.com/?console=ps5&page={page}";
string html;
try { html = await _storeHttp.GetStringAsync(url); }
catch (Exception ex)
// Retry every page — the backend 500s or edge-times-out on
// roughly half of all requests, so a single-attempt walk
// dies halfway through the catalog almost every time.
string? html = await FetchStorePageAsync(url, 4);
if (html == null)
{
if (page == 1) throw;
Log($"🛒 Page {page} unreachable ({ex.Message}) — stopping");
break;
consecutiveFailures++;
if (page == 1) throw new Exception("catalog unreachable after 4 attempts");
if (consecutiveFailures >= 3) { Log($"🛒 Page {page} unreachable — stopping"); break; }
continue;
}
consecutiveFailures = 0;
var arts = articleRx.Matches(html);
if (arts.Count == 0) break;
int newOnes = 0;
@@ -1806,32 +1837,36 @@ namespace PS5Upload
if (newOnes == 0) break;
StoreStatusText.Text = $"Fetched {items.Count} packages...";
}
if (items.Count == 0) throw new Exception("catalog returned no PS5 packages");
_storeItems.Clear();
_storeItems.AddRange(items);
ApplyStoreFilter();
foreach (var it in items) TryLoadCachedCover(it);
StoreStatusText.Text = $"{items.Count} PS5 packages";
Log($"🛒 Catalog: {items.Count} PS5 packages");
_ = Task.Run(async () =>
{
foreach (var it in _storeItems)
{
try
{
var bytes = await _storeHttp.GetByteArrayAsync($"https://pkg-zone.com/images/{it.Id}/cover.png");
using var ms = new MemoryStream(bytes);
var bmp = new Bitmap(ms);
await Dispatcher.UIThread.InvokeAsync(() => { it.Cover = bmp; });
}
catch { }
}
});
try { File.WriteAllText(StoreCacheFile, JsonSerializer.Serialize(items)); } catch { }
}
catch (Exception ex)
{
StoreStatusText.Text = "Fetch failed";
Log($"❌ Store error: {ex.Message}");
if (_storeItems.Count > 0)
{
StoreStatusText.Text = $"{_storeItems.Count} PS5 packages (cached — pkg-zone.com down)";
Log($"⚠️ pkg-zone.com unreachable, showing cached catalog ({ex.Message})");
}
else
{
StoreStatusText.Text = "Fetch failed";
Log($"❌ Store error: {ex.Message}");
}
}
finally
{
_storeFetching = false;
// Covers are fetched regardless of whether the catalog refresh
// succeeded — cached items deserve icons too, and the image
// endpoint often stays up while the listing 500s.
if (_storeItems.Count > 0) FetchStoreCoversAsync();
}
}
@@ -1848,24 +1883,112 @@ namespace PS5Upload
private void StoreSearch_TextChanged(object? sender, TextChangedEventArgs e) => ApplyStoreFilter();
// Downloads carry no client timeout — 60MB+ streams would trip the
// 20s catalog limit; we bound them with our own cancellation instead.
private static readonly HttpClient _storeDl = CreateStoreDl();
private static HttpClient CreateStoreDl()
{
var h = new HttpClient { Timeout = Timeout.InfiniteTimeSpan };
h.DefaultRequestHeaders.UserAgent.ParseAdd(
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36");
return h;
}
private bool _storeInstalling;
// pkg-zone only speaks HTTPS and the console can't fetch that itself —
// the PC downloads the PKG once (disk cache for re-installs), then
// serves it over plain LAN HTTP through TryServePkgAsync.
private async Task<bool> DownloadStorePkgAsync(string id, string name, string destPath)
{
for (int attempt = 0; attempt < 4; attempt++)
{
try
{
using var cts = new CancellationTokenSource(TimeSpan.FromMinutes(15));
using var resp = await _storeDl.GetAsync(
$"https://pkg-zone.com/download/ps5/{id}/latest",
HttpCompletionOption.ResponseHeadersRead, cts.Token);
resp.EnsureSuccessStatusCode();
long total = resp.Content.Headers.ContentLength ?? 0;
await using var src = await resp.Content.ReadAsStreamAsync(cts.Token);
await using var dst = new FileStream(destPath, FileMode.Create, FileAccess.Write, FileShare.None, 1 << 20);
var buf = new byte[512 * 1024];
long done = 0; int rd; int lastBucket = -1;
while ((rd = await src.ReadAsync(buf, cts.Token)) > 0)
{
await dst.WriteAsync(buf.AsMemory(0, rd), cts.Token);
done += rd;
if (total > 0)
{
int bucket = (int)(done * 10 / total); // log every ~10%
if (bucket != lastBucket)
{
lastBucket = bucket;
Log($"📥 {name}: {done / 1048576}/{total / 1048576} MB");
StoreStatusText.Text = $"Downloading {name} — {done / 1048576}/{total / 1048576} MB";
}
}
}
if (total > 0 && done < total) throw new IOException($"short read {done}/{total}");
// PKG sanity: Sony packages start with 0x7F 'CNT'.
await using (var chk = new FileStream(destPath, FileMode.Open, FileAccess.Read))
{
var magic = new byte[4];
if (await chk.ReadAsync(magic) < 4 || magic[0] != 0x7F || magic[1] != 'C')
throw new IOException("downloaded file is not a PKG");
}
return true;
}
catch (Exception ex)
{
try { File.Delete(destPath); } catch { }
Log($"⚠️ Download failed ({ex.Message}){(attempt < 3 ? $" — retry {attempt + 2}/4" : "")}");
if (attempt < 3) await Task.Delay(1500);
}
}
return false;
}
private async void StoreInstall_Click(object? sender, RoutedEventArgs e)
{
if (_storeInstalling) return; // one install at a time
if (!_protocol.IsConnected) { await ShowMessageAsync("Not connected to PS5", "Error"); return; }
string? id = (sender as Control)?.Tag as string;
if (string.IsNullOrEmpty(id)) return;
var item = _storeItems.FirstOrDefault(i => i.Id == id);
string name = item?.Name ?? id;
if (!await ShowConfirmAsync($"Install \"{name}\" ({id}) on the PS5?")) return;
_storeInstalling = true;
try
{
string url = $"https://pkg-zone.com/download/ps5/{id}/latest";
Log($"📥 Installing {name} from pkg-zone...");
string dir = Path.Combine(AppContext.BaseDirectory, "pkgzone_pkgs");
Directory.CreateDirectory(dir);
string local = Path.Combine(dir, id + ".pkg");
if (!File.Exists(local) || new FileInfo(local).Length < 1024)
{
if (!await DownloadStorePkgAsync(id, name, local))
{
StoreStatusText.Text = "download failed";
return;
}
Log($"✅ Downloaded {name} ({new FileInfo(local).Length / 1048576} MB)");
}
else Log($"� Using cached {name} ({new FileInfo(local).Length / 1048576} MB)");
StoreStatusText.Text = $"Installing {name}...";
string? url = await TryServePkgAsync(local);
if (url == null) { Log("❌ Could not start the PKG stream server"); return; }
var (success, result) = await _protocol.InstallPkgAsync(url);
Log(success
? $"✅ Install accepted: {name} — check the PS5 home screen"
: $"❌ Install failed: {result}");
if (success) _ = PollPkgInstallStatus();
StoreStatusText.Text = "";
}
catch (Exception ex) { Log($"❌ Install error: {ex.Message}"); }
finally { _storeInstalling = false; }
}
}
}
+524
View File
@@ -0,0 +1,524 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.IO.Compression;
using System.Linq;
using System.Net.Http;
using System.Security.Cryptography;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Threading;
using System.Threading.Tasks;
using Avalonia.Controls;
using Avalonia.Interactivity;
using Avalonia.Media;
using Avalonia.Media.Imaging;
using Avalonia.Threading;
namespace PS5Upload
{
// Prospero Store Site tab — homebrew.page/api/v1 catalog.
// Signed static JSON API (Cloudflare CDN, fast + reliable):
// index.json → app list (titleid/name/version/format/size/…)
// apps/<TID>.json → per-app record (artifact_url, sha256, size)
// icons/<TID>-256.png → store icon
//
// Artifacts are NOT PKGs — they are ZIP archives holding the app folder
// (<TITLEID>/ with eboot.bin + sce_sys/), hosted on the dev's GitHub
// Releases. Install path per catalog spec:
// download → SHA-256 verify → extract → upload tree to /data/homebrew/
// → our own Mount pipeline registers it on the PS5 home screen
// (/data/homebrew is in the payload's GAME_SCAN_PATHS).
public partial class MainWindow
{
public class ProsperoItem : System.ComponentModel.INotifyPropertyChanged
{
public string TitleId { get; set; } = "";
public string Name { get; set; } = "";
public string Author { get; set; } = "";
public string Version { get; set; } = "";
public string Kind { get; set; } = ""; // "app" | "game" | "tool"
public string Format { get; set; } = ""; // "zip" | "ffpfsc" | ""
public long Size { get; set; }
public string Status { get; set; } = ""; // "available" | "coming_soon"
public string IconSmall { get; set; } = "";
public string Updated { get; set; } = ""; // ISO date
[JsonIgnore]
private Bitmap? _icon;
[JsonIgnore]
public Bitmap? Icon
{
get => _icon;
set { _icon = value; PropertyChanged?.Invoke(this, new System.ComponentModel.PropertyChangedEventArgs(nameof(Icon))); }
}
[JsonIgnore]
public string SizeDisplay => Size > 0 ? PS5StorageInfo.FormatBytes((ulong)Size) : "—";
[JsonIgnore]
public string KindDisplay => Kind switch
{
"game" => "🎮 game",
"tool" => "🔧 tool",
"app" => "🧩 app",
_ => Kind,
};
[JsonIgnore]
public bool CanInstall => Status == "available" && Format == "zip";
[JsonIgnore]
public string InstallLabel => CanInstall ? "📥 Install"
: Status == "coming_soon" ? "Coming soon"
: Format == "ffpfsc" ? "ffpfsc — unsupported" : "Unavailable";
[JsonIgnore]
public string StatusDisplay => Status == "coming_soon" ? "🕒 coming soon"
: Format == "ffpfsc" ? "⚠️ ffpfsc image — install via Prospero Store on console"
: Format == "zip" ? "zip — installs to /data/homebrew" : "";
[JsonIgnore]
public IBrush StatusColor => Status == "coming_soon"
? new SolidColorBrush(Color.Parse("#888888"))
: Format == "ffpfsc"
? new SolidColorBrush(Color.Parse("#FFC107"))
: new SolidColorBrush(Color.Parse("#6A9955"));
public event System.ComponentModel.PropertyChangedEventHandler? PropertyChanged;
}
private const string ProsperoApi = "https://homebrew.page/api/v1";
private const string ProsperoCacheFile = "prospero_cache.json";
private const string ProsperoIconDir = "prospero_icons";
private const string ProsperoDlDir = "prospero_dl";
private readonly List<ProsperoItem> _prosperoItems = new();
private bool _prosperoFetching;
private bool _prosperoInstalling;
private bool _prosperoLoadedOnce;
// ---------- catalog fetch / cache ----------
private static List<ProsperoItem> ParseProsperoIndex(string json)
{
var list = new List<ProsperoItem>();
using var doc = JsonDocument.Parse(json);
if (!doc.RootElement.TryGetProperty("apps", out var apps)) return list;
foreach (var a in apps.EnumerateArray())
{
static string S(JsonElement e, string k) =>
e.TryGetProperty(k, out var v) && v.ValueKind == JsonValueKind.String
? v.GetString() ?? "" : "";
list.Add(new ProsperoItem
{
TitleId = S(a, "titleid"),
Name = S(a, "name") is { Length: > 0 } n ? n : S(a, "titleid"),
Author = S(a, "author"),
Version = S(a, "version"),
Kind = S(a, "kind"),
Format = S(a, "format"),
Status = S(a, "status"),
IconSmall = S(a, "icon_small"),
Updated = S(a, "updated"),
Size = a.TryGetProperty("size", out var sz) && sz.ValueKind == JsonValueKind.Number ? sz.GetInt64() : 0,
});
}
return list;
}
private int LoadProsperoCache()
{
try
{
if (!File.Exists(ProsperoCacheFile)) return 0;
var items = JsonSerializer.Deserialize<List<ProsperoItem>>(File.ReadAllText(ProsperoCacheFile));
if (items == null || items.Count == 0) return 0;
_prosperoItems.Clear();
_prosperoItems.AddRange(items);
ApplyProsperoFilter();
ProsperoStatusText.Text = $"{items.Count} apps (cached)";
foreach (var it in items) TryLoadProsperoIcon(it);
return items.Count;
}
catch { return 0; }
}
private void TryLoadProsperoIcon(ProsperoItem it)
{
try
{
string p = Path.Combine(ProsperoIconDir, it.TitleId + ".png");
if (File.Exists(p)) it.Icon = new Bitmap(p);
}
catch { }
}
private void FetchProsperoIconsAsync()
{
var items = _prosperoItems.ToList();
_ = Task.Run(async () =>
{
try { Directory.CreateDirectory(ProsperoIconDir); } catch { }
await Task.WhenAll(items.Select(async it =>
{
if (it.Icon != null) return;
TryLoadProsperoIcon(it);
if (it.Icon != null) return;
string url = !string.IsNullOrEmpty(it.IconSmall)
? it.IconSmall
: $"{ProsperoApi}/icons/{it.TitleId}-256.png";
for (int a = 0; a < 4 && it.Icon == null; a++)
{
try
{
var bytes = await _storeHttp.GetByteArrayAsync(url);
if (bytes.Length < 100 || bytes[0] != 0x89 || bytes[1] != 0x50)
throw new Exception("not a PNG");
using var ms = new MemoryStream(bytes);
var bmp = new Bitmap(ms);
await Dispatcher.UIThread.InvokeAsync(() => { it.Icon = bmp; });
try { File.WriteAllBytes(Path.Combine(ProsperoIconDir, it.TitleId + ".png"), bytes); } catch { }
}
catch { await Task.Delay(600 * (a + 1)); }
}
}));
});
}
private async void ProsperoRefresh_Click(object? sender, RoutedEventArgs? e) => await RefreshProsperoAsync();
private async Task RefreshProsperoAsync()
{
if (_prosperoFetching) return;
_prosperoFetching = true;
try
{
int cached = LoadProsperoCache();
ProsperoStatusText.Text = cached > 0
? $"{cached} apps (cached) — refreshing…"
: "Fetching homebrew.page catalog...";
Log("🌐 Fetching Prospero catalog...");
var items = ParseProsperoIndex(await _storeHttp.GetStringAsync($"{ProsperoApi}/index.json"));
if (items.Count == 0) throw new Exception("catalog returned no apps");
_prosperoItems.Clear();
_prosperoItems.AddRange(items);
ApplyProsperoFilter();
foreach (var it in items) TryLoadProsperoIcon(it);
int avail = items.Count(i => i.CanInstall);
ProsperoStatusText.Text = $"{items.Count} apps ({avail} installable)";
Log($"🌐 Prospero catalog: {items.Count} apps ({avail} installable)");
try { File.WriteAllText(ProsperoCacheFile, JsonSerializer.Serialize(items)); } catch { }
}
catch (Exception ex)
{
ProsperoStatusText.Text = _prosperoItems.Count > 0
? $"{_prosperoItems.Count} apps (cached — homebrew.page unreachable)"
: "Fetch failed";
Log($"⚠️ Prospero catalog: {ex.Message}");
}
finally
{
_prosperoFetching = false;
if (_prosperoItems.Count > 0) FetchProsperoIconsAsync();
}
}
// ---------- search / sort ----------
// Site sections: apps / games / tools / coming soon — kind field +
// status, same split as homebrew.page. Search applies inside the
// selected category.
private void ApplyProsperoFilter()
{
if (ProsperoListBox == null || ProsperoSearchBox == null || ProsperoSortComboBox == null
|| ProsperoCategoryComboBox == null) return;
IEnumerable<ProsperoItem> view = ProsperoCategoryComboBox.SelectedIndex switch
{
1 => _prosperoItems.Where(i => i.Kind == "app"),
2 => _prosperoItems.Where(i => i.Kind == "game"),
3 => _prosperoItems.Where(i => i.Kind == "tool"),
4 => _prosperoItems.Where(i => i.Status == "coming_soon"),
_ => _prosperoItems,
};
string q = ProsperoSearchBox.Text?.Trim() ?? "";
if (!string.IsNullOrEmpty(q))
view = view.Where(i => i.Name.Contains(q, StringComparison.OrdinalIgnoreCase)
|| i.TitleId.Contains(q, StringComparison.OrdinalIgnoreCase)
|| i.Author.Contains(q, StringComparison.OrdinalIgnoreCase));
view = ProsperoSortComboBox.SelectedIndex switch
{
1 => view.OrderByDescending(i => i.Name, StringComparer.OrdinalIgnoreCase),
2 => view.OrderBy(i => i.Size),
3 => view.OrderByDescending(i => i.Size),
4 => view.OrderByDescending(i => i.Updated, StringComparer.Ordinal),
5 => view.OrderBy(i => i.Author, StringComparer.OrdinalIgnoreCase),
_ => view.OrderBy(i => i.Name, StringComparer.OrdinalIgnoreCase),
};
var list = view.ToList();
ProsperoListBox.ItemsSource = null;
ProsperoListBox.ItemsSource = list;
if (!string.IsNullOrEmpty(q))
ProsperoStatusText.Text = $"{list.Count}/{_prosperoItems.Count} apps";
else if (_prosperoItems.Count > 0)
ProsperoStatusText.Text = $"{_prosperoItems.Count} apps ({_prosperoItems.Count(i => i.CanInstall)} installable)";
}
private void ProsperoSearch_TextChanged(object? sender, TextChangedEventArgs e) => ApplyProsperoFilter();
private void ProsperoSort_SelectionChanged(object? sender, SelectionChangedEventArgs e) => ApplyProsperoFilter();
private void ProsperoCategory_SelectionChanged(object? sender, SelectionChangedEventArgs e) => ApplyProsperoFilter();
// ---------- install: zip → verify → /data/homebrew → mount ----------
private record ProsperoDetail(string Url, string Sha256, string Format, string Status, long Size);
private static ProsperoDetail ParseProsperoDetail(string json)
{
using var doc = JsonDocument.Parse(json);
var r = doc.RootElement;
static string S(JsonElement e, string k) =>
e.TryGetProperty(k, out var v) && v.ValueKind == JsonValueKind.String
? v.GetString() ?? "" : "";
return new ProsperoDetail(
S(r, "artifact_url"), S(r, "sha256"), S(r, "format"), S(r, "status"),
r.TryGetProperty("size", out var sz) && sz.ValueKind == JsonValueKind.Number ? sz.GetInt64() : 0);
}
// GitHub release URLs redirect to objects.githubusercontent.com —
// HttpClient follows redirects automatically.
private async Task<bool> DownloadProsperoZipAsync(string url, string name, long expected, string destPath)
{
for (int attempt = 0; attempt < 3; attempt++)
{
try
{
using var cts = new CancellationTokenSource(TimeSpan.FromMinutes(15));
using var resp = await _storeDl.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, cts.Token);
resp.EnsureSuccessStatusCode();
long total = resp.Content.Headers.ContentLength ?? expected;
await using var src = await resp.Content.ReadAsStreamAsync(cts.Token);
// Fresh download targets get scanned by AV/indexers and may
// stay briefly locked — retry the open instead of failing
// the whole attempt on "file in use".
FileStream? dst = null;
for (int o = 0; o < 8 && dst == null; o++)
{
try { dst = new FileStream(destPath, FileMode.Create, FileAccess.Write, FileShare.None, 1 << 20); }
catch (IOException) { if (o == 7) throw; await Task.Delay(500); }
}
await using (var outFs = dst!) {
var buf = new byte[512 * 1024];
long done = 0; int rd; int lastBucket = -1;
while ((rd = await src.ReadAsync(buf, cts.Token)) > 0)
{
await outFs.WriteAsync(buf.AsMemory(0, rd), cts.Token);
done += rd;
if (total > 0)
{
int bucket = (int)(done * 10 / total);
if (bucket != lastBucket)
{
lastBucket = bucket;
ProsperoStatusText.Text = $"Downloading {name} — {done / 1048576}/{total / 1048576} MB";
}
}
}
if (total > 0 && done < total) throw new IOException($"short read {done}/{total}");
}
await using (var chk = new FileStream(destPath, FileMode.Open, FileAccess.Read))
{
var magic = new byte[4];
if (await chk.ReadAsync(magic) < 4 || magic[0] != 'P' || magic[1] != 'K')
throw new IOException("downloaded file is not a ZIP");
}
return true;
}
catch (Exception ex)
{
try { File.Delete(destPath); } catch { }
Log($"⚠️ Download failed ({ex.Message}){(attempt < 2 ? $" — retry {attempt + 2}/3" : "")}");
if (attempt < 2) await Task.Delay(1200);
}
}
return false;
}
// Catalog spec: zip root holds the app folder <TITLEID>/ containing
// eboot.bin + sce_sys/. Locate it defensively — name it what the
// archive says, fall back to the folder holding eboot.bin.
private static string? FindAppFolder(string extRoot, string titleId)
{
var cand = Path.Combine(extRoot, titleId);
if (Directory.Exists(cand) && File.Exists(Path.Combine(cand, "eboot.bin")))
return cand;
foreach (var dir in Directory.EnumerateDirectories(extRoot))
if (File.Exists(Path.Combine(dir, "eboot.bin")))
return dir;
return null;
}
private async void ProsperoInstall_Click(object? sender, RoutedEventArgs e)
{
if (_prosperoInstalling) return;
if (!_protocol.IsConnected) { await ShowMessageAsync("Not connected to PS5", "Error"); return; }
string? tid = (sender as Control)?.Tag as string;
if (string.IsNullOrEmpty(tid)) return;
var item = _prosperoItems.FirstOrDefault(i => i.TitleId == tid);
if (item == null || !item.CanInstall) return;
string name = item.Name;
if (!await ShowConfirmAsync(
$"Install \"{name}\" ({tid}) on the PS5?\n\n" +
$"The app folder will be placed in /data/homebrew/{tid} and mounted to the home screen.")) return;
_prosperoInstalling = true;
string? zipPath = null, extDir = null;
try
{
// 1. per-app record: artifact_url + sha256 + format
ProsperoStatusText.Text = $"Resolving {name}...";
var det = ParseProsperoDetail(await _storeHttp.GetStringAsync($"{ProsperoApi}/apps/{tid}.json"));
if (det.Format != "zip" || det.Status != "available" || det.Url.Length == 0)
{ Log($"❌ {name}: not installable (format={det.Format}, status={det.Status})"); return; }
// 2. download zip (redirects followed by HttpClient)
string dlDir = Path.Combine(AppContext.BaseDirectory, ProsperoDlDir);
Directory.CreateDirectory(dlDir);
zipPath = Path.Combine(dlDir, tid + ".zip");
Log($"📥 {name}: downloading {PS5StorageInfo.FormatBytes((ulong)(det.Size > 0 ? det.Size : item.Size))}...");
if (!await DownloadProsperoZipAsync(det.Url, name, det.Size, zipPath))
{ ProsperoStatusText.Text = "download failed"; return; }
// 3. SHA-256 verify — mandatory per catalog spec. Runs on the
// thread pool: File.OpenRead is a sync stream and hashing a
// few hundred MB on the UI thread stalls repaints.
if (det.Sha256.Length == 64)
{
ProsperoStatusText.Text = $"Verifying {name}...";
string hex = await Task.Run(async () =>
Convert.ToHexString(await SHA256.HashDataAsync(File.OpenRead(zipPath))));
if (!hex.Equals(det.Sha256, StringComparison.OrdinalIgnoreCase))
{ Log($"❌ {name}: SHA-256 mismatch — archive rejected (got {hex.Substring(0, 16)}…, catalog says {det.Sha256.Substring(0, 16)}…)"); return; }
}
else Log($"⚠️ {name}: catalog has no sha256 — installing unverified");
// 4. extract → locate <TITLEID>/ with eboot.bin. Extraction of
// 10k+ entry zips is sync CPU+disk — off the UI thread.
string? appDir = null; string extLocal = "";
await Task.Run(() =>
{
extLocal = Path.Combine(dlDir, tid + "_ext");
extDir = extLocal;
if (Directory.Exists(extLocal)) Directory.Delete(extLocal, true);
ZipFile.ExtractToDirectory(zipPath, extLocal);
appDir = FindAppFolder(extLocal, tid);
});
if (appDir == null)
{ Log($"❌ {name}: no app folder with eboot.bin inside the zip"); return; }
string folderName = Path.GetFileName(appDir);
// 5. upload tree → /data/homebrew/<FOLDER>/ — same pooled-lane
// model the File Transfer tab uses: N parallel connections each
// pulling files off a shared index. The whole fan-out runs on
// the thread pool so the 13k+ await continuations don't flood
// the UI dispatcher (that was the tab-switch freeze).
var files = Directory.GetFiles(appDir, "*", SearchOption.AllDirectories);
string remoteBase = $"/data/homebrew/{folderName}";
Log($"📤 {name}: uploading {files.Length} files → {remoteBase}");
ProsperoStatusText.Text = $"Uploading {name} — 0/{files.Length} files";
var rels = files.Select(f => $"{remoteBase}/{Path.GetRelativePath(appDir, f).Replace('\\', '/')}").ToArray();
Exception? uploadError = null;
await Task.Run(async () =>
{
await _protocol.CreateDirAsync(remoteBase);
foreach (var dir in Directory.GetDirectories(appDir, "*", SearchOption.AllDirectories))
{
string rel = Path.GetRelativePath(appDir, dir).Replace('\\', '/');
await _protocol.CreateDirAsync($"{remoteBase}/{rel}");
}
int lanes = Math.Min(16, files.Length);
int nextFile = -1, doneFiles = 0;
long lastUiPost = 0;
var laneTasks = Enumerable.Range(0, lanes).Select(async _ =>
{
PS5Protocol? lane = null;
try
{
lane = await AcquireUploadConnectionAsync();
while (Volatile.Read(ref uploadError) == null)
{
int i = Interlocked.Increment(ref nextFile);
if (i >= files.Length) break;
if (!await lane.UploadFileAsync(files[i], rels[i]))
{
Interlocked.CompareExchange(ref uploadError,
new Exception($"upload failed: {rels[i]} — {lane.LastError}"), null);
break;
}
int d = Interlocked.Increment(ref doneFiles);
long now = Environment.TickCount64;
if (d == files.Length || now - lastUiPost > 150)
{
Interlocked.Exchange(ref lastUiPost, now);
Dispatcher.UIThread.Post(() =>
ProsperoStatusText.Text = $"Uploading {name} — {d}/{files.Length} files");
}
}
}
finally
{
if (lane != null)
{
if (lane.IsConnected) ReleaseUploadConnection(lane);
else DestroyConnection(lane);
}
}
}).ToList();
await Task.WhenAll(laneTasks);
});
if (uploadError != null)
{ Log($"❌ {name}: {uploadError.Message}"); ProsperoStatusText.Text = "upload failed"; return; }
Log($"📤 {name}: {files.Length} files uploaded");
// zip + temp no longer needed — delete so nothing is stored twice
try { File.Delete(zipPath); zipPath = null; } catch { }
try { if (extDir != null) { Directory.Delete(extDir, true); extDir = null; } } catch { }
// 6. mount via our own Mount pipeline → home screen entry
ProsperoStatusText.Text = $"Mounting {name}...";
Log($"🗂️ {name}: mounting {folderName} to home screen...");
var mountRes = await _protocol.MountGameAsync(folderName, msg =>
Dispatcher.UIThread.Post(() => ProsperoStatusText.Text = $"Mounting {name} — {msg}"));
if (mountRes == null)
{
Log($"⚠️ {name}: uploaded to {remoteBase} but mount gave no response — try Mount Games in the Games tab");
ProsperoStatusText.Text = $"{name} installed (mount manually)";
}
else
{
Log($"✅ {name}: installed to {remoteBase} — {mountRes.Split('\n')[0]}");
ProsperoStatusText.Text = $"{name} installed";
}
}
catch (Exception ex)
{
Log($"❌ Install error: {ex.Message}");
ProsperoStatusText.Text = "install failed";
}
finally
{
_prosperoInstalling = false;
try { if (zipPath != null) File.Delete(zipPath); } catch { }
try { if (extDir != null && Directory.Exists(extDir)) Directory.Delete(extDir, true); } catch { }
}
}
}
}
+37 -5
View File
@@ -380,8 +380,43 @@ namespace PS5Upload
// ============================================================
// GAMES TAB
// ============================================================
private List<PS5MountedGame> _allGames = new();
private async void RefreshGameListButton_Click(object? sender, RoutedEventArgs e) => await RefreshGameListAsync();
private void GameSearchBox_TextChanged(object? sender, Avalonia.Controls.TextChangedEventArgs e) => ApplyGameFilter();
private void GameSortComboBox_SelectionChanged(object? sender, SelectionChangedEventArgs e) => ApplyGameFilter();
private void ApplyGameFilter()
{
if (MountedGamesListBox == null || GameSearchBox == null || GameSortComboBox == null) return;
var q = GameSearchBox.Text?.Trim() ?? "";
IEnumerable<PS5MountedGame> view = string.IsNullOrEmpty(q)
? _allGames
: _allGames.Where(g =>
g.Name.Contains(q, StringComparison.OrdinalIgnoreCase) ||
g.TitleId.Contains(q, StringComparison.OrdinalIgnoreCase) ||
g.Region.Contains(q, StringComparison.OrdinalIgnoreCase) ||
g.Path.Contains(q, StringComparison.OrdinalIgnoreCase));
view = GameSortComboBox.SelectedIndex switch
{
1 => view.OrderByDescending(g => g.Name, StringComparer.OrdinalIgnoreCase),
2 => view.OrderBy(g => g.Size),
3 => view.OrderByDescending(g => g.Size),
4 => view.OrderBy(g => g.TitleId, StringComparer.OrdinalIgnoreCase),
5 => view.OrderByDescending(g => g.IsActive),
_ => view.OrderBy(g => g.Name, StringComparer.OrdinalIgnoreCase),
};
var list = view.ToList();
MountedGamesListBox.ItemsSource = list;
GameCountText.Text = string.IsNullOrEmpty(q) || list.Count == _allGames.Count
? $" ({_allGames.Count} games)"
: $" ({list.Count}/{_allGames.Count} games)";
}
private async Task RefreshGameListAsync()
{
if (!_protocol.IsConnected) { Log("❌ Not connected to PS5"); return; }
@@ -389,11 +424,8 @@ namespace PS5Upload
try
{
var games = await _protocol.GetGameListAsync();
await Dispatcher.UIThread.InvokeAsync(() =>
{
MountedGamesListBox.ItemsSource = games;
GameCountText.Text = $" ({games.Count} games)";
});
_allGames = games;
await Dispatcher.UIThread.InvokeAsync(() => ApplyGameFilter());
if (games.Count > 0)
{
+479
View File
@@ -0,0 +1,479 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Text.Json;
using System.Threading.Tasks;
using Avalonia.Controls;
using Avalonia.Interactivity;
using Avalonia.Media.Imaging;
using Avalonia.Threading;
namespace PS5Upload
{
// Trophies tab — read-only NpTrophy V2 viewer.
// Payload ships raw tropconf.json + tropmeta_<lang>.json + TRPTITLE.DAT
// per registered set; we parse the JSON here and lazy-load PNG icons
// through CMD_TROPHY_ICON on a dedicated connection.
public partial class MainWindow
{
private List<PS5TrophySet> _trophySets = new();
private void TrophySetSearchBox_TextChanged(object? sender, Avalonia.Controls.TextChangedEventArgs e) => ApplyTrophySetFilter();
private void TrophySetSortComboBox_SelectionChanged(object? sender, SelectionChangedEventArgs e) => ApplyTrophySetFilter();
private void ApplyTrophySetFilter()
{
if (TrophySetsListBox == null || TrophySetSearchBox == null || TrophySetSortComboBox == null) return;
var prevNpwr = (TrophySetsListBox.SelectedItem as PS5TrophySet)?.NpCommunicationId;
var q = TrophySetSearchBox.Text?.Trim() ?? "";
IEnumerable<PS5TrophySet> view = string.IsNullOrEmpty(q)
? _trophySets
: _trophySets.Where(s =>
s.GameName.Contains(q, StringComparison.OrdinalIgnoreCase) ||
s.NpCommunicationId.Contains(q, StringComparison.OrdinalIgnoreCase) ||
s.TitleId.Contains(q, StringComparison.OrdinalIgnoreCase));
view = TrophySetSortComboBox.SelectedIndex switch
{
1 => view.OrderByDescending(s => s.GameName, StringComparer.OrdinalIgnoreCase),
2 => view.OrderBy(s => s.EarnedCount).ThenByDescending(s => s.TotalCount),
3 => view.OrderByDescending(s => s.EarnedCount).ThenByDescending(s => s.TotalCount),
4 => view.OrderByDescending(s => s.TotalCount),
_ => view.OrderBy(s => s.GameName, StringComparer.OrdinalIgnoreCase),
};
var list = view.ToList();
TrophySetsListBox.ItemsSource = null;
TrophySetsListBox.ItemsSource = list;
TrophyStatusText.Text = string.IsNullOrEmpty(q) || list.Count == _trophySets.Count
? $"({_trophySets.Count} sets)"
: $"({list.Count}/{_trophySets.Count} sets)";
if (prevNpwr != null)
TrophySetsListBox.SelectedItem =
list.FirstOrDefault(s => s.NpCommunicationId == prevNpwr);
}
private bool _trophyLoading;
// Auto-load the catalog every time the Trophies tab gets focus —
// no manual Refresh button needed.
private async void GamesTabControl_SelectionChanged(object? sender, SelectionChangedEventArgs e)
{
if (e.Source is not TabControl tc) return;
if (tc.SelectedItem is TabItem { Header: "🏆 Trophies" })
await RefreshTrophiesAsync();
else if (tc.SelectedItem is TabItem { Header: "🌐 Prospero Store Site" } && !_prosperoLoadedOnce)
{
// Remote JSON catalog — no PS5 connection needed to browse.
_prosperoLoadedOnce = true;
await RefreshProsperoAsync();
}
}
private async Task RefreshTrophiesAsync()
{
if (_trophyLoading) return; // auto-load + unlock-refresh can overlap
if (!_protocol.IsConnected) { Log("❌ Not connected to PS5"); return; }
_trophyLoading = true;
Log("🏆 Loading trophy sets...");
TrophyStatusText.Text = "loading…";
try
{
var sets = await _protocol.GetTrophyListAsync();
foreach (var set in sets)
ParseTrophySet(set);
// Game names come from tropmeta titleMetadata — the real title,
// not a guess. Fall back to the mounted-games list or the TID.
var mounted = await _protocol.GetGameListAsync();
var nameByTitle = mounted.ToDictionary(g => g.TitleId, g => g.Name, StringComparer.OrdinalIgnoreCase);
foreach (var set in sets)
{
if (string.IsNullOrEmpty(set.GameName))
set.GameName = !string.IsNullOrEmpty(set.TitleId)
? (nameByTitle.TryGetValue(set.TitleId, out var gn) ? gn : set.TitleId)
: set.NpCommunicationId;
}
_trophySets = sets;
// ApplyTrophySetFilter rebuilds the view and restores the set the
// user was working on — without this the next Unlock click hits
// a null selection and silently no-ops.
await Dispatcher.UIThread.InvokeAsync(() => ApplyTrophySetFilter());
Log($"🏆 {sets.Count} trophy sets, {sets.Sum(s => s.TotalCount)} trophies total");
// Lazy-load set icons + trophy icons on a side connection so the
// main command channel stays responsive.
_ = Task.Run(() => LoadTrophyIconsAsync(sets));
}
catch (Exception ex)
{
TrophyStatusText.Text = "error";
Log($"❌ Trophy load failed: {ex.Message}");
}
finally { _trophyLoading = false; }
}
// tropconf.json → ids/grades/hidden/groups; tropmeta_<lang>.json →
// title name + per-trophy name/detail. Id strings ("0000") map to
// icon files trop0000.png.
private static void ParseTrophySet(PS5TrophySet set)
{
try
{
var confGrades = new Dictionary<string, (string grade, bool hidden, string group)>();
using (var doc = JsonDocument.Parse(set.TropConfJson))
{
var root = doc.RootElement;
if (root.TryGetProperty("trophies", out var trs))
foreach (var t in trs.EnumerateArray())
{
string id = t.TryGetProperty("id", out var idp) ? idp.GetString() ?? "" : "";
if (id.Length == 0) continue;
string grade = t.TryGetProperty("grade", out var g) ? g.GetString() ?? "" : "";
bool hidden = t.TryGetProperty("hidden", out var h) && h.GetBoolean();
string group = t.TryGetProperty("groupId", out var gr) ? gr.GetString() ?? "" : "";
confGrades[id] = (grade, hidden, group);
}
}
var names = new Dictionary<string, (string name, string detail)>();
var groupNames = new Dictionary<string, string>();
string? titleName = null;
if (set.TropMetaJson.Length > 0)
{
using var doc = JsonDocument.Parse(set.TropMetaJson);
if (doc.RootElement.TryGetProperty("metadata", out var meta))
{
if (meta.TryGetProperty("titleMetadata", out var tm) &&
tm.TryGetProperty("name", out var tn))
titleName = tn.GetString();
if (meta.TryGetProperty("groupMetadata", out var gms))
foreach (var g in gms.EnumerateArray())
{
string id = g.TryGetProperty("id", out var i) ? i.GetString() ?? "" : "";
string name = g.TryGetProperty("name", out var n) ? n.GetString() ?? "" : "";
if (id.Length > 0) groupNames[id] = name;
}
if (meta.TryGetProperty("trophyMetadata", out var tms))
foreach (var t in tms.EnumerateArray())
{
string id = t.TryGetProperty("id", out var i) ? i.GetString() ?? "" : "";
string name = t.TryGetProperty("name", out var n) ? n.GetString() ?? "" : "";
string det = t.TryGetProperty("detail", out var d) ? d.GetString() ?? "" : "";
if (id.Length > 0) names[id] = (name, det);
}
}
}
if (!string.IsNullOrEmpty(titleName)) set.GameName = titleName;
set.Trophies.Clear();
foreach (var kv in confGrades.OrderBy(k => k.Key, StringComparer.Ordinal))
{
var (grade, hidden, group) = kv.Value;
names.TryGetValue(kv.Key, out var nd);
set.Trophies.Add(new PS5Trophy
{
Id = int.TryParse(kv.Key, out int idNum) ? idNum : 0,
Name = string.IsNullOrEmpty(nd.name) ? $"Trophy {kv.Key}" : nd.name,
Detail = nd.detail ?? "",
Grade = grade,
Hidden = hidden,
GroupId = groupNames.TryGetValue(group, out var gn) ? gn : group,
StateKnown = false // TRPTITLE.DAT parse decides below — honest "—" if unknown
});
}
ApplyTrpTitleState(set);
}
catch (Exception ex)
{
Console.WriteLine($"[Trophy] parse failed for {set.NpCommunicationId}: {ex.Message}");
}
}
// TRPTITLE.DAT (T2PD) — per-trophy unlock state, reversed from a real
// user dump (verified: Witcher 3 set, 76/79 unlocked, missing trophies
// 0=P/9=S/10=G match PSN progress exactly).
//
// Layout essentials:
// record stream of typed entries: u32be type | u32be size | 8B | body[size]
// type 0x500 (size 0xC0): trophy def record — body+0x00 u32be trophy id
// type 0x700 (size 0xB0): group state record — body+0x00 u32be group idx,
// body+0x30 unlock bitmask for that group
// A global unlock bitmask (bit i = trophy id i, LSB-first) exists
// elsewhere in the file; we locate it by scanning for a zero-padded
// window whose set bits ⊆ [0,total) and whose popcount equals the
// union of the group masks (self-validating — no trust without match).
private static void ApplyTrpTitleState(PS5TrophySet set)
{
var d = set.TrpTitleData;
if (d.Length < 0x200 || d[0] != 'T' || d[1] != '2' || d[2] != 'P' || d[3] != 'D')
return;
static bool IsRec(byte[] d, int o, byte typeHi, byte szLo)
=> o + 8 <= d.Length && d[o] == 0 && d[o + 1] == 0 &&
d[o + 2] == typeHi && d[o + 3] == 0 &&
d[o + 4] == 0 && d[o + 5] == 0 && d[o + 6] == 0 && d[o + 7] == szLo;
// 1) trophy count from the 0x500 definition records (max id + 1)
int maxId = -1;
for (int o = 0x200; o + 0x14 <= d.Length; o += 4)
{
if (!IsRec(d, o, 5, 0xC0)) continue;
int id = (d[o + 0x10] << 24) | (d[o + 0x11] << 16) | (d[o + 0x12] << 8) | d[o + 0x13];
if (id < 512 && id > maxId) maxId = id;
}
if (maxId < 0) return;
int nbits = maxId + 1, nb = (nbits + 7) / 8;
// 2) union of the per-group unlock masks (0x700 records, past headers)
var unionMask = new byte[nb];
int groupRecs = 0;
for (int o = 0x800; o + 0x10 + 0x30 + nb <= d.Length; o += 4)
{
if (!IsRec(d, o, 7, 0xB0)) continue;
groupRecs++;
int mo = o + 0x10 + 0x30;
for (int j = 0; j < nb; j++) unionMask[j] |= d[mo + j];
}
if (groupRecs == 0) return;
// 2.5) per-trophy state rows (0x800 records):
// body+0x00 u32be trophy id, +0x04 u32be unlocked flag,
// +0x10 u64be unlock time — microseconds since 0001-01-01 UTC
// (verified against trophy_tracker.db: rec with ts 0xE31C37B254CD80
// decodes to 2026-09-24T11:14:14Z, the file's last_update).
var rowState = new Dictionary<int, (bool unlocked, DateTime when)>();
var epoch = new DateTime(1, 1, 1, 0, 0, 0, DateTimeKind.Utc);
for (int o = 0x800; o + 0x10 + 0x18 <= d.Length; o += 4)
{
if (!IsRec(d, o, 8, 0x50)) continue;
int id = (d[o + 0x10] << 24) | (d[o + 0x11] << 16) | (d[o + 0x12] << 8) | d[o + 0x13];
if (id < 0 || id >= nbits) continue;
int fl = (d[o + 0x14] << 24) | (d[o + 0x15] << 16) | (d[o + 0x16] << 8) | d[o + 0x17];
long ts = 0;
for (int j = 0; j < 8; j++) ts = (ts << 8) | d[o + 0x20 + j];
// bit0 = earned; upper bits are per-game flags (LNEE rows use
// 0x11 = earned|notified — a bare "== 1" misses them entirely)
bool unlocked = (fl & 1) != 0;
DateTime when = default;
if (unlocked && ts > 0)
{
try { when = epoch + TimeSpan.FromTicks(ts * 10); } catch { }
}
rowState[id] = (unlocked, when);
}
int unionPop = 0;
for (int i = 0; i < nb * 8; i++)
{
if (((unionMask[i >> 3] >> (i & 7)) & 1) == 0) continue;
if (i >= nbits) return; // stray bits → not a mask field here
unionPop++;
}
byte[] mask;
if (unionPop == 0)
{
mask = new byte[nb]; // state records exist → truly all locked
}
else
{
// 3) global mask: bits ⊆ [0,nbits), popcount == unionPop, zero tail
byte[]? found = null;
for (int o = 0x200; o + nb + 4 <= d.Length && found == null; o += 8)
{
int pop = 0; bool ok = true;
for (int j = 0; j < nb; j++)
{
int b = d[o + j];
for (int bit = 0; bit < 8; bit++)
if (((b >> bit) & 1) != 0)
{
if (j * 8 + bit >= nbits) { ok = false; break; }
pop++;
}
if (!ok) break;
}
if (!ok || pop != unionPop) continue;
if (d[o + nb] != 0 || d[o + nb + 1] != 0 || d[o + nb + 2] != 0 || d[o + nb + 3] != 0)
continue;
found = new byte[nb];
Array.Copy(d, o, found, 0, nb);
}
if (found == null)
{
// Counts known, per-trophy flags unknown — keep "—" per row.
set.EarnedFallback = unionPop;
// Still apply verified per-row flags from the 0x800 table
// where present (Witcher file: flags matched the mask 100%).
if (rowState.Count == 0) return;
foreach (var t in set.Trophies)
if (rowState.TryGetValue(t.Id, out var rs))
{
t.StateKnown = true;
t.IsUnlocked = rs.unlocked;
t.UnlockedTime = rs.when == default ? null : rs.when;
}
return;
}
mask = found;
}
set.UnlockMask = mask;
set.StateKnown = true;
foreach (var t in set.Trophies)
{
if (t.Id < 0 || t.Id >= nbits) continue;
bool unlocked = ((mask[t.Id >> 3] >> (t.Id & 7)) & 1) != 0;
DateTime? when = null;
if (rowState.TryGetValue(t.Id, out var rs))
{
unlocked |= rs.unlocked;
if (rs.when != default) when = rs.when;
}
t.StateKnown = true;
t.IsUnlocked = unlocked;
t.UnlockedTime = when;
}
}
// Debug-unlock path: the daemon binds the trophy commId from the RUNNING
// game process, so we verify the matching title is actually running first.
private async Task<bool> EnsureTrophyGameRunningAsync(PS5TrophySet set)
{
var apps = await _protocol.GetRunningAppsAsync();
if (!string.IsNullOrEmpty(set.TitleId) &&
apps.Any(a => string.Equals(a.TitleId, set.TitleId, StringComparison.OrdinalIgnoreCase)))
return true;
if (apps.Count == 0)
{
Log($"⚠️ No game running on the PS5 — launch '{set.GameName}' first (the trophy API binds to the running game).");
await ShowMessageAsync(
$"The game is not running on the PS5.\n\nLaunch \"{set.GameName}\" first — trophies can only be unlocked while the matching game is running (the unlock goes through the game's own trophy pipeline).",
"Launch the game first");
}
else
{
Log($"⚠️ Running title is '{apps[0].Name}' but the selected set belongs to '{set.TitleId}'. Launch the matching game first.");
await ShowMessageAsync(
$"A different title is running: \"{apps[0].Name}\".\n\nLaunch \"{set.GameName}\" ({set.TitleId}) first — trophies can only be unlocked while the matching game is running.",
"Launch the game first");
}
return false;
}
private async Task RunTrophyUnlockAsync(string spec)
{
var sw = Stopwatch.StartNew();
var (ok, msg) = await _protocol.TrophyUnlockAsync(spec);
if (ok)
{
Log($"🏆 {msg} ({sw.ElapsedMilliseconds} ms)");
// The daemon writes TRPTITLE.DAT asynchronously — give it a moment
// then refresh so the new state/timestamp is re-read from disk.
await Task.Delay(1200);
await RefreshTrophiesAsync();
}
else
Log($"❌ Trophy unlock failed: {msg}");
}
private async void TrophyUnlock_Click(object? sender, RoutedEventArgs e)
{
if (sender is not Button { DataContext: PS5Trophy t }) return;
if (TrophySetsListBox.SelectedItem is not PS5TrophySet set) return;
if (!await EnsureTrophyGameRunningAsync(set)) return;
if (!await ShowConfirmAsync(
$"Unlock trophy \"{t.Name}\" ({t.GradeDisplay}) on the PS5?\n\n" +
"The unlock goes through the running game's own UDS event pipeline — it is permanent and fires a real trophy notification.")) return;
await RunTrophyUnlockAsync($"unlock:{t.Id}");
}
// Re-lock: pure file rewrite on the console (TRPTITLE.DAT) — no running
// game needed, and in fact best done with the game closed so the trophy
// daemon can't flush a cached copy over our edit.
private async void TrophyLock_Click(object? sender, RoutedEventArgs e)
{
if (sender is not Button { DataContext: PS5Trophy t }) return;
if (TrophySetsListBox.SelectedItem is not PS5TrophySet set) return;
if (!_protocol.IsConnected) { Log("❌ Not connected to PS5"); return; }
if (!await ShowConfirmAsync(
$"Re-lock trophy \"{t.Name}\" on the PS5?\n\n" +
"This rewrites TRPTITLE.DAT on the console — the unlock bit and timestamp are cleared.\n\n" +
"Close the game first if it's running, so the trophy daemon doesn't overwrite the edit.")) return;
var (ok, msg) = await _protocol.TrophyUnlockAsync($"lock:{set.NpCommunicationId}:{t.Id}");
if (ok)
{
Log($"🔒 {msg}");
await Task.Delay(600);
await RefreshTrophiesAsync();
}
else Log($"❌ Trophy lock failed: {msg}");
}
private async void TrophySetsListBox_SelectionChanged(object? sender, SelectionChangedEventArgs e)
{
if (TrophySetsListBox.SelectedItem is not PS5TrophySet set) return;
TrophyItemsListBox.ItemsSource = null;
TrophyItemsListBox.ItemsSource = set.Trophies;
TrophyStatusText.Text = $"{set.GameName} — {set.TotalCount} trophies";
// Load this set's trophy icons if not already fetched.
if (set.Trophies.Any(t => t.Icon == null))
_ = Task.Run(() => LoadTrophyItemIconsAsync(set));
}
private async Task LoadTrophyIconsAsync(List<PS5TrophySet> sets)
{
try
{
using var proto = new PS5Protocol();
if (!await proto.ConnectAsync(_ps5IpAddress)) return;
foreach (var set in sets)
{
// icon0_<lang>.png is the set cover; fall back to plain icon0.png.
foreach (var cand in new[] { "icon0_en-US.png", "icon0.png" })
{
var bytes = await proto.GetTrophyIconAsync(set.NpCommunicationId, cand);
if (bytes == null || bytes.Length == 0) continue;
await Dispatcher.UIThread.InvokeAsync(() =>
{
try { set.Icon = new Bitmap(new MemoryStream(bytes)); } catch { }
});
break;
}
}
}
catch { }
}
private async Task LoadTrophyItemIconsAsync(PS5TrophySet set)
{
try
{
using var proto = new PS5Protocol();
if (!await proto.ConnectAsync(_ps5IpAddress)) return;
foreach (var t in set.Trophies)
{
if (t.Icon != null) continue;
var bytes = await proto.GetTrophyIconAsync(set.NpCommunicationId, $"trop{t.Id:0000}.png");
if (bytes == null || bytes.Length == 0) continue;
await Dispatcher.UIThread.InvokeAsync(() =>
{
try { t.Icon = new Bitmap(new MemoryStream(bytes)); } catch { }
});
if (!_protocol.IsConnected) break;
}
}
catch { }
}
}
}
+215 -17
View File
@@ -375,10 +375,11 @@
<!-- PAGE 2: Games -->
<Grid x:Name="PageGames" IsVisible="False">
<TabControl Background="#1E1E1E">
<TabControl x:Name="GamesTabControl" Background="#1E1E1E" SelectionChanged="GamesTabControl_SelectionChanged">
<TabItem Header="🎮 Games">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
@@ -392,7 +393,25 @@
</WrapPanel>
</Border>
<ListBox Grid.Row="1" x:Name="MountedGamesListBox" Background="#1E1E1E" BorderThickness="0" DoubleTapped="MountedGamesListBox_DoubleTapped">
<Border Grid.Row="1" Background="#252526" CornerRadius="4" Padding="8" Margin="0,0,0,10">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBox x:Name="GameSearchBox" PlaceholderText="🔍 Search games..." TextChanged="GameSearchBox_TextChanged" Background="#1E1E1E" BorderBrush="#3E3E42" VerticalContentAlignment="Center"/>
<ComboBox Grid.Column="1" x:Name="GameSortComboBox" Width="150" Margin="8,0,0,0" SelectedIndex="0" SelectionChanged="GameSortComboBox_SelectionChanged" VerticalContentAlignment="Center">
<ComboBoxItem>Name A→Z</ComboBoxItem>
<ComboBoxItem>Name Z→A</ComboBoxItem>
<ComboBoxItem>Size ↑</ComboBoxItem>
<ComboBoxItem>Size ↓</ComboBoxItem>
<ComboBoxItem>Title ID</ComboBoxItem>
<ComboBoxItem>Mounted first</ComboBoxItem>
</ComboBox>
</Grid>
</Border>
<ListBox Grid.Row="2" x:Name="MountedGamesListBox" Background="#1E1E1E" BorderThickness="0" DoubleTapped="MountedGamesListBox_DoubleTapped">
<ListBox.ContextMenu>
<ContextMenu>
<MenuItem Header="▶ Launch Game" Click="LaunchGameMenuItem_Click"/>
@@ -466,9 +485,14 @@
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<Border Grid.Column="0" Width="64" Height="64" Margin="0,0,15,0" Background="#3E3E42" CornerRadius="8" ClipToBounds="True">
<Image Source="{Binding Cover}" Stretch="UniformToFill"/>
</Border>
<Grid Grid.Column="0" Width="64" Height="64" Margin="0,0,15,0">
<Border Background="#3E3E42" CornerRadius="8">
<TextBlock Text="📦" FontSize="28" HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
<Border CornerRadius="8" ClipToBounds="True">
<Image Source="{Binding Cover}" Stretch="UniformToFill"/>
</Border>
</Grid>
<StackPanel Grid.Column="1" VerticalAlignment="Center">
<TextBlock Text="{Binding Name}" FontSize="15" FontWeight="Bold"/>
<StackPanel Orientation="Horizontal" Margin="0,4,0,0">
@@ -487,6 +511,192 @@
</ListBox>
</Grid>
</TabItem>
<!-- Prospero Store Site (homebrew.page native PS5 catalog) -->
<TabItem Header="🌐 Prospero Store Site">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<Border Grid.Row="0" Background="#252526" CornerRadius="4" Padding="12" Margin="0,0,0,15">
<WrapPanel>
<TextBlock Text="🌐 Prospero Store Site" FontSize="16" FontWeight="Bold" Foreground="#007ACC" VerticalAlignment="Center" Margin="0,0,15,0"/>
<Button Content="🔄 Refresh Catalog" Click="ProsperoRefresh_Click" Padding="10,5" Margin="0,0,8,0"/>
<TextBlock x:Name="ProsperoStatusText" Text="" Foreground="#999999" VerticalAlignment="Center"/>
</WrapPanel>
</Border>
<Border Grid.Row="1" Background="#252526" CornerRadius="4" Padding="8" Margin="0,0,0,10">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBox x:Name="ProsperoSearchBox" PlaceholderText="🔍 Search apps..." TextChanged="ProsperoSearch_TextChanged" Background="#1E1E1E" BorderBrush="#3E3E42" VerticalContentAlignment="Center"/>
<ComboBox Grid.Column="1" x:Name="ProsperoCategoryComboBox" Width="140" Margin="8,0,0,0" SelectedIndex="0" SelectionChanged="ProsperoCategory_SelectionChanged" VerticalContentAlignment="Center">
<ComboBoxItem>All</ComboBoxItem>
<ComboBoxItem>Apps</ComboBoxItem>
<ComboBoxItem>Games</ComboBoxItem>
<ComboBoxItem>Tools</ComboBoxItem>
<ComboBoxItem>Coming soon</ComboBoxItem>
</ComboBox>
<ComboBox Grid.Column="2" x:Name="ProsperoSortComboBox" Width="150" Margin="8,0,0,0" SelectedIndex="0" SelectionChanged="ProsperoSort_SelectionChanged" VerticalContentAlignment="Center">
<ComboBoxItem>Name A→Z</ComboBoxItem>
<ComboBoxItem>Name Z→A</ComboBoxItem>
<ComboBoxItem>Size ↑</ComboBoxItem>
<ComboBoxItem>Size ↓</ComboBoxItem>
<ComboBoxItem>Newest first</ComboBoxItem>
<ComboBoxItem>Author</ComboBoxItem>
</ComboBox>
</Grid>
</Border>
<ListBox Grid.Row="2" x:Name="ProsperoListBox" Background="#1E1E1E" BorderThickness="0">
<ListBox.ItemTemplate>
<DataTemplate>
<Border Background="#2D2D30" CornerRadius="6" Padding="12" Margin="3">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<Grid Grid.Column="0" Width="64" Height="64" Margin="0,0,15,0">
<Border Background="#3E3E42" CornerRadius="8">
<TextBlock Text="🧩" FontSize="28" HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
<Border CornerRadius="8" ClipToBounds="True">
<Image Source="{Binding Icon}" Stretch="UniformToFill"/>
</Border>
</Grid>
<StackPanel Grid.Column="1" VerticalAlignment="Center">
<TextBlock Text="{Binding Name}" FontSize="15" FontWeight="Bold"/>
<StackPanel Orientation="Horizontal" Margin="0,4,0,0">
<TextBlock Text="{Binding TitleId}" FontSize="12" Foreground="#007ACC" FontWeight="SemiBold"/>
<TextBlock Text=" • " Foreground="#666666"/>
<TextBlock Text="{Binding Version}" FontSize="12" Foreground="#FFC107"/>
<TextBlock Text=" • " Foreground="#666666"/>
<TextBlock Text="{Binding Author}" FontSize="12" Foreground="#9CDCFE"/>
<TextBlock Text=" • " Foreground="#666666"/>
<TextBlock Text="{Binding KindDisplay}" FontSize="12" Foreground="#CE9178"/>
<TextBlock Text=" • " Foreground="#666666"/>
<TextBlock Text="{Binding SizeDisplay}" FontSize="12" Foreground="#888888"/>
</StackPanel>
<TextBlock Text="{Binding StatusDisplay}" FontSize="11" Foreground="{Binding StatusColor}" Margin="0,3,0,0"/>
</StackPanel>
<Button Grid.Column="2" Content="{Binding InstallLabel}" Click="ProsperoInstall_Click" Tag="{Binding TitleId}" Padding="12,6" Background="#28A745" VerticalAlignment="Center" IsEnabled="{Binding CanInstall}"/>
</Grid>
</Border>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</Grid>
</TabItem>
<!-- Trophies (NpTrophy V2 viewer) -->
<TabItem Header="🏆 Trophies">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<Border Grid.Row="0" Background="#252526" CornerRadius="4" Padding="12" Margin="0,0,0,15">
<WrapPanel>
<TextBlock Text="🏆 Trophies" FontSize="16" FontWeight="Bold" Foreground="#007ACC" VerticalAlignment="Center" Margin="0,0,15,0"/>
<TextBlock x:Name="TrophyStatusText" Text="" Foreground="#999999" VerticalAlignment="Center"/>
</WrapPanel>
</Border>
<Border Grid.Row="1" Background="#252526" CornerRadius="4" Padding="8" Margin="0,0,0,10">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBox x:Name="TrophySetSearchBox" PlaceholderText="🔍 Search games..." TextChanged="TrophySetSearchBox_TextChanged" Background="#1E1E1E" BorderBrush="#3E3E42" VerticalContentAlignment="Center"/>
<ComboBox Grid.Column="1" x:Name="TrophySetSortComboBox" Width="150" Margin="8,0,0,0" SelectedIndex="0" SelectionChanged="TrophySetSortComboBox_SelectionChanged" VerticalContentAlignment="Center">
<ComboBoxItem>Name A→Z</ComboBoxItem>
<ComboBoxItem>Name Z→A</ComboBoxItem>
<ComboBoxItem>Progress ↑</ComboBoxItem>
<ComboBoxItem>Progress ↓</ComboBoxItem>
<ComboBoxItem>Trophy count</ComboBoxItem>
</ComboBox>
</Grid>
</Border>
<Grid Grid.Row="2">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="320"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<ListBox Grid.Column="0" x:Name="TrophySetsListBox" Background="#1E1E1E" BorderThickness="0" SelectionChanged="TrophySetsListBox_SelectionChanged" Margin="0,0,8,0">
<ListBox.ItemTemplate>
<DataTemplate>
<Border Background="#2D2D30" CornerRadius="6" Padding="10" Margin="3">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<Border Grid.Column="0" Width="48" Height="48" Margin="0,0,10,0" Background="#3E3E42" CornerRadius="6" ClipToBounds="True">
<Image Source="{Binding Icon}" Stretch="UniformToFill"/>
</Border>
<StackPanel Grid.Column="1" VerticalAlignment="Center">
<TextBlock Text="{Binding GameName}" FontSize="13" FontWeight="Bold" TextTrimming="CharacterEllipsis"/>
<StackPanel Orientation="Horizontal" Margin="0,3,0,0">
<TextBlock Text="{Binding NpCommunicationId}" FontSize="11" Foreground="#007ACC"/>
<TextBlock Text=" • " Foreground="#666666"/>
<TextBlock Text="{Binding ProgressDisplay}" FontSize="11" Foreground="#9CDCFE"/>
</StackPanel>
</StackPanel>
</Grid>
</Border>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<ListBox Grid.Column="1" x:Name="TrophyItemsListBox" Background="#1E1E1E" BorderThickness="0">
<ListBox.ItemTemplate>
<DataTemplate>
<Border Background="#2D2D30" CornerRadius="6" Padding="10" Margin="3">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<Border Grid.Column="0" Width="48" Height="48" Margin="0,0,10,0" Background="#3E3E42" CornerRadius="6" ClipToBounds="True">
<Image Source="{Binding Icon}" Stretch="UniformToFill"/>
</Border>
<StackPanel Grid.Column="1" VerticalAlignment="Center">
<StackPanel Orientation="Horizontal">
<TextBlock Text="{Binding Name}" FontSize="13" FontWeight="Bold"/>
<TextBlock Text="{Binding HiddenDisplay}" Margin="6,0,0,0"/>
</StackPanel>
<TextBlock Text="{Binding Detail}" FontSize="11" Foreground="#AAAAAA" TextWrapping="Wrap" Margin="0,3,0,0"/>
<StackPanel Orientation="Horizontal" Margin="0,4,0,0">
<TextBlock Text="{Binding GroupDisplay}" FontSize="10" Foreground="#888888"/>
</StackPanel>
</StackPanel>
<StackPanel Grid.Column="2" VerticalAlignment="Center" HorizontalAlignment="Right">
<Border Background="{Binding GradeBrush}" CornerRadius="10" Padding="10,4" HorizontalAlignment="Right">
<TextBlock Text="{Binding GradeDisplay}" FontSize="11" Foreground="#202020" FontWeight="Bold"/>
</Border>
<TextBlock Text="{Binding StateDisplay}" FontSize="10" Foreground="#999999" Margin="0,5,0,0" HorizontalAlignment="Right"/>
<Button Content="🔓 Unlock" FontSize="10" Padding="10,2" Margin="0,5,0,0" HorizontalAlignment="Right"
IsVisible="{Binding CanUnlock}" Click="TrophyUnlock_Click"
ToolTip.Tip="Debug-unlock this trophy — requires the game to be RUNNING on the PS5"/>
<Button Content="🔒 Lock" FontSize="10" Padding="10,2" Margin="0,5,0,0" HorizontalAlignment="Right"
IsVisible="{Binding CanLock}" Click="TrophyLock_Click"
ToolTip.Tip="Re-lock this trophy — rewrites TRPTITLE.DAT on the console (close the game first)"/>
</StackPanel>
</Grid>
</Border>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</Grid>
</Grid>
</TabItem>
</TabControl>
</Grid>
@@ -1177,18 +1387,6 @@
</StackPanel>
</Border>
<TextBlock Text="💿 Disc Dump" FontWeight="Bold" FontSize="16" Foreground="#0E9CFF" Margin="0,0,0,15"/>
<Border Background="#252526" BorderBrush="#3E3E42" BorderThickness="1" CornerRadius="6" Padding="15" Margin="0,0,0,15">
<StackPanel>
<WrapPanel Margin="0,0,0,10">
<Button Content="▶ Start Dump" Click="DiscDumpStart_Click" Padding="12,6" Background="#28A745" Margin="0,0,8,0"/>
<Button Content="🔄 Status" Click="DiscDumpStatus_Click" Padding="12,6" Background="#3E3E42" Margin="0,0,8,0"/>
<Button Content="⏹ Cancel" Click="DiscDumpCancel_Click" Padding="12,6" Background="#DC3545"/>
</WrapPanel>
<ProgressBar x:Name="DiscDumpProgress" Height="8" Minimum="0" Maximum="100" Value="0" Margin="0,0,0,8"/>
<TextBlock x:Name="DiscDumpStatusText" Text="Insert a disc, then Start — copies /mnt/disc → /user/data/disc_dumps" Foreground="#999999" FontSize="11" TextWrapping="Wrap"/>
</StackPanel>
</Border>
</StackPanel>
</ScrollViewer>
</TabItem>
+203 -4
View File
@@ -92,12 +92,16 @@ namespace PS5Upload
PowerAction = 0x77,
UsbList = 0x78,
PadInfo = 0x79,
DiscDump = 0x7A,
Screenshot = 0x7B,
Notify = 0x7C,
PadAction = 0x7D,
IccControl = 0x7E,
// Trophy viewer (NpTrophy V2) — read-only listing + icons
TrophyList = 0x80,
TrophyIcon = 0x81,
TrophyUnlock = 0x82,
Shutdown = 0xFF
}
@@ -1253,9 +1257,6 @@ namespace PS5Upload
}
}
public Task<(bool success, string message)> DiscDumpAsync(string action)
=> SendTextCommandAsync(Command.DiscDump, action);
public async Task<(bool success, string message)> CaptureScreenshotAsync()
{
await _commandLock.WaitAsync();
@@ -1684,6 +1685,108 @@ namespace PS5Upload
}
}
// Trophy viewer — returns every registered trophy set with its raw
// tropconf.json / tropmeta.json payloads and the per-user TRPTITLE.DAT
// blob (empty when the user never earned anything in that set).
public async Task<List<PS5TrophySet>> GetTrophyListAsync()
{
var sets = new List<PS5TrophySet>();
await _commandLock.WaitAsync();
try
{
await SendCommandAsync(Command.TrophyList);
var (response, data) = await ReceiveResponseAsync(180000);
if (response != Response.Data || data == null || data.Length < 2)
return sets;
int off = 0;
ushort count = BitConverter.ToUInt16(data, off); off += 2;
string rdStr()
{
int l = data[off]; off++;
string s = Encoding.UTF8.GetString(data, off, l); off += l;
return s;
}
byte[] rdBlob()
{
uint l = BitConverter.ToUInt32(data, off); off += 4;
var b = new byte[l];
Array.Copy(data, off, b, 0, l); off += (int)l;
return b;
}
for (int i = 0; i < count; i++)
{
var set = new PS5TrophySet
{
NpCommunicationId = rdStr(),
TitleId = rdStr(),
UserId = rdStr(),
TropConfJson = rdBlob(),
TropMetaJson = rdBlob(),
TrpTitleData = rdBlob()
};
sets.Add(set);
}
}
catch (Exception ex)
{
LastError = $"GetTrophyListAsync: {ex.GetType().Name}: {ex.Message}";
}
finally
{
_commandLock.Release();
}
return sets;
}
// Fetch a trophy PNG ("trop0000.png", "icon0_en-US.png", …) from a set's UCP.
public async Task<byte[]?> GetTrophyIconAsync(string npwr, string entry)
{
await _commandLock.WaitAsync();
try
{
byte[] req = Encoding.UTF8.GetBytes($"{npwr}|{entry}");
await SendCommandAsync(Command.TrophyIcon, req);
var (response, data) = await ReceiveResponseAsync();
if (response != Response.Data || data == null || data.Length == 0)
return null;
return data;
}
catch
{
return null;
}
finally
{
_commandLock.Release();
}
}
// Trophy unlock — routed through the trophy daemon's debug API inside
// the RUNNING game process (the daemon binds the commId from it).
// spec: "unlock:<id|all>" or "lock:<id>". Returns daemon diagnostics.
public async Task<(bool ok, string msg)> TrophyUnlockAsync(string spec)
{
await _commandLock.WaitAsync();
try
{
await SendCommandAsync(Command.TrophyUnlock, Encoding.UTF8.GetBytes(spec + "\0"));
var (response, data) = await ReceiveResponseAsync(120000);
var msg = Encoding.UTF8.GetString(data ?? Array.Empty<byte>());
return (response == Response.Data || response == Response.Ok, msg);
}
catch (Exception ex)
{
return (false, $"TrophyUnlockAsync: {ex.GetType().Name}: {ex.Message}");
}
finally
{
_commandLock.Release();
}
}
// NEW: Get detailed info about a game
public async Task<Dictionary<string, string>?> GetGameDetailsAsync(string titleId)
{
@@ -3630,4 +3733,100 @@ namespace PS5Upload
public event System.ComponentModel.PropertyChangedEventHandler? PropertyChanged;
}
// ============ Trophy viewer (NpTrophy V2) ============
// One registered trophy set: raw UCP payloads + per-user TRPTITLE.DAT.
public class PS5TrophySet : System.ComponentModel.INotifyPropertyChanged
{
public string NpCommunicationId { get; set; } = "";
public string TitleId { get; set; } = ""; // empty when npbind map misses
public string UserId { get; set; } = ""; // uid that owns TRPTITLE.DAT
public byte[] TropConfJson { get; set; } = Array.Empty<byte>();
public byte[] TropMetaJson { get; set; } = Array.Empty<byte>();
public byte[] TrpTitleData { get; set; } = Array.Empty<byte>();
public List<PS5Trophy> Trophies { get; } = new();
// Parsed from TRPTITLE.DAT (client-side T2PD parse). StateKnown=false
// → show honest "—"; UnlockMask bit i = trophy id i (LSB-first).
public byte[] UnlockMask { get; set; } = Array.Empty<byte>();
public bool StateKnown { get; set; }
public int EarnedFallback { get; set; } = -1; // count known, flags unknown
private string _gameName = "";
public string GameName
{
get => _gameName;
set { _gameName = value; PropertyChanged?.Invoke(this, new System.ComponentModel.PropertyChangedEventArgs(nameof(GameName))); }
}
private Avalonia.Media.IImage? _icon;
public Avalonia.Media.IImage? Icon
{
get => _icon;
set { _icon = value; PropertyChanged?.Invoke(this, new System.ComponentModel.PropertyChangedEventArgs(nameof(Icon))); }
}
public int EarnedCount => StateKnown
? Trophies.Count(t => t.IsUnlocked)
: EarnedFallback >= 0 ? EarnedFallback : Trophies.Count(t => t.IsUnlocked);
public int TotalCount => Trophies.Count;
public string ProgressDisplay => TotalCount == 0
? "—"
: (StateKnown || EarnedFallback >= 0)
? $"{EarnedCount}/{TotalCount}"
: $"—/{TotalCount}";
public event System.ComponentModel.PropertyChangedEventHandler? PropertyChanged;
}
// One trophy definition merged with (optional) per-user unlock state.
public class PS5Trophy : System.ComponentModel.INotifyPropertyChanged
{
public int Id { get; set; } // index → icon "trop%04d.png"
public string Name { get; set; } = "";
public string Detail { get; set; } = "";
public string Grade { get; set; } = ""; // B/S/G/P
public bool Hidden { get; set; }
public string GroupId { get; set; } = ""; // "default" or dlc group
public bool IsUnlocked { get; set; }
public bool StateKnown { get; set; } // false until TRPTITLE.DAT format is parsed
public DateTime? UnlockedTime { get; set; }
public string GradeDisplay => Grade switch
{
"P" => "Platinum",
"G" => "Gold",
"S" => "Silver",
"B" => "Bronze",
_ => "?"
};
public Avalonia.Media.IBrush GradeBrush => Grade switch
{
"P" => new Avalonia.Media.SolidColorBrush(Avalonia.Media.Color.Parse("#7FD4FF")),
"G" => new Avalonia.Media.SolidColorBrush(Avalonia.Media.Color.Parse("#FFD24A")),
"S" => new Avalonia.Media.SolidColorBrush(Avalonia.Media.Color.Parse("#C0C0C0")),
"B" => new Avalonia.Media.SolidColorBrush(Avalonia.Media.Color.Parse("#CD7F32")),
_ => new Avalonia.Media.SolidColorBrush(Avalonia.Media.Color.Parse("#6C757D"))
};
public string StateDisplay => !StateKnown
? "—"
: IsUnlocked
? (UnlockedTime?.ToString("yyyy-MM-dd HH:mm") ?? "Unlocked")
: "Locked";
public string HiddenDisplay => Hidden ? "🙈" : "";
public string GroupDisplay => string.IsNullOrEmpty(GroupId) || GroupId == "default" ? "Base" : GroupId;
public bool CanUnlock => StateKnown && !IsUnlocked;
public bool CanLock => StateKnown && IsUnlocked;
private Avalonia.Media.IImage? _icon;
public Avalonia.Media.IImage? Icon
{
get => _icon;
set { _icon = value; PropertyChanged?.Invoke(this, new System.ComponentModel.PropertyChangedEventArgs(nameof(Icon))); }
}
public event System.ComponentModel.PropertyChangedEventHandler? PropertyChanged;
}
}
File diff suppressed because it is too large. Load diff
-68
View File
@@ -1,68 +0,0 @@
lpp_version
lpp_abi_version
lpp_keys_available
lpp_last_error
lpp_is_valid_content_id
lpp_is_valid_title_id
lpp_compose_content_id
lpp_build_package
lpp_build_package_ex
lpp_detect_package_type
lpp_build_inner_image
lpp_encrypt_pfs_image
lpp_pack_pfs_image
lpp_unpack_pfs_image
lpp_is_self
lpp_is_elf
lpp_is_ucp
lpp_read_self_info
lpp_make_fself
lpp_make_fself_ex
lpp_make_fself_file
lpp_fake_sign_folder
lpp_application_type_name
lpp_application_drm_type
lpp_parse_application_type
lpp_read_auth_info
lpp_write_auth_info
lpp_read_npdrm_content_info
lpp_inspect_package
lpp_extract_package
lpp_extract_package_ekpfs
lpp_rif_record_count
lpp_read_rif_content_id
lpp_read_rif_summary
lpp_validate_package
lpp_validate_package_report
lpp_disc_backup_reassemble
lpp_disc_backup_verify
lpp_convert_backup
lpp_convert_backup_ex
lpp_read_package_summary
lpp_package_entry_count
lpp_read_package_entry
lpp_list_package_files
lpp_list_package_files_ekpfs
lpp_compare_containers
lpp_merge_split_package_dir
lpp_package_homebrew
lpp_inspect_launch_readiness
lpp_launch_readiness_issues
lpp_build_pfs_layout
lpp_pfs_image_is_encrypted
lpp_decrypt_pfs_image
lpp_read_elf_header
lpp_normalize_elf_module
lpp_ucp_validate_file
lpp_ucp_verify_digest_file
lpp_ucp_build_from_directory
lpp_ucp_repair_digest_file
lpp_rif_create
lpp_derive_image_key
lpp_entitlement_key_validate
lpp_disc_backup_content_info
lpp_disc_backup_verify_chunk_crcs
lpp_encode_png_to_dds
lpp_build_playgo_chunk_dat
lpp_create_param_json
lpp_create_gp5
-767
View File
@@ -1,767 +0,0 @@
/*
* libprosperopkg.h - C interface for the LibProsperoPkg shared library.
*
* The shared library (libprosperopkg.so / libprosperopkg.dylib) is produced by the build
* workflows. All strings are UTF-8 and NUL-terminated. Output strings are written into
* caller-provided buffers; the library allocates no memory the caller must free.
*
* String-output functions return the number of bytes written (excluding the terminator) on
* success. When the buffer is too small they return the negative of the required size
* (including the terminator), so a caller can size a buffer and retry. Status functions return
* 0 on success and a negative value on failure; call lpp_last_error for a description. Detection
* predicates (lpp_is_*) return 1 or 0 and never fail. Struct-output functions fill a
* caller-provided struct (fixed char arrays inside are UTF-8 and NUL-terminated) and return 0 on
* success or a negative value on failure.
*/
#ifndef LIBPROSPEROPKG_H
#define LIBPROSPEROPKG_H
#include <stdint.h>
#ifdef __cplusplus
extern "C" {
#endif
/* ABI version of the exported surface (see lpp_abi_version). Bumped when exports change. */
#define LPP_ABI_VERSION 7
/* Build mode (build `mode`). */
#define LPP_MODE_APPLICATION 0
#define LPP_MODE_HOMEBREW 1
#define LPP_MODE_ADDITIONAL_CONTENT_DATA 2
#define LPP_MODE_ADDITIONAL_CONTENT_NO_DATA 3
/* Output container format (build `output_format`). */
#define LPP_OUTPUT_METADATA_CONTAINER 0
#define LPP_OUTPUT_DEBUG_IMAGE 1
/* Inner-image codec (build `inner_compression`). */
#define LPP_INNER_NONE 0
#define LPP_INNER_ZLIB 1
#define LPP_INNER_KRAKEN 2
/* Installable data-first inner image: raw-concatenated per-file payloads described by a generated
* naps_pkg_layout.dat. This is the recommended codec for installable homebrew packages. */
#define LPP_INNER_NWONLY_DATA_FIRST 3
/* Inner-image form (lpp_build_inner_image `form`). */
#define LPP_FORM_PLAINTEXT 0
#define LPP_FORM_ENCRYPTED 1
#define LPP_FORM_COMPRESSED 2
#define LPP_FORM_KRAKEN_COMPRESSED 3
/* Package type (lpp_detect_package_type return value). */
#define LPP_TYPE_META 0
#define LPP_TYPE_FULL_RETAIL 1
#define LPP_TYPE_FULL_DEBUG 2
/* Application type (build `application_type`, and the app-type helper functions). */
#define LPP_APP_TYPE_NOT_SPECIFIED 0
#define LPP_APP_TYPE_PAID_STANDALONE_FULL 1
#define LPP_APP_TYPE_UPGRADABLE 2
#define LPP_APP_TYPE_DEMO 3
#define LPP_APP_TYPE_FREEMIUM 4
/* Patch kind (lpp_npdrm_content_info.patch_kind). */
#define LPP_PATCH_NONE 0
#define LPP_PATCH_FIRST 1
#define LPP_PATCH_SUBSEQUENT 2
#define LPP_PATCH_DELTA 3
#define LPP_PATCH_CUMULATIVE 4
/* Authentication-info authority category (lpp_read_auth_info `category`). */
#define LPP_AUTH_CATEGORY_UNKNOWN 0x00
#define LPP_AUTH_CATEGORY_FAKE 0x31
#define LPP_AUTH_CATEGORY_GENUINE 0x45
#define LPP_AUTH_CATEGORY_PRIVILEGED 0x48
/* param.json DRM type (lpp_create_param_json `drm_type`). */
#define LPP_PARAM_DRM_STANDARD 0
#define LPP_PARAM_DRM_FREE 1
#define LPP_PARAM_DRM_FREEMIUM 2
/* GP5 volume type (lpp_create_gp5 `volume_type`). */
#define LPP_GP5_VOLUME_APP 0
#define LPP_GP5_VOLUME_PATCH 1
#define LPP_GP5_VOLUME_AC 2
#define LPP_GP5_VOLUME_AC_NODATA 3
/* ELF machine (lpp_elf_info.machine). */
#define LPP_ELF_MACHINE_X86_64 0x3E
#define LPP_ELF_MACHINE_AARCH64 0xB7
/* Package entry id (lpp_package_entry.id). */
#define LPP_ENTRY_UNKNOWN 0x0000
#define LPP_ENTRY_DIGESTS 0x0001
#define LPP_ENTRY_ENTRY_KEYS 0x0010
#define LPP_ENTRY_IMAGE_KEY 0x0020
#define LPP_ENTRY_GENERAL_DIGESTS 0x0080
#define LPP_ENTRY_METAS 0x0100
#define LPP_ENTRY_ENTRY_NAMES 0x0200
#define LPP_ENTRY_LICENSE_DAT 0x0400
#define LPP_ENTRY_LICENSE_INFO 0x0401
#define LPP_ENTRY_PARAM_JSON 0x1000
#define LPP_ENTRY_PARAM_SFO 0x1001
#define LPP_ENTRY_ICON0_PNG 0x1200
#define LPP_ENTRY_PIC0_PNG 0x1220
#define LPP_ENTRY_SND0_AT9 0x1240
#define LPP_ENTRY_ICON0_DDS 0x1280
#define LPP_ENTRY_PIC0_DDS 0x12A0
#define LPP_ENTRY_PIC1_DDS 0x12C0
#define LPP_ENTRY_PLAYGO_CHUNK_DAT 0x1300
#define LPP_ENTRY_PLAYGO_CHUNK_SHA 0x1301
#define LPP_ENTRY_PLAYGO_MANIFEST_XML 0x1302
#define LPP_ENTRY_PIC2_DDS 0x2060
/*
* Full option set for lpp_build_package_ex. Zero-initialize the whole struct, set struct_size to
* sizeof(lpp_build_options), then fill the fields you need. Any string pointer may be NULL; a NULL
* or empty `passcode` uses the 32-zero default, and a NULL or empty `version` uses "01.00". Set
* `content_badge_type` to a negative value to omit it. Set `has_authority_id` to 1 to apply
* `authority_id`; otherwise it is derived from the ELF during fake-signing. Set `license_free`
* to 1 to produce a DRM/license-free debug package (fake-signs modules and forces the free DRM
* bucket). `license_free` is an appended field: a smaller `struct_size` leaves it disabled.
*/
typedef struct lpp_build_options {
int32_t struct_size; /* sizeof(lpp_build_options) */
int32_t mode; /* LPP_MODE_* */
int32_t output_format; /* LPP_OUTPUT_* */
int32_t inner_compression; /* LPP_INNER_* */
int32_t application_type; /* LPP_APP_TYPE_* */
int32_t content_badge_type; /* < 0 to omit */
int32_t generate_param_json; /* 0/1 (generate param.json when the source lacks one) */
int32_t compress_inner_image; /* 0/1 (zlib PFSC inner image) */
int32_t fake_sign_self; /* 0/1 (fake-sign raw ELF modules before packing) */
int32_t has_authority_id; /* 0/1 (apply authority_id below) */
uint64_t app_version; /* fake-self application version */
uint64_t firmware_version; /* fake-self firmware version */
uint64_t authority_id; /* fake-self authority-id override (see has_authority_id) */
const char* source_folder;
const char* output_folder;
const char* content_id;
const char* passcode; /* NULL/empty -> 32 zeroes */
const char* title;
const char* title_id;
const char* version; /* NULL/empty -> "01.00" */
const char* application_drm_type; /* NULL -> derived from application_type */
int32_t license_free; /* 0/1 (DRM/license-free debug package: fake-sign + free bucket) */
} lpp_build_options;
/*
* Projected NpDrm content-info filled by lpp_read_npdrm_content_info. Zero-initialize before the
* call; on return content_id and title_id are UTF-8, NUL-terminated.
*/
typedef struct lpp_npdrm_content_info {
int32_t struct_size; /* sizeof(lpp_npdrm_content_info) */
uint32_t drm_type;
uint32_t content_type;
uint32_t content_flags;
int32_t patch_kind; /* LPP_PATCH_* */
int32_t is_patch; /* 0/1 */
int32_t is_nested; /* 0/1 */
int32_t is_finalized; /* 0/1 */
int64_t container_offset;
char content_id[64];
char title_id[16];
} lpp_npdrm_content_info;
/*
* Package inspection result filled by lpp_inspect_package. Zero-initialize before the call; on
* return content_id is UTF-8, NUL-terminated (empty when the package carries no content id).
*/
typedef struct lpp_package_info {
int32_t struct_size; /* sizeof(lpp_package_info) */
int32_t package_type; /* LPP_TYPE_* */
int32_t is_retail; /* 0/1 */
int32_t outer_encrypted; /* 0/1 */
int32_t requires_key; /* 0/1 (a supplied key is required to extract) */
int32_t reserved;
int64_t pfs_image_offset;
int64_t pfs_image_size;
char content_id[64];
} lpp_package_info;
/*
* Multi-content RIF summary filled by lpp_read_rif_summary. Zero-initialize before the call; on
* return app_content_id and service_id are UTF-8, NUL-terminated.
*/
typedef struct lpp_rif_summary {
int32_t struct_size; /* sizeof(lpp_rif_summary) */
int32_t record_count; /* n_rif */
int32_t has_app; /* 0/1 */
int32_t additional_count; /* n_ac */
int64_t expected_size;
int64_t actual_size;
char app_content_id[64];
char service_id[16];
} lpp_rif_summary;
/*
* Package header/image-header summary filled by lpp_read_package_summary. Zero-initialize before
* the call; on return content_id is UTF-8, NUL-terminated.
*/
typedef struct lpp_package_summary {
int32_t struct_size; /* sizeof(lpp_package_summary) */
int32_t package_type; /* LPP_TYPE_* */
int32_t is_official; /* 0/1 */
int32_t fih_format_version;
uint32_t flags;
uint32_t entry_count;
uint32_t sc_entry_count;
uint32_t drm_type;
uint32_t content_type;
uint32_t content_flags;
int64_t pfs_image_offset;
int64_t pfs_image_size;
int64_t embedded_cnt_offset;
char content_id[64];
} lpp_package_summary;
/*
* Single package entry filled by lpp_read_package_entry. Zero-initialize before the call; on
* return name is UTF-8, NUL-terminated (empty when the entry is unnamed).
*/
typedef struct lpp_package_entry {
int32_t struct_size; /* sizeof(lpp_package_entry) */
uint32_t raw_id;
int32_t id; /* LPP_ENTRY_* */
uint32_t flags1;
uint32_t flags2;
uint32_t data_offset;
uint32_t data_size;
int32_t encrypted; /* 0/1 */
uint32_t key_index;
char name[64];
} lpp_package_entry;
/*
* ELF header fields filled by lpp_read_elf_header. Zero-initialize before the call.
*/
typedef struct lpp_elf_info {
int32_t struct_size; /* sizeof(lpp_elf_info) */
int32_t elf_class;
int32_t data;
int32_t os_abi;
int32_t abi_version;
int32_t type;
int32_t machine; /* LPP_ELF_MACHINE_* */
uint64_t entry;
uint32_t flags;
int32_t program_header_count;
int32_t is_executable; /* 0/1 */
int32_t is_dynamic; /* 0/1 */
int32_t is_module_type; /* 0/1 */
int32_t is_module_ready; /* 0/1 */
} lpp_elf_info;
/*
* Launch-readiness summary filled by lpp_inspect_launch_readiness and, optionally, by
* lpp_package_homebrew. Zero-initialize before the call.
*/
typedef struct lpp_launch_readiness {
int32_t struct_size; /* sizeof(lpp_launch_readiness) */
int32_t has_eboot; /* 0/1 */
int32_t has_param_json; /* 0/1 */
int32_t has_param_sfo; /* 0/1 */
int32_t requires_debug_console; /* 0/1 */
int32_t is_launch_ready; /* 0/1 */
int32_t module_count;
int32_t issue_count;
} lpp_launch_readiness;
/* Returns a pointer to a static, NUL-terminated version string. */
const char* lpp_version(void);
/* Returns the numeric ABI version of this library (compare against LPP_ABI_VERSION). */
int lpp_abi_version(void);
/*
* Returns 1 when the wired-in publishing key material is present (the build path can sign the
* package), or 0 when it is absent and signing is skipped. Never fails.
*/
int lpp_keys_available(void);
/* Copies the current thread's most recent error message into `buffer`. */
int lpp_last_error(char* buffer, int capacity);
/* Returns 1 when `content_id` is a valid 36-character content id, otherwise 0. */
int lpp_is_valid_content_id(const char* content_id);
/* Returns 1 when `title_id` looks like a PPSAxxxxx title id, otherwise 0. */
int lpp_is_valid_title_id(const char* title_id);
/*
* Composes a 36-character content id from a publisher prefix, a title id and a label.
* Any argument may be NULL to accept the default for that field. Writes the result into
* `out_buffer` as UTF-8.
*/
int lpp_compose_content_id(const char* publisher, const char* title_id, const char* label,
char* out_buffer, int capacity);
/*
* Builds a package from a prepared source folder. `passcode` and `version` may be NULL/empty
* to accept their defaults (a 32-zero passcode and "01.00"). On success the output path is
* written to `out_path` and the function returns 0. On failure it returns a negative value;
* call lpp_last_error for a description.
*/
int lpp_build_package(const char* source_folder,
const char* output_folder,
const char* content_id,
const char* passcode,
const char* title,
const char* title_id,
const char* version,
int mode,
int output_format,
int inner_compression,
char* out_path,
int out_path_capacity);
/*
* Builds a package using the full option set in `options` (application type, fake-signing,
* param.json generation, inner compression, badge and DRM overrides). Zero-initialize the struct
* and set struct_size = sizeof(lpp_build_options). On success writes the output path to `out_path`
* and returns 0; on failure returns a negative value (call lpp_last_error).
*/
int lpp_build_package_ex(const lpp_build_options* options,
char* out_path, int out_path_capacity);
/*
* Detects the package type of the file at `path`. Returns the package type (LPP_TYPE_*), or -1
* when the file is not a recognized package or cannot be read (call lpp_last_error).
*/
int lpp_detect_package_type(const char* path);
/*
* Lays a prepared folder out into an inner-PFS image. `form` selects the image form (LPP_FORM_*).
* `passcode` may be NULL/empty to accept the 32-zero default. The written image path is copied
* into `out_path`. Returns 0 on success; a negative value on failure (call lpp_last_error).
*/
int lpp_build_inner_image(const char* source_folder,
const char* output_path,
const char* content_id,
const char* passcode,
int form,
char* out_path,
int out_path_capacity);
/*
* AES-XTS-encrypts a plaintext inner-PFS image in place, using keys derived from the content id
* and passcode. `passcode` may be NULL/empty to accept the 32-zero default. Returns 0 on success;
* a negative value on failure (call lpp_last_error).
*/
int lpp_encrypt_pfs_image(const char* pfs_image_path, const char* content_id, const char* passcode);
/*
* Packs a plaintext PFS image into a PFSv3 PFSC (Kraken) container. A non-positive `level` or
* `block_size` selects the default (7 / 262144). Returns 0 on success; a negative value on
* failure (call lpp_last_error).
*/
int lpp_pack_pfs_image(const char* input_image_path, const char* output_path,
int level, int block_size);
/*
* Unpacks a PFSv3 PFSC container back into a plaintext PFS image. Returns the number of bytes
* written on success, or -1 on failure (call lpp_last_error).
*/
long long lpp_unpack_pfs_image(const char* input_path, const char* output_path);
/* Returns 1 when `data` (`length` bytes) holds a SELF container, otherwise 0. */
int lpp_is_self(const unsigned char* data, int length);
/* Returns 1 when `data` (`length` bytes) holds a 64-bit ELF, otherwise 0. */
int lpp_is_elf(const unsigned char* data, int length);
/* Returns 1 when `data` (`length` bytes) holds a UCP archive, otherwise 0. */
int lpp_is_ucp(const unsigned char* data, int length);
/*
* Reads the SELF extended-info and segment count from an in-memory SELF module. Any output
* pointer may be NULL to skip that field; `digest32` must point to 32 bytes when non-NULL. When
* the module carries no extended-info block the ext-info outputs are zero-filled and the call
* still returns 0. Returns -1 when the buffer is not a valid SELF (call lpp_last_error).
*/
int lpp_read_self_info(const unsigned char* data, int length,
uint64_t* authority_id, uint64_t* program_type,
uint64_t* app_version, uint64_t* firmware_version,
unsigned char* digest32, int* segment_count);
/*
* Generates a fake-self from a 64-bit ELF. Pass out_buffer=NULL or capacity=0 to query the
* required size (returned positive, nothing written). On success returns the number of bytes
* written. Returns -1 and sets lpp_last_error on failure or when a non-zero buffer is too small.
*/
int lpp_make_fself(const unsigned char* elf, int elf_length,
unsigned char* out_buffer, int capacity);
/*
* Like lpp_make_fself, with explicit fake-self options: `app_version` and `firmware_version` are
* written to the extended info, and `authority_id` overrides the derived id when `has_authority_id`
* is non-zero. The size-query and buffer semantics match lpp_make_fself.
*/
int lpp_make_fself_ex(const unsigned char* elf, int elf_length,
uint64_t app_version, uint64_t firmware_version,
uint64_t authority_id, int has_authority_id,
unsigned char* out_buffer, int capacity);
/*
* Reads a 64-bit ELF from `elf_path`, generates a fake-self and writes it to `out_path`. The
* version/authority arguments match lpp_make_fself_ex. Returns the number of bytes written on
* success, or -1 on failure (call lpp_last_error).
*/
int lpp_make_fself_file(const char* elf_path, const char* out_path,
uint64_t app_version, uint64_t firmware_version,
uint64_t authority_id, int has_authority_id);
/*
* Fake-signs every raw ELF module under `source_folder` in place (eboot.bin, *.elf, *.prx,
* *.sprx). Files already SELF, or that are not a 64-bit ELF, are skipped. The version/authority
* arguments match lpp_make_fself_ex. Returns the number of modules converted, or -1 on failure
* (call lpp_last_error).
*/
int lpp_fake_sign_folder(const char* source_folder,
uint64_t app_version, uint64_t firmware_version,
uint64_t authority_id, int has_authority_id);
/*
* Copies the display name of an application type (LPP_APP_TYPE_*) into `out_buffer` as UTF-8.
* Returns the number of bytes written, or a negative value (call lpp_last_error).
*/
int lpp_application_type_name(int application_type, char* out_buffer, int capacity);
/*
* Copies the generated param.json applicationDrmType token ("free" / "standard" / "freemium") for
* an application type (LPP_APP_TYPE_*) into `out_buffer`. Returns the number of bytes written, or a
* negative value (call lpp_last_error).
*/
int lpp_application_drm_type(int application_type, char* out_buffer, int capacity);
/*
* Parses an application-type display name (case-insensitive) into its code (LPP_APP_TYPE_*).
* Unknown or empty input yields LPP_APP_TYPE_NOT_SPECIFIED (0).
*/
int lpp_parse_application_type(const char* name);
/*
* Reads a SELF authentication-info sidecar (a 0x88-byte *.auth_info record). Any output pointer
* may be NULL; `capabilities4` and `attributes4` each point to four 64-bit words, and `category`
* receives the authority category (LPP_AUTH_CATEGORY_*). Returns 0 on success, or -1 on failure
* (call lpp_last_error).
*/
int lpp_read_auth_info(const char* path, uint64_t* paid,
uint64_t* capabilities4, uint64_t* attributes4, int* category);
/*
* Builds a SELF authentication-info sidecar from supplied fields and writes it to `path`.
* `capabilities4` and `attributes4` each point to four 64-bit words, or may be NULL to write
* zeroes. Returns 0 on success, or -1 on failure (call lpp_last_error).
*/
int lpp_write_auth_info(const char* path, uint64_t paid,
const uint64_t* capabilities4, const uint64_t* attributes4);
/*
* Reads and projects the NpDrm content-info of the package at `path` into `out_info`. Returns 0
* on success, or -1 on failure (call lpp_last_error).
*/
int lpp_read_npdrm_content_info(const char* path, lpp_npdrm_content_info* out_info);
/*
* Inspects the package at `path` without a key, filling `out_info` (package type, retail flag,
* outer-PFS offset and size, encryption state, whether a supplied key is required, and the content
* id when present). Returns 0 on success, or -1 on failure (call lpp_last_error).
*/
int lpp_inspect_package(const char* path, lpp_package_info* out_info);
/*
* Extracts a package to `output_directory`, deriving the outer key from `passcode` (and the
* package's own content id). A NULL or empty passcode uses the 32-zero default. Set `extract_outer`
* non-zero to also write the outer metadata files. Returns the number of extracted files on
* success, or -1 on failure (call lpp_last_error).
*/
int lpp_extract_package(const char* path, const char* output_directory,
const char* passcode, int extract_outer);
/*
* Extracts a package to `output_directory` using a supplied 32-byte outer key (`ekpfs32`). Set
* `extract_outer` non-zero to also write the outer metadata files. Returns the number of extracted
* files on success, or -1 on failure (call lpp_last_error).
*/
int lpp_extract_package_ekpfs(const char* path, const char* output_directory,
const unsigned char* ekpfs32, int extract_outer);
/*
* Counts the records in a RIF license file (n_rif). Returns the record count on success, or -1 on
* failure (call lpp_last_error).
*/
int lpp_rif_record_count(const char* path);
/*
* Copies the content id of the first record in a RIF file into `out_buffer` as UTF-8. Returns the
* number of bytes written, or a negative value (call lpp_last_error).
*/
int lpp_read_rif_content_id(const char* path, char* out_buffer, int capacity);
/*
* Summarizes a multi-content RIF file into `out_summary`. `app_title_id` may be NULL to skip the
* application match. Returns 0 on success, or -1 on failure (call lpp_last_error).
*/
int lpp_read_rif_summary(const char* path, const char* app_title_id, lpp_rif_summary* out_summary);
/*
* Runs the structural acceptance-gate checks on the package at `path`. `expected_content_id` may be
* NULL to skip the content-id match. The pass, warning and fail counts are written to the output
* pointers when non-NULL. Returns 1 when accepted (no failing check), 0 when rejected, or -1 on
* error (call lpp_last_error).
*/
int lpp_validate_package(const char* path, const char* expected_content_id,
int* pass_count, int* warn_count, int* fail_count);
/*
* Runs the structural acceptance-gate checks on the package at `path` and writes each check as one
* line ("[Status] Name: Detail") into `out_buffer`. `expected_content_id` may be NULL to skip the
* content-id match. Follows the string-output convention (bytes written, or the negative required
* size).
*/
int lpp_validate_package_report(const char* path, const char* expected_content_id,
char* out_buffer, int capacity);
/*
* Reassembles a split disc-backup package (from an app.json path or a directory that contains one)
* into a single package file at `output_path`. Returns the number of bytes written on success, or
* -1 on failure (call lpp_last_error).
*/
long long lpp_disc_backup_reassemble(const char* manifest_path, const char* output_path);
/*
* Verifies a split disc-backup package. The package-digest and chunk-CRC results are written to the
* output pointers (1 = match, 0 = mismatch) when non-NULL. Returns 0 on success, or -1 on failure
* (call lpp_last_error).
*/
int lpp_disc_backup_verify(const char* manifest_path, int* digest_ok, int* chunk_crc_ok);
/*
* Converts a decrypted application backup into a debug package. Substitutes each signed executable
* with its raw ELF from the decrypted subtree, fake-signs the modules, and builds a debug image whose
* mount key derives from the content id and passcode. `content_id` and `version` may be NULL/empty to
* take them from the backup's param.json; `passcode` may be NULL/empty for the all-zero default. The
* substituted-module count is written to `substituted_count` when non-NULL. The output path is written
* into `out_path` as UTF-8. Returns 0 on success, or -1 on failure (call lpp_last_error).
*/
int lpp_convert_backup(const char* backup_folder, const char* output_folder, const char* content_id,
const char* passcode, const char* version, int* substituted_count,
char* out_path, int out_path_capacity);
/*
* Converts a decrypted application backup into a debug package with the full option set. Extends
* lpp_convert_backup with the name of the decrypted-module subtree (`decrypted_subfolder`, NULL/empty
* for "decrypted"), a flag to drop the backup's own right.sprx so the embedded debug module is
* injected instead (`use_embedded_right_sprx`), and the inner-image codec (`inner_compression`,
* LPP_INNER_*). The substituted, plaintext and unresolved module counts are written to the output
* pointers when non-NULL. The output path is written to `out_path`. Returns 0 on success, or -1 on
* failure (call lpp_last_error).
*/
int lpp_convert_backup_ex(const char* backup_folder, const char* output_folder, const char* content_id,
const char* passcode, const char* version, const char* decrypted_subfolder,
int use_embedded_right_sprx, int inner_compression, int* substituted_count,
int* plaintext_count, int* unresolved_count,
char* out_path, int out_path_capacity);
/*
* Reads the package at `path` and fills `out_info` with header and image-header fields (package
* type, entry counts, DRM/content type and flags, content id, PFS image offset/size, embedded
* container offset). Returns 0 on success, or -1 on failure (call lpp_last_error).
*/
int lpp_read_package_summary(const char* path, lpp_package_summary* out_info);
/*
* Returns the entry count of the package at `path`, or -1 on failure (call lpp_last_error).
*/
int lpp_package_entry_count(const char* path);
/*
* Fills `out_entry` with the entry at zero-based `index` in the package at `path`. Returns 0 on
* success, or -1 on failure or an out-of-range index (call lpp_last_error).
*/
int lpp_read_package_entry(const char* path, int index, lpp_package_entry* out_entry);
/*
* Lists the inner files of the package at `path` (newline-separated relative paths) using a
* passcode. `passcode` may be NULL/empty for the 32-zero default. Follows the string-output
* convention (bytes written, or the negative required size).
*/
int lpp_list_package_files(const char* path, const char* passcode, char* out_buffer, int capacity);
/*
* Lists the inner files of the package at `path` (newline-separated relative paths) using a
* supplied 32-byte image key (`ekpfs32`). Follows the string-output convention.
*/
int lpp_list_package_files_ekpfs(const char* path, const unsigned char* ekpfs32,
char* out_buffer, int capacity);
/*
* Compares two metadata containers and writes the differences (newline-separated) into
* `out_buffer`; an empty result means the containers match. Follows the string-output convention.
*/
int lpp_compare_containers(const char* reference_path, const char* candidate_path,
char* out_buffer, int capacity);
/*
* Merges every split package found in `input_dir` and writes the resulting output paths
* (newline-separated) into `out_buffer`. `output_dir` may be NULL to write beside the input; set
* `compute_digest` non-zero to compute a SHA-256 per merged package. Follows the string-output
* convention.
*/
int lpp_merge_split_package_dir(const char* input_dir, const char* output_dir, int compute_digest,
char* out_buffer, int capacity);
/*
* Builds a package from a homebrew folder. `content_id`, `title` and `version` may be NULL/empty to
* take defaults; `passcode` may be NULL/empty for the 32-zero default; `module_name` may be
* NULL/empty for "eboot.bin". When non-NULL, `out_readiness` receives the launch-readiness summary.
* Writes the output path to `out_path`. Returns 0 on success, or -1 on failure (call lpp_last_error).
*/
int lpp_package_homebrew(const char* homebrew_folder, const char* output_folder, const char* content_id,
const char* passcode, const char* title, const char* version,
const char* module_name, int inner_compression,
lpp_launch_readiness* out_readiness, char* out_path, int out_path_capacity);
/*
* Inspects an application root and fills `out_readiness` with its launch-readiness summary. Returns
* 0 on success, or -1 on failure (call lpp_last_error).
*/
int lpp_inspect_launch_readiness(const char* app_root, lpp_launch_readiness* out_readiness);
/*
* Inspects an application root and writes its blocking launch-readiness reasons (newline-separated)
* into `out_buffer`; an empty result means the tree is launch-ready. Pairs with
* lpp_inspect_launch_readiness, which reports the issue count. Follows the string-output convention.
*/
int lpp_launch_readiness_issues(const char* app_root, char* out_buffer, int capacity);
/*
* Lays a source folder out into a plaintext inner-PFS image. The file and directory counts are
* written to `file_count` and `directory_count` when non-NULL; the output path is written to
* `out_path`. Returns 0 on success, or -1 on failure (call lpp_last_error).
*/
int lpp_build_pfs_layout(const char* source_folder, const char* output_path, int* file_count,
int* directory_count, char* out_path, int out_path_capacity);
/*
* Reports whether the inner image at `path` is encrypted. Returns 1 when encrypted, 0 when
* plaintext, or -1 on failure (call lpp_last_error).
*/
int lpp_pfs_image_is_encrypted(const char* path);
/*
* Decrypts the inner image at `pfs_image_path` in place, deriving the image key from `content_id`
* and `passcode` (NULL/empty passcode uses the 32-zero default). Returns 0 on success, or -1 on
* failure (call lpp_last_error).
*/
int lpp_decrypt_pfs_image(const char* pfs_image_path, const char* content_id, const char* passcode);
/*
* Reads the ELF header at `path` into `out_info`. Returns 0 on success, or -1 on failure (call
* lpp_last_error).
*/
int lpp_read_elf_header(const char* path, lpp_elf_info* out_info);
/*
* Normalizes the ELF at `in_path` for use as a module and writes the result to `out_path`. When
* non-NULL, `changed` receives 1 if any header field changed, otherwise 0. Returns 0 on success, or
* -1 on failure (call lpp_last_error).
*/
int lpp_normalize_elf_module(const char* in_path, const char* out_path, int* changed);
/*
* Validates the content protection file at `path`. When non-NULL, `error_buffer` receives a
* description when the file is invalid. Returns 1 when valid, 0 when invalid, or -1 on failure
* (call lpp_last_error).
*/
int lpp_ucp_validate_file(const char* path, char* error_buffer, int error_capacity);
/*
* Verifies the digest of the content protection file at `path`. Returns 1 when the digest matches,
* 0 when it does not, or -1 on failure (call lpp_last_error).
*/
int lpp_ucp_verify_digest_file(const char* path);
/*
* Builds a content protection file from `directory` and writes it to `output_path`. Returns the
* number of bytes written, or -1 on failure (call lpp_last_error).
*/
long long lpp_ucp_build_from_directory(const char* directory, const char* output_path);
/*
* Reads the content protection file at `in_path`, repairs its digest, and writes it to `out_path`.
* Returns the number of bytes written, or -1 on failure (call lpp_last_error).
*/
long long lpp_ucp_repair_digest_file(const char* in_path, const char* out_path);
/*
* Creates a structural license record for `content_id` and writes it to `out_path`. Pass 0 for
* `expiry` to create a non-expiring record. Returns the number of bytes written, or -1 on failure
* (call lpp_last_error).
*/
long long lpp_rif_create(const char* content_id, long long expiry, const char* out_path);
/*
* Derives the 32-byte image key from `content_id` and `passcode` (NULL/empty passcode uses the
* 32-zero default) and writes it to `out32`. Returns 0 on success, or -1 on failure (call
* lpp_last_error).
*/
int lpp_derive_image_key(const char* content_id, const char* passcode, unsigned char* out32);
/*
* Validates a 32-hex-character entitlement key. Returns 1 when valid, 0 when invalid, or -1 on
* failure (call lpp_last_error).
*/
int lpp_entitlement_key_validate(const char* hex);
/*
* Opens a disc backup from its manifest and fills `out_info` with the content info of the
* reassembled package. Returns 0 on success, or -1 on failure (call lpp_last_error).
*/
int lpp_disc_backup_content_info(const char* manifest_path, lpp_npdrm_content_info* out_info);
/*
* Verifies the chunk CRCs of a disc backup. When non-NULL, `mismatch_chunk` receives the index of
* the first mismatch (or -1 when all match). Returns 1 when all chunks match, 0 on mismatch, or -1
* on failure (call lpp_last_error).
*/
int lpp_disc_backup_verify_chunk_crcs(const char* manifest_path, int* mismatch_chunk);
/*
* Encodes the PNG file at `png_path` to a BC7 DDS texture and writes it to `dds_path`. Returns the
* number of bytes written, or -1 on failure (call lpp_last_error).
*/
long long lpp_encode_png_to_dds(const char* png_path, const char* dds_path);
/*
* Builds a chunk descriptor file for `content_id` and writes it to `out_path`. Returns the number
* of bytes written, or -1 on failure (call lpp_last_error).
*/
long long lpp_build_playgo_chunk_dat(const char* content_id, const char* out_path);
/*
* Creates a default param.json for the given ids (`drm_type` is LPP_PARAM_DRM_*) and writes it to
* `out_path`. Returns the number of bytes written, or -1 on failure (call lpp_last_error).
*/
long long lpp_create_param_json(const char* content_id, const char* title_id, const char* title_name,
int drm_type, const char* out_path);
/*
* Builds a GP5 project referencing `source_folder` and writes it to `out_path`. `volume_type` is one of
* LPP_GP5_VOLUME_* and `passcode` may be NULL/empty for the all-zero default. Returns the number of bytes
* written, or -1 on failure (call lpp_last_error).
*/
long long lpp_create_gp5(const char* source_folder, const char* out_path, int volume_type,
const char* passcode);
#ifdef __cplusplus
}
#endif
#endif /* LIBPROSPEROPKG_H */
-124
View File
@@ -1,124 +0,0 @@
name: Native library (Linux)
# Builds the LibProsperoPkg shared library (.so) with NativeAOT for linux-x64 and
# linux-arm64. The AOT and shared-library settings are injected into the project at build
# time; the committed project is never modified. Each architecture builds on its own runner,
# so no cross-compilation toolchain is required. After the build the workflow verifies the
# exported symbol table and packages the library, header, license text and a checksum manifest
# as an artifact. The workflow also publishes the desktop GUI application for the same
# architecture into a separate GUI folder within the artifact.
on:
workflow_dispatch:
permissions:
contents: read
concurrency:
group: native-linux-${{ github.ref }}
cancel-in-progress: true
jobs:
build:
strategy:
fail-fast: false
matrix:
include:
- rid: linux-x64
runner: ubuntu-latest
- rid: linux-arm64
runner: ubuntu-24.04-arm
runs-on: ${{ matrix.runner }}
env:
PROJECT: src/LibProsperoPkg/LibProsperoPkg.csproj
PROJECT_DIR: src/LibProsperoPkg
NATIVE_DIR: .github/native
LICENSE_FILE: LICENSE
NOTICE_FILE: NOTICE
steps:
- name: Check out
uses: actions/checkout@v4
- name: Set up .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: '10.0.x'
- name: Install build prerequisites
run: |
sudo apt-get update
sudo apt-get install -y clang zlib1g-dev
- name: Inject AOT settings and export surface
shell: pwsh
run: |
$csproj = $env:PROJECT
$text = Get-Content -Raw $csproj
if ($text -notmatch '<PublishAot>') {
$inject = " <PublishAot>true</PublishAot>`n <NativeLib>Shared</NativeLib>`n </PropertyGroup>"
$idx = $text.IndexOf('</PropertyGroup>')
$text = $text.Substring(0, $idx) + $inject + $text.Substring($idx + '</PropertyGroup>'.Length)
Set-Content -NoNewline -Path $csproj -Value $text
}
Copy-Item (Join-Path $env:NATIVE_DIR 'NativeExports.cs') (Join-Path $env:PROJECT_DIR 'NativeExports.cs') -Force
if (-not (Select-String -Path $csproj -Pattern '<PublishAot>true</PublishAot>' -Quiet)) {
Write-Error 'csproj patch did not apply'
exit 1
}
- name: Publish shared library
run: dotnet publish "$PROJECT" -c Release -r ${{ matrix.rid }} --nologo
- name: Stage artifacts
shell: pwsh
run: |
$pub = Join-Path $env:PROJECT_DIR "bin/Release/net10.0/${{ matrix.rid }}/publish"
$mine = Get-ChildItem -Path $pub -Recurse -Include '*LibProsperoPkg.so'
if (-not $mine) {
Write-Error "LibProsperoPkg shared library (.so) was not produced in $pub"
Get-ChildItem -Path $pub -Recurse -Include *.so | ForEach-Object { Write-Host $_.FullName }
exit 1
}
New-Item -ItemType Directory -Force artifacts | Out-Null
$mine | Copy-Item -Destination artifacts
# A lib-prefixed alias so downstream consumers can link with -lprosperopkg.
Copy-Item $mine[0].FullName (Join-Path 'artifacts' 'libprosperopkg.so') -Force
Copy-Item (Join-Path $env:NATIVE_DIR 'libprosperopkg.h') artifacts
Copy-Item $env:LICENSE_FILE artifacts
Copy-Item $env:NOTICE_FILE artifacts
- name: Verify exported symbols
shell: bash
run: |
set -euo pipefail
lib=$(ls artifacts/*LibProsperoPkg.so 2>/dev/null | head -n 1)
if [ -z "$lib" ]; then
echo "LibProsperoPkg shared library (.so) not found in artifacts:"
ls -la artifacts
exit 1
fi
echo "Inspecting $lib"
syms=$(nm -D --defined-only "$lib" | awk '{print $NF}' | sed -e 's/^_//' -e 's/@.*//')
missing=0
for s in $(cat "$NATIVE_DIR/exports.txt"); do
if ! grep -Fxq "$s" <<< "$syms"; then echo "MISSING: $s"; missing=1; fi
done
if [ "$missing" -ne 0 ]; then echo "Expected C exports are missing"; exit 1; fi
echo "All expected exports present"
- name: Checksums
shell: bash
run: |
set -euo pipefail
( cd artifacts && sha256sum \
*LibProsperoPkg.so libprosperopkg.so libprosperopkg.h \
"$(basename "$LICENSE_FILE")" "$(basename "$NOTICE_FILE")" > SHA256SUMS )
cat artifacts/SHA256SUMS
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
name: libprosperopkg-${{ matrix.rid }}
path: artifacts/
if-no-files-found: error
-114
View File
@@ -1,114 +0,0 @@
name: Native library (macOS)
# Builds the LibProsperoPkg shared library (.dylib) with NativeAOT for osx-x64 and
# osx-arm64. The AOT and shared-library settings are injected into the project at build
# time; the committed project is never modified. Each architecture builds on its own runner
# (Intel and Apple Silicon), so no cross-compilation toolchain is required. After the build
# the workflow verifies the exported symbol table and packages the library, header, license
# text and a checksum manifest as an artifact. The workflow also publishes the desktop GUI
# application for the same architecture into a separate GUI folder within the artifact.
on:
workflow_dispatch:
permissions:
contents: read
concurrency:
group: native-macos-${{ github.ref }}
cancel-in-progress: true
jobs:
build:
strategy:
fail-fast: false
matrix:
include:
- rid: osx-arm64
runner: macos-14
runs-on: ${{ matrix.runner }}
env:
PROJECT: src/LibProsperoPkg/LibProsperoPkg.csproj
PROJECT_DIR: src/LibProsperoPkg
NATIVE_DIR: .github/native
LICENSE_FILE: LICENSE
NOTICE_FILE: NOTICE
steps:
- name: Check out
uses: actions/checkout@v4
- name: Set up .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: '10.0.x'
- name: Inject AOT settings and export surface
shell: pwsh
run: |
$csproj = $env:PROJECT
$text = Get-Content -Raw $csproj
if ($text -notmatch '<PublishAot>') {
$inject = " <PublishAot>true</PublishAot>`n <NativeLib>Shared</NativeLib>`n </PropertyGroup>"
$idx = $text.IndexOf('</PropertyGroup>')
$text = $text.Substring(0, $idx) + $inject + $text.Substring($idx + '</PropertyGroup>'.Length)
Set-Content -NoNewline -Path $csproj -Value $text
}
Copy-Item (Join-Path $env:NATIVE_DIR 'NativeExports.cs') (Join-Path $env:PROJECT_DIR 'NativeExports.cs') -Force
if (-not (Select-String -Path $csproj -Pattern '<PublishAot>true</PublishAot>' -Quiet)) {
Write-Error 'csproj patch did not apply'
exit 1
}
- name: Publish shared library
run: dotnet publish "$PROJECT" -c Release -r ${{ matrix.rid }} --nologo
- name: Stage artifacts
shell: pwsh
run: |
$pub = Join-Path $env:PROJECT_DIR "bin/Release/net10.0/${{ matrix.rid }}/publish"
$mine = Get-ChildItem -Path $pub -Recurse -Include '*LibProsperoPkg.dylib'
if (-not $mine) {
Write-Error "LibProsperoPkg shared library (.dylib) was not produced in $pub"
Get-ChildItem -Path $pub -Recurse -Include *.dylib | ForEach-Object { Write-Host $_.FullName }
exit 1
}
New-Item -ItemType Directory -Force artifacts | Out-Null
$mine | Copy-Item -Destination artifacts
Copy-Item (Join-Path $env:NATIVE_DIR 'libprosperopkg.h') artifacts
Copy-Item $env:LICENSE_FILE artifacts
Copy-Item $env:NOTICE_FILE artifacts
- name: Verify exported symbols
shell: bash
run: |
lib=$(ls artifacts/*LibProsperoPkg.dylib 2>/dev/null | head -n 1)
if [ -z "$lib" ]; then
echo "LibProsperoPkg shared library (.dylib) not found in artifacts:"
ls -la artifacts
exit 1
fi
echo "Inspecting $lib"
syms=$(nm -gU "$lib" | awk '{print $NF}' | sed -e 's/^_//' -e 's/@.*//')
missing=0
for s in $(cat "$NATIVE_DIR/exports.txt"); do
if ! grep -Fxq "$s" <<< "$syms"; then echo "MISSING: $s"; missing=1; fi
done
if [ "$missing" -ne 0 ]; then echo "Expected C exports are missing"; exit 1; fi
echo "All expected exports present"
- name: Checksums
shell: bash
run: |
set -euo pipefail
( cd artifacts && shasum -a 256 \
*LibProsperoPkg.dylib libprosperopkg.h \
"$(basename "$LICENSE_FILE")" "$(basename "$NOTICE_FILE")" > SHA256SUMS )
cat artifacts/SHA256SUMS
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
name: libprosperopkg-${{ matrix.rid }}
path: artifacts/
if-no-files-found: error
-5
View File
@@ -1,5 +0,0 @@
bin/
obj/
.vs/
*.user
*.suo
-674
View File
@@ -1,674 +0,0 @@
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU General Public License is a free, copyleft license for
software and other kinds of works.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
the GNU General Public License is intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users. We, the Free Software Foundation, use the
GNU General Public License for most of our software; it applies also to
any other work released this way by its authors. You can apply it to
your programs, too.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
To protect your rights, we need to prevent others from denying you
these rights or asking you to surrender the rights. Therefore, you have
certain responsibilities if you distribute copies of the software, or if
you modify it: responsibilities to respect the freedom of others.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must pass on to the recipients the same
freedoms that you received. You must make sure that they, too, receive
or can get the source code. And you must show them these terms so they
know their rights.
Developers that use the GNU GPL protect your rights with two steps:
(1) assert copyright on the software, and (2) offer you this License
giving you legal permission to copy, distribute and/or modify it.
For the developers' and authors' protection, the GPL clearly explains
that there is no warranty for this free software. For both users' and
authors' sake, the GPL requires that modified versions be marked as
changed, so that their problems will not be attributed erroneously to
authors of previous versions.
Some devices are designed to deny users access to install or run
modified versions of the software inside them, although the manufacturer
can do so. This is fundamentally incompatible with the aim of
protecting users' freedom to change the software. The systematic
pattern of such abuse occurs in the area of products for individuals to
use, which is precisely where it is most unacceptable. Therefore, we
have designed this version of the GPL to prohibit the practice for those
products. If such problems arise substantially in other domains, we
stand ready to extend this provision to those domains in future versions
of the GPL, as needed to protect the freedom of users.
Finally, every program is threatened constantly by software patents.
States should not allow patents to restrict development and use of
software on general-purpose computers, but in those that do, we wish to
avoid the special danger that patents applied to a free program could
make it effectively proprietary. To prevent this, the GPL assures that
patents cannot be used to render the program non-free.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Use with the GNU Affero General Public License.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU Affero General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the special requirements of the GNU Affero General Public License,
section 13, concerning interaction through a network will apply to the
combination as such.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU General Public License from time to time. Such new versions will
be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If the program does terminal interaction, make it output a short
notice like this when it starts in an interactive mode:
<program> Copyright (C) <year> <name of author>
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, your program's commands
might be different; for a GUI interface, you would use an "about box".
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU GPL, see
<https://www.gnu.org/licenses/>.
The GNU General Public License does not permit incorporating your program
into proprietary programs. If your program is a subroutine library, you
may consider it more useful to permit linking proprietary applications with
the library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License. But first, please read
<https://www.gnu.org/licenses/why-not-lgpl.html>.
-48
View File
@@ -1,48 +0,0 @@
LibProsperoPkg
Copyright (C) 2026 SvenGDK
This program is free software: you can redistribute it and/or modify it under the
terms of the GNU General Public License as published by the Free Software Foundation,
either version 3 of the License, or (at your option) any later version.
This program is distributed in the hope that it will be useful, but WITHOUT ANY
WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
PARTICULAR PURPOSE. See the GNU General Public License for more details.
You should have received a copy of the GNU General Public License along with this
program. If not, see <https://www.gnu.org/licenses/>.
------------------------------------------------------------------------
Derived code
------------------------------------------------------------------------
The following components are derived from LibOrbisPkg by maxton, licensed
under the GNU General Public License v3.0:
<https://github.com/maxton/LibOrbisPkg>.
* General-purpose utilities in src/LibProsperoPkg/Util/: Crypto.cs,
Keys.cs, MemoryMapped.cs, OffsetStream.cs, StreamExtensions.cs,
SubStream.cs, WriterBase.cs and XtsBlockTransform.cs.
* The PFS filesystem model, builder and reader in src/LibProsperoPkg/PFS/:
ProsperoPfsStructs.cs, ProsperoPfsBuilder.cs, ProsperoPfsReader.cs,
ProsperoPfsProperties.cs, ProsperoFlatPathTable.cs, ProsperoFsTree.cs
and ProsperoXtsDecryptReader.cs.
* The container model and writer in src/LibProsperoPkg/PKG/:
ProsperoCnt.cs, ProsperoCntEntry.cs, ProsperoCntEnums.cs and
ProsperoCntWriter.cs.
These files were adapted for the PS5 package and PFSv3 filesystem formats;
their public types carry the Prospero prefix in this library.
The Kraken (newLZ) decode and bitstream logic in
src/LibProsperoPkg/PFS/Compression/Oodle/ (KrakenDecoder.cs,
KrakenBitWriter.cs and the array-entropy helpers) is an index-based
translation of the decompression reference implementation "ooz" by
Powzix (kraken.cpp), licensed under the GNU General Public License v3.0:
<https://github.com/powzix/ooz>.
GPL-3.0 section 13 expressly permits conveying a combined work that
links or combines GPL-covered code, which this library relies on for the
combination above.
-219
View File
@@ -1,219 +0,0 @@
# LibProsperoPkg
A .NET class library for building and inspecting **PS5** packages. It turns a prepared application
folder into a complete, installable debug package end to end, in-process, without an external
command-line tool.
The library is written in C# 14 and targets .NET 10. It is self-contained and exposes a small,
documented public API any .NET application can consume.
---
## Highlights
- **End-to-end pipeline.** Folder -> inner PFS layout -> AES-XTS encryption -> outer PFS ->
`\x7FCNT` metadata container -> finalized `\x7FFIH` debug image, in one process.
- **Self-contained.** The GP5 project model, the PFS image builder, AES-XTS encryption,
RSA-3072 metadata signing and the finalized debug image are produced by the library itself.
- **Reader and writer.** Parse and inspect existing PS5 packages (`\x7FCNT` / `\x7FFIH`) and build
new ones.
- **Extraction.** Extract the application filesystem from a finalized debug/keyed image with
`ProsperoPackageExtractor`, or from any image whose 32-byte image key is supplied.
- **Disc-backup packages.** Open a split disc backup (the ordered `app_0` / `app_sc` pieces)
through its `app.json` manifest, reassemble it on the fly, and verify the package digest and
64 KiB chunk CRCs.
- **License (`rif`).** Read, write and create the per-title license record — including multi-title
files — through `LibProsperoPkg.License`.
- **Acceptance checks.** Validate a package against the structural gate the console mount path
enforces (`ProsperoPkgValidator`).
- **Fake-signed packages (fPKG).** Optionally fake-sign raw ELF modules (`eboot.bin`, `*.elf`,
`*.prx`, `*.sprx`) to fake-self before packing, producing an installable fake package. The
conversion is non-destructive: source modules are restored after the build.
- **Backup conversion.** `ProsperoBackupConverter` repackages a decrypted application backup into a
debug fPKG, substituting each executable with its decrypted ELF and fake-signing it, so the image
mounts from the content id and passcode with no rif.
- **Launch-readiness inspection.** `ProsperoLaunchReadiness` inspects an assembled application root
and reports whether it meets the debug-launch conditions: every executable module is a plaintext
module the loader accepts, `eboot.bin` is present, and the metadata is a `param.json` rather than a
PS4 `param.sfo`.
- **Homebrew packaging.** `ProsperoHomebrewPackager` turns a compiled homebrew folder into an
installable debug fPKG: it assembles a clean source tree, builds a license-free debug image whose
mount key derives from the content id and passcode, and checks launch-readiness. A worked sample
lives in `src/HomebrewTest`.
- **Application type.** `ProsperoApplicationType` selects the generated `param.json`
`applicationDrmType` (`free` / `standard` / `freemium`), covering paid, upgradable, demo and
freemium apps.
- **Texture generation.** The `sce_sys` icon/picture DDS (BC7) encoder decodes the source PNG and
block-compresses it to a DX10 BC7 texture in-process.
---
## Requirements
| | |
|---|---|
| Toolchain | .NET 10 SDK or newer |
| Language | C# 14 |
---
## Building
```bash
cd LibProsperoPKG/src/LibProsperoPkg
dotnet build -c Release
```
This produces `LibProsperoPkg.dll`.
---
## Quick start
Add the project (or the built `LibProsperoPkg.dll`) to your build and create a package from a
prepared application folder:
```csharp
using LibProsperoPkg;
var options = new ProsperoBuildOptions
{
Mode = ProsperoPackageMode.Application,
OutputFormat = ProsperoOutputFormat.DebugImage, // installable on a debug-mode console
SourceFolder = @"/path/to/prepared/app", // must contain sce_sys/
OutputFolder = @"/path/to/output",
ContentId = "UP9000-PPSA00000_00-PROSPERO00000000",
TitleId = "PPSA00000",
Title = "My PS5 Application",
Version = "01.00",
};
ProsperoBuildResult result = ProsperoPackageBuilder.Build(options, Console.WriteLine);
Console.WriteLine($"Package written to: {result.OutputPath}");
foreach (var warning in result.Warnings)
Console.WriteLine($"Warning: {warning}");
```
### Fake-signing modules (fPKG)
Set `FakeSignSelfModules` to convert raw ELF modules in the source folder to fake-self before
packing. Use `ApplicationType` to control the generated `param.json` `applicationDrmType`:
```csharp
var options = new ProsperoBuildOptions
{
Mode = ProsperoPackageMode.Application,
OutputFormat = ProsperoOutputFormat.DebugImage,
SourceFolder = @"/path/to/prepared/app",
OutputFolder = @"/path/to/output",
ContentId = "UP9000-PPSA00000_00-PROSPERO00000000",
TitleId = "PPSA00000",
Title = "My PS5 Application",
Version = "01.00",
ApplicationType = ProsperoApplicationType.FreemiumApp,
FakeSignSelfModules = true,
};
```
Files that are already SELF are left untouched, and the original module bytes are restored once
packing completes.
To build a DRM-free, license-free package in one step, set `LicenseFree`. It fake-signs modules and
derives the debug mount key from the content id and passcode, so no rif is written.
`ProsperoBuildResult.DebugLicense` reports the grant and `ProsperoBuildResult.LicenseFree` echoes the
option. The output is a debug finalized image for a debug-enabled console.
To repackage a decrypted application backup into a debug fPKG, call `ProsperoBackupConverter.Convert`.
It substitutes each signed executable with its decrypted raw ELF, fake-signs the modules, and builds
a debug image that mounts from the content id and passcode. The backup stays untouched; the converter
works from a staging copy.
```csharp
var result = ProsperoBackupConverter.Convert(new ProsperoBackupConversionOptions
{
BackupFolder = @"/path/to/backup/PPSA00000-app0",
OutputFolder = @"/path/to/output",
});
```
### Packaging a homebrew module
To package a compiled homebrew folder (a raw ELF `eboot.bin` plus an optional `sce_sys/` tree) into
an installable debug fPKG, call `ProsperoHomebrewPackager.Package`. It fake-signs the module and
derives the mount key from the content id and passcode, then checks launch-readiness. A worked sample
lives in `src/HomebrewTest`.
```csharp
var result = ProsperoHomebrewPackager.Package(new ProsperoHomebrewPackageOptions
{
HomebrewFolder = @"/path/to/HomebrewTest",
OutputFolder = @"/path/to/output",
ContentId = "UP9000-PPSA99099_00-PROSPERO00000000",
Title = "LibProsperoPKG",
});
Console.WriteLine($"Launch ready: {result.LaunchReadiness.IsLaunchReady}");
```
Every build path stores the inner image as the data-first image.
### Inspecting an existing package
```csharp
using LibProsperoPkg.PKG;
ProsperoPkg pkg = ProsperoPkgReader.Read(@"/path/to/package.pkg");
Console.WriteLine($"Type: {pkg.Type}");
Console.WriteLine($"Content ID: {pkg.Header?.ContentId}");
Console.WriteLine($"Entries: {pkg.Entries.Count}");
```
---
## Public surface, at a glance
| Namespace | Key types |
|---|---|
| `LibProsperoPkg` | `ProsperoPackageBuilder`, `ProsperoBackupConverter`, `ProsperoHomebrewPackager`, `ProsperoBuildOptions`, `ProsperoBuildResult`, `ProsperoBackupConversionOptions`, `ProsperoBackupConversionResult`, `ProsperoHomebrewPackageOptions`, `ProsperoHomebrewPackageResult`, `ProsperoPackageMode`, `ProsperoOutputFormat`, `InnerImageForm`, `ProsperoApplicationType` |
| `LibProsperoPkg.PKG` | `ProsperoPkgBuilder`, `ProsperoPkgReader`, `ProsperoCntWriter`, `ProsperoFihBuilder`, `ProsperoPkgSigner`, `ProsperoDdsEncoder`, `ProsperoPackageExtractor`, `ProsperoExtractionKey`, `ProsperoPkgValidator`, `ProsperoPkg`, `ProsperoPkgHeader` |
| `LibProsperoPkg.PFS` | `ProsperoPfsLayout`, `ProsperoPfsImage`, `ProsperoPfsc`, `ProsperoPfsExtractor` |
| `LibProsperoPkg.License` | `ProsperoRif`, `ProsperoRifSet`, `ProsperoEntitlementKey` |
| `LibProsperoPkg.NpDrm` | `ProsperoNpDrmContentInfo` |
| `LibProsperoPkg.DiscBackup` | `ProsperoDiscBackup`, `ProsperoDiscBackupManifest`, `ProsperoPlaygoChunkCrc` |
| `LibProsperoPkg.GP5` | `Gp5Creator`, `Gp5Project` and its element model |
| `LibProsperoPkg.Keys` | `ProsperoKeys` |
| `LibProsperoPkg.PlayGo` | `ProsperoPlayGo` |
| `LibProsperoPkg.Content` | `ProsperoUcp`, `ProsperoFself`, `ProsperoSelfAuthInfo`, `ProsperoElfHeader`, `ProsperoLaunchReadiness` |
See **[docs/](docs/)** for the full feature status and the PS5 package technical write-up.
---
## Documentation
- **[docs/README.md](docs/README.md)** - documentation index.
- **[docs/getting-started.md](docs/getting-started.md)** - install, build and first package.
- **[docs/api-overview.md](docs/api-overview.md)** - public API by namespace.
- **[docs/implementation-status.md](docs/implementation-status.md)** - what is implemented and what
is still missing.
- **[docs/ps5-pkg-format.md](docs/ps5-pkg-format.md)** - technical write-up of the PS5 package
format and the creation process.
---
## Coverage
LibProsperoPkg produces a complete debug package end to end: the finalized `\x7FFIH` image, the
`\x7FCNT` metadata container, the inner-image assembly, the layout/metric metadata and the trailing
install-metadata archive, all of which round-trip through the reader. A console running in **debug
mode**, which relaxes finalized-image verification, is the intended target. The retail (submitted)
image path and the per-console license and key material are gated on console-side material the
library does not have. On-console acceptance depends on the console's mode and firmware. See
[docs/implementation-status.md](docs/implementation-status.md) for the precise breakdown.
---
## License
LibProsperoPkg is licensed under the GNU General Public License v3.0 or later
(GPL-3.0-or-later). See [LICENSE](LICENSE). Third-party attributions are listed in [NOTICE](NOTICE).
-43
View File
@@ -1,43 +0,0 @@
# LibProsperoPkg Documentation
This folder contains the technical documentation for **LibProsperoPkg**, a
.NET 10 / C# 14 library for building and inspecting PS5 packages.
## Contents
| Document | Description |
|---|---|
| [getting-started.md](getting-started.md) | Install the SDK, build the library, and produce your first package. |
| [api-overview.md](api-overview.md) | The public API, organized by namespace, with usage notes. |
| [implementation-status.md](implementation-status.md) | A precise breakdown of what is implemented and what is still missing. |
| [ps5-pkg-format.md](ps5-pkg-format.md) | A technical write-up of the PS5 package format and the end-to-end creation process. |
## At a glance
LibProsperoPkg turns a prepared PS5 application folder into a complete, installable package
in-process. The pipeline is:
```
prepared folder (sce_sys/ + eboot + data)
│
▼ optional fake-sign of raw ELF modules (FakeSignSelfModules / LicenseFree)
│
▼ inner PFS layout (ProsperoPfsLayout)
plaintext inner PFS image
│
▼ data-first inner image (raw-concatenated per-file payloads + naps_pkg_layout.dat)
│
▼ outer PFS + metadata (ProsperoPkgBuilder)
\x7FCNT metadata container
│
▼ finalize + install-metadata archive (ProsperoFihBuilder)
\x7FFIH debug image ──► installable on a debug-mode console
```
The library also has a read side. `ProsperoPackageExtractor` inspects and unpacks debug packages,
`ProsperoDiscBackup` reassembles split `app_0` / `app_sc` backups from an `app.json` manifest, and
the `License` and `NpDrm` namespaces read `rif` licences and package content-info.
`ProsperoBackupConverter` closes the loop from the read side back to the build side: it turns a
decrypted backup into a debug fPKG by substituting each executable with its decrypted ELF and
fake-signing it. See [api-overview.md](api-overview.md) for the full surface and
[ps5-pkg-format.md](ps5-pkg-format.md) §9–§11 for the RIF, disc-backup and extraction formats.
-382
View File
@@ -1,382 +0,0 @@
# API Overview
This document summarizes the public surface of LibProsperoPkg, grouped by namespace. Every
public type and member carries XML documentation, so IntelliSense and the generated
documentation file give the authoritative detail; this page is the orientation map.
---
## `LibProsperoPkg` — high-level builder
### `ProsperoPackageBuilder` (static)
The primary entry point.
| Member | Purpose |
|---|---|
| `Build(ProsperoBuildOptions, Action<string>?)` | Build a package from a prepared folder. Returns `ProsperoBuildResult`. |
| `BuildInnerPfsLayout(...)` | Lay a folder out into a plaintext inner-PFS image. Returns `ProsperoPfsLayoutResult`. |
| `BuildInnerImage(...)` | Run the full inner-image pipeline (plaintext / encrypted / zlib-compressed / Kraken-compressed). Returns the written image path. |
| `EncryptPfsImage(...)` | AES-XTS-encrypt a prepared plaintext inner-PFS image in place. Returns `ProsperoPfsImageResult`. |
| `CompareContainers(...)` | Compare two packages field by field. Returns the list of differences. |
| `ComposeContentId(publisher, titleId, label)` | Build a well-formed 36-char content id. |
| `IsValidContentId` / `IsValidTitleId` | Validate identifiers. |
| `VolumeTypeForMode(mode)` | Map a `ProsperoPackageMode` to the GP5 `Gp5VolumeType`. |
| `ProsperoVolumeTypeForMode(mode)` | Map a `ProsperoPackageMode` to the container `ProsperoVolumeType`. |
| `IsDlcMode(mode)` | Report whether a mode is additional-content. |
| `KeysAvailable` | Reports whether the built-in signing key material is available. |
### Supporting types
- **`ProsperoBuildOptions`** — the build description: `Mode`, `OutputFormat`, `SourceFolder`,
`OutputFolder`, `ContentId`, `Passcode`, `Title`, `TitleId`, `Version`,
`GenerateParamJsonIfMissing`, `ApplicationType`, `ApplicationDrmType`, `ContentBadgeType`,
`FakeSignSelfModules`, `FselfOptions`, `LicenseFree`.
- **`ProsperoApplicationType`** — the application type: `NotSpecified`,
`PaidStandaloneFullApp`, `UpgradableApp`, `DemoApp`, `FreemiumApp`. `ProsperoApplicationTypes`
maps it to the generated `param.json` `applicationDrmType` (`free` / `standard` / `freemium`)
and provides `DisplayName` and `Parse`.
- **`ProsperoBuildResult`** — `OutputPath`, a list of non-fatal `Warnings`, `LicenseFree` (echoes
the option), and `DebugLicense` (the constructed `ProsperoDebugLicense` when `LicenseFree` is set,
otherwise null).
- **`ProsperoPackageMode`** — `Application`, `Homebrew`, `AdditionalContentData`,
`AdditionalContentNoData`.
- **`ProsperoOutputFormat`** — `MetadataContainer` (`\x7FCNT` only, not installable) or
`DebugImage` (`\x7FFIH`, the default, installable on a debug-mode console).
- **`InnerImageForm`** — `Plaintext`, `Encrypted`, `Compressed` (zlib PFSC),
`KrakenCompressed` (PFSv3 Kraken). Selects how `BuildInnerImage` renders a laid-out inner image.
The package build path always stores the inner `pfs_image.dat` as the data-first image: a raw
concatenation of per-file payloads (raw or headerless Kraken) with the geometry described by a
generated `naps_pkg_layout.dat`.
### `ProsperoBackupConverter` (static)
Repackages a decrypted application backup into a debug fPKG that mounts and launches without a rif
or a console secret.
| Member | Purpose |
|---|---|
| `Convert(ProsperoBackupConversionOptions, Action<string>?)` | Assemble a staging tree from the backup, substitute each signed executable with its decrypted counterpart, fake-sign, and build a debug image. Returns `ProsperoBackupConversionResult`. |
- **`ProsperoBackupConversionOptions`** — `BackupFolder`, `OutputFolder`, `DecryptedSubfolder`
(default `decrypted`), `ContentId`, `Passcode`, `Version`, `StagingFolder`, `KeepStaging`,
`UseEmbeddedRightSprx`, `FselfOptions`. Content id and version fall back to the backup's
`param.json` when not supplied.
- **`ProsperoBackupConversionResult`** — `OutputPath`, `DebugLicense`, `SubstitutedModules`,
`PlaintextModules`, `UnresolvedModules`, `Warnings`, `LaunchReadiness`, `StagingFolder`.
`LaunchReadiness` is a `ProsperoLaunchReadinessReport` (below) over the assembled tree.
### `ProsperoHomebrewPackager` (static)
Packages a compiled homebrew module into an installable debug fPKG. It assembles a clean source tree
from the module folder (the module lands as `eboot.bin`, the `sce_sys/` tree is copied) and builds a
finalized debug image with the license-free path enabled, so the module is fake-signed and the mount
key derives from the content id and passcode.
| Member | Purpose |
|---|---|
| `Package(ProsperoHomebrewPackageOptions, Action<string>?)` | Assemble the source tree and build a debug image. Returns `ProsperoHomebrewPackageResult`. |
- **`ProsperoHomebrewPackageOptions`** — `HomebrewFolder`, `OutputFolder` (both required), `ModuleName`
(default `eboot.bin`), `ContentId`, `Passcode`, `Title`, `Version`, `StagingFolder`, `KeepStaging`,
`FselfOptions`. Content id and version fall back to the homebrew's `param.json` when not supplied.
- **`ProsperoHomebrewPackageResult`** — `OutputPath`, `DebugLicense` (`RequiresRif` always false),
`ModulePath` (`eboot.bin`), `LaunchReadiness`, `Warnings`, `StagingFolder`.
### `ProsperoLaunchReadiness` (static, `LibProsperoPkg.Content`)
Inspects an assembled application root and reports whether it meets the debug-launch conditions:
every executable module is a plaintext module the loader accepts, `eboot.bin` is present, and the
metadata is a `param.json` rather than the older `param.sfo`. It inspects only — it never signs, mounts,
or launches.
| Member | Purpose |
|---|---|
| `InspectModule(string path, ReadOnlySpan<byte> data)` | Classify one module. Returns `ModuleLaunchReadiness`. |
| `InspectAppRoot(string root)` | Scan `eboot.bin` + `*.prx` / `*.sprx`, check `param.json` / `param.sfo`, aggregate issues. Returns `ProsperoLaunchReadinessReport`. |
- **`ModuleAuthorityKind`** — `NotExecutable`, `RawElf`, `FakeAuthoritySelf`, `GenuineAuthoritySelf`,
`UnknownAuthoritySelf`, `SignedEncrypted`.
- **`ModuleLaunchReadiness`** — `Path`, `Kind`, `AuthorityId`, `WillRunOnDebugConsole`, `Note`.
- **`ProsperoLaunchReadinessReport`** — `AppRoot`, `Modules`, `HasEboot`, `HasParamJson`,
`HasParamSfo`, `RequiresDebugConsole`, `Issues`, `IsLaunchReady`.
---
## `LibProsperoPkg.PKG` — container, signing, finalization
| Type | Purpose |
|---|---|
| `ProsperoPkgBuilder` | Build the outer PFS + `\x7FCNT` metadata container. |
| `ProsperoPkgReader` | `DetectType(path/stream)` and `Read(path/stream)` for existing packages. |
| `ProsperoCntWriter` | Low-level `\x7FCNT` container writer over the `ProsperoCnt` model (`ProsperoCntEntry`, `ProsperoCntHeader`, `ProsperoCntEntryNames`, entry-id enums). |
| `ProsperoFihBuilder` | Wrap a `\x7FCNT` into a finalized `\x7FFIH` image. `BuildFromCnt(cntPath, fihOutputPath, ProsperoFihVariant)`. |
| `ProsperoPkgSigner` | RSA-3072 metadata signing and EKPFS derivation. |
| `ProsperoNapsLayout` | PS5 `naps_pkg_layout.dat` decoder and serializer for the `nwonly` streaming layout. `Parse`/`DecodeHeader` (returning a `NapsLayoutDocument` over the `Naps*` record types), `BuildLayout` (decoder and serializer are mutually consistent, including zero padding), the per-section `Encode*`/`Decode*` helpers, `SectionMap`. Record values are data-dependent on the inner-image compression run. The data-first build path generates a valid layout for its assembled inner image through `ProsperoNwonlyNapsGenerator`. |
| `ProsperoImageDigests` | PS5 finalized-image / CNT digest algorithms (single primitive: **SHA3-256**). Computes digests for all documented formulas. `ComputeSblockDigest`/`ComputeGameDigest` (`SHA3-256(plaintext outer superblock, 0x10000)` = FIH `0x30/0x70/0xD0`), `ComputeFixedInfoDigest` (`SHA3-256(FIH block)`), `ComputeBodyDigest` (`SHA3-256(CNT body)`), `ComputeEntryDigest` + `BuildEntryDigestTable` (CNT entry `0x0001`; self-slot zeroed), `ComputePackageDigest` (`SHA3-256(CNT[0:0xFE0])` = CNT `+0xFE0` = `<package-digest>`), `ComputeCntHeaderRollupDigest` (`SHA3-256(CNT[off:off+size])` = CNT `+0x100`), `ComputeContentDigest` / `ComputeHeaderDigest` / `ComputeConcatDigest` / `ForceFihRelativeImageOffset` (the GeneralDigests block — content/header/system/playgo/target, wired via `ProsperoPkgBuilder.ComputeGeneralDigests`), `LocateSuperblock`/`ComputeSblockDigestFromImage` (scan `version 2` + magic `0x0b2a3301`), `Sha3_256`. The FIH `0xB0` nested-image-content slot is computed from the uncompressed inner PFS image during finalization. |
| `ProsperoDdsEncoder` | Re-encode `sce_sys` icon/picture images to BC7 DDS. |
| `ProsperoFihVariant` | Finalized-image variant for `ProsperoFihBuilder`: `Debug`, `Official`. |
| `ProsperoNapsMeta` | Build the `naps_meta_*` install-metadata descriptors. `BuildMeta300` / `BuildMeta300FromInnerImageSize` produce the 48-byte `naps_meta_300/301/302/308` descriptor from the inner-image geometry; `BuildMeta18` builds the AES-128-XTS TLV metric blob (`naps_meta_18.dat`) over the finalized image and its content-file table. |
| `ProsperoSystemFiles` | Validate backend-signed `sce_sys` files before packing. `Validate`, `ValidateNpbind`, `ValidateNptitle`, `ValidateLicenseDat`, `ValidateLicenseInfo`. |
| `ProsperoSiArchive` | Build the trailing `sce_suppl` install archive: `pfsimage.xml`, the `naps_meta_*` descriptors, and the copied PlayGo files. `BuildDebugSiSegment`, `BuildPfsImageXml`, `WriteZip`, with `ProsperoSiMember` and `ProsperoPfsImageXmlOptions`. |
| `ProsperoChunkInfoModel` | Chunk/scenario model threaded from the GP5 project into the install archive. |
### Read model
- **`ProsperoPkg`** — `Type` (`ProsperoPkgType`), `Header` (`ProsperoPkgHeader?`), `Entries`
(`IReadOnlyList<ProsperoPkgEntry>`), `Fih` (`ProsperoFihHeader?`).
- **`ProsperoPkgHeader`** — `Magic`, `Flags`, `EntryCount`, `EntryTableOffset`, `BodyOffset`,
`BodySize`, `ContentId`, `DrmType`, `ContentType`.
- **`ProsperoPkgEntry`** — `Id` (`ProsperoEntryId`), `DataOffset`, `DataSize`, `Name`, and the
raw header fields.
- **`ProsperoFihHeader`** — `SignedByte` (0x00 debug / 0x80 retail), `PfsImageOffset`,
`PfsImageSize`, `EmbeddedCntOffset`.
### Build properties
- **`ProsperoPkgBuildProperties`** and **`ProsperoVolumeType`** drive the low-level builder.
- **`ProsperoPkgLayout`** and **`ProsperoEntryId`** describe the container layout and entry ids.
### Reading and extracting existing packages
| Type | Purpose |
|---|---|
| `ProsperoPackageExtractor` (static) | Extract the application filesystem from a finalized image end to end (finalized image → outer PFS → `pfs_image.dat` PFSC → inner PFS → files). `Inspect(path)` reports type, retail flag, outer-PFS offset/size, whether the outer PFS is encrypted, and whether a supplied key is required — without a key. `ListFiles(path, key)` enumerates the inner filesystem without writing. `Extract(...)` (two overloads) writes the files and returns a `ProsperoPackageManifest`. Supporting records: `ProsperoExtractionOptions`, `ProsperoPackageExtractionInfo`, `ProsperoPackageManifest`. |
| `ProsperoExtractionKey` (sealed) | Key material for the outer PFS. `FromPasscode(passcode)` / `FromPasscode(contentId, passcode)` derive the outer EKPFS from public inputs (SHA-256 and SHA3-256 candidates; the extractor auto-selects whichever opens the image), `FromEkpfs(bytes)` takes a supplied 32-byte image key, `None` attempts a plaintext outer PFS. `ResolveEkpfsCandidates`, `Kind` (`ProsperoExtractionKeyKind`), `ContentId`, `Passcode`, `Ekpfs`. |
| `ProsperoPkgValidator` (static) | Check a package against the structural acceptance gate the mount path enforces. `Validate(path, expectedContentId=null)` / `Validate(ProsperoPkg, expectedContentId=null)` return a `ProsperoAcceptanceReport` of named `Pass` / `Warning` / `Fail` checks (`ProsperoAcceptanceCheck`, `ProsperoCheckStatus`) with `Accepted` and `HasWarnings` roll-ups. |
A finalized retail image (signed byte `0x80`) encrypts the whole outer PFS including its
superblock; its image key arrives through the console entitlement path and is not derivable from
public inputs. `Inspect` reports this (`RequiresSuppliedKey = true`) and `Extract` refuses with a
clear message rather than returning a fabricated result.
---
## `LibProsperoPkg.PFS` — filesystem image
| Type | Purpose |
|---|---|
| `ProsperoPfsLayout` | Build a plaintext inner-PFS image from a folder. `BuildFromFolder`, `VerifyRoundTrip`. |
| `ProsperoPfsImage` | AES-XTS encrypt/decrypt a PFS image. `EncryptInPlace`, `VerifyRoundTrip`. |
| `ProsperoOuterPfsImage` | AES-XTS encrypt/decrypt the PS5 nwonly **outer** finalized-image PFS (whole 0x10000 block = one XTS unit; sector = block index, or `0x800000000000 | index` for signed blocks; superblock block left plaintext). `Transform` (block-index or `ProsperoOuterBlockKind[]` overload), `EncryptInPlace`/`DecryptInPlace` (key- or content-id/passcode-driven), `MetadataBlockIndex`. Decrypt and re-encrypt round-trips. |
| `ProsperoOuterPfsSignature` | PS5 nwonly outer-PFS signing primitives. `ComputeBlockHash` (plain SHA3-256 per-block/dinode hash), `ComputeSuperblockIcv`/`WriteSuperblockIcv` (`SHA3-256(superblock[0:0x5a0])` with the `icv` field zeroed), `BlockSector(index, signed)` (the bit-47 signed-block sector flag). |
| `ProsperoOuterPfsBuilder` | PS5 nwonly outer-PFS **structure generator**: assembles the data-first 11-block plaintext outer image from its outer files (`pfs_image.dat`, `naps_pkg_layout.dat`) — inode table with per-block SHA3 hashes, super-root/uroot dirents, the `\x7fFLT` inode_flat_path_table (custom reduced-Keccak path hash), and the signed superblock (+`icv`). `BuildPlaintext`, `Encrypt`, `BuildEncrypted`. Types: `ProsperoOuterFile`, `ProsperoOuterPfsBuildParameters`, `ProsperoOuterPfsBuildResult`. |
| `ProsperoPfsKeys` | PFS-image key derivation using SHA3-256. `DeriveEkpfs(contentId, passcode)`, `DeriveImageEncryptionKeys(ekpfs, seed)` → `(tweakKey, dataKey)`, overload `DeriveImageEncryptionKeys(contentId, passcode, seed)` → `(tweakKey, dataKey)`, `DeriveImageSignKey(ekpfs, seed)`. |
| `ProsperoPfsc` | High-level PFSC block compression. `PackFile`, `Unpack`, `IsPfsc`. |
| `ProsperoPfscEncoder` | Lower-level PFSC container encoder. `Encode` (buffer or stream), `HeaderSize`, `ShouldSkipExecutableCompression`, with `ProsperoPfscEncoderOptions` / `ProsperoPfscEncodeStats`. |
| `ProsperoPfscReader` | Random-access reader over a PFSC container. `Read`, `ReadSector`, `SectorSize`, `DataLength`. |
| `ProsperoPfsExtractor` (static) | Write every file of an opened (and, if encrypted, decrypted) `ProsperoPfsReader` to a directory, PFSC-decompressing per file, confined to the output directory. `Extract(reader, outputDirectory, logger=null)`, `ListEntries(reader)`; owns `ProsperoExtractedEntry` and `ProsperoExtractionException`. This is the single-image half that `ProsperoPackageExtractor` composes twice around the middle PFSC decode. |
Each high-level entry carries an options/result record pair (`ProsperoPfsLayoutOptions`/`Result`,
`ProsperoPfsImageOptions`/`Result`, `ProsperoPfscOptions`/`Result`).
The namespace also exposes the low-level filesystem model that the builder and reader operate on:
`ProsperoPfsBuilder`, `ProsperoPfsReader`, `ProsperoPfsHeader`, `ProsperoInode`, the on-disk dinode
records (`ProsperoDinodeD32`, `ProsperoDinodeS32`, `ProsperoDinodeS64`), `ProsperoFlatPathTable`,
`ProsperoPfsDirent`, the filesystem-tree nodes (`ProsperoFsNode`, `ProsperoFsDir`, `ProsperoFsFile`),
`ProsperoXtsDecryptReader`, `ProsperoPfscWriter` (low-level PFSC header writer), and the supporting
enums (`ProsperoDirentType`, `ProsperoInodeFlags`,
`ProsperoInodeMode`, `ProsperoPfsMode`, `ProsperoOuterBlockKind`).
### `LibProsperoPkg.PFS.Compression` — PS5 PFSv3 Kraken codec
The PS5 compression-file (`PFSC` v3) codec used by the `nwonly` path.
| Type | Purpose |
|---|---|
| `ProsperoCompressedPfsImage` | Public façade for the inner-image use of the codec — packs/unpacks a whole PFS image as a self-describing `PFSC` v3 container. `Pack`/`PackStored`/`PackFile`, `Unpack`/`UnpackFile`, detection helpers, `ValidateRoundTrip`; returns `ProsperoCompressedPfsImageResult` (raw/encoded sizes, block + stored counts, gain %). Used to compress the inner metadata block and by the standalone PFSC pack/unpack tool. |
| `ProsperoCompressedPfsFileWriter` | Produce a PFSv3 `PFSC` container. `WriteCompressed(payload, level, blockSize, useHuffmanArrays=true)` (Kraken with default-on Huffman entropy arrays, per-block stored fallback) / `WriteStored(payload)`. |
| `ProsperoCompressedPfsFile` | Parse a PFSv3 `PFSC` container. `Parse`, detection helpers, `VerifyFileDigest`, and `Decompress()` for a full decode. |
| `ProsperoPfsDigest` | SHA3-256 helpers for the per-block hashes and the `@0x28` file digest. |
| `ProsperoPfsCompressionConstants` | The Kraken window-bits constant for the codec. |
| `ProsperoCompressionAlgorithm` / `ProsperoPfsCompressionFormat` | The codec (`QuickZ`, `Zlib`, `Kraken`) and container-format version (`Version0`..`Version3`) enums. `ProsperoPfsShufflePattern` names the pre-compression shuffle patterns. |
The newLZ (Kraken) decoder and the Huffman entropy-array encoder are internal implementation
details of these types and are not part of the public surface. `PfsBlock` and
`ProsperoCompressedPfsImageResult` describe a single block and the pack result.
---
## `LibProsperoPkg.GP5` — project model
- **`Gp5Creator`** — `FromFolder(...)` / `FromFolderExplicit(...)` build a `Gp5Project` from a
folder.
- **`Gp5Project`** — the GP5 document model, with both the "normal" (`rootdir`-walked) and
"flat" (`files`/`folders`-listed) layouts represented via `Gp5Layout`. Elements:
`Gp5Volume`, `Gp5Package`, `Gp5ChunkInfo`, `Gp5Chunk`, `Gp5Scenarios`, `Gp5Scenario`,
`Gp5RootDir`, `Gp5File`, `Gp5Dir`. `Gp5VolumeType` names the volume kind
(`prospero_app`, `prospero_patch`, `prospero_ac`, `prospero_ac_nodata`).
---
## `LibProsperoPkg.Keys` — signing key access
- **`ProsperoKeys`** — exposes the wired-in PS5 signing key material (`IsAvailable` and the
individual key accessors). Used by the signer and the package builder.
---
## `LibProsperoPkg.PlayGo` — auxiliary file generators
- **`ProsperoPlayGo`** — generates the auxiliary `sce_sys` files (`about/right.sprx`,
`playgo-chunk.dat`, `playgo-manifest.xml`) that the builder injects into the inner PFS so the
produced file set is complete.
---
## `LibProsperoPkg.Content` — content file codecs
- **`ProsperoUcp`** — reads, builds, validates, verifies, and repairs UCP archives
(`trophy2/*.ucp`, `uds/*.ucp`). `IsUcp`, `Read`, `Build`, `BuildFromDirectory`, `Validate`,
`VerifyDigest`, and `WithRepairedDigest`.
- **`ProsperoFself`** — parses SELF containers and generates a fake-self from a 64-bit ELF.
`IsSelf`, `IsElf`, `Parse`, `Validate`, and `MakeFself` (with `FselfOptions` for app and firmware
version and an optional authority id). The read model exposes `SelfImage`, `SelfSegment`, and
`SelfExtInfo`. `MakeFself` normalizes the input module header by default (see
`FselfOptions.NormalizeHeader`), working on a private copy so the caller's buffer is untouched. The
high-level builder wires this in through `ProsperoBuildOptions.FakeSignSelfModules`,
which fake-signs raw ELF modules in the source tree before packing (producing an fPKG) and restores
the originals afterward.
- **`ProsperoSelfAuthInfo`** — reads, validates, builds, and round-trips the SELF authentication-info
sidecar (`*.auth_info`), a fixed 0x88-byte record: program authority id (`paid`) at `0x00`, four
64-bit capability words at `0x08`, four 64-bit attribute words at `0x28`, and a 0x40-byte reserved
tail, all little-endian. `IsAuthInfo`, `Parse`, `Read`, `ReadFile`, `Create`, `ToBytes`, `Write`,
`WriteFile`; `Paid` / `AuthorityId`, `Capabilities`, `Attributes`, `Reserved`, and `Category`
(`ProsperoAuthorityCategory`, with `IsFakeAuthority` / `IsGenuineAuthority` / `IsPrivilegedSystem`).
Grant words are copied verbatim.
- **`ProsperoElfHeader`** — reads and edits the 64-bit ELF header. `Read`, `ReadFile`, the typed
header properties, and the `IsExecutable` / `IsDynamic` / `IsModuleType` / `IsModuleReady`
predicates report its shape. `SetOsAbi`, `SetAbiVersion`, `SetType`, and `SetMachine` rewrite
single fields in place; `NormalizeForModule` retargets a module ELF (machine to x86-64, OS/ABI to
FreeBSD, placeholder type to executable) before `MakeFself`, returning an `ElfNormalizeResult`.
`ElfType`, `ElfClass`, `ElfData`, `ElfOsAbi`, and `ElfMachine` name the value spaces.
---
## `LibProsperoPkg.License` — per-title license (`rif`)
| Type | Purpose |
|---|---|
| `ProsperoRif` (sealed) | The per-title license record (`license/rif`): a fixed `0x400`-byte structure with a big-endian header (magic `RIF\0`, version, flags, the `QPaC` format tag, expiry, a 36-byte content id) and a 448-byte encrypted key blob at `0x240`. `Parse` / `Read` decode one record; `ReadAll` / `WriteAll` handle a multi-title file (one record per sub-title); `Create(contentId, keyBlob=null, expiry)` builds a structural record (copying a supplied key blob verbatim or leaving it zero); `ToBytes` / `Write` serialize; `Validate`. Exposes `ContentId`, `TitleId`, `ServiceLabel`, `Expiry` / `IsNonExpiring`, `HasKeyBlob`. |
| `ProsperoRifSet` (sealed) | A whole license file as the ordered set of records. `ReadFile` / `Read` / `FromRecords`, `Validate` (non-empty, whole-file size a positive multiple of `0x400`, per-record validity), `Summarize(appTitleId)` → `ProsperoRifSetSummary` (`n_rif`, per-record `ServiceID`, `has_app`, `n_ac`, size), `Describe`. |
| `ProsperoEntitlementKey` (sealed) | The 128-bit content key (`entitlement_key`) the builder accepts as an alternative to a passcode. `FromBytes`, `ParseHex` / `ToHex`, `Value`, `IsZero`, `Validate`, and `ResolveMode(passcode, entitlementKey, out mode, out error)` (`ProsperoKeyMode`) enforcing the mutual-exclusivity rule. A validated carrier for supplied material; it never derives or forges a license key body. |
| `ProsperoDebugLicense` (sealed) | The debug grant for a content id + passcode, where the mount key is recomputed from public inputs rather than granted. `Create(contentId, passcode=null)` (passcode defaults to 32 zeros), `RequiresRif` (always false), `DeriveEkpfs`, `DeriveImageEncryptionKeys(seed)`, `DeriveImageSignKey(seed)`, and `DeriveKeySet(seed)` → `ProsperoDebugKeySet` (EKPFS + AES-XTS tweak/data + sign key) delegate to `ProsperoPfsKeys`; `ToStructuralRif(expiry)` emits a zero-blob record for pipelines that expect a license file present; `Validate`. Holds no device secret. |
The 448-byte key blob at `0x240` is encrypted with per-console material and cannot be produced
off-console. `Create` covers the structural / templated path only.
---
## `LibProsperoPkg.NpDrm` — content-info projection
| Type | Purpose |
|---|---|
| `ProsperoNpDrmContentInfo` (sealed) | Projects a package header into the compact classification the mount path consumes before it accepts an image: container offset, content id, derived title id, `DrmType` (`0x70`), `ContentType` (`0x74`), `ContentFlags` (`0x78`), `IsNestedImage`, `IsFinalized`, and the decoded `PatchKind` (`ProsperoPatchKind`: `None` / `First` / `Subsequent` / `Delta` / `Cumulative`) with an `IsPatch` roll-up. `Read(path)` / `Read(stream)` parse and project; `FromPackage` projects a parsed `ProsperoPkg`; `ResolveContainerOffset` mirrors the container-offset switch on magic and version; `DeriveTitleId(contentId)`. |
---
## `LibProsperoPkg.DiscBackup` — split disc-backup packages
| Type | Purpose |
|---|---|
| `ProsperoDiscBackup` (sealed) | Opens a split disc-backup package (the ordered `app_0` / `app_sc` pieces) described by an `app.json` manifest and presents it as one package. `Open(path)`, `OpenPackageStream` / `ReassembleTo(stream/path, progress)`, `ReadPackage`, `ReadContentInfo`, `ComputePackageDigest` / `VerifyPackageDigest`, `ReadChunkCrc` / `VerifyChunkCrcHash` / `VerifyChunkCrcs(out mismatchChunk, progress)`, `FindImageKeyEntry`, `ExtractEntry` / `ExtractEntryBytes`. Props: `Directory`, `Manifest`, `OriginalFileSize`. |
| `ProsperoDiscBackupManifest` (sealed) | Parses `app.json`: `NumberOfSplitFiles`, `OriginalFileSize`, `PackageDigest`, the ordered `Pieces` (`ProsperoDiscBackupPiece`: `DiscNumber`, `FileOffset`, `FileSize`, `Url`), `PlaygoChunkCrcHashValue`, `PlaygoChunkCrcUrl`. `Parse(json)`, `Read(path)`. |
| `ProsperoConcatStream` (sealed) | A read-only, seekable stream that concatenates the piece windows without materializing a temporary file. |
| `ProsperoPlaygoChunkCrc` (sealed) | Parses the headerless little-endian CRC-32C array (one value per 64 KiB chunk, from `app.crc`). `Parse`, `Read(path)`, `VerifyPackage(stream, out mismatchChunk, progress)`. |
The EEKPFS key entry (id `0x20`) is returned as stored (encrypted); off-console key derivation from
it is not implemented.
---
## `LibProsperoPkg.Util` — low-level helpers
Building blocks shared across the library. Most consumers use the higher-level types above; these
are exposed for advanced use.
- **`Crypto`** — SHA-256 / SHA3-256, HMAC-SHA-256, AES-CBC/CFB, RSA (PKCS#1) key wrapping, the PFS
key-generation primitives (`PfsGenCryptoKey`, `PfsGenEncKey`, `PfsGenSignKey`), `ComputeKeys`,
`CreateKeystone`, and `Xor`.
- **`CryptoKeys`** — the key constants the signer and mount-key derivation use.
- **`ProsperoCrc32C`** — CRC-32C (`Compute`, `Update`).
- **`XtsBlockTransform`** — AES-XTS sector encrypt/decrypt.
- Stream helpers: `OffsetStream`, `SubStream`, `StreamReader`, `WriterBase`, and the
`IMemoryReader` / `IMemoryAccessor` accessors with `MemoryMappedViewAccessor_`.
---
## Shared library (C ABI)
The library can be built with a flat C export surface and a matching `libprosperopkg.h` header.
The committed project stays unchanged; build properties are injected at build time. Each build
checks the exported symbol table and packages the library, header, license text, and a
`SHA256SUMS` manifest as an artifact.
| Function | Purpose |
|---|---|
| `lpp_version` | Return the library version string. |
| `lpp_abi_version` | Return the numeric ABI version of the export surface (`LPP_ABI_VERSION`). |
| `lpp_last_error` | Return the last error message on the calling thread. |
| `lpp_is_valid_content_id` / `lpp_is_valid_title_id` | Validate identifiers. |
| `lpp_compose_content_id` | Compose a 36-char content id. |
| `lpp_build_package` | Run the full build from a prepared folder. |
| `lpp_build_package_ex` | Run the full build with the complete option set (`lpp_build_options`). |
| `lpp_detect_package_type` | Return the package type of a file (`LPP_TYPE_*`). |
| `lpp_build_inner_image` | Lay a folder out into an inner-PFS image (`LPP_FORM_*`). |
| `lpp_encrypt_pfs_image` | AES-XTS-encrypt a plaintext inner-PFS image in place. |
| `lpp_pack_pfs_image` / `lpp_unpack_pfs_image` | Pack / unpack a PFSv3 PFSC container. |
| `lpp_is_self` / `lpp_is_elf` / `lpp_is_ucp` | Detect a SELF, a 64-bit ELF, or a UCP archive. |
| `lpp_read_self_info` | Read the extended info and segment count from a SELF module. |
| `lpp_make_fself` | Generate a fake-self from a 64-bit ELF. |
| `lpp_make_fself_ex` | Generate a fake-self with explicit version and authority-id options. |
| `lpp_make_fself_file` | Read an ELF from a path, fake-sign it, and write the result to a path. |
| `lpp_fake_sign_folder` | Fake-sign every raw ELF module under a folder in place. |
| `lpp_application_type_name` | Return the display name of an application type (`LPP_APP_TYPE_*`). |
| `lpp_application_drm_type` | Return the generated `applicationDrmType` token for an application type. |
| `lpp_parse_application_type` | Parse an application-type display name into its code. |
| `lpp_read_auth_info` / `lpp_write_auth_info` | Read / write a SELF authentication-info sidecar. |
| `lpp_read_npdrm_content_info` | Read the NpDrm content-info of a package (`lpp_npdrm_content_info`). |
| `lpp_inspect_package` | Inspect a package without a key (`lpp_package_info`). |
| `lpp_read_package_summary` | Read header and image-header fields (`lpp_package_summary`). |
| `lpp_package_entry_count` / `lpp_read_package_entry` | Count entries / read one entry (`lpp_package_entry`). |
| `lpp_list_package_files` / `lpp_list_package_files_ekpfs` | List the inner files by passcode or image key. |
| `lpp_compare_containers` | List the differences between two metadata containers. |
| `lpp_extract_package` / `lpp_extract_package_ekpfs` | Extract a package by passcode or image key. |
| `lpp_validate_package` | Run the acceptance checks on a package. |
| `lpp_merge_split_package_dir` | Merge split packages found in a directory. |
| `lpp_package_homebrew` | Build a package from a homebrew folder. |
| `lpp_inspect_launch_readiness` | Summarize the launch readiness of an application root (`lpp_launch_readiness`). |
| `lpp_build_pfs_layout` | Lay a folder out into a plaintext inner-PFS image. |
| `lpp_pfs_image_is_encrypted` / `lpp_decrypt_pfs_image` | Report encryption state / decrypt an inner image in place. |
| `lpp_read_elf_header` | Read an ELF header (`lpp_elf_info`). |
| `lpp_normalize_elf_module` | Normalize an ELF for use as a module. |
| `lpp_ucp_validate_file` / `lpp_ucp_verify_digest_file` | Validate a content protection file / verify its digest. |
| `lpp_ucp_build_from_directory` / `lpp_ucp_repair_digest_file` | Build a content protection file / repair its digest. |
| `lpp_rif_record_count` / `lpp_read_rif_content_id` / `lpp_read_rif_summary` | Read the record count, first content id, or a summary (`lpp_rif_summary`). |
| `lpp_rif_create` | Create a structural license record. |
| `lpp_derive_image_key` | Derive the 32-byte image key from a content id and passcode. |
| `lpp_entitlement_key_validate` | Validate a 32-hex-character entitlement key. |
| `lpp_disc_backup_reassemble` / `lpp_disc_backup_verify` | Reassemble / verify a split disc backup. |
| `lpp_disc_backup_content_info` / `lpp_disc_backup_verify_chunk_crcs` | Read a disc backup's content info / verify its chunk CRCs. |
| `lpp_convert_backup` | Convert a decrypted application backup into a debug package. |
| `lpp_encode_png_to_dds` | Encode a PNG file to a BC7 DDS texture. |
| `lpp_build_playgo_chunk_dat` | Build a chunk descriptor file for a content id. |
| `lpp_create_param_json` | Create a default param.json for a set of ids. |
`lpp_build_package_ex` takes a pointer to `lpp_build_options`, a zero-initialized struct whose
`struct_size` field is set to `sizeof(lpp_build_options)` before use. It carries the application
type, badge and DRM overrides, param.json generation, inner compression, and the fake-self settings
(version, firmware and authority-id) applied to raw ELF modules before packing. The trailing
`license_free` field builds a DRM/license-free debug package; it is appended, so a smaller
`struct_size` from an older caller leaves it disabled.
Strings cross the boundary as UTF-8. String-output functions return the number of bytes written, or
the negative of the required size (including the terminator) when the caller buffer is too small.
Status functions return 0 on success and a negative value on failure, with the message available
through `lpp_last_error`. Struct-output functions fill a caller-provided struct — `lpp_build_options`,
`lpp_npdrm_content_info`, `lpp_package_info`, `lpp_rif_summary`, `lpp_package_summary`,
`lpp_package_entry`, `lpp_elf_info`, and `lpp_launch_readiness` — whose `struct_size` field is set
before use. Enums pass as ints; the header defines the `LPP_ABI_VERSION`, `LPP_MODE_*`,
`LPP_OUTPUT_*`, `LPP_INNER_*`, `LPP_FORM_*`, `LPP_TYPE_*`, `LPP_APP_TYPE_*`, `LPP_PATCH_*`,
`LPP_AUTH_CATEGORY_*`, `LPP_PARAM_DRM_*`, `LPP_ELF_MACHINE_*`, and `LPP_ENTRY_*` values.
-224
View File
@@ -1,224 +0,0 @@
# Getting Started
## Prerequisites
- **.NET 10 SDK** or newer. Verify with:
```bash
dotnet --version
```
- A C# 14 capable toolchain (included with the .NET 10 SDK).
## Project layout
```
LibProsperoPKG/
├── README.md
├── NOTICE
├── docs/
└── src/
└── LibProsperoPkg/
├── LibProsperoPkg.csproj
├── ProsperoPackageBuilder.cs high-level entry point
├── PKG/ container build/read/write, signing, DDS, FIH, extraction
├── PFS/ inner PFS layout, AES-XTS, PFSC/data-first compression, extraction
├── Content/ UCP, fake-self, and auth-info content codecs
├── License/ per-title license (rif) read/write/create
├── NpDrm/ package content-info projection
├── DiscBackup/ split disc-backup (app_0 / app_sc) open and verify
├── GP5/ GP5 project model
├── Keys/ signing key access
├── PlayGo/ PlayGo / "about" helper file generators
└── Util/ crypto, keys, and shared helpers
```
## Building the library
```bash
cd LibProsperoPKG/src/LibProsperoPkg
dotnet build -c Release
```
The Release build is written under `bin/Release/net10.0/`.
## Using the library from another project
Point another project at either the compiled assembly or the `.csproj` directly:
```xml
<ItemGroup>
<ProjectReference Include="..\LibProsperoPKG\src\LibProsperoPkg\LibProsperoPkg.csproj" />
</ItemGroup>
```
## Preparing an application folder
The builder consumes a folder that already contains the standard PS5 layout:
- `sce_sys/` — system metadata directory (must be present). When `param.json` is missing and
`GenerateParamJsonIfMissing` is left `true`, a minimal one is generated from the build options.
- The application executable (`eboot.bin`) and any data files.
## Building your first package
```csharp
using LibProsperoPkg;
var options = new ProsperoBuildOptions
{
Mode = ProsperoPackageMode.Application,
OutputFormat = ProsperoOutputFormat.DebugImage,
SourceFolder = "/path/to/prepared/app",
OutputFolder = "/path/to/output",
ContentId = "UP9000-PPSA00000_00-PROSPERO00000000",
TitleId = "PPSA00000",
Title = "My PS5 Application",
Version = "01.00",
};
var result = ProsperoPackageBuilder.Build(options, Console.WriteLine);
Console.WriteLine(result.OutputPath);
```
## Fake-signing modules and application type
To pack raw ELF modules that are not yet SELF, set `FakeSignSelfModules`. The builder converts
`eboot.bin` and any `*.elf` / `*.prx` / `*.sprx` in the source folder to fake-self before layout
and restores the original files afterward. Modules that are already SELF are skipped.
`ApplicationType` selects the `applicationDrmType` written to a generated `param.json`:
| `ProsperoApplicationType` | `applicationDrmType` |
|---|---|
| `PaidStandaloneFullApp` | `standard` |
| `UpgradableApp` | `standard` |
| `FreemiumApp` | `freemium` |
| `DemoApp` | `free` |
| `NotSpecified` | `free` |
```csharp
var options = new ProsperoBuildOptions
{
Mode = ProsperoPackageMode.Application,
OutputFormat = ProsperoOutputFormat.DebugImage,
SourceFolder = "/path/to/prepared/app",
OutputFolder = "/path/to/output",
ContentId = "UP9000-PPSA00000_00-PROSPERO00000000",
TitleId = "PPSA00000",
Title = "My PS5 Application",
Version = "01.00",
ApplicationType = ProsperoApplicationType.FreemiumApp,
FakeSignSelfModules = true,
};
```
## DRM/license-free debug package
Set `LicenseFree` to build a single package that is DRM-free, license-free, fake-signed, and
runnable on a debug-enabled console. The flag fake-signs raw ELF modules (the same conversion as
`FakeSignSelfModules`) and constructs a debug grant whose mount key is recomputed from the content
id and passcode, so no rif or license file is written. The output stays a debug finalized image.
`param.json` `applicationDrmType` is descriptive metadata read by the installer, not a boot gate; a
generated `param.json` already carries the derived value and is not rewritten.
```csharp
var options = new ProsperoBuildOptions
{
Mode = ProsperoPackageMode.Application,
OutputFormat = ProsperoOutputFormat.DebugImage,
SourceFolder = "/path/to/prepared/app",
OutputFolder = "/path/to/output",
ContentId = "UP9000-PPSA00000_00-PROSPERO00000000",
TitleId = "PPSA00000",
Title = "My PS5 Application",
Version = "01.00",
LicenseFree = true,
};
ProsperoBuildResult result = ProsperoPackageBuilder.Build(options);
// result.LicenseFree == true
// result.DebugLicense holds the content id + passcode grant (RequiresRif == false)
```
The source tree is unchanged after the build: the fake-sign step restores the original bytes when
the build finishes or throws.
Read an existing package's header and entries:
```csharp
using LibProsperoPkg.PKG;
ProsperoPkg pkg = ProsperoPkgReader.Read("/path/to/package.pkg");
Console.WriteLine($"{pkg.Type} {pkg.Header?.ContentId}");
```
Check a package against the structural acceptance gate the console mount path enforces:
```csharp
ProsperoAcceptanceReport report = ProsperoPkgValidator.Validate("/path/to/package.pkg");
Console.WriteLine(report.Accepted);
```
Extract the application filesystem from a finalized debug/keyed image. `Inspect` first reports
whether a supplied key is required, without needing one:
```csharp
ProsperoPackageExtractionInfo info = ProsperoPackageExtractor.Inspect("/path/to/package.pkg");
if (!info.RequiresSuppliedKey)
{
ProsperoPackageManifest manifest = ProsperoPackageExtractor.Extract(
"/path/to/package.pkg", "/path/to/output", new string('0', 32));
Console.WriteLine(manifest.ExtractedFileCount);
}
```
A finalized retail image (signed byte `0x80`) reports `RequiresSuppliedKey = true`; its image key
is not derivable from public inputs, so extraction needs a supplied 32-byte key through
`ProsperoExtractionKey.FromEkpfs`.
## Opening a disc-backup
A split disc backup is a set of `app_0` / `app_sc` pieces described by an `app.json` manifest. Open
the directory, verify integrity, and read the reassembled package:
```csharp
using LibProsperoPkg.DiscBackup;
ProsperoDiscBackup backup = ProsperoDiscBackup.Open("/path/to/backup/dir");
Console.WriteLine(backup.VerifyPackageDigest());
ProsperoPkg pkg = backup.ReadPackage();
```
## Converting a decrypted backup to a debug fPKG
A decrypted backup carries the app tree plus a `decrypted/` subfolder that mirrors every executable
as raw ELF. `ProsperoBackupConverter` substitutes each signed executable with its decrypted
counterpart, fake-signs the modules, and builds a debug image that mounts from the content id and
passcode alone. No executable byte-patching and no `param.json` edit are involved.
```csharp
var options = new ProsperoBackupConversionOptions
{
BackupFolder = "/path/to/backup/PPSA00000-app0",
OutputFolder = "/path/to/output",
// ContentId and Version fall back to the backup's param.json when omitted.
};
ProsperoBackupConversionResult result = ProsperoBackupConverter.Convert(options);
Console.WriteLine(result.OutputPath);
Console.WriteLine($"substituted {result.SubstitutedModules.Count} modules");
// result.DebugLicense.RequiresRif == false
```
The backup is never modified; the converter works from a staging copy. Every module in the output
is a fake-self, the image is debug (not retail), and extraction round-trips every file.
## Notes on content identifiers
- **Content ID** is 36 characters: `XXYYYY-XXXXYYYYY_00-ZZZZZZZZZZZZZZZZ`.
Validate with `ProsperoPackageBuilder.IsValidContentId` or compose one with
`ProsperoPackageBuilder.ComposeContentId(publisher, titleId, label)`.
- **Title ID** is 9 characters (for example `PPSA00000`). Validate with
`ProsperoPackageBuilder.IsValidTitleId`.
- **Passcode** is exactly 32 characters and defaults to all zeroes.
@@ -1,359 +0,0 @@
# Implementation Status
This document describes the current LibProsperoPkg package-building and reading capabilities. It is a public technical status file: it lists implemented behavior, known limits, and remaining work without process notes.
## Implemented
### Container format
- Builds the outer PFS plus the `\x7FCNT` metadata container with big-endian header, entry table, and entry-name table.
- Reads `\x7FCNT` and finalized `\x7FFIH` packages through `ProsperoPkgReader`, including header fields, content id, entry table, and entry names.
- Produces containers that parse back with the expected PS5 stamping.
- `ProsperoPackageBuilder.Build` runs end to end from a source folder through inner PFS image creation, the data-first inner image, AES-XTS outer PFS, `\x7FCNT`, metadata signature, and `\x7FFIH` finalization.
### Inner PFS image
- Lays out a prepared folder into a plaintext inner PFS image that round-trips through the reader. The superblock version is always 2 (PS5).
- Supports AES-XTS encryption over 0x1000-byte sectors using SHA3-256 EKPFS derivation. Tweak and data keys are derived from EKPFS plus the image header seed. The header block remains plaintext, and encrypted images decrypt to the original image.
- The package build path always stores the inner `pfs_image.dat` as the data-first image: a raw concatenation of per-file payloads (raw or headerless Kraken) with the geometry described by a generated `naps_pkg_layout.dat`. Application payloads compress to compact Kraken blocks; keystone and executable modules are stored raw, as is any file the Kraken result does not shrink.
- The PFSC container codec (`LibProsperoPkg.PFS.Compression.ProsperoCompressedPfsImage`, with pack, unpack, format-check and validation helpers) is used to compress the inner metadata block and by the standalone PFSC pack/unpack tool.
### Outer PFS encryption and signing
- Implements the PS5 finalized-image key schedule for `nwonly`: SHA3-256 EKPFS plus `new_crypt` tweak/data keys over 0x10000-byte sectors numbered by image block. Public API: `PFS.ProsperoPfsKeys.DeriveEkpfs`, `DeriveImageEncryptionKeys`, and `DeriveImageSignKey`.
- Decrypts an outer image to coherent plaintext and re-encrypts it deterministically across the encrypted blocks.
- Uses AES-128-XTS with one 0x10000-byte block per XTS unit. File-data blocks use the block index as the sector number; signed metadata blocks use the bit-47 sector flag; the superblock block remains plaintext.
- Implements per-block and dinode integrity hashes as `SHA3-256(plaintext block)`. Dinodes store the 32-byte hash and owning block index; the super-root inode stores the same shape for the inode-table block.
- Implements the superblock `icv` as `SHA3-256(superblock[0:0x5a0])` with the 32-byte `icv` field zeroed during the computation.
- Implements the data-first outer-PFS structure generator in `PFS/ProsperoOuterPfsBuilder.cs`. It builds file-data blocks, a plaintext superblock, inode table, super-root directory entries, `\x7fFLT` flat-path table, and `uroot` directory entries.
- The generated plaintext and encrypted output follow the 11-block layout.
### Metadata signing
- Signs package metadata with RSA-3072, PKCS#1 v1.5, and SHA-256.
- Performs EKPFS and PFS key derivation as part of signing.
- Verifies the published key fingerprint and a sign/verify round-trip.
### Finalized debug image and FIH
- Wraps a `\x7FCNT` container into a finalized debug `\x7FFIH` image with signed byte `0x00`.
- Writes the structural fields: magic, signed byte, PFS image offset and size, embedded CNT offset and size.
- Produces a reader-round-trippable `FullDebug` image with signed byte `0x00`, PFS image offset `0x10000`, block-aligned PFS image size, and embedded CNT offset inside the file.
- Supports the PS5 data-first finalized layout: FIH header, outer PFS image, plaintext superblock at a non-zero image block, CNT body, and optional install-metadata archive.
- Uses the trailing metadata archive as optional debug install metadata. The debug variant is a plain ZIP with stored entries; the encrypted retail variant is not produced.
### Digests
- Uses `SHA3-256` for finalized-image and CNT digest values listed here.
- Computes the `game-digest` / inner `sblock-digest` as `SHA3-256` of the plaintext outer superblock block at the offset stored in FIH.
- Computes `package-digest` as `SHA3-256(CNT[0:0xFE0])` and writes it at `CNT+0xFE0`.
- Computes the CNT-header rollup as `SHA3-256(CNT[off:off+size])`, where `off = BE64(CNT+0x20)` and `size = BE32(CNT+0x1c)`.
- Computes `body-digest` as `SHA3-256(CNT body)` and `fixed-info-digest` as `SHA3-256(FIH block)`.
- Builds the per-entry digest table (entry `0x0001`) as `SHA3-256(entry payload)` for each entry, with the digest table's own slot left all-zero. This covers all 13 entries.
- Computes the CNT GeneralDigests block (entry `0x0080`, `set_digests = 0x10DE`, length `0x1E0`):
- `content-digest = SHA3-256(CNT[0x40:0x78] || game-digest || major-param(32 zeros))`.
- `header-digest = SHA3-256(CNT[0:0x40] || CNT[0x400:0x480])`, with `CNT+0x410` forced to the FIH-relative `0x10000` value.
- `system-digest` and `playgo-digest` as `SHA3-256` of concatenated per-entry digests for the matching CNT entries in ascending id order.
- `param-digest = SHA3-256(param.json payload)`.
- `target` as a copy of `game-digest`.
- Computes FIH `0xB0` nested-image-content digest as `SHA3-256` of the uncompressed inner PFS image at its logical size. The CNT build path threads this preimage through finalization, so the FIH value and CNT `pfs_signed_digest` are mutually consistent. The standalone finalization path without an inner image falls back to an outer-image hash.
- Computes `imagedigs.dat` (CNT entry `0x040A`, unnamed) as an `N * 32` byte table, one digest per 64 KiB outer-image block. It is stored as an outer-CNT body entry, not as an inner `sce_sys` file. Each stored 32-byte digest is written in opposite byte order. The build patches the captured per-block descriptor digests after `WriteImage`.
- All populated digest slots are generated from finished on-disk CNT and image data. An independently built package remains internally self-consistent; compressed inner-image bytes can vary by input and layout choices.
### sce\_sys files
- Injects `about/right.sprx` into the inner PFS. A `right.sprx` supplied in the source tree is packed verbatim; an embedded debug module is injected only when the source provides none. `ProsperoPkgBuilder.EnsureAboutRightSprx`.
- Reads and produces UCP archives (`trophy2/trophyNN.ucp`, `uds/udsNN.ucp`) through `Content.ProsperoUcp`. The codec parses and rebuilds both archive kinds while preserving payloads and digests, including the SHA-1 integrity digest. Public API covers reading, building from entries, building from a directory, structural validation, digest verification, and digest repair. During a build, `ProsperoPkgBuilder.EnsureUcpArchives` repairs a stale digest on a supplied `.ucp` file but never synthesizes archive contents.
- Validates backend-signed system files before packing them, through `PKG.ProsperoSystemFiles`. `npbind.dat` (532 bytes, magic `0xD294A018`) is checked and its communication id extracted from the TLV chain; `nptitle.dat` (160 bytes, magic `NPTD`) is checked and its title id extracted; `license.dat` / `license.info` require a non-empty payload. Invalid inputs stop the build with a descriptive error.
- Emits `playgo-chunk.dat` (CNT entry `0x1001`), `playgo-hash-table.dat` (`0x2010`), and `playgo-ficm.dat` (`0x2011`) as outer-CNT body entries.
- Builds `playgo-hash-table.dat` as a content-independent constant structure with size `0x38 + n * 8`, where `n = ficmCount / 2`.
- Converts source icon/picture PNGs to `icon0.dds`, `pic0.dds`, `pic1.dds`, and `pic2.dds` as BC7_UNORM DX10 textures through `PKG.ProsperoDdsEncoder`. `PKG.ProsperoPngDecoder` decodes the PNG (all color types, bit depths 1/2/4/8/16, every scanline filter, Adam7 interlace, and `tRNS`) to an 8-bit RGBA surface, which is block-compressed to BC7 behind the 148-byte DX10 header. The 148-byte header and the output file size follow the DX10 layout; the BC7 payload is a valid re-encoding of the same surface.
- Packs any backend-authored system file supplied under `sce_sys/` whose relative path maps to a known CNT id as an outer-CNT body entry: `license.dat`/`license.info` (`0x0400`/`0x0401`), `nptitle.dat` (`0x0402`), `npbind.dat` (`0x0403`), `selfinfo.dat` (`0x0404`), `origin-deltainfo.dat`/`target-deltainfo.dat` (`0x0408`/`0x0407`), `pubtoolinfo.dat` (`0x1007`), `pronunciation.xml`/`.sig` (`0x1004`/`0x1005`), `changeinfo/changeinfo*.xml` (`0x1260`+), the `keymap_rp/` image set (`0x1600`+), and `trophy/` archives. These files are excluded from the inner PFS and stored verbatim; the library never fabricates them. `CollectMediaEntries` in `ProsperoPkgBuilder`.
### Application type and generated param.json
- Models the application type through `ProsperoApplicationType` (`NotSpecified` = 0, `PaidStandaloneFullApp` = 1, `UpgradableApp` = 2, `DemoApp` = 3, `FreemiumApp` = 4). `ProsperoApplicationTypes` maps each type onto the PS5 `sce_sys/param.json` `applicationDrmType` bucket (`free` / `standard` / `freemium`) and exposes `DisplayName` and a case-insensitive `Parse`.
- When the builder generates a minimal `param.json` (source folder has none), `ProsperoBuildOptions.ApplicationType` selects the emitted `applicationDrmType`; `ApplicationDrmType` and `ContentBadgeType` allow explicit overrides. An existing `sce_sys/param.json` is always used verbatim. The `pfsimage.xml` `<application-type>` mirrors the resolved `applicationDrmType`.
### param.json document model
- `Metadata.ProsperoParam` reads, edits, creates, and writes the full `sce_sys/param.json` document through a live `JsonObject` so unrecognised keys and property order survive a round-trip. `Parse` / `Load` accept an existing document; `Create` starts an empty one; `Save` / `ToJson` serialize as UTF-8 without BOM at 2-space indent. `ToJson()` preserves parsed property order; `ToJson(sorted: true)` emits a canonical key order for a fresh document. Non-ASCII values are written as literal UTF-8.
- Typed accessors cover the 34 recognised top-level keys (`ProsperoParamKeys`): the identifiers (`titleId`, `contentId`, `conceptId`, `masterVersion`, `contentVersion`, `versionFileUri`), the localized-title block (`localizedParameters` with per-language `titleName` objects and `defaultLanguage`), the age/country map (`ageLevel`), the DRM/type buckets (`applicationDrmType`, `applicationCategoryType`, `contentBadgeType`, `attribute`, `attribute2`, `attribute3`, `attributeInternal`), the sizing hints (`downloadDataSize`, `applicationDataSize`), the deep-link and service keys, and the `pubtoolinfo` / `kernel` / `localizedParameters` nested objects.
- `Metadata.ProsperoParamEnums` exposes the constrained vocabularies for validated fields: the three `applicationDrmType` tokens (`standard` / `free` / `freemium`), the two deep-link intent tokens (`launchActivity` / `joinSession`), the 30 language codes used by `localizedParameters` and `defaultLanguage`, and the 70 country codes used by `ageLevel`.
### manifest.json document model
- `Metadata.ProsperoManifest` reads, edits, creates, and writes the framework-application `manifest.json` document through a live `JsonObject` that preserves property order and unrecognised keys. `Parse` / `Load` / `Create` / `Save` / `ToJson` mirror the param model; the document serializes as UTF-8 without BOM at 4-space indent, and `ToJson(canonical: true)` emits the fixed field order a fresh document uses.
- Typed accessors cover the recognised keys (`ProsperoManifestKeys`): `applicationName`, `applicationVersion`, `commitHash`, `bootAnimation`, `titleId`, `repositoryUrl`, `reactNativePlaystationVersion`, `twinTurbo`, and the optional `enableHttpCache` / `enableAccessibility` flags, plus the nested `applicationData` block with its `branchType`.
### SELF container
- Parses the SELF (Signed ELF) container through `Content.ProsperoFself`: header, segment table, embedded ELF header and program headers, and the extended-info block (authority id, program type, app and firmware version, digest).
- Generates a fake-self from any 64-bit ELF with `MakeFself`. A digest/data segment pair is emitted for each program header whose file size is non-zero and whose type is `PT_LOAD`, module-data (`0x61000000`), relro (`0x61000010`), or comment (`0x6FFFFF00`), in program-header index order. Header size, metadata size, segment layout, and 16-byte data padding preserve the source module's field layout.
- Sets the extended-info digest to `SHA-256` of the input ELF and derives the authority id and program type from the ELF type and the byte at file offset `0x3f00`. Digest and signature slots on the fake path are zero-filled.
- Normalizes the input module header before wrapping, on by default (`FselfOptions.NormalizeHeader`). `MakeFself` works on a private copy and calls `ProsperoElfHeader.NormalizeForModule`, so a self-authored module built by an ordinary toolchain wraps without a manual header fix-up and the caller's buffer stays unchanged. The step is a no-op for a module whose header is already correct, and it can be turned off.
- Round-trips through the container parser: the type-based segment selection preserves each module's content-segment set, and every data segment matches the source program-header payload.
- `IsSelf`, `IsElf`, `Parse`, `Validate`, and `MakeFself` form the public API. Package builds continue to embed a fixed `right.sprx` asset when the source provides none; the generator is a standalone capability for arbitrary ELF input.
- The build pipeline can fake-sign the source tree before packing, producing an installable fake package (fPKG). `ProsperoBuildOptions.FakeSignSelfModules` converts every raw 64-bit ELF module in the source folder (`eboot.bin` and `*.elf` / `*.prx` / `*.sprx`) to a debug fake-self through `MakeFself`, driven by an optional `FselfOptions` (app/firmware version, authority-id override). Files that are already SELF are left untouched. The conversion is non-destructive: the original module bytes are saved and restored after packing (in a `finally` block), so the source tree is unchanged when the build finishes or throws. `ProsperoPackageBuilder.PrepareFakeSelfModules` / `RestoreFakeSelfModules`.
### SELF authentication-info sidecar
- Parses the SELF authentication-info sidecar (`*.auth_info`) through `Content.ProsperoSelfAuthInfo`: a fixed 0x88-byte record holding the program authority id (`paid`, offset `0x00`), four 64-bit capability words (`0x08`), four 64-bit attribute words (`0x28`), and a 0x40-byte reserved tail (`0x48`), all little-endian. This is the on-disk form of the record that the privileged auth-info query fills at runtime; it is separate from the authority id in the SELF extended-info block.
- `Parse` / `Read` / `ReadFile` decode a record and reject any buffer shorter than 0x88. `Paid` / `AuthorityId`, `Capabilities`, `Attributes`, and `Reserved` expose the decoded fields. `Category` (with `IsFakeAuthority` / `IsGenuineAuthority` / `IsPrivilegedSystem`) reads the top-byte category (`0x31` fake, `0x45` genuine, `0x48` privileged) consistent with the authority-id model.
- `Create(paid, capabilities, attributes, reserved)` builds a record from supplied material, zero-extending short inputs; `ToBytes` / `Write` / `WriteFile` serialize back to the 0x88-byte form. Grant words are copied verbatim — the builder never fabricates capability or attribute bits.
- Every sidecar record round-trips through the parser and writer, and a record rebuilt from decoded fields via `Create` restores the original bytes.
### ELF header editor
- Reads and edits the 64-bit ELF header through `Content.ProsperoElfHeader`: the identification bytes (class, data, OS/ABI, ABI version), `e_type`, `e_machine`, `e_version`, `e_entry`, the program- and section-header table fields, and the flags. `Read` / `ReadFile` parse the header; the typed properties and the `IsExecutable` / `IsDynamic` / `IsModuleType` / `IsModuleReady` predicates report its shape.
- `SetOsAbi`, `SetAbiVersion`, `SetType`, and `SetMachine` rewrite single header fields in place. `NormalizeForModule` applies the minimum edits a module ELF needs before fake-signing: machine to x86-64, OS/ABI from System V or GNU to FreeBSD, and a placeholder type to executable, keeping any existing executable or dynamic type. It reports which fields changed through `ElfNormalizeResult`.
- The editor touches only the 0x40-byte header; program headers, section headers, and segment data are unchanged, so a normalized module still hashes into the same extended-info digest `MakeFself` computes over the file body. It accepts 64-bit little-endian ELF input only and rejects anything else. `ProsperoElfHeader.NormalizeForModule` is the preparation step ahead of `ProsperoFself.MakeFself`.
### Keystone
- Computes the 96-byte `sce_sys/keystone` from the passcode for version 2 and version 3.
- Uses deterministic chained HMAC-SHA256: `KeyBlock1 = HMAC-SHA256(seed1, passcode_ascii)` at `0x20`, then `KeyBlock2 = HMAC-SHA256(seed2, keystone[0x00:0x40])` at `0x40`, with seed pairs selected by version.
- The version-3 seed pair differs from the version-2 seed pair.
### PlayGo
- Generates PlayGo and about-file outputs used by package builds.
- Builds `playgo-chunk.crc` as CRC-32C (Castagnoli), reflected polynomial `0x82F63B78`, init/xorout `0xFFFFFFFF`, over each 64 KiB block of the finalized image from offset 0. Each checksum is serialized as little-endian `uint32` in block order. The trailing partial block containing the metadata archive and CRC file is excluded, avoiding self-dependency. Implemented by `ProsperoCrc32C` and `ProsperoPlayGo.BuildChunkCrc`.
### NAPS streaming and Kraken inner compression
- Implements `ProsperoNapsLayout` as a parser and serializer for `naps_pkg_layout.dat`.
- The layout serializer round-trips a 544-byte record: 533 bytes of section content plus 11 trailing zero pad bytes.
- Implements the 16-byte layout header bit packing: file count, compression type, key count, shuffle-pattern count, uncompressed-block count, outer-block count, and compressed-block-info count.
- Implements the section order and strides: outer block digest (8 bytes), shuffle pattern (8 bytes), uncompressed offset by file index (6 bytes), compressed-info offset by uncompressed-block index (10 bytes), and compressed-block info (9 bytes).
- Implements both 9-byte compressed-block-info record formats and all bit offsets used by the 45-record layout.
- `BuildLayout` defaults to 16-byte alignment.
- Data-dependent NAPS record value generation is self-consistent for the library's own inner-image compressor output. Package-specific NAPS values depend on exact inner-image block sizes, so remaining differences are tied to inner-PFS layout generation rather than the compression codec.
- The data-first inner image is assembled by `ProsperoPs5InnerImageAssembler` from the file tree: inode table, afid assignment, dirents, both flat-path tables, per-file logical offsets, the data-first on-disk layout, and the inner-mount geometry. `ProsperoNwonlyNapsGenerator` derives a valid `naps_pkg_layout.dat` for that image, and the builder places it as an outer-PFS file alongside `pfs_image.dat`.
- Implements a Kraken encoder and `KrakenDecoder` under `PFS/Compression`, plus `ProsperoCompressedPfsFileWriter` and `ProsperoCompressedPfsFile` for the PFSC container.
- `ProsperoCompressedPfsFileWriter.WriteCompressed(payload)` output is accepted by a conformant decoder and round-trips for the covered cases: single chunk, multi-block payloads over 256 KiB, the exact 0x40000 boundary, two internal chunks per block with cross-chunk back-matches, and stored fallback for incompressible chunks.
- `ProsperoCompressedPfsFile.Parse(pfs).Decompress()` reconstructs the original payload in process for the same cases.
- The encoder implements the excess mode for single long matches, including the control byte high bit and forward excess substream. Periodic-tile cases produce the expected output, while chunks with multiple over-long matches split them into valid shorter matches.
- The encoder enforces the newLZ rule that a match may not start in the last 16 bytes of a chunk. It caps match starts at `chunkEnd - 16`, flushes the remainder as trailing literals, and falls back to stored chunks if needed.
- Huffman entropy arrays are implemented and enabled by default through `KrakenHuffmanArrayEncoder`. The literal, command, length, and offset streams are each Huffman-coded as type-2 arrays with the internal three-stream split, and fall back to raw when the Huffman form is not smaller. The offset array is written in single-table offset mode.
- `KrakenDecoder` reads raw and Huffman-coded literal/command/offset/length arrays, both code-length encodings, the 3-stream split, excess framing, both literal models, multi-chunk and multi-block payloads, and stored fallback. It decodes the embedded verification vectors and checks SHA3-256 of the decoded payloads.
- Kraken `nwonly` inner compression is implemented and produces valid output that a conformant decoder accepts. The windowed Optimal3 parse uses best-of greedy `mml=4/3/8` and seeded forward-DP by emitted size for single- and multi-chunk blocks, including cross-chunk back-references. The level-7 forward-DP implements sublength fill for matches below 128 bytes, the `longestReach >= 128` lrl-loop exit, long-match parse-position skipping, and frontier commits for matches of 128 bytes or longer. The production `Pack` path produces compact blocks for covered payloads. Any residual sub-optimality on some inputs is isolated to match-finder, seed, or code-cost selection rather than DP parse logic.
- The `nwonly` inner `pfs_image.dat` described by `sce_suppl/.../pfsimage.xml` `<nested-image>` contains metadata tables, executable modules (`keystone`, `right.sprx`, `.bundle`) stored raw, and compressed application payloads. Files such as `icon0.dds`, `param.json`, PlayGo files, and `imagedigs.dat` are SC/container entries, not inner-image files. The inner block format uses a 16-byte PFSv3 boundary table, even/odd chunks up to 128 KiB, optional shuffle, and header-stripped Kraken blocks. `ProsperoCompressedPfsFileWriter` implements the compress-vs-store rule in `KeepCompressed`: keep a compressed block only when `comp <= (uncomp * 15) >> 4` (at least 6.25% saved).
### Reader and writer support
- `ProsperoPkgReader` detects both `\x7FCNT` and `\x7FFIH`, resolves embedded CNT data, and reports finalized debug images.
- `ProsperoPkgReader` reads the finalized-image format-version field (FIH offset `0x06`, little-endian) and exposes it as `ProsperoFihHeader.FormatVersion`, with `IsSupportedFormatVersion` testing the value the mount path requires (`3`).
- `ProsperoCntWriter`, `ProsperoPkgBuilder`, `ProsperoFihBuilder`, and related builders write the package structures described above.
- `ProsperoSiArchive` generates the debug install-metadata ZIP container with stored entries, member paths, `playgo-chunk.dat`, structural `pfsimage.xml` fields, and `playgo-chunk.crc`.
- The SI segment (the trailing `sce_suppl` ZIP) is emitted automatically by the `nwonly` build. `ProsperoPkgBuilder` captures the deterministic `pfsimage.xml` options, the CNT `playgo-chunk.dat`, and the block-aligned inner-image size during the CNT build; `ProsperoPackageBuilder` then passes them to `ProsperoFihBuilder.BuildFromCnt` through an `siArchiveFactory` that calls `ProsperoSiArchive.BuildDebugSiSegment` on the finalized mount image. The produced segment carries `pfsimage.xml` (with the build's own self-consistent digests, entries and geometry), the four `naps_meta_300/301/302/308.dat` records (`R = alignUp(pfs_image.dat) - 0x10000`, captured at build time), the copied `playgo-chunk.dat`, and a deterministic `config/<content-id>/playgo-chunk.crc` (CRC-32C). The `naps_meta_18.dat` metric blob is built by `ProsperoNapsMeta.BuildMeta18` over the finalized image and its content-file table and emitted in the segment.
- `ProsperoSiArchive.BuildPfsImageXml` builds the descriptor structure through `<config>`, `<digests>` framing, `<params>`, `<container>`, `<mount-image>`, and `<entries>`. It includes derived long name, version constants, container geometry, extended mount-image fields, the `pfs-image-seed` block, and CNT entries. Keyed digest rows that are not supplied remain zero placeholders with warnings.
- `BuildPfsImageXml` also emits the deep introspection trees `<chunkinfo>`, `<pfs-image>` (outer PFS), and `<nested-image>` (inner PFS). `ProsperoPkgBuilder` captures the outer and inner inode layouts and the chunk geometry during the CNT build (`ProsperoPfsBuilder.CaptureImageTree`) and passes them into the SI options. The walk reflects only inodes actually materialized into each image: inner `sce_sys` files that are packed as outer CNT entries (for example `icon0.png`) receive no inner inode and are correctly excluded from the `<nested-image>` tree.
- The GP5 project model is parsed and emitted for both root-directory-walked and flat files/folders layouts.
### License (rif)
- Reads, writes, and creates the per-title license file (`license/rif`) through `License.ProsperoRif`. Each record is a fixed `0x400`-byte structure with a big-endian header (magic `RIF\0`, version `0x0002`, flags, the `QPaC` format tag, expiry, a 36-byte content id, an 8-byte format descriptor, an entry-count field) and a 448-byte encrypted key blob at offset `0x240`.
- `Parse` / `Read` decode a single record; `ReadAll` decodes a multi-title file (one record per sub-title) and `WriteAll` re-emits it. `ToBytes` / `Write` serialize a record, and `Create` builds a structural record for a content id, copying a supplied 448-byte key blob verbatim or leaving it zero.
- Exposes `ContentId`, `TitleId` (parsed from the content id), `Expiry` / `IsNonExpiring`, `HasKeyBlob`, and a structural `Validate`.
- Single-title records and multi-title files parse, validate, and round-trip through `ToBytes`.
- The 448-byte key blob is encrypted with per-console material and cannot be produced off-console, so `Create` covers the fake/debug path or templating from an existing blob; an existing entitlement blob is copied verbatim, never synthesized.
- `ProsperoRif.ServiceLabel` exposes the per-record service token — the content-id prefix before the first `-` — the value the console verify path reports as each record's `ServiceID`.
### Content key and multi-content license set
- `License.ProsperoEntitlementKey` models the 128-bit content key (`entitlement_key`) that the builder accepts as an alternative to a passcode. It carries the 16-byte value with hex parse/format (`ParseHex` / `ToHex`, optional `0x` prefix), a zero check, and `Validate`. It is a validated carrier for supplied material and never derives or forges a license key body.
- `ProsperoEntitlementKey.ResolveMode(passcode, entitlementKey, out mode, out error)` implements the builder's mutual-exclusivity rule: exactly one of a 32-character passcode or a content key must be supplied. Supplying both is rejected (`entitlement_key must not be specified when a passcode is used`), supplying neither is rejected, and a wrong-length passcode is rejected. The result selects the fake/debug schedule (passcode) or the finalized/keyed schedule (content key).
- `License.ProsperoRifSet` models a whole license file as the ordered set of `0x400` records (one per sub-title, concatenated with no container header) and models the console verify-path report: the record count (`n_rif`), the per-record `ServiceID`, the distinct service labels / title ids / content ids, and the whole-file-size rule (a positive multiple of `0x400`, guarded by `unexpected ac_rif_file_size`). `ReadFile` / `Read` decode a file or buffer, `FromRecords` wraps an existing list, and `Validate` enforces a non-empty count, the size rule, and per-record validity.
- `Summarize(appTitleId)` produces the compact `ServiceID / rif_size(exp/act) / has_app / n_ac / n_rif` projection. Because the app-versus-additional-content split (`n_ac`) is not derivable from RIF fixed fields alone, the caller supplies the application title id (from `app.json` / `param.json`); records matching it count as the app and the remainder as additional content.
- Single-title files report `n_rif=1` / `has_app=true` / `n_ac=0`, and a three-record multi-title file reports `n_rif=3` / `has_app=true` / `n_ac=2` at exactly `3 × 0x400 = 3072` bytes.
### Debug license grant
- `License.ProsperoDebugLicense` states the debug grant for a content id + passcode in one place. A
debug image derives its mount key from public inputs only, so the grant needs no license record:
`RequiresRif` is always false. `Create(contentId, passcode=null)` builds the grant with the all-zero
default passcode when none is supplied and validates the content id (1..`0x24` ASCII bytes) and
passcode (32 characters).
- `DeriveEkpfs` returns the 32-byte image key; `DeriveKeySet(seed)` returns the full
`ProsperoDebugKeySet` for a 16-byte superblock seed (EKPFS, AES-XTS tweak and data keys, and the
32-byte sign key); `DeriveImageEncryptionKeys(seed)` and `DeriveImageSignKey(seed)` expose the parts.
All derivation delegates to `PFS.ProsperoPfsKeys`. `ToStructuralRif(expiry)` emits a zero-blob record
for pipelines that expect a license file present. It holds no device secret.
- `ProsperoBuildOptions.LicenseFree` builds a DRM-free, license-free package in one option. It
fake-signs raw ELF modules (as `FakeSignSelfModules` does) and constructs the `ProsperoDebugLicense`
grant for the content id and passcode, so the debug mount key is recomputed rather than granted and
no rif is written. `param.json` `applicationDrmType` is descriptive metadata and is not rewritten.
The fake-sign step restores the original source bytes after the build.
`ProsperoBuildResult.LicenseFree` echoes the option and `ProsperoBuildResult.DebugLicense` reports
the grant. The output is a debug finalized image.
- `ProsperoBackupConverter.Convert` repackages a decrypted application backup into a debug fPKG. It
copies the app tree (excluding the `decrypted/` mirror), substitutes each signed executable with
its decrypted raw ELF at the same relative path, then builds a debug image with fake-signing on so
the mount key derives from the content id and passcode. The fake-self wrap is the only transform
applied: content segments and the dynamic-linker tables are carried unchanged, and the executables
carry no in-module DRM boot gate. The backup is untouched; the converter works from a staging copy.
`ProsperoBackupConversionResult` reports the output path, the debug grant, the substituted /
plaintext / unresolved module lists, and a `LaunchReadiness` report (below) over the assembled tree.
### Launch-readiness inspection (`ProsperoLaunchReadiness`)
- `ProsperoLaunchReadiness.InspectAppRoot` reads an assembled application root and reports whether it
meets the debug-launch conditions the console enforces: every executable module is a plaintext module
the loader accepts (a fake-authority SELF or a raw ELF the builder fake-signs), `eboot.bin` is
present, and the metadata is a `param.json` rather than a PS4 `param.sfo`, which the launch service
refuses. It classifies each module (`InspectModule`) by magic and authority id into
`ModuleAuthorityKind` (`RawElf`, `FakeAuthoritySelf`, `GenuineAuthoritySelf`, `UnknownAuthoritySelf`,
`SignedEncrypted`, `NotExecutable`), reports `WillRunOnDebugConsole` per module, and aggregates
`Issues` plus an `IsLaunchReady` verdict on `ProsperoLaunchReadinessReport`. A signed/encrypted module,
a missing `eboot.bin`, a missing `param.json`, or a present `param.sfo` each block readiness.
`RequiresDebugConsole` records the one condition outside the package: a debug workspace mounts only on
a debug-enabled console. The inspector reads only; it never signs, mounts, or launches.
### Homebrew packaging (`ProsperoHomebrewPackager`)
- `ProsperoHomebrewPackager.Package` turns a compiled homebrew folder into an installable debug fPKG. It
validates the module folder and the module file, reads the content id and version from the homebrew's
`sce_sys/param.json` (options override), constructs the debug grant for the content id and passcode up
front so a malformed id fails before any file work, assembles a clean source tree (the module lands as
`eboot.bin`, the `sce_sys/` tree is copied), and builds a finalized debug image through
`ProsperoPackageBuilder.Build` with `LicenseFree = true` and the data-first inner image. It reads
the assembled tree back
with `ProsperoLaunchReadiness.InspectAppRoot` and returns the report alongside the output path, the
debug grant (`RequiresRif` false), the packed module path, and any warnings. The temporary tree is
removed unless `KeepStaging` is set. `ProsperoHomebrewPackageOptions` needs only `HomebrewFolder` and
`OutputFolder`; the rest default.
- The homebrew build produces an installable finalized image end to end: the `\x7FFIH` header with its
accounting fields populated (inner-image and metadata block counts, data-region block count,
content-version echo, outer file and flat-path-table counts), the `\x7FCNT` metadata container, the
data-first inner-image assembly, the `naps_pkg_layout.dat` outer file, the `naps_meta_18` and
`naps_meta_300/301/302/308` metric records, and the trailing `sce_suppl` install-metadata archive.
### Disc-backup packages (`app_0` / `app_sc`)
- Opens a split disc-backup package described by an `app.json` manifest through `DiscBackup.ProsperoDiscBackup`. `DiscBackup.ProsperoDiscBackupManifest` parses the manifest (`numberOfSplitFiles`, `originalFileSize`, `packageDigest`, the ordered `pieces[]` with `fileOffset` / `fileSize` / `url`, and the PlayGo chunk-CRC pointer).
- Reassembles the pieces on the fly with `DiscBackup.ProsperoConcatStream`, a read-only seekable stream that concatenates the piece windows without materializing a temporary file. `OpenPackageStream` presents the whole package as one stream; `ReassembleTo` writes it to a file or stream.
- Reads the reassembled finalized (`\x7FFIH`) image and its embedded `\x7FCNT` through the existing `ProsperoPkgReader` over the concat stream, including entries that straddle the split boundary. `FindImageKeyEntry` locates the EEKPFS key entry (id `0x20`), and `ExtractEntry` / `ExtractEntryBytes` copy any entry's stored bytes (encrypted entries stay encrypted).
- Verifies integrity three ways: `VerifyPackageDigest` recomputes the reassembled `SHA-256` and compares it to `packageDigest`; `VerifyChunkCrcHash` checks the `SHA-256` of the chunk-CRC file against `playgoChunkCrcHashValue`; `VerifyChunkCrcs` recomputes every 64 KiB CRC-32C and reports the first mismatch. `DiscBackup.ProsperoPlaygoChunkCrc` parses the headerless little-endian CRC-32C array (one value per 64 KiB chunk).
- For a split disc backup, the reassembled length equals `originalFileSize`, the image begins with `\x7FFIH`, the embedded `\x7FCNT` is found across the piece split, the EEKPFS key entry is present and extractable, the chunk-CRC file hash matches the manifest, the chunk count equals `ceil(originalFileSize / 64 KiB)`, and recomputed chunk CRC-32C values match the table.
### Split-package merge (`*_0` / `*_1` / `*_sc`)
- `PKG.ProsperoPkgMerger` reassembles a distribution-split package back into one finalized image. `MergeDirectory` discovers the split set in a folder, groups pieces by base name, orders the numbered pieces `_0 .. _N` ascending, and appends the `_sc` metadata piece last; `Merge` takes an explicit ordered piece list. The merged output is the byte concatenation of the ordered pieces, streamed through `DiscBackup.ProsperoConcatStream` so no oversized temporary buffer is materialized.
- `Validate` checks the split-set invariants before writing: the leading piece begins with a finalized (`\x7FFIH`) header whose format version is `3`, the signed byte selects the image type (`0x80` retail / `0x00` debug), the embedded-subcontainer offset in the header equals the shared PFS offset plus PFS size and equals the summed size of the numbered pieces, and the metadata piece carries the `\x7FCNT` subcontainer the header locates. `ProsperoPkgMergeValidation` reports the resolved image type, offsets, and per-piece sizes; `ProsperoPkgMergeResult` reports the written length and optional `SHA-256`.
- Cutting a finalized image at its embedded-subcontainer offset and merging the pieces back reproduces the original image (`SHA-256` match); an update split set validates with all invariants satisfied and the image type reported as full retail.
### Acceptance-gate validation
- `PKG.ProsperoPkgValidator` checks a parsed package against the structural preconditions the console mount path enforces. It returns a `ProsperoAcceptanceReport` of named `Pass` / `Warning` / `Fail` checks with an `Accepted` roll-up and a `HasWarnings` flag.
- Checks: a finalized (`\x7FFIH`) image is present (a bare `\x7FCNT` is not mountable), the FIH format version is `3` (the value the key-derivation path requires), the shared PFS image begins at `0x10000` and is non-empty, the embedded `\x7FCNT` parses, the EEKPFS key entry (`0x20`) is present, and the content id is present and — when an expected value such as a `rif` or `param.json` content id is supplied — matches.
- This validates the structural gate only; it does not perform the console's cryptographic checks. Reports `Accepted` for split disc-backup packages, with the embedded content id matching the corresponding `rif` content id.
- The report also includes a `Content-info` line projected through `NpDrm.ProsperoNpDrmContentInfo` (see below): the derived title id, drm/content type, content flags, patch kind, and nested-image flag.
### NpDrm content-info
- `NpDrm.ProsperoNpDrmContentInfo` projects a package header into the compact classification the mount path consumes before it accepts an image. It exposes the container offset, content id, derived title id, `DrmType` (`0x70`), `ContentType` (`0x74`), `ContentFlags` (`0x78`), `IsNestedImage`, `IsFinalized`, and the decoded `PatchKind` (`None` / `First` / `Subsequent` / `Delta` / `Cumulative`) with an `IsPatch` roll-up.
- `Read(path)` / `Read(stream)` parse and project in one call; `FromPackage` projects an already-parsed `ProsperoPkg`. `ResolveContainerOffset` mirrors the console's container-offset switch on the raw magic and version (`\x7FCNT` → `0`, `\x7FLIH` → u64 `0x30`, `\x7FFIH` → u64 `0x58`), and `DeriveTitleId` extracts the title id from a content id.
- `ProsperoPkgReader` now reads `ContentFlags` (`0x78`, big-endian) into `ProsperoPkgHeader`, and `DiscBackup.ProsperoDiscBackup.ReadContentInfo` projects the content-info from the reassembled image (the CNT metadata is carried by the tail piece, so this uses the full reassembled stream).
- For every reassembled image the projected content id and title id match the manifest, the container offset equals the embedded-CNT offset, `IsNestedImage` is set, and the patch kind is decoded from the content flags (base images report `None`; images carrying the subsequent-patch flag report `Subsequent`).
### Package extraction
- `PKG.ProsperoPackageExtractor` extracts the application filesystem from a finalized image end to end. It reads the FIH header for the outer-PFS offset/size and signed byte, opens the AES-XTS outer PFS, locates the nested `pfs_image.dat`, PFSC-decodes it to the inner image, opens the inner PFS, and writes every file with per-file PFSC decompression, confining all writes inside the output directory.
- `PKG.ProsperoExtractionKey` models the key material: `FromPasscode(passcode)` / `FromPasscode(contentId, passcode)` derive the outer EKPFS from public inputs (it materializes both the SHA-256 and SHA3-256 candidates and the extractor auto-selects whichever opens the outer PFS), `FromEkpfs(bytes)` takes a supplied 32-byte image key directly, and `None` attempts a plaintext outer PFS. It never derives or forges a retail image key.
- `PFS.ProsperoPfsExtractor` is the reusable single-image half: given an already-opened (and, if encrypted, decrypted) `ProsperoPfsReader`, it walks the `uroot` tree and writes the files. The package extractor composes two of these around the middle PFSC decode.
- `Inspect(path)` reports the package type, retail flag, outer-PFS offset/size, whether the outer PFS is encrypted, and whether a supplied key is required — without needing any key. `ListFiles(path, key)` enumerates the inner filesystem without writing. `Extract(...)` returns a manifest (package type, retail flag, content id, EKPFS fingerprint, outer file count, inner-image-compressed flag, and the written entries).
- A build/extract round trip holds: a debug image built from a known folder extracts to files with matching SHA-256 hashes, and the auto-selected EKPFS fingerprint matches the fingerprint the build pipeline reports for the same content id + passcode.
- A finalized retail image (signed byte `0x80`) encrypts the whole outer PFS including block 0, so its superblock is unreadable without the console-provisioned image key. `Inspect` reports `IsRetail = true` / `RequiresSuppliedKey = true`, and `Extract` throws a clear message naming the supplied-key requirement rather than returning a fabricated result.
## Known gaps / not implemented
- Retail finalized images with signed byte `0x80` are not implemented. They require console-side finalization material that the library does not have.
- Retail install-metadata archives are not implemented. The retail variant is encrypted and is not produced by the library.
- On-console installation acceptance is not guaranteed. Library code verifies structure and round-tripping; acceptance depends on console mode and firmware.
- The `rif` key blob (offset `0x240`, 448 bytes) is encrypted with per-console material and cannot be produced off-console. `ProsperoRif.Create` builds a structural record and copies a supplied blob verbatim; it does not derive a retail entitlement blob.
- The finalized/keyed image-key schedule is not implemented. The content key (`entitlement_key`) is modeled as a validated carrier and the finalize sc encryption is a standard AES-128-CBC pass (deterministic given key and IV), but the step that seals the content key into the 448-byte license key body and the retail image-key unwrap both use per-device material absent from any host binary. `ProsperoEntitlementKey` therefore carries supplied material only and never fabricates a seal.
- The EEKPFS key entry (`0x20`) extracted from a disc-backup package is returned as stored (encrypted). Off-console PFS key derivation from that entry is not implemented; extraction preserves the bytes for inspection only.
- Package extraction of a finalized retail image is console-gated. The outer PFS of a retail image is encrypted at block 0 with the image key delivered through the entitlement/kernel path, which is absent from any host binary and is neither derivable from public inputs nor brute-forceable (AES-128 / RSA-2048 wrap). `ProsperoPackageExtractor` therefore extracts debug/keyed images and any image whose 32-byte image key is supplied, and refuses a retail image without a supplied key with a clear message.
- The data-first outer generator, `naps_pkg_layout.dat` emission, `naps_meta_*` generation, and `\x7FFIH` assembly are implemented for the data-first path. The full streaming outer generator for arbitrary inputs is not complete: remaining pieces include rolling/weak/strong deduplication, block shuffle, per-outer-block encryption/CRC/digest integration, and full `pfsimage.xml` named-digest population for all package shapes.
- NAPS layout record values are not fully generated from arbitrary input. The format parser and serializer are implemented, but values derived from exact compression bookkeeping are only self-consistent for this library's own compressor output.
- `ProsperoNapsMeta` builds `naps_meta_300/301/302/308.dat` (48-byte records) from the build's own inner-image size and `naps_meta_18.dat` (the AES-128-XTS TLV metric blob) from the finalized image and its content-file table, and emits both in the SI segment automatically.
- The `pfsimage.xml` `<chunkinfo>`, `<pfs-image>`, and `<nested-image>` introspection trees are emitted from the build's own captured outer/inner-PFS inode layout. The outer superblock `<icv>` is the captured superblock HMAC and the `<seed>` is all-zero. Because the builder writes a superblock-first outer PFS, reported block indices and metadata offsets reflect that layout. The nested `<metadata>` pseudo-element and per-file `poffset` are intentionally omitted because compressed inner content does not provide stable values. These sections live in the supplemental `sce_suppl` ZIP that the console loader does not read, so they do not affect installability.
- Keyed or console-produced `pfsimage.xml` digest members in the install-metadata archive are supplied by the caller or left as placeholders; they are not fabricated.
- The Kraken inner compression codec implements level-7 block generation for covered `nwonly` inner-image files. Application payloads compress to compact blocks, and incompressible or executable modules are stored raw when compression is not beneficial.
- Whole `nwonly` package generation depends on non-Kraken factors: the inner PFS layout, supplied `sce_sys` inputs, and the recorded PFS build timestamp. Only a subset of `sce_sys` entries is derivable from the loose source folder and passcode; caller-supplied files are copied as provided.
- The data-first inner PFS format stores per-file Kraken-compressed data in a single PFS with the superblock at block `Ndblock-1`. Each compressible file's inode carries the `compressed` flag and stores standalone Kraken block data at the file's data offset; incompressible files are stored raw. `ProsperoPs5InnerImageAssembler` assembles the image: per-file compression, data-first block ordering, compressed-inode block tables, and the per-file compress-vs-store decision.
- `naps_pkg_layout.dat` is generated for the data-first build path (`ProsperoNwonlyNapsGenerator`) and placed as an outer-PFS file; it describes the outer download-stream block layout. The generated layout is valid for inputs whose compression schedule the emitted layout describes; arbitrary-input dedup/shuffle bookkeeping is not yet generated. `ProsperoNapsLayout` remains the round-trip parser/serializer.
## Summary table
| Capability | Status |
| --- | --- |
| `\\x7FCNT` build | Implemented |
| `\\x7FCNT` / `\\x7FFIH` read | Implemented |
| End-to-end debug package build | Implemented |
| Inner PFS layout | Implemented |
| Inner PFS AES-XTS encryption | Implemented |
| Kraken PFSC v3 container codec | Implemented; produces compact level-7 blocks for covered inputs |
| Data-first inner image + `naps_pkg_layout.dat` emission | Implemented; `ProsperoPs5InnerImageAssembler` assembles the per-file data-first image and `ProsperoNwonlyNapsGenerator` emits the layout |
| Kraken decoder | Implemented for the covered blocks |
| Kraken Huffman encoder arrays | Implemented |
| PS5 outer-image key derivation | Implemented |
| PS5 outer-image AES-XTS encryption | Implemented |
| PS5 outer-PFS signing hashes and `icv` | Implemented |
| PS5 outer-PFS data-first structure generator | Implemented |
| Metadata signing | Implemented |
| Finalized debug image (`\\x7FFIH`) | Implemented |
| Finalized digest table: `game-digest` / superblock digest | Implemented |
| CNT per-entry, body, fixed-info, param, package, and header-rollup digests | Implemented |
| CNT GeneralDigests block | Implemented and self-consistent |
| FIH `0xB0` nested-image-content digest | Implemented; self-consistent, exact value depends on exact inner compression bytes |
| `imagedigs.dat` CNT entry | Implemented |
| Fake-self generation (`MakeFself`) | Implemented |
| SELF authentication-info sidecar (`ProsperoSelfAuthInfo`: read / validate / build / write `*.auth_info`) | Implemented; round-trip verified, grant words supplied verbatim |
| ELF header editor (`ProsperoElfHeader`: read / edit OS/ABI, type, machine; normalize a module ELF) | Implemented; round-trip verified |
| Fake-sign build option (`FakeSignSelfModules`, fPKG) | Implemented; non-destructive in-place conversion |
| Application type / generated `param.json` app-type | Implemented; `ProsperoApplicationType` maps to `applicationDrmType` |
| `param.json` document model (`ProsperoParam`: read / edit / create / write) | Implemented; order-preserving round-trip, canonical order for fresh documents, typed accessors and constrained vocabularies |
| `manifest.json` document model (`ProsperoManifest`: read / edit / create / write) | Implemented; order-preserving round-trip at 4-space indent, typed accessors and nested `applicationData` |
| Supplied `sce_sys` system files (license, np, self, delta-info, keymap_rp, changeinfo, pronunciation, trophy) | Implemented; packed verbatim as outer CNT entries when present |
| `playgo-chunk.dat`, `playgo-hash-table.dat`, `playgo-ficm.dat` | Implemented |
| UCP archives (`trophy2/*.ucp`, `uds/*.ucp`) | Implemented, round-trip and digest verified |
| `npbind.dat` / `nptitle.dat` structural validation | Implemented; validated and identifiers extracted, packed verbatim |
| `playgo-chunk.crc` | Implemented |
| Debug install-metadata (SI) archive | Implemented; produces `naps_meta_18` / `naps_meta_300…`, `pfsimage.xml`, `playgo-chunk.dat` and `playgo-chunk.crc` |
| `pfsimage.xml` structural descriptor | Implemented, including `<chunkinfo>`/`<pfs-image>`/`<nested-image>` trees; self-consistent, supplied keyed digest rows remain placeholders |
| Keystone (`sce_sys/keystone`) | Implemented from passcode for version 2 and version 3 |
| PNG to BC7 DX10 DDS conversion (`icon0` / `pic0` / `pic1` / `pic2`) | Implemented; 148-byte DX10 header and file size follow the DX10 layout, BC7 payload re-encoded from the same surface |
| GP5 project model | Implemented |
| NAPS layout parser and serializer | Implemented; the data-first build path emits a valid layout via `ProsperoNwonlyNapsGenerator` |
| NAPS streaming outer generator | Data-first outer generation implemented; full streaming dedup/shuffle for arbitrary inputs incomplete |
| NAPS metric metadata (`naps_meta_18` / `naps_meta_300/301/302/308`) | Implemented; built by `ProsperoNapsMeta` and emitted in the SI segment |
| Retail install-metadata archive | Not implemented |
| Retail finalized image (`0x80`) | Not implemented |
| On-console acceptance guarantee | Not implemented; depends on console mode and firmware |
| License (`rif`) read / write / create | Implemented; validated and round-trip verified for single- and multi-title files |
| Disc-backup open / reassemble (`app_0` + `app_sc`) | Implemented |
| Disc-backup digest and chunk-CRC verification | Implemented; `SHA-256` package digest, chunk-CRC file hash, and 64 KiB CRC-32C recompute |
| Disc-backup embedded-CNT entry extraction | Implemented; stored bytes copied, encrypted entries stay encrypted |
| Split-package merge (`*_0` + `*_1` + `*_sc`) | Implemented; invariant-validated |
| FIH format-version read (`ProsperoFihHeader.FormatVersion`) | Implemented |
| NpDrm content-info projection (`ProsperoNpDrmContentInfo`) | Implemented |
| Content-flags read (`ProsperoPkgHeader.ContentFlags`, `0x78`) | Implemented |
| Patch-kind classification (`None` / `First` / `Subsequent` / `Delta` / `Cumulative`) | Implemented |
| Acceptance-gate structural validation (`ProsperoPkgValidator`) | Implemented; structural gate only, not console cryptographic checks |
| Content key model (`ProsperoEntitlementKey`) + passcode/entitlement-key mode rule | Implemented; validated carrier for supplied material, never forged |
| Multi-content license set (`ProsperoRifSet`: `n_rif` / `ServiceID` / `has_app` / `n_ac` / size) | Implemented |
| Debug license grant (`ProsperoDebugLicense`: derived key set, no rif required, structural zero-blob rif) | Implemented; derivation and round-trip verified |
| DRM/license-free build option (`LicenseFree`: fake-sign + derived mount key, no rif) | Implemented; round-trip verified, source restored |
| Decrypted-backup to debug fPKG conversion (`ProsperoBackupConverter`: substitute executables, fake-sign, derive mount key) | Implemented; round-trip verified across the backups |
| Launch-readiness inspection (`ProsperoLaunchReadiness`: module authority classes, eboot presence, `param.json`-not-`param.sfo`, debug-console requirement) | Implemented; round-trip verified |
| Homebrew module to installable debug fPKG (`ProsperoHomebrewPackager`: assemble source tree, license-free build, launch-readiness check) | Implemented; end-to-end verified |
| Per-record service label (`ProsperoRif.ServiceLabel`) | Implemented |
| Finalized/keyed license key-body seal + retail image-key unwrap | Not implemented; console-gated, supply verbatim |
| Package extraction (debug/keyed image → outer PFS → `pfs_image.dat` PFSC → inner PFS → files) | Implemented; verified by build → extract round trip |
| Package extraction key model (`ProsperoExtractionKey`: passcode / supplied EKPFS / none) | Implemented; derives debug EKPFS from public inputs, never forges a retail key |
| Package inspection without a key (`ProsperoPackageExtractor.Inspect`) | Implemented; type / retail flag / outer-PFS offset+size / encrypted / key-required |
| Package extraction of a finalized retail image (block-0 encrypted) | Not implemented; console-gated, refused cleanly unless the image key is supplied |
-460
View File
@@ -1,460 +0,0 @@
# PS5 Package Format — Technical Write-up
This document describes the structure of a PS5 package and the end-to-end
process LibProsperoPkg follows to create one. It is a technical write-up for developers working
with the format; the offsets and field names below match the library's own reader, builder and
finalizer.
> Endianness is mixed: the outer container (`\x7FCNT`) header is **big-endian**, while the
> finalized-image (`\x7FFIH`) header fields are **little-endian**. This is called out per section.
---
## 1. Overview
A complete, installable PS5 package is a **finalized image** with the magic `\x7FFIH`. It wraps
a **metadata container** with the magic `\x7FCNT`,
together with a shared, encrypted **PFS** image holding the actual
application files.
At the highest level a finished package is four consecutive segments:
```
┌──────────────────────────────────────────────────────────────┐
│ FIH header + finalization digest table (0x00000–0x10000) │ little-endian
├──────────────────────────────────────────────────────────────┤
│ PFS shared AES-XTS-encrypted outer PFS image │
├──────────────────────────────────────────────────────────────┤
│ SC embedded \x7FCNT metadata container │ big-endian header
├──────────────────────────────────────────────────────────────┤
│ SI install-metadata archive │
└──────────────────────────────────────────────────────────────┘
```
The console reports these segments as **FIH / PFS / SC / SI**.
---
## 2. The metadata container (`\x7FCNT`)
The `\x7FCNT` container holds the package metadata: the entry table, the content id, and the
descriptor entries (param.json, icons, PlayGo data, license, digests, keys, …). It is metadata
only — by itself it is **not** an installable package.
### 2.1 Header (big-endian)
| Field | Notes |
|---|---|
| Magic | `0x7F 'C' 'N' 'T'` |
| Flags | Container flags |
| Entry count | Number of records in the entry table |
| Entry-table offset | File offset of the first entry-meta record |
| Body offset / size | Region holding the entry payloads |
| Content ID | 36-character ASCII identifier, stored in a `0x30`-byte field |
| DRM type / content type | Package classification |
The container header region is `0x5A0` bytes.
### 2.2 Entry table
The entry table is an array of fixed-size **`0x20`-byte** records. Each record describes one
entry:
| Field | Notes |
|---|---|
| Id | A well-known entry id (see below) |
| Name-table offset | Offset of the entry's name within the entry name table (`0x0200`) |
| Flags | Includes the encrypted-entry flag (`0x80000000` in `Flags1`) |
| Data offset / size | Location and length of the entry payload |
Entry names are resolved from the `ENTRY_NAMES` table (entry id `0x0200`).
### 2.3 Well-known entry ids
The subset relevant to inspection and
creation:
| Id | Entry |
|---|---|
| `0x0001` | Digests |
| `0x0010` | Entry keys |
| `0x0020` | Image key |
| `0x0080` | General digests |
| `0x0100` | Metas |
| `0x0200` | Entry names |
| `0x0400` / `0x0401` | License data / license info |
| `0x0402` / `0x0403` / `0x0404` | `nptitle.dat` / `npbind.dat` / `selfinfo.dat` |
| `0x0407` / `0x0408` | `target-deltainfo.dat` / `origin-deltainfo.dat` |
| `0x040A` | `imagedigs.dat` (**unnamed** entry) — `N × 32` outer-block digest table |
| `0x1001` | `playgo-chunk.dat` |
| `0x1004` / `0x1005` | `pronunciation.xml` / `pronunciation.sig` |
| `0x1007` | `pubtoolinfo.dat` |
| `0x1200` / `0x1220` / `0x1240` | `icon0.png` / `pic0.png` / `snd0.at9` |
| `0x1260`+ | `changeinfo/changeinfo.xml` (and `changeinfo_NN.xml`) |
| `0x1280` / `0x12A0` / `0x12C0` / `0x2060` | `icon0.dds` / `pic0.dds` / `pic1.dds` / `pic2.dds` |
| `0x1600`+ | `keymap_rp/...` Remote Play key-map images |
| `0x2000` | `param.json` |
| `0x2010` / `0x2011` | `playgo-hash-table.dat` / `playgo-ficm.dat` |
> A `nwonly` debug CNT carries 13 entries:
> `0x0001 0x0010 0x0020 0x0080 0x0100 0x0200 0x040A 0x1001 0x1200 0x1280 0x2000 0x2010 0x2011`
> (no license entries). `imagedigs.dat` (`0x040A`) is the only **unnamed** body entry. The
> license, network-platform, self-info, delta-info, keymap_rp, changeinfo and pronunciation entries
> are added only when the source folder supplies those files (see §8.1).
---
## 3. The PFS image
The application's files live inside a **PFS** image. There are two
nested images:
- The **inner image** holds the actual file tree (`uroot`): `sce_sys/`, `eboot.bin`, data, etc.
- The **outer image** wraps the inner image (optionally compressed) plus the metadata, and is
the segment the finalized image points to.
### 3.1 Layout
The inner PFS image is laid out from the prepared folder:
1. The folder tree is walked and turned into PFS directory and file inodes.
2. Inode tables, the directory structure and the data region are written.
3. A superblock records the image geometry and the format version (always 2 for PS5).
The resulting plaintext image round-trips through the reader through the PFS reader.
### 3.2 Merkle integrity
PFS protects its data with a **SHA-256 Merkle** hash tree: each block's hash rolls up through
parent levels to a root, so any tampering is detectable. This is built as part of the image.
### 3.3 Encryption (AES-XTS)
The image is encrypted with **AES-XTS** over **`0x1000`-byte (4 KiB) sectors**:
1. The **EKPFS** (encrypted-key PFS) roots the key schedule. For the **inner PFS image**, the
EKPFS is derived from the content id and passcode using SHA3-256. For the **shared outer
finalized-image PFS**, the EKPFS is the `pfs-image-key` stored in the package metadata (it
is *not* the passcode-derived inner key) and is consumed directly.
2. From the EKPFS and the 16-byte superblock **seed**, the per-image key material (tweak key,
data key, sign key) is derived using the SHA3-256-based `new_crypt` key schedule.
The XTS sector number is the **image-relative** sector index (the first encrypted sector is
tweak 0), with a `0x1000` sector size.
3. Every sector except the plaintext header block is encrypted; the encryption flag and the
seed are stamped in the superblock.
`Util/Crypto.cs` (`PfsGenCryptoKey`/`PfsGenEncKey`/`PfsGenSignKey`) implements this
schedule.
The header (block 0) stays plaintext because the kernel needs to read the superblock before it
has the keys. The encrypted image decrypts back to the original image.
### 3.4 Compression (PFSC)
The inner image can optionally be stored as a **PFSC** container — a block-compressed form that
substantially reduces the dominant size driver (`pfs_image.dat`). Each block is compressed
independently; incompressible blocks (and incompressible images as a whole) fall back to a raw
wrapper. A PFSC image decompresses back to a valid, mountable inner PFS.
---
## 4. Metadata signing
The package metadata is signed with **RSA-3072, PKCS#1 v1.5, SHA-256**. The same key material
drives the EKPFS/PFS key derivation used for the inner-image encryption. The signer verifies the
published key fingerprint and performs a sign/verify round-trip before use.
---
## 5. The finalized image (`\x7FFIH`)
Finalization wraps the `\x7FCNT` container and the shared PFS image into the installable
`\x7FFIH` image.
### 5.1 Header (little-endian)
| Offset | Field | Notes |
|---|---|---|
| `0x00` | Magic | `0x7F 'F' 'I' 'H'` |
| `0x05` | Signed byte | `0x00` = debug, `0x80` = retail/submitted |
| `0x10` | PFS image offset | u64; always `0x10000` |
| `0x18` | PFS image size | u64 |
| `0x58` | Embedded CNT (SC) offset | u64 |
| `0xA0` | Embedded CNT (SC) size | u64 |
The header + digest-table region is always **`0x10000`** bytes, and the PFS segment always
begins at `0x10000`. The FIH offset (`0`) and FIH size (`0x10000`) are constant; only the
segment sizes vary.
### 5.2 The signed byte
The single byte at offset `0x05` is what distinguishes a **debug** finalized image (`0x00`)
from a **retail/submitted** one (`0x80`). A console in debug mode relaxes finalized-image
verification and accepts the debug variant.
### 5.3 Finalization digest table
The remainder of the FIH region holds the finalized digests. The `game-digest` (`0x30`/`0x70`/`0xD0`)
is `SHA3-256` of the plaintext outer superblock, and the embedded CNT carries the package-digest
self-seal, the CNT-header rollup, the per-entry digest table and the GeneralDigests block
(content/header/system/param/playgo/target). LibProsperoPkg computes these values from the finalized CNT and image data.
The FIH `0xB0` slot is `SHA3-256` of the **uncompressed inner PFS image** at its plain size and is threaded through the build path.
See [implementation-status.md](implementation-status.md).
### 5.4 The SI segment
The image ends with a trailing **STORED ZIP** archive of install-time metadata (every member
uncompressed / `STORED`), in this member order:
| Path | Notes |
|---|---|
| `common/etc/naps_meta_18.dat` | per-package metric blob (AES-128-XTS TLV), built by `ProsperoNapsMeta.BuildMeta18` over the finalized image and its content-file table; size scales with the file table and outer-block count. |
| `common/etc/naps_meta_300.dat` | 48 B; generated from inner-image geometry (`R = alignUp(pfs_image.dat) - 0x10000` at 0x10/0x20, kind id `0x3E9` at 0x18, block size `0x10000` at 0x28) |
| `common/etc/naps_meta_301.dat` | 48 B, same structure as `_300` |
| `common/etc/naps_meta_302.dat` | 48 B, same structure as `_300` |
| `common/etc/naps_meta_308.dat` | 48 B, same structure as `_300` |
| `common/etc/pfsimage.xml` | machine-readable image descriptor (see below) |
| `common/etc/playgo-chunk.dat` | 416 B; identical to the CNT `0x1001` copy |
| `config/<content-id>/playgo-chunk.crc` | CRC-32C per 64 KiB block of the mount image |
The SI segment is **emitted automatically** by the `nwonly` build: `ProsperoPkgBuilder` captures the
deterministic `pfsimage.xml` options, the CNT `playgo-chunk.dat`, and the block-aligned inner-image size
during the CNT build, and `ProsperoFihBuilder.BuildFromCnt` appends the ZIP produced by
`ProsperoSiArchive.BuildDebugSiSegment` after the embedded CNT. `BuildMembers` → `WriteZip` writes the
member order, paths, `STORED` framing and `naps_meta_30x` identity exactly; the `playgo-chunk.crc` is
recomputed from the finalized mount image (CRC-32C). The `naps_meta_300` `R` is the inner-image
data-region size and is legitimately `0` when the inner image fits in one 0x10000 block (tiny
inputs); real multi-MB application content yields the expected non-zero value (for example `0x40000`).
`pfsimage.xml` is generated through its `<config>`, `<digests>`, `<params>`,
`<container>`, `<mount-image>` and `<entries>` sections — including the version constants
`<version-date>0x20200722</version-date>` / `<version-hash>0x01fe52e9</version-hash>`, the derived
`<longname>`, the full container/mount geometry and the CNT entry table, all populated with the build's
own self-consistent digests. The deep `<chunkinfo>`/`<pfs-image>` (outer PFS) / `<nested-image>` (inner
PFS) introspection trees are emitted as well, walked from the build's own captured outer/inner inode
layout (`ProsperoPfsBuilder.CaptureImageTree`). They describe the library's generated image: the outer superblock `<icv>` is the captured superblock HMAC
and the `<seed>` is all-zero. Because the builder writes a superblock-first outer PFS, the reported block indices and metadata offsets reflect that layout. The nested
`<metadata>` pseudo-element and per-file `poffset` are intentionally omitted. Inner `sce_sys` files packed
as outer CNT entries (e.g. `icon0.png`) receive no inner inode and are correctly absent from the
`<nested-image>` tree. These trees are informational metadata that the console loader does not read.
The `naps_meta_18.dat` metric blob is built by `ProsperoNapsMeta.BuildMeta18` from the finalized image
and its content-file table. See [implementation-status.md](implementation-status.md).
---
## 6. End-to-end creation process
Putting the pieces together, the library builds a package as follows:
1. **Validate inputs** — content id (36 chars), title id, and passcode (32 chars). Optionally generate a minimal `param.json` if the source folder
lacks one. When `ApplicationType` is set, the generated `param.json` carries the matching
`applicationDrmType` (`free` / `standard` / `freemium`).
2. **Fake-sign modules (optional)** — when `FakeSignSelfModules` is set, convert each raw ELF in
the source tree (`eboot.bin`, `*.elf`, `*.prx`, `*.sprx`) to fake-self in place. Files that are
already SELF are skipped. The original bytes are restored after the build so the source folder
is left unchanged.
3. **Generate auxiliary `sce_sys` files** — `about/right.sprx`, `playgo-chunk.dat`,
`playgo-manifest.xml`, and the BC7 DDS siblings of the icon/picture images — so the file set
is complete.
4. **Lay out the inner PFS** — walk the folder into a plaintext inner-PFS image with the SHA-256
Merkle tree and the correct (PS5) superblock version.
5. **Render the inner image** — leave it plaintext, **AES-XTS-encrypt** it with the EKPFS
(the `pfs-image-key`; §3.3), or **PFSC-compress** it.
6. **Build the outer PFS + `\x7FCNT`** — assemble the metadata container, the entry table and
the entry-name table around the inner image. Any backend-authored system file supplied under
`sce_sys/` (license, network-platform, self-info, delta-info, keymap_rp, changeinfo,
pronunciation, trophy; §8.1) is added here as an outer CNT entry with its fixed id.
7. **Sign the metadata** — RSA-3072 / SHA-256.
8. **Finalize** — wrap the container and shared PFS image into a `\x7FFIH` **debug** image
(signed byte `0x00`), writing the FIH header and the segment offsets/sizes.
The result round-trips through `ProsperoPkgReader` as a full debug image whose embedded
container and shared PFS image are intact.
---
## 7. Glossary
| Term | Meaning |
|---|---|
| **CNT** | The `\x7FCNT` metadata container. |
| **FIH** | The `\x7FFIH` finalized image — the installable package wrapper. |
| **PFS** | Package file system — the encrypted, integrity-protected image holding the files. |
| **PFSC** | The block-compressed form of a PFS image. |
| **EKPFS** | The encrypted-key PFS, the root of the PFS key schedule. The shared outer image uses the package's `pfs-image-key`; inner images use a passcode/content-id-derived key. |
| **AES-XTS** | The sector-based block-cipher mode used to encrypt the PFS image. |
| **Merkle tree** | The SHA-256 hash tree that protects PFS block integrity. |
| **SC / SI** | The embedded metadata container segment and the trailing install-metadata archive within a finalized image. |
---
## 8. `sce_sys` / `sce_suppl` metadata files
These auxiliary files fall into two groups. `imagedigs.dat` and the PlayGo files (`playgo-chunk.dat`,
`playgo-hash-table.dat`, `playgo-ficm.dat`) are **outer-CNT body entries** — they live in the `\x7FCNT`
container metadata, NOT inside the inner PFS image. (An extractor presents them under a `sce_sys/` view,
which historically caused them to be modelled as inner-PFS files; they are not.) The `sce_suppl/common/etc`
SI archive (`naps_meta_*`, the second `playgo-chunk.dat` copy, `pfsimage.xml`) is a separate supplemental
stream. CNT-entry placement for each file is described below.
| File | Location | Description |
|---|---|---|
| `imagedigs.dat` | CNT entry `0x040A` (unnamed) | `N × 32` byte digest table, one entry per 64 KiB **outer** image block (e.g. 11 blocks = 352 B). The outer-PFS builder captures the per-block descriptor digests of the finalized outer image (`CaptureImageDigests`), and the builder patches them into the entry after `WriteImage`. Each stored 32-byte digest is written in opposite byte order. Because it digests the outer image but does **not** live in it, there is no self-dependency / fixpoint; the build is single-pass and the entry size (`outerBlocks × 32`) is known up front. |
| `playgo-chunk.dat` | CNT entry `0x1001` **and** `sce_suppl/common/etc` (SI) | 416-byte PlayGo chunk descriptor. Both copies contain the same payload. Generated by `PlayGo.ProsperoPlayGo.BuildChunkDat`. |
| `playgo-hash-table.dat` | CNT entry `0x2010` | PlayGo file hash table; `0x38 + n × 8` bytes (n = `ficmCount / 2`). A content-independent constant structure (version=1, `\x7FFLT` magic at `0x18`, fixed 16-byte prefix + `n × 8` constant table entries). `PlayGo.ProsperoPlayGo.BuildHashTable`. |
| `playgo-ficm.dat` | CNT entry `0x2011` | PlayGo file-in-chunk map; 16-byte header + `fileCount` bytes. `PlayGo.ProsperoPlayGo.BuildFicm`. |
| `playgo-chunk.crc` | `config/<content-id>/` (SI) | CRC-32C over each 64 KiB block of the finalized mount image. `ProsperoPlayGo.BuildChunkCrc`. |
| `naps_meta_18.dat` | `sce_suppl/common/etc` (SI) | Per-package NAPS metric blob (AES-128-XTS TLV); size scales with the content-file table and outer-block count. Built by `ProsperoNapsMeta.BuildMeta18` over the finalized image and its content-file table. |
| `naps_meta_300/301/302/308.dat` | `sce_suppl/common/etc` (SI) | 48-byte NAPS records; `301/302/308` share the `300` record structure. Generated by `ProsperoNapsMeta`. |
| `pfsimage.xml` | `sce_suppl/common/etc` (SI) | Machine-readable image descriptor; includes `<entries>` plus the `<chunkinfo>`/`<pfs-image>`/`<nested-image>` introspection trees (self-consistent; see §5.4). |
> **`naps_pkg_layout.dat`.** For the data-first inner image the builder generates a valid
> `naps_pkg_layout.dat` as an outer-PFS file (alongside `pfs_image.dat`) through
> `ProsperoNwonlyNapsGenerator`; it describes the outer download-stream block layout.
> `ProsperoNapsLayout` is the round-trip serializer/parser this builds on.
### 8.1 Supplied system files
The source folder may contain backend-authored files under `sce_sys/` that carry a fixed CNT id:
`license.dat` / `license.info`, `nptitle.dat`, `npbind.dat`, `selfinfo.dat`,
`origin-deltainfo.dat` / `target-deltainfo.dat`, `pubtoolinfo.dat`, `pronunciation.xml` /
`pronunciation.sig`, `changeinfo/changeinfo*.xml`, the `keymap_rp/` image set, and the `trophy/`
archives.
These are **outer-CNT body entries**, not inner-PFS files: the inner-image builder keeps every
named system file out of the inner PFS, so they are carried in the `\x7FCNT` container instead.
LibProsperoPkg packs each supplied file whose `sce_sys`-relative path maps to a known entry id
(`ProsperoPkgBuilder.CollectMediaEntries`); files that are absent are simply skipped. The payloads
are produced by a signing backend and are stored verbatim — the library does not generate them.
`keymap_rp` uses two path shapes, flat `keymap_rp/0NN.png` and nested `keymap_rp/NN/0NN.png`; each
image is its own CNT entry. The key-map set is capped at 1 MiB total.
Because the standard `nwonly` debug package supplies none of these files, its CNT stays at
13 entries. Each supplied system file adds one entry.
### 8.2 UCP archives (`trophy2/*.ucp`, `uds/*.ucp`)
The trophy set and universal data system are carried as UCP archives inside the inner PFS
(`sce_sys/trophy2/trophyNN.ucp`, `sce_sys/uds/udsNN.ucp`). Unlike the signed system files in §8.1,
UCP archives are inner-PFS files and are fully producible.
A UCP file is a flat container: a `0x60`-byte big-endian header (magic `0xB228C60A`, version 1, total
size, entry count, and a 20-byte SHA-1 digest at `0x1C`) followed by `0x40`-byte entry records
(32-byte name, u64 offset, u64 size) sorted in ascending ordinal name order, then the blobs. Each
blob begins at the next strictly-greater 16-byte boundary after the previous blob's end. The digest
is a plain SHA-1 over the whole file with the digest field zeroed, so it can be verified and repaired
without keys.
`Content.ProsperoUcp` reads, builds (from entries or from a directory), validates, verifies, and
repairs UCP files; rebuilt archives round-trip through the parser. During a build,
`ProsperoPkgBuilder.EnsureUcpArchives` repairs a stale digest on a supplied archive but never
synthesizes its contents.
### 8.3 System-file validation
Before packing, backend-signed files are structurally validated by `PKG.ProsperoSystemFiles`:
`npbind.dat` (532 bytes, magic `0xD294A018`, communication id in the TLV chain at `0x80`) and
`nptitle.dat` (160 bytes, magic `NPTD`, title id at `0x10`) are checked and their identifiers
extracted; `license.dat` / `license.info` require a non-empty payload. A malformed file stops the
build with a descriptive error rather than producing an invalid package.
### 8.4 SELF container and fake-self
`sce_sys/about/right.sprx` is a SELF (Signed ELF) module. `Content.ProsperoFself` parses the SELF
header, segment table, embedded ELF header and program headers, and the extended-info block, and can
generate a fake-self from any 64-bit ELF with `MakeFself`.
The generator emits a digest/data segment pair for each program header whose file size is non-zero and
whose type is `PT_LOAD`, module-data (`0x61000000`), relro (`0x61000010`), or comment (`0x6FFFFF00`),
in program-header index order. The extended-info digest is `SHA-256` of the input ELF; the authority id
and program type are derived from the ELF type and the byte at file offset `0x3f00`; digest and
signature slots are zero-filled. A generated module round-trips through the parser and preserves the
segment layout of the source module. Package builds embed a fixed `right.sprx` asset when the source
provides none (§6); the generator is a standalone capability for arbitrary ELF input.
When `FakeSignSelfModules` is enabled on the build options (§6, step 2), the builder applies
`MakeFself` to every raw ELF module in the source tree before layout — `eboot.bin` and any
`*.elf` / `*.prx` / `*.sprx`. Inputs that are already SELF, or that are not ELF, are skipped. Each
converted file is written in place for the duration of the build and restored to its original bytes
once packing completes, so the resulting fake package embeds fake-self modules while the source
folder is left unmodified. Per-module conversion settings come from `FselfOptions` when supplied.
> **Digest boundary.** The `content` / `game` / `header` / `system` / `param` / `package` / `body` /
> `sblock` / `fixed-info` digests, the superblock `icv`, the per-entry digest table and `imagedigs.dat`
> are all computed by the library as SHA3-256 over the finalized CNT regions and outer image, and are
> self-consistent for a debug image. The fields that need console-side material the library does not
> have are the retail image-key seal, the `rif` key blob, and the encrypted retail finalization
> material; those are emitted as structurally valid placeholders for a debug image and reported as
> warnings.
---
## 9. License file (`rif`)
A `rif` is a fixed **0x400-byte** record with a big-endian header. Multiple sub-title licences are
concatenated with no container header, so a whole-file size is always a positive multiple of `0x400`
(one record = single content; `N × 0x400` = a set). Layout of one record:
| Offset | Size | Field |
|---|---|---|
| `0x00` | 4 | Magic `52 49 46 00` (`RIF\0`). |
| `0x04` | 2 | Version, big-endian (`0x0002`). |
| `0x06` | 2 | Flags, big-endian (commonly `0xFFFF`). |
| `0x14` | 4 | Format tag `51 50 61 43` (`QPaC`). |
| `0x18` | 8 | Expiry / timestamp, big-endian (`0x7FFFFFFFFFFFFFFF` = non-expiring). |
| `0x20` | `0x24` | Content-id (36 chars, NUL-trimmed). |
| `0x50` | 8 | Format descriptor (`01 04 00 10 00 20 00 03`). |
| `0x60` | 8 | Entry-count / flag, big-endian (commonly `1`). |
| `0x240` | `0x1C0` | Encrypted key blob (448 bytes). |
`License.ProsperoRif` reads/writes a single record; `License.ProsperoRifSet` handles the
concatenated multi-content case (per-record content-id, service label, `has_app`, `n_ac`, whole-file
size). `License.ProsperoEntitlementKey` carries the 16-byte content key and enforces the
passcode-XOR-content-key selection rule.
> **Keyed-field boundary.** The 448-byte key blob at `0x240` is encrypted with per-device
> material. The header is fully readable and deterministic; the key blob is carried and validated,
> never forged.
---
## 10. Split disc-backup packages (`app_0` + `app_sc`)
A disc backup stores one finalized image split across several piece files, described by an
`app.json` manifest that sits beside them:
| File | Role |
|---|---|
| `app.json` | Manifest: reassembled `originalFileSize`, the SHA-256 package `digest`, an ordered `pieces[]` list (each with a `url` and `fileSize`), and the `playgo-chunk.crc` path. |
| `app_0`, `app_sc`, ... | The ordered pieces. Concatenated in manifest order, they reconstruct the original finalized package. |
| `app.crc` | PlayGo chunk CRC file for the reassembled image. |
`DiscBackup.ProsperoDiscBackup.Open(path)` parses the manifest and resolves each piece relative to
its directory. `OpenPackageStream` exposes the reassembled image as one seekable stream without
writing it to disk; `ReassembleTo` writes the joined package; `VerifyPackageDigest` checks the joined
stream against the manifest SHA-256; `ReadPackage` / `ReadContentInfo` parse the reassembled image
directly. The embedded CNT of a split retail image lives in the `app_sc` piece tail, so content-info
resolves only after reassembly.
---
## 11. Reading and extracting a package
`PKG.ProsperoPackageExtractor` is the read side of the creation pipeline:
- `Inspect(path)` reports package type, whether the outer image is encrypted, the outer offset/size,
and whether a supplied key is required — without decrypting.
- `ListFiles(...)` walks the inner PFS directory.
- `Extract(...)` unpacks to a directory. A debug/passcode image is opened from public inputs: the
EKPFS is `ComputeKeys(content-id, passcode, index = 1)` (SHA-256 primary, SHA3-256 fallback), and
the AES-XTS counter starts at `block_size / 0x1000` so the `0x10000` superblock is plaintext.
`PKG.ProsperoExtractionKey` materializes those EKPFS candidates from a passcode/content-id or accepts
a supplied 32-byte EKPFS. `PFS.ProsperoPfsExtractor` is the reusable single-image walker underneath.
> **Keyed-field boundary.** A finalized retail outer image is encrypted at block 0 with an image
> key delivered through the entitlement/kernel path; it is not derivable from public inputs and not
> brute-forceable. `Inspect` flags such an image as requiring a supplied key and `Extract` refuses
> cleanly rather than emitting corrupt output.
-96
View File
@@ -1,96 +0,0 @@
<Application xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="clr-namespace:LibProsperoPkg.Gui.ViewModels"
xmlns:fields="clr-namespace:LibProsperoPkg.Gui.ViewModels.Fields"
x:Class="LibProsperoPkg.Gui.App"
RequestedThemeVariant="Default">
<Application.DataTemplates>
<DataTemplate DataType="fields:TextFieldViewModel">
<StackPanel Margin="0,0,0,12" Spacing="4">
<TextBlock Text="{Binding Label}" FontWeight="SemiBold" />
<Grid ColumnDefinitions="*,Auto">
<TextBox Grid.Column="0"
Text="{Binding Value, Mode=TwoWay}"
PlaceholderText="{Binding Watermark}"
AcceptsReturn="{Binding Multiline}"
TextWrapping="Wrap"
Height="{Binding InputHeight}"
VerticalContentAlignment="Top" />
<Button Grid.Column="1"
Content="Browse"
Margin="8,0,0,0"
VerticalAlignment="Top"
IsVisible="{Binding ShowBrowse}"
Command="{Binding BrowseCommand}" />
</Grid>
<TextBlock Text="{Binding Description}"
IsVisible="{Binding HasDescription}"
Opacity="0.7"
TextWrapping="Wrap"
FontSize="12" />
</StackPanel>
</DataTemplate>
<DataTemplate DataType="fields:CheckFieldViewModel">
<StackPanel Margin="0,0,0,12" Spacing="2">
<CheckBox Content="{Binding Label}" IsChecked="{Binding Value, Mode=TwoWay}" />
<TextBlock Text="{Binding Description}"
IsVisible="{Binding HasDescription}"
Opacity="0.7"
TextWrapping="Wrap"
FontSize="12"
Margin="28,0,0,0" />
</StackPanel>
</DataTemplate>
<DataTemplate DataType="fields:ChoiceFieldViewModel">
<StackPanel Margin="0,0,0,12" Spacing="4">
<TextBlock Text="{Binding Label}" FontWeight="SemiBold" />
<ComboBox ItemsSource="{Binding Options}"
SelectedItem="{Binding Selected, Mode=TwoWay}"
HorizontalAlignment="Stretch" />
<TextBlock Text="{Binding Description}"
IsVisible="{Binding HasDescription}"
Opacity="0.7"
TextWrapping="Wrap"
FontSize="12" />
</StackPanel>
</DataTemplate>
<DataTemplate DataType="vm:ActionViewModel">
<Button Content="{Binding Name}"
Command="{Binding Command}"
Classes.accent="{Binding IsPrimary}"
Margin="0,0,8,8"
Padding="16,8" />
</DataTemplate>
<DataTemplate DataType="vm:ToolPageViewModel">
<ScrollViewer HorizontalScrollBarVisibility="Disabled">
<StackPanel Margin="24" Spacing="8" MaxWidth="720" HorizontalAlignment="Left">
<TextBlock Text="{Binding Title}" FontSize="22" FontWeight="Bold" />
<TextBlock Text="{Binding Description}"
Opacity="0.75"
TextWrapping="Wrap"
Margin="0,0,0,12" />
<ItemsControl ItemsSource="{Binding Fields}" />
<ItemsControl ItemsSource="{Binding Actions}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<WrapPanel Orientation="Horizontal" />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
</ItemsControl>
</StackPanel>
</ScrollViewer>
</DataTemplate>
</Application.DataTemplates>
<Application.Styles>
<FluentTheme />
</Application.Styles>
</Application>
-27
View File
@@ -1,27 +0,0 @@
using Avalonia;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Markup.Xaml;
using LibProsperoPkg.Gui.Services;
using LibProsperoPkg.Gui.ViewModels;
using LibProsperoPkg.Gui.Views;
namespace LibProsperoPkg.Gui;
public partial class App : Application
{
public override void Initialize() => AvaloniaXamlLoader.Load(this);
public override void OnFrameworkInitializationCompleted()
{
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{
var storage = new StorageService();
var model = new MainWindowViewModel(storage);
var window = new MainWindow { DataContext = model };
storage.Owner = window;
desktop.MainWindow = window;
}
base.OnFrameworkInitializationCompleted();
}
}
@@ -1,28 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<BuiltInComInteropSupport>true</BuiltInComInteropSupport>
<ApplicationManifest>app.manifest</ApplicationManifest>
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
<AssemblyName>LibProsperoPkg.Gui</AssemblyName>
<RootNamespace>LibProsperoPkg.Gui</RootNamespace>
<Version>2.6.0</Version>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Avalonia" Version="12.0.5" />
<PackageReference Include="Avalonia.Desktop" Version="12.0.5" />
<PackageReference Include="Avalonia.Themes.Fluent" Version="12.0.5" />
<PackageReference Include="Avalonia.Fonts.Inter" Version="12.0.5" />
</ItemGroup>
<ItemGroup>
<Reference Include="LibProsperoPkg">
<HintPath>..\LibProsperoPkg\bin\Release\net10.0\LibProsperoPkg.dll</HintPath>
</Reference>
</ItemGroup>
</Project>
@@ -1,41 +0,0 @@
using System;
using System.Threading.Tasks;
using System.Windows.Input;
namespace LibProsperoPkg.Gui.Mvvm;
public sealed class AsyncRelayCommand(Func<Task> execute, Func<bool>? canExecute = null) : ICommand
{
private readonly Func<Task> _execute = execute;
private readonly Func<bool>? _canExecute = canExecute;
private bool _running;
public event EventHandler? CanExecuteChanged;
public bool CanExecute(object? parameter) => !_running && (_canExecute?.Invoke() ?? true);
public async void Execute(object? parameter)
{
if (!CanExecute(parameter))
return;
_running = true;
RaiseCanExecuteChanged();
try
{
await _execute();
}
catch
{
// A faulting command delegate must not escape as an unhandled async-void exception,
// which would tear down the application. The running state is still reset below.
}
finally
{
_running = false;
RaiseCanExecuteChanged();
}
}
public void RaiseCanExecuteChanged() => CanExecuteChanged?.Invoke(this, EventArgs.Empty);
}
@@ -1,18 +0,0 @@
using System;
using System.Windows.Input;
namespace LibProsperoPkg.Gui.Mvvm;
public sealed class RelayCommand(Action execute, Func<bool>? canExecute = null) : ICommand
{
private readonly Action _execute = execute;
private readonly Func<bool>? _canExecute = canExecute;
public event EventHandler? CanExecuteChanged;
public bool CanExecute(object? parameter) => _canExecute?.Invoke() ?? true;
public void Execute(object? parameter) => _execute();
public void RaiseCanExecuteChanged() => CanExecuteChanged?.Invoke(this, EventArgs.Empty);
}
@@ -1,23 +0,0 @@
using System.Collections.Generic;
using System.ComponentModel;
using System.Runtime.CompilerServices;
namespace LibProsperoPkg.Gui.Mvvm;
public abstract class ViewModelBase : INotifyPropertyChanged
{
public event PropertyChangedEventHandler? PropertyChanged;
protected void OnPropertyChanged([CallerMemberName] string? name = null)
=> PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
protected bool SetProperty<T>(ref T field, T value, [CallerMemberName] string? name = null)
{
if (EqualityComparer<T>.Default.Equals(field, value))
return false;
field = value;
OnPropertyChanged(name);
return true;
}
}
-17
View File
@@ -1,17 +0,0 @@
using Avalonia;
using System;
namespace LibProsperoPkg.Gui;
internal static class Program
{
[STAThread]
public static void Main(string[] args) =>
BuildAvaloniaApp().StartWithClassicDesktopLifetime(args);
public static AppBuilder BuildAvaloniaApp() =>
AppBuilder.Configure<App>()
.UsePlatformDetect()
.WithInterFont()
.LogToTrace();
}
@@ -1,11 +0,0 @@
using System;
using System.Threading.Tasks;
namespace LibProsperoPkg.Gui.Services;
public interface IAppHost
{
IStorageService Storage { get; }
Task RunAsync(string label, Action<Action<string>> operation);
}
@@ -1,13 +0,0 @@
using System.Collections.Generic;
using System.Threading.Tasks;
namespace LibProsperoPkg.Gui.Services;
public interface IStorageService
{
Task<string?> OpenFileAsync(string title, string? filterName = null, IReadOnlyList<string>? extensions = null);
Task<string?> OpenFolderAsync(string title);
Task<string?> SaveFileAsync(string title, string? suggestedName = null, string? filterName = null, IReadOnlyList<string>? extensions = null);
}
@@ -1,65 +0,0 @@
using Avalonia.Controls;
using Avalonia.Platform.Storage;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
namespace LibProsperoPkg.Gui.Services;
public sealed class StorageService : IStorageService
{
public TopLevel? Owner { get; set; }
public async Task<string?> OpenFileAsync(string title, string? filterName = null, IReadOnlyList<string>? extensions = null)
{
if (Owner is null)
return null;
var options = new FilePickerOpenOptions { Title = title, AllowMultiple = false };
if (extensions is { Count: > 0 })
{
options.FileTypeFilter =
[
new FilePickerFileType(filterName ?? "Files")
{
Patterns = [.. extensions.Select(e => "*." + e.TrimStart('.'))],
},
];
}
var result = await Owner.StorageProvider.OpenFilePickerAsync(options);
return result.Count > 0 ? result[0].TryGetLocalPath() : null;
}
public async Task<string?> OpenFolderAsync(string title)
{
if (Owner is null)
return null;
var result = await Owner.StorageProvider.OpenFolderPickerAsync(
new FolderPickerOpenOptions { Title = title, AllowMultiple = false });
return result.Count > 0 ? result[0].TryGetLocalPath() : null;
}
public async Task<string?> SaveFileAsync(string title, string? suggestedName = null, string? filterName = null, IReadOnlyList<string>? extensions = null)
{
if (Owner is null)
return null;
var options = new FilePickerSaveOptions { Title = title, SuggestedFileName = suggestedName };
if (extensions is { Count: > 0 })
{
options.DefaultExtension = extensions[0].TrimStart('.');
options.FileTypeChoices =
[
new FilePickerFileType(filterName ?? "Files")
{
Patterns = [.. extensions.Select(e => "*." + e.TrimStart('.'))],
},
];
}
var result = await Owner.StorageProvider.SaveFilePickerAsync(options);
return result?.TryGetLocalPath();
}
}
@@ -1,15 +0,0 @@
using LibProsperoPkg.Gui.Mvvm;
using System;
using System.Threading.Tasks;
using System.Windows.Input;
namespace LibProsperoPkg.Gui.ViewModels;
public sealed class ActionViewModel(string name, Func<Task> execute, bool primary = false) : ViewModelBase
{
public string Name { get; } = name;
public bool IsPrimary { get; } = primary;
public ICommand Command { get; } = new AsyncRelayCommand(execute);
}
@@ -1,12 +0,0 @@
namespace LibProsperoPkg.Gui.ViewModels.Fields;
public sealed class CheckFieldViewModel : FieldViewModel
{
private bool _value;
public bool Value
{
get => _value;
set => SetProperty(ref _value, value);
}
}
@@ -1,18 +0,0 @@
using System.Collections.Generic;
namespace LibProsperoPkg.Gui.ViewModels.Fields;
public sealed class ChoiceFieldViewModel(IReadOnlyList<ChoiceOption> options, ChoiceOption? selected = null) : FieldViewModel
{
private ChoiceOption? _selected = selected ?? (options.Count > 0 ? options[0] : null);
public IReadOnlyList<ChoiceOption> Options { get; } = options;
public ChoiceOption? Selected
{
get => _selected;
set => SetProperty(ref _selected, value);
}
public T SelectedAs<T>() => (T)Selected!.Value;
}
@@ -1,10 +0,0 @@
namespace LibProsperoPkg.Gui.ViewModels.Fields;
public sealed class ChoiceOption(string label, object value)
{
public string Label { get; } = label;
public object Value { get; } = value;
public override string ToString() => Label;
}
@@ -1,12 +0,0 @@
using LibProsperoPkg.Gui.Mvvm;
namespace LibProsperoPkg.Gui.ViewModels.Fields;
public abstract class FieldViewModel : ViewModelBase
{
public string Label { get; init; } = "";
public string? Description { get; init; }
public bool HasDescription => !string.IsNullOrWhiteSpace(Description);
}
@@ -1,9 +0,0 @@
namespace LibProsperoPkg.Gui.ViewModels.Fields;
public enum PickKind
{
None,
OpenFile,
OpenFolder,
SaveFile,
}
@@ -1,56 +0,0 @@
using LibProsperoPkg.Gui.Mvvm;
using LibProsperoPkg.Gui.Services;
using System.Collections.Generic;
using System.Threading.Tasks;
namespace LibProsperoPkg.Gui.ViewModels.Fields;
public sealed class TextFieldViewModel : FieldViewModel
{
private readonly IStorageService _storage;
private string _value = "";
public TextFieldViewModel(IStorageService storage)
{
_storage = storage;
BrowseCommand = new AsyncRelayCommand(BrowseAsync);
}
public string Value
{
get => _value;
set => SetProperty(ref _value, value);
}
public string? Watermark { get; init; }
public bool Multiline { get; init; }
public double InputHeight => Multiline ? 120 : double.NaN;
public PickKind Pick { get; init; } = PickKind.None;
public string? FileFilterName { get; init; }
public IReadOnlyList<string>? FileExtensions { get; init; }
public string? SuggestedFileName { get; init; }
public bool ShowBrowse => Pick != PickKind.None;
public AsyncRelayCommand BrowseCommand { get; }
private async Task BrowseAsync()
{
string? picked = Pick switch
{
PickKind.OpenFile => await _storage.OpenFileAsync(Label, FileFilterName, FileExtensions),
PickKind.OpenFolder => await _storage.OpenFolderAsync(Label),
PickKind.SaveFile => await _storage.SaveFileAsync(Label, SuggestedFileName, FileFilterName, FileExtensions),
_ => null,
};
if (!string.IsNullOrEmpty(picked))
Value = picked;
}
}
@@ -1,104 +0,0 @@
using Avalonia.Threading;
using LibProsperoPkg.Gui.Mvvm;
using LibProsperoPkg.Gui.Services;
using System;
using System.Collections.ObjectModel;
using System.Text;
using System.Threading.Tasks;
namespace LibProsperoPkg.Gui.ViewModels;
public sealed class MainWindowViewModel : ViewModelBase, IAppHost
{
private readonly StringBuilder _log = new();
private ToolPageViewModel? _selectedPage;
private bool _isBusy;
private string _status = "Ready";
private string _logText = "";
public MainWindowViewModel(IStorageService storage)
{
Storage = storage;
Pages = new ObservableCollection<ToolPageViewModel>(PageCatalog.Create(this));
_selectedPage = Pages.Count > 0 ? Pages[0] : null;
ClearLogCommand = new RelayCommand(ClearLog);
}
public static string Title => "LibProsperoPKG GUI by SvenGDK";
public IStorageService Storage { get; }
public ObservableCollection<ToolPageViewModel> Pages { get; }
public ToolPageViewModel? SelectedPage
{
get => _selectedPage;
set => SetProperty(ref _selectedPage, value);
}
public bool IsBusy
{
get => _isBusy;
private set => SetProperty(ref _isBusy, value);
}
public string Status
{
get => _status;
private set => SetProperty(ref _status, value);
}
public string LogText
{
get => _logText;
private set => SetProperty(ref _logText, value);
}
public RelayCommand ClearLogCommand { get; }
public async Task RunAsync(string label, Action<Action<string>> operation)
{
if (IsBusy)
return;
IsBusy = true;
Status = label + " running";
Append("> " + label);
void Log(string message) => Dispatcher.UIThread.Post(() => Append(message));
try
{
await Task.Run(() => operation(Log));
Status = label + " complete";
Append(label + ": done");
}
catch (Exception ex)
{
Status = label + " failed";
Append(label + ": error - " + ex.Message);
}
finally
{
IsBusy = false;
}
}
// The retained log is bounded so a long-running operation does not turn each appended line into a
// full re-stringify of an ever-growing buffer (which is quadratic over the run).
private const int MaxLogChars = 64 * 1024;
private void Append(string message)
{
_log.AppendLine(message);
if (_log.Length > MaxLogChars)
_log.Remove(0, _log.Length - MaxLogChars);
LogText = _log.ToString();
}
private void ClearLog()
{
_log.Clear();
LogText = "";
}
}
@@ -1,35 +0,0 @@
using LibProsperoPkg.Gui.Services;
using LibProsperoPkg.Gui.ViewModels.Pages;
using System.Collections.Generic;
namespace LibProsperoPkg.Gui.ViewModels;
internal static class PageCatalog
{
public static IReadOnlyList<ToolPageViewModel> Create(IAppHost host) =>
[
new BuildPage(host),
new HomebrewPage(host),
new BackupConvertPage(host),
new InspectPage(host),
new ExtractPage(host),
new ValidatePage(host),
new MergePage(host),
new ComparePage(host),
new InnerImagePage(host),
new PfsImagePage(host),
new PfscPage(host),
new FselfPage(host),
new ElfPage(host),
new AuthInfoPage(host),
new LaunchReadinessPage(host),
new RifPage(host),
new EntitlementPage(host),
new DiscBackupPage(host),
new UcpPage(host),
new DdsPage(host),
new PlayGoPage(host),
new MetadataPage(host),
new IdHelpersPage(host),
];
}
@@ -1,55 +0,0 @@
using LibProsperoPkg.Content;
using LibProsperoPkg.Gui.Services;
using LibProsperoPkg.Gui.ViewModels.Fields;
using System;
using System.Collections.Generic;
namespace LibProsperoPkg.Gui.ViewModels.Pages;
public sealed class AuthInfoPage : ToolPageViewModel
{
private readonly TextFieldViewModel _readPath;
private readonly TextFieldViewModel _writePath;
private readonly TextFieldViewModel _paid;
private readonly TextFieldViewModel _capabilities;
private readonly TextFieldViewModel _attributes;
public AuthInfoPage(IAppHost host)
: base(host, "Auth info", "Read or write a SELF authentication-info sidecar.")
{
_readPath = Text("Auth-info file", pick: PickKind.OpenFile);
Run("Read", Read, primary: true);
_writePath = Text("Output file", pick: PickKind.SaveFile, suggestedName: "eboot.bin.auth_info");
_paid = Text("Program id", watermark: "Decimal or 0x hex");
_capabilities = Text("Capabilities", watermark: "Up to 4 hex words, space-separated");
_attributes = Text("Attributes", watermark: "Up to 4 hex words, space-separated");
Run("Write", Write);
}
private void Read(Action<string> log)
{
var info = ProsperoSelfAuthInfo.ReadFile(_readPath.Value.Trim());
log($"Program id: 0x{info.Paid:X16}");
log("Category: " + info.Category);
log("Capabilities: " + string.Join(' ', FormatWords(info.Capabilities)));
log("Attributes: " + string.Join(' ', FormatWords(info.Attributes)));
}
private void Write(Action<string> log)
{
ulong paid = ParseHelpers.U64(_paid.Value);
ulong[] capabilities = ParseHelpers.HexWords(_capabilities.Value, ProsperoSelfAuthInfo.CapabilityWordCount);
ulong[] attributes = ParseHelpers.HexWords(_attributes.Value, ProsperoSelfAuthInfo.AttributeWordCount);
ProsperoSelfAuthInfo.Create(paid, capabilities, attributes).WriteFile(_writePath.Value.Trim());
log("Wrote: " + _writePath.Value.Trim());
}
private static string[] FormatWords(IReadOnlyList<ulong> words)
{
var text = new string[words.Count];
for (int i = 0; i < words.Count; i++)
text[i] = "0x" + words[i].ToString("X16");
return text;
}
}
@@ -1,68 +0,0 @@
using LibProsperoPkg.Gui.Services;
using LibProsperoPkg.Gui.ViewModels.Fields;
using System;
namespace LibProsperoPkg.Gui.ViewModels.Pages;
public sealed class BackupConvertPage : ToolPageViewModel
{
private readonly TextFieldViewModel _source;
private readonly TextFieldViewModel _output;
private readonly TextFieldViewModel _contentId;
private readonly TextFieldViewModel _passcode;
private readonly TextFieldViewModel _version;
private readonly TextFieldViewModel _decryptedSubfolder;
private readonly CheckFieldViewModel _embeddedModule;
public BackupConvertPage(IAppHost host)
: base(host, "Convert backup", "Convert a decrypted application backup into a debug package.")
{
_source = Text("Backup folder", pick: PickKind.OpenFolder);
_output = Text("Output folder", pick: PickKind.OpenFolder);
_contentId = Text("Content id", watermark: "Optional; taken from the backup when blank");
_passcode = Text("Passcode", watermark: "32 characters (blank uses the all-zero default)");
_version = Text("Version", watermark: "Optional; taken from the backup when blank");
_decryptedSubfolder = Text("Decrypted subfolder", watermark: "Holds the raw module copies; blank uses \"decrypted\"");
_embeddedModule = Check("Use the embedded debug module");
Run("Convert", Convert, primary: true);
}
private void Convert(Action<string> log)
{
var options = new ProsperoBackupConversionOptions
{
BackupFolder = _source.Value.Trim(),
OutputFolder = _output.Value.Trim(),
ContentId = _contentId.Value.Trim(),
Passcode = ParseHelpers.Passcode(_passcode.Value),
Version = _version.Value.Trim(),
UseEmbeddedRightSprx = _embeddedModule.Value,
};
string subfolder = _decryptedSubfolder.Value.Trim();
if (subfolder.Length > 0)
options.DecryptedSubfolder = subfolder;
ProsperoBackupConversionResult result = ProsperoBackupConverter.Convert(options, log);
log("Output: " + result.OutputPath);
log("Substituted modules: " + result.SubstitutedModules.Count);
foreach (string module in result.SubstitutedModules)
log(" " + module);
log("Plaintext modules: " + result.PlaintextModules.Count);
foreach (string module in result.PlaintextModules)
log(" " + module);
if (result.UnresolvedModules.Count > 0)
{
log("Unresolved modules: " + result.UnresolvedModules.Count);
foreach (string module in result.UnresolvedModules)
log(" " + module);
}
var readiness = result.LaunchReadiness;
log($"Launch ready: {(readiness.IsLaunchReady ? "yes" : "no")} (main module={readiness.HasEboot}, param.json={readiness.HasParamJson}, modules={readiness.Modules.Count})");
foreach (string warning in result.Warnings)
log("warning: " + warning);
}
}
@@ -1,72 +0,0 @@
using LibProsperoPkg.Gui.Services;
using LibProsperoPkg.Gui.ViewModels.Fields;
namespace LibProsperoPkg.Gui.ViewModels.Pages;
public sealed class BuildPage : ToolPageViewModel
{
private readonly TextFieldViewModel _source;
private readonly TextFieldViewModel _output;
private readonly TextFieldViewModel _contentId;
private readonly TextFieldViewModel _titleId;
private readonly TextFieldViewModel _title;
private readonly TextFieldViewModel _version;
private readonly TextFieldViewModel _passcode;
private readonly TextFieldViewModel _drm;
private readonly ChoiceFieldViewModel _mode;
private readonly ChoiceFieldViewModel _format;
private readonly ChoiceFieldViewModel _applicationType;
private readonly CheckFieldViewModel _generateParam;
private readonly CheckFieldViewModel _signModules;
private readonly CheckFieldViewModel _licenseFree;
public BuildPage(IAppHost host)
: base(host, "Build package", "Build a package from a prepared source folder.")
{
_source = Text("Source folder", pick: PickKind.OpenFolder, watermark: "Folder holding the prepared file set");
_output = Text("Output folder", pick: PickKind.OpenFolder, watermark: "Where the built package is written");
_contentId = Text("Content id", watermark: "36-character content id");
_titleId = Text("Title id", watermark: "PPSAxxxxx");
_title = Text("Title", watermark: "Display name");
_version = Text("Version", value: "01.00");
_passcode = Text("Passcode", watermark: "32 characters (blank uses the all-zero default)");
_mode = EnumChoice("Mode", ProsperoPackageMode.Application);
_format = EnumChoice("Output format", ProsperoOutputFormat.DebugImage);
_applicationType = EnumChoice("Application type", ProsperoApplicationType.PaidStandaloneFullApp);
_drm = Text("Application DRM type", watermark: "Optional token override (free / standard / freemium)");
_generateParam = Check("Generate param.json when missing", value: true);
_signModules = Check("Sign executable modules", value: true);
_licenseFree = Check("License-free debug package");
Run("Build", Build, primary: true);
}
private void Build(System.Action<string> log)
{
var options = new ProsperoBuildOptions
{
SourceFolder = _source.Value.Trim(),
OutputFolder = _output.Value.Trim(),
ContentId = _contentId.Value.Trim(),
TitleId = _titleId.Value.Trim(),
Title = _title.Value,
Version = string.IsNullOrWhiteSpace(_version.Value) ? "01.00" : _version.Value.Trim(),
Passcode = string.IsNullOrWhiteSpace(_passcode.Value) ? new string('0', 32) : _passcode.Value.Trim(),
Mode = _mode.SelectedAs<ProsperoPackageMode>(),
OutputFormat = _format.SelectedAs<ProsperoOutputFormat>(),
ApplicationType = _applicationType.SelectedAs<ProsperoApplicationType>(),
GenerateParamJsonIfMissing = _generateParam.Value,
FakeSignSelfModules = _signModules.Value,
LicenseFree = _licenseFree.Value,
};
string drm = _drm.Value.Trim();
if (drm.Length > 0)
options.ApplicationDrmType = drm;
ProsperoBuildResult result = ProsperoPackageBuilder.Build(options, log);
log("Output: " + result.OutputPath);
foreach (string warning in result.Warnings)
log("warning: " + warning);
}
}
@@ -1,34 +0,0 @@
using LibProsperoPkg.Gui.Services;
using LibProsperoPkg.Gui.ViewModels.Fields;
using System;
namespace LibProsperoPkg.Gui.ViewModels.Pages;
public sealed class ComparePage : ToolPageViewModel
{
private readonly TextFieldViewModel _reference;
private readonly TextFieldViewModel _candidate;
public ComparePage(IAppHost host)
: base(host, "Compare containers", "List the differences between two metadata containers.")
{
_reference = Text("Reference file", pick: PickKind.OpenFile, filterName: "Package", extensions: ["pkg"]);
_candidate = Text("Candidate file", pick: PickKind.OpenFile, filterName: "Package", extensions: ["pkg"]);
Run("Compare", Compare, primary: true);
}
private void Compare(Action<string> log)
{
var diffs = ProsperoPackageBuilder.CompareContainers(_reference.Value.Trim(), _candidate.Value.Trim());
if (diffs.Count == 0)
{
log("Containers match.");
return;
}
log("Differences: " + diffs.Count);
foreach (string diff in diffs)
log(" " + diff);
}
}
@@ -1,28 +0,0 @@
using LibProsperoPkg.Gui.Services;
using LibProsperoPkg.Gui.ViewModels.Fields;
using LibProsperoPkg.PKG;
using System;
using System.IO;
namespace LibProsperoPkg.Gui.ViewModels.Pages;
public sealed class DdsPage : ToolPageViewModel
{
private readonly TextFieldViewModel _input;
private readonly TextFieldViewModel _output;
public DdsPage(IAppHost host)
: base(host, "Texture", "Encode a PNG image to a DDS texture.")
{
_input = Text("PNG file", pick: PickKind.OpenFile, filterName: "PNG image", extensions: ["png"]);
_output = Text("DDS file", pick: PickKind.SaveFile, suggestedName: "icon0.dds");
Run("Encode", Encode, primary: true);
}
private void Encode(Action<string> log)
{
byte[] dds = ProsperoDdsEncoder.EncodePngToDds(File.ReadAllBytes(_input.Value.Trim()));
File.WriteAllBytes(_output.Value.Trim(), dds);
log($"Output: {_output.Value.Trim()} ({dds.Length} bytes)");
}
}
@@ -1,48 +0,0 @@
using LibProsperoPkg.DiscBackup;
using LibProsperoPkg.Gui.Services;
using LibProsperoPkg.Gui.ViewModels.Fields;
using LibProsperoPkg.NpDrm;
using System;
namespace LibProsperoPkg.Gui.ViewModels.Pages;
public sealed class DiscBackupPage : ToolPageViewModel
{
private readonly TextFieldViewModel _manifest;
private readonly TextFieldViewModel _output;
public DiscBackupPage(IAppHost host)
: base(host, "Disc backup", "Reassemble and verify a split disc backup.")
{
_manifest = Text("Manifest or folder", pick: PickKind.OpenFile, watermark: "app.json or the folder containing it");
_output = Text("Output file", pick: PickKind.SaveFile, suggestedName: "backup.pkg");
Run("Reassemble", Reassemble, primary: true);
Run("Verify", Verify);
Run("Content info", ContentInfo);
}
private ProsperoDiscBackup Open() => ProsperoDiscBackup.Open(_manifest.Value.Trim());
private void Reassemble(Action<string> log)
{
long written = Open().ReassembleTo(_output.Value.Trim());
log($"Output: {_output.Value.Trim()} ({written} bytes)");
}
private void Verify(Action<string> log)
{
ProsperoDiscBackup backup = Open();
log("Package digest: " + (backup.VerifyPackageDigest() ? "match" : "mismatch"));
log("Chunk CRC hash: " + (backup.VerifyChunkCrcHash() ? "match" : "mismatch"));
bool crcs = backup.VerifyChunkCrcs(out int mismatch);
log("Chunk CRCs: " + (crcs ? "all match" : "mismatch at chunk " + mismatch));
}
private void ContentInfo(Action<string> log)
{
ProsperoNpDrmContentInfo info = Open().ReadContentInfo();
log("Content id: " + info.ContentId);
log("Title id: " + info.TitleId);
log($"DRM type: {info.DrmType} Content type: {info.ContentType}");
}
}
@@ -1,43 +0,0 @@
using LibProsperoPkg.Content;
using LibProsperoPkg.Gui.Services;
using LibProsperoPkg.Gui.ViewModels.Fields;
using System;
using System.IO;
namespace LibProsperoPkg.Gui.ViewModels.Pages;
public sealed class ElfPage : ToolPageViewModel
{
private readonly TextFieldViewModel _path;
private readonly TextFieldViewModel _normalizeIn;
private readonly TextFieldViewModel _normalizeOut;
public ElfPage(IAppHost host)
: base(host, "ELF", "Read an ELF header, or normalize an ELF for use as a module.")
{
_path = Text("ELF file", pick: PickKind.OpenFile);
Run("Read header", ReadHeader, primary: true);
_normalizeIn = Text("Input ELF", pick: PickKind.OpenFile);
_normalizeOut = Text("Output ELF", pick: PickKind.SaveFile);
Run("Normalize module", Normalize);
}
private void ReadHeader(Action<string> log)
{
var header = ProsperoElfHeader.ReadFile(_path.Value.Trim());
log($"Class: {header.Class} Data: {header.Data} OS ABI: {header.OsAbi} ABI version: {header.AbiVersion}");
log($"Type: {header.Type} Machine: {header.Machine} Entry: 0x{header.Entry:X}");
log($"Program headers: {header.ProgramHeaderCount} Flags: 0x{header.Flags:X}");
log($"Executable: {header.IsExecutable} Dynamic: {header.IsDynamic} Module: {header.IsModuleType} Module ready: {header.IsModuleReady}");
}
private void Normalize(Action<string> log)
{
byte[] elf = File.ReadAllBytes(_normalizeIn.Value.Trim());
ElfNormalizeResult result = ProsperoElfHeader.NormalizeForModule(elf);
File.WriteAllBytes(_normalizeOut.Value.Trim(), elf);
log("Changed: " + (result.Changed ? "yes" : "no"));
log("Output: " + _normalizeOut.Value.Trim());
}
}
@@ -1,89 +0,0 @@
using LibProsperoPkg.Gui.Services;
using LibProsperoPkg.Gui.ViewModels.Fields;
using LibProsperoPkg.License;
using System;
using System.IO;
namespace LibProsperoPkg.Gui.ViewModels.Pages;
public sealed class EntitlementPage : ToolPageViewModel
{
private readonly TextFieldViewModel _keyHex;
private readonly TextFieldViewModel _passcode;
private readonly TextFieldViewModel _debugContentId;
private readonly TextFieldViewModel _debugPasscode;
private readonly TextFieldViewModel _debugSeed;
private readonly TextFieldViewModel _debugRecordOutput;
public EntitlementPage(IAppHost host)
: base(host, "Entitlement and debug license", "Validate an entitlement key, resolve the key mode, and derive debug-license material.")
{
_keyHex = Text("Entitlement key (hex)", watermark: "32 hex characters");
_passcode = Text("Passcode", watermark: "Optional; used for mode resolution");
Run("Validate key", ValidateKey, primary: true);
Run("Resolve mode", ResolveMode);
_debugContentId = Text("Content id");
_debugPasscode = Text("Passcode (debug)", watermark: "32 characters (blank uses the all-zero default)");
_debugSeed = Text("Key-set seed (hex)", watermark: "Optional; derives a full key set when set");
_debugRecordOutput = Text("Structural record output", pick: PickKind.SaveFile, suggestedName: "license.rif");
Run("Derive image key", DeriveImageKey);
Run("Derive key set", DeriveKeySet);
Run("Write structural record", WriteStructuralRecord);
}
private void ValidateKey(Action<string> log)
{
var key = ProsperoEntitlementKey.ParseHex(_keyHex.Value.Trim());
bool ok = key.Validate(out string? error);
log("Valid: " + (ok ? "yes" : "no"));
if (!ok && error is not null)
log("Reason: " + error);
}
private void ResolveMode(Action<string> log)
{
string passcode = _passcode.Value.Trim();
string keyHex = _keyHex.Value.Trim();
ProsperoEntitlementKey? key = keyHex.Length > 0 ? ProsperoEntitlementKey.ParseHex(keyHex) : null;
bool ok = ProsperoEntitlementKey.ResolveMode(passcode.Length > 0 ? passcode : null, key, out ProsperoKeyMode mode, out string? error);
log(ok ? "Mode: " + mode : "Cannot resolve: " + error);
}
private ProsperoDebugLicense BuildDebug()
=> ProsperoDebugLicense.Create(_debugContentId.Value.Trim(), ParseHelpers.Passcode(_debugPasscode.Value));
private void DeriveImageKey(Action<string> log)
=> log("Image key: " + Convert.ToHexString(BuildDebug().DeriveEkpfs()).ToLowerInvariant());
private void DeriveKeySet(Action<string> log)
{
string seedHex = _debugSeed.Value.Trim();
if (seedHex.Length == 0)
{
log("Provide a key-set seed to derive a full key set.");
return;
}
ProsperoDebugKeySet set = BuildDebug().DeriveKeySet(Convert.FromHexString(seedHex));
log("Image key: " + Convert.ToHexString(set.Ekpfs).ToLowerInvariant());
log("Tweak key: " + Convert.ToHexString(set.TweakKey).ToLowerInvariant());
log("Data key: " + Convert.ToHexString(set.DataKey).ToLowerInvariant());
log("Sign key: " + Convert.ToHexString(set.SignKey).ToLowerInvariant());
}
private void WriteStructuralRecord(Action<string> log)
{
ProsperoDebugLicense license = BuildDebug();
if (!license.Validate(out string? error))
{
log("Invalid: " + error);
return;
}
byte[] bytes = license.ToStructuralRif().ToBytes();
File.WriteAllBytes(_debugRecordOutput.Value.Trim(), bytes);
log($"Output: {_debugRecordOutput.Value.Trim()} ({bytes.Length} bytes)");
}
}
@@ -1,56 +0,0 @@
using LibProsperoPkg.Gui.Services;
using LibProsperoPkg.Gui.ViewModels.Fields;
using LibProsperoPkg.PFS;
using LibProsperoPkg.PKG;
using System;
namespace LibProsperoPkg.Gui.ViewModels.Pages;
public sealed class ExtractPage : ToolPageViewModel
{
private readonly TextFieldViewModel _path;
private readonly TextFieldViewModel _output;
private readonly TextFieldViewModel _passcode;
private readonly TextFieldViewModel _imageKey;
private readonly CheckFieldViewModel _extractOuter;
public ExtractPage(IAppHost host)
: base(host, "Extract package", "Extract inner files using a passcode or a 32-byte image key.")
{
_path = Text("Package file", pick: PickKind.OpenFile, filterName: "Package", extensions: ["pkg"]);
_output = Text("Output folder", pick: PickKind.OpenFolder);
_passcode = Text("Passcode", watermark: "32 characters (blank uses the all-zero default)");
_imageKey = Text("Image key (hex)", watermark: "Optional 64 hex characters; overrides the passcode");
_extractOuter = Check("Also write outer metadata files");
Run("List files", ListFiles);
Run("Extract", Extract, primary: true);
}
private ProsperoExtractionKey BuildKey()
{
string hex = _imageKey.Value.Trim();
if (hex.Length > 0)
return ProsperoExtractionKey.FromEkpfs(Convert.FromHexString(hex));
return ProsperoExtractionKey.FromPasscode(ParseHelpers.Passcode(_passcode.Value));
}
private void ListFiles(Action<string> log)
{
int count = 0;
foreach (ProsperoExtractedEntry entry in ProsperoPackageExtractor.ListFiles(_path.Value.Trim(), BuildKey()))
{
log($" {entry.RelativePath} ({entry.Size} bytes){(entry.IsCompressed ? " [compressed]" : "")}");
count++;
}
log("Files: " + count);
}
private void Extract(Action<string> log)
{
var options = new ProsperoExtractionOptions { ExtractOuterMetadata = _extractOuter.Value };
ProsperoPackageManifest manifest = ProsperoPackageExtractor.Extract(_path.Value.Trim(), _output.Value.Trim(), BuildKey(), options);
log("Extracted files: " + manifest.ExtractedFileCount);
}
}
@@ -1,56 +0,0 @@
using LibProsperoPkg.Content;
using LibProsperoPkg.Gui.Services;
using LibProsperoPkg.Gui.ViewModels.Fields;
using System;
using System.IO;
namespace LibProsperoPkg.Gui.ViewModels.Pages;
public sealed class FselfPage : ToolPageViewModel
{
private readonly TextFieldViewModel _elfPath;
private readonly TextFieldViewModel _outPath;
private readonly TextFieldViewModel _appVersion;
private readonly TextFieldViewModel _firmwareVersion;
private readonly TextFieldViewModel _authorityId;
private readonly TextFieldViewModel _folder;
public FselfPage(IAppHost host)
: base(host, "Fake-self", "Generate a fake-self from an ELF, or fake-sign every module in a folder.")
{
_elfPath = Text("ELF file", pick: PickKind.OpenFile);
_outPath = Text("Output file", pick: PickKind.SaveFile, suggestedName: "eboot.bin");
_appVersion = Text("Application version", watermark: "Decimal or 0x hex; blank for 0");
_firmwareVersion = Text("Firmware version", watermark: "Decimal or 0x hex; blank for 0");
_authorityId = Text("Authority id", watermark: "Optional; decimal or 0x hex");
Run("Generate", Generate, primary: true);
_folder = Text("Module folder", pick: PickKind.OpenFolder);
Run("Fake-sign folder", FakeSignFolder);
}
private FselfOptions BuildOptions()
{
string authority = _authorityId.Value.Trim();
return new FselfOptions
{
AppVersion = ParseHelpers.U64(_appVersion.Value),
FirmwareVersion = ParseHelpers.U64(_firmwareVersion.Value),
AuthorityId = authority.Length > 0 ? (ulong?)ParseHelpers.U64(authority) : null,
};
}
private void Generate(Action<string> log)
{
byte[] elf = File.ReadAllBytes(_elfPath.Value.Trim());
byte[] fself = ProsperoFself.MakeFself(elf, BuildOptions());
File.WriteAllBytes(_outPath.Value.Trim(), fself);
log($"Output: {_outPath.Value.Trim()} ({fself.Length} bytes)");
}
private void FakeSignFolder(Action<string> log)
{
int count = ProsperoPackageBuilder.FakeSignModulesInPlace(_folder.Value.Trim(), BuildOptions());
log("Modules converted: " + count);
}
}
@@ -1,57 +0,0 @@
using LibProsperoPkg.Gui.Services;
using LibProsperoPkg.Gui.ViewModels.Fields;
using System;
namespace LibProsperoPkg.Gui.ViewModels.Pages;
public sealed class HomebrewPage : ToolPageViewModel
{
private readonly TextFieldViewModel _source;
private readonly TextFieldViewModel _output;
private readonly TextFieldViewModel _contentId;
private readonly TextFieldViewModel _title;
private readonly TextFieldViewModel _version;
private readonly TextFieldViewModel _passcode;
private readonly TextFieldViewModel _module;
public HomebrewPage(IAppHost host)
: base(host, "Homebrew package", "Build a package from a homebrew folder.")
{
_source = Text("Homebrew folder", pick: PickKind.OpenFolder, watermark: "Folder holding the main module and assets");
_output = Text("Output folder", pick: PickKind.OpenFolder);
_contentId = Text("Content id", watermark: "Optional; a default is used when blank");
_title = Text("Title", watermark: "Optional display name");
_version = Text("Version", value: "01.00");
_passcode = Text("Passcode", watermark: "32 characters (blank uses the all-zero default)");
_module = Text("Main module", watermark: "Defaults to eboot.bin");
Run("Build", Build, primary: true);
}
private void Build(Action<string> log)
{
var options = new ProsperoHomebrewPackageOptions
{
HomebrewFolder = _source.Value.Trim(),
OutputFolder = _output.Value.Trim(),
ContentId = _contentId.Value.Trim(),
Passcode = ParseHelpers.Passcode(_passcode.Value),
Title = _title.Value,
Version = _version.Value.Trim(),
};
string module = _module.Value.Trim();
if (module.Length > 0)
options.ModuleName = module;
ProsperoHomebrewPackageResult result = ProsperoHomebrewPackager.Package(options, log);
log("Output: " + result.OutputPath);
var readiness = result.LaunchReadiness;
log($"Launch ready: {(readiness.IsLaunchReady ? "yes" : "no")} (main module={readiness.HasEboot}, param.json={readiness.HasParamJson}, modules={readiness.Modules.Count}, issues={readiness.Issues.Count})");
foreach (string issue in readiness.Issues)
log("issue: " + issue);
foreach (string warning in result.Warnings)
log("warning: " + warning);
}
}
@@ -1,62 +0,0 @@
using LibProsperoPkg.Gui.Services;
using LibProsperoPkg.Gui.ViewModels.Fields;
using System;
namespace LibProsperoPkg.Gui.ViewModels.Pages;
public sealed class IdHelpersPage : ToolPageViewModel
{
private readonly TextFieldViewModel _publisher;
private readonly TextFieldViewModel _titleId;
private readonly TextFieldViewModel _label;
private readonly TextFieldViewModel _contentIdCheck;
private readonly TextFieldViewModel _titleIdCheck;
private readonly ChoiceFieldViewModel _applicationType;
private readonly TextFieldViewModel _typeName;
public IdHelpersPage(IAppHost host)
: base(host, "Id helpers", "Compose and validate ids, and inspect application types.")
{
_publisher = Text("Publisher", watermark: "Publisher prefix");
_titleId = Text("Title id");
_label = Text("Label");
Run("Compose content id", Compose, primary: true);
_contentIdCheck = Text("Content id to check");
Run("Check content id", CheckContentId);
_titleIdCheck = Text("Title id to check");
Run("Check title id", CheckTitleId);
_applicationType = EnumChoice("Application type", ProsperoApplicationType.PaidStandaloneFullApp);
Run("Describe type", DescribeType);
_typeName = Text("Type name to parse");
Run("Parse type", ParseType);
}
private void Compose(Action<string> log)
{
string id = ProsperoPackageBuilder.ComposeContentId(_publisher.Value.Trim(), _titleId.Value.Trim(), _label.Value.Trim());
log("Content id: " + id);
}
private void CheckContentId(Action<string> log)
=> log("Valid content id: " + (ProsperoPackageBuilder.IsValidContentId(_contentIdCheck.Value.Trim()) ? "yes" : "no"));
private void CheckTitleId(Action<string> log)
=> log("Valid title id: " + (ProsperoPackageBuilder.IsValidTitleId(_titleIdCheck.Value.Trim()) ? "yes" : "no"));
private void DescribeType(Action<string> log)
{
ProsperoApplicationType type = _applicationType.SelectedAs<ProsperoApplicationType>();
log("Name: " + ProsperoApplicationTypes.DisplayName(type));
log("DRM token: " + ProsperoApplicationTypes.ApplicationDrmType(type));
}
private void ParseType(Action<string> log)
{
ProsperoApplicationType type = ProsperoApplicationTypes.Parse(_typeName.Value.Trim());
log("Parsed: " + type);
}
}
@@ -1,57 +0,0 @@
using LibProsperoPkg.Gui.Services;
using LibProsperoPkg.Gui.ViewModels.Fields;
using System;
namespace LibProsperoPkg.Gui.ViewModels.Pages;
public sealed class InnerImagePage : ToolPageViewModel
{
private readonly TextFieldViewModel _source;
private readonly TextFieldViewModel _output;
private readonly TextFieldViewModel _contentId;
private readonly TextFieldViewModel _passcode;
private readonly ChoiceFieldViewModel _form;
private readonly TextFieldViewModel _encryptPath;
private readonly TextFieldViewModel _encryptContentId;
private readonly TextFieldViewModel _encryptPasscode;
public InnerImagePage(IAppHost host)
: base(host, "Inner image", "Lay out an inner image from a folder, or encrypt an existing image in place.")
{
_source = Text("Source folder", pick: PickKind.OpenFolder);
_output = Text("Output image", pick: PickKind.SaveFile, suggestedName: "uroot.img");
_contentId = Text("Content id");
_passcode = Text("Passcode", watermark: "32 characters (blank uses the all-zero default)");
_form = EnumChoice("Image form", InnerImageForm.Plaintext);
Run("Build image", BuildImage, primary: true);
_encryptPath = Text("Image to encrypt", pick: PickKind.OpenFile);
_encryptContentId = Text("Content id (encrypt)");
_encryptPasscode = Text("Passcode (encrypt)", watermark: "32 characters (blank uses the all-zero default)");
Run("Encrypt in place", Encrypt);
}
private void BuildImage(Action<string> log)
{
string result = ProsperoPackageBuilder.BuildInnerImage(
_source.Value.Trim(),
_output.Value.Trim(),
_contentId.Value.Trim(),
ParseHelpers.Passcode(_passcode.Value),
_form.SelectedAs<InnerImageForm>(),
log);
log("Output: " + result);
}
private void Encrypt(Action<string> log)
{
ProsperoPackageBuilder.EncryptPfsImage(
_encryptPath.Value.Trim(),
_encryptContentId.Value.Trim(),
ParseHelpers.Passcode(_encryptPasscode.Value),
seed: null,
logger: log);
log("Encrypted: " + _encryptPath.Value.Trim());
}
}
@@ -1,63 +0,0 @@
using LibProsperoPkg.Gui.Services;
using LibProsperoPkg.Gui.ViewModels.Fields;
using LibProsperoPkg.NpDrm;
using LibProsperoPkg.PKG;
using System;
namespace LibProsperoPkg.Gui.ViewModels.Pages;
public sealed class InspectPage : ToolPageViewModel
{
private readonly TextFieldViewModel _path;
public InspectPage(IAppHost host)
: base(host, "Inspect package", "Read the package type, header fields, entries and content info.")
{
_path = Text("Package file", pick: PickKind.OpenFile, filterName: "Package", extensions: ["pkg"]);
Run("Detect type", DetectType, primary: true);
Run("Summary", Summary);
Run("List entries", ListEntries);
Run("Content info", ContentInfo);
}
private string PkgPath => _path.Value.Trim();
private void DetectType(Action<string> log)
{
ProsperoPkgType? type = ProsperoPkgReader.DetectType(PkgPath);
log(type is null ? "Not a recognized package." : "Type: " + type.Value);
}
private void Summary(Action<string> log)
{
ProsperoPackageExtractionInfo info = ProsperoPackageExtractor.Inspect(PkgPath);
log("Package type: " + info.PackageType);
log("Retail: " + (info.IsRetail ? "yes" : "no"));
log("Outer encrypted: " + (info.OuterEncrypted ? "yes" : "no"));
log("Requires supplied key: " + (info.RequiresSuppliedKey ? "yes" : "no"));
log("Content id: " + info.ContentId);
log($"Inner image: offset={info.PfsImageOffset}, size={info.PfsImageSize}");
ProsperoPkg pkg = ProsperoPkgReader.Read(PkgPath);
log("Entry count: " + pkg.Entries.Count);
}
private void ListEntries(Action<string> log)
{
ProsperoPkg pkg = ProsperoPkgReader.Read(PkgPath);
log("Entries: " + pkg.Entries.Count);
foreach (ProsperoPkgEntry entry in pkg.Entries)
log($" {entry.Id} offset=0x{entry.DataOffset:X} size={entry.DataSize} encrypted={(entry.Encrypted ? 1 : 0)} {entry.Name}");
}
private void ContentInfo(Action<string> log)
{
var info = ProsperoNpDrmContentInfo.Read(PkgPath);
log("Content id: " + info.ContentId);
log("Title id: " + info.TitleId);
log($"DRM type: {info.DrmType} Content type: {info.ContentType} Flags: 0x{info.ContentFlags:X}");
log($"Patch: {info.IsPatch} Nested: {info.IsNestedImage} Finalized: {info.IsFinalized}");
log("Container offset: " + info.ContainerOffset);
}
}
@@ -1,29 +0,0 @@
using LibProsperoPkg.Content;
using LibProsperoPkg.Gui.Services;
using LibProsperoPkg.Gui.ViewModels.Fields;
using System;
namespace LibProsperoPkg.Gui.ViewModels.Pages;
public sealed class LaunchReadinessPage : ToolPageViewModel
{
private readonly TextFieldViewModel _root;
public LaunchReadinessPage(IAppHost host)
: base(host, "Launch readiness", "Inspect an application root for launch readiness.")
{
_root = Text("Application root", pick: PickKind.OpenFolder);
Run("Inspect", Inspect, primary: true);
}
private void Inspect(Action<string> log)
{
ProsperoLaunchReadinessReport report = ProsperoLaunchReadiness.InspectAppRoot(_root.Value.Trim());
log("Launch ready: " + (report.IsLaunchReady ? "yes" : "no"));
log($"Main module: {report.HasEboot} param.json: {report.HasParamJson} param.sfo: {report.HasParamSfo}");
log("Requires debug console: " + report.RequiresDebugConsole);
log($"Modules: {report.Modules.Count} Issues: {report.Issues.Count}");
foreach (string issue in report.Issues)
log("issue: " + issue);
}
}
@@ -1,35 +0,0 @@
using LibProsperoPkg.Gui.Services;
using LibProsperoPkg.Gui.ViewModels.Fields;
using LibProsperoPkg.PKG;
using System;
namespace LibProsperoPkg.Gui.ViewModels.Pages;
public sealed class MergePage : ToolPageViewModel
{
private readonly TextFieldViewModel _input;
private readonly TextFieldViewModel _output;
private readonly CheckFieldViewModel _digest;
public MergePage(IAppHost host)
: base(host, "Merge split package", "Merge split package parts found in a folder.")
{
_input = Text("Input folder", pick: PickKind.OpenFolder);
_output = Text("Output folder", pick: PickKind.OpenFolder, watermark: "Optional; defaults next to the input");
_digest = Check("Compute SHA-256 for each merged package");
Run("Merge", Merge, primary: true);
}
private void Merge(Action<string> log)
{
string? output = _output.Value.Trim();
if (output.Length == 0)
output = null;
var results = ProsperoPkgMerger.MergeDirectory(_input.Value.Trim(), output, _digest.Value);
log("Merged packages: " + results.Count);
foreach (ProsperoPkgMergeResult result in results)
log(" " + result.OutputPath);
}
}
@@ -1,67 +0,0 @@
using LibProsperoPkg.Gui.Services;
using LibProsperoPkg.Gui.ViewModels.Fields;
using LibProsperoPkg.Metadata;
using System;
namespace LibProsperoPkg.Gui.ViewModels.Pages;
public sealed class MetadataPage : ToolPageViewModel
{
private readonly TextFieldViewModel _contentId;
private readonly TextFieldViewModel _titleId;
private readonly TextFieldViewModel _titleName;
private readonly ChoiceFieldViewModel _drmType;
private readonly TextFieldViewModel _paramOutput;
private readonly TextFieldViewModel _appName;
private readonly TextFieldViewModel _appVersion;
private readonly TextFieldViewModel _manifestTitleId;
private readonly TextFieldViewModel _runtimeVersion;
private readonly TextFieldViewModel _branchType;
private readonly CheckFieldViewModel _twinTurbo;
private readonly TextFieldViewModel _manifestOutput;
public MetadataPage(IAppHost host)
: base(host, "Metadata", "Create a default param file or an application manifest.")
{
_contentId = Text("Content id");
_titleId = Text("Title id");
_titleName = Text("Title name");
_drmType = EnumChoice("DRM type", ProsperoDrmType.Standard);
_paramOutput = Text("param output", pick: PickKind.SaveFile, suggestedName: "param.json");
Run("Create param", CreateParam, primary: true);
_appName = Text("Application name");
_appVersion = Text("Application version", value: "01.00");
_manifestTitleId = Text("Title id (manifest)");
_runtimeVersion = Text("Runtime version");
_branchType = Text("Branch type", value: "release");
_twinTurbo = Check("Twin turbo", value: true);
_manifestOutput = Text("manifest output", pick: PickKind.SaveFile, suggestedName: "manifest.json");
Run("Create manifest", CreateManifest);
}
private void CreateParam(Action<string> log)
{
var param = ProsperoParam.CreateDefault(
_contentId.Value.Trim(),
_titleId.Value.Trim(),
_titleName.Value,
_drmType.SelectedAs<ProsperoDrmType>());
param.Save(_paramOutput.Value.Trim());
log("Output: " + _paramOutput.Value.Trim());
}
private void CreateManifest(Action<string> log)
{
var manifest = ProsperoManifest.Create(
_appName.Value.Trim(),
_appVersion.Value.Trim(),
_manifestTitleId.Value.Trim(),
_runtimeVersion.Value.Trim(),
string.IsNullOrWhiteSpace(_branchType.Value) ? "release" : _branchType.Value.Trim(),
_twinTurbo.Value);
manifest.Save(_manifestOutput.Value.Trim());
log("Output: " + _manifestOutput.Value.Trim());
}
}
@@ -1,34 +0,0 @@
using System;
using System.Globalization;
namespace LibProsperoPkg.Gui.ViewModels.Pages;
internal static class ParseHelpers
{
public static ulong U64(string? text)
{
text = text?.Trim();
if (string.IsNullOrEmpty(text))
return 0;
if (text.StartsWith("0x", StringComparison.OrdinalIgnoreCase))
return ulong.Parse(text.AsSpan(2), NumberStyles.HexNumber, CultureInfo.InvariantCulture);
return ulong.Parse(text, NumberStyles.Integer, CultureInfo.InvariantCulture);
}
public static string Passcode(string? text)
=> string.IsNullOrWhiteSpace(text) ? new string('0', 32) : text.Trim();
public static ulong[] HexWords(string? text, int max)
{
text = text?.Trim();
if (string.IsNullOrEmpty(text))
return [];
string[] parts = text.Split([' ', ',', ';'], StringSplitOptions.RemoveEmptyEntries);
int count = Math.Min(parts.Length, max);
var words = new ulong[count];
for (int i = 0; i < count; i++)
words[i] = U64(parts[i]);
return words;
}
}
@@ -1,47 +0,0 @@
using LibProsperoPkg.Gui.Services;
using LibProsperoPkg.Gui.ViewModels.Fields;
using LibProsperoPkg.PFS;
using System;
namespace LibProsperoPkg.Gui.ViewModels.Pages;
public sealed class PfsImagePage : ToolPageViewModel
{
private readonly TextFieldViewModel _source;
private readonly TextFieldViewModel _layoutOutput;
private readonly TextFieldViewModel _imagePath;
private readonly TextFieldViewModel _contentId;
private readonly TextFieldViewModel _passcode;
public PfsImagePage(IAppHost host)
: base(host, "PFS image", "Build an image layout from a folder, or check and decrypt an image.")
{
_source = Text("Source folder", pick: PickKind.OpenFolder);
_layoutOutput = Text("Output image", pick: PickKind.SaveFile, suggestedName: "uroot.img");
Run("Build layout", BuildLayout, primary: true);
_imagePath = Text("Image file", pick: PickKind.OpenFile);
_contentId = Text("Content id");
_passcode = Text("Passcode", watermark: "32 characters (blank uses the all-zero default)");
Run("Check encryption", CheckEncryption);
Run("Decrypt in place", Decrypt);
}
private void BuildLayout(Action<string> log)
{
ProsperoPfsLayoutResult result = ProsperoPfsLayout.BuildFromFolder(_source.Value.Trim(), _layoutOutput.Value.Trim(), options: null, logger: log);
log("Output: " + result.OutputPath);
log($"Files: {result.FileCount}, directories: {result.DirectoryCount}");
}
private void CheckEncryption(Action<string> log)
=> log("Encrypted: " + (ProsperoPfsImage.IsEncrypted(_imagePath.Value.Trim()) ? "yes" : "no"));
private void Decrypt(Action<string> log)
{
byte[] ekpfs = ProsperoPfsKeys.DeriveEkpfs(_contentId.Value.Trim(), ParseHelpers.Passcode(_passcode.Value));
ProsperoPfsImage.DecryptInPlace(_imagePath.Value.Trim(), new ProsperoPfsImageOptions { Ekpfs = ekpfs }, log);
log("Decrypted: " + _imagePath.Value.Trim());
}
}
@@ -1,51 +0,0 @@
using LibProsperoPkg.Gui.Services;
using LibProsperoPkg.Gui.ViewModels.Fields;
using LibProsperoPkg.PFS;
using LibProsperoPkg.PFS.Compression;
using System;
namespace LibProsperoPkg.Gui.ViewModels.Pages;
public sealed class PfscPage : ToolPageViewModel
{
private readonly TextFieldViewModel _input;
private readonly TextFieldViewModel _output;
public PfscPage(IAppHost host)
: base(host, "PFSC container", "Pack and unpack compressed PFS containers.")
{
_input = Text("Source file", pick: PickKind.OpenFile);
_output = Text("Destination file", pick: PickKind.SaveFile);
Run("Pack (zlib)", PackZlib, primary: true);
Run("Unpack (zlib)", UnpackZlib);
Run("Pack (block)", PackBlock);
Run("Unpack (block)", UnpackBlock);
Run("Check container", CheckContainer);
}
private string Input => _input.Value.Trim();
private string Output => _output.Value.Trim();
private void PackZlib(Action<string> log)
{
ProsperoPfscResult result = ProsperoPfsc.PackFile(Input, Output, null, log);
log($"Output: {result.OutputPath} (raw={result.RawSize}, encoded={result.EncodedSize}, stored raw={result.StoredRaw})");
}
private void UnpackZlib(Action<string> log)
=> log("Unpacked bytes: " + ProsperoPfsc.Unpack(Input, Output, log));
private void PackBlock(Action<string> log)
{
ProsperoCompressedPfsImage.PackFile(Input, Output, ProsperoCompressedPfsImage.DefaultLevel, ProsperoCompressedPfsImage.DefaultBlockSize, log);
log("Packed: " + Output);
}
private void UnpackBlock(Action<string> log)
=> log("Unpacked bytes: " + ProsperoCompressedPfsImage.UnpackFile(Input, Output, log));
private void CheckContainer(Action<string> log)
=> log("PFSC container: " + (ProsperoPfsc.IsPfsc(Input) ? "yes" : "no"));
}
@@ -1,58 +0,0 @@
using LibProsperoPkg.Gui.Services;
using LibProsperoPkg.Gui.ViewModels.Fields;
using LibProsperoPkg.PlayGo;
using System;
using System.IO;
namespace LibProsperoPkg.Gui.ViewModels.Pages;
public sealed class PlayGoPage : ToolPageViewModel
{
private readonly TextFieldViewModel _contentId;
private readonly TextFieldViewModel _chunkOutput;
private readonly TextFieldViewModel _moduleOutput;
private readonly TextFieldViewModel _mountImage;
private readonly TextFieldViewModel _crcOutput;
public PlayGoPage(IAppHost host)
: base(host, "PlayGo", "Generate auxiliary PlayGo files.")
{
_contentId = Text("Content id");
_chunkOutput = Text("Chunk file output", pick: PickKind.SaveFile, suggestedName: "playgo-chunk.dat");
Run("Build chunk file", BuildChunk, primary: true);
_moduleOutput = Text("Module output", pick: PickKind.SaveFile, suggestedName: "right.sprx");
Run("Write module", WriteModule);
_mountImage = Text("Mount image", pick: PickKind.OpenFile);
_crcOutput = Text("Chunk CRC output", pick: PickKind.SaveFile, suggestedName: "playgo-chunk.sha");
Run("Build chunk CRC", BuildChunkCrc);
}
private void BuildChunk(Action<string> log)
{
byte[] data = ProsperoPlayGo.BuildChunkDat(_contentId.Value.Trim());
File.WriteAllBytes(_chunkOutput.Value.Trim(), data);
log($"Output: {_chunkOutput.Value.Trim()} ({data.Length} bytes)");
}
private void WriteModule(Action<string> log)
{
byte[]? module = ProsperoPlayGo.GetRightSprx();
if (module is null)
{
log("No module is available.");
return;
}
File.WriteAllBytes(_moduleOutput.Value.Trim(), module);
log($"Output: {_moduleOutput.Value.Trim()} ({module.Length} bytes)");
}
private void BuildChunkCrc(Action<string> log)
{
byte[] crc = ProsperoPlayGo.BuildChunkCrc(File.ReadAllBytes(_mountImage.Value.Trim()));
File.WriteAllBytes(_crcOutput.Value.Trim(), crc);
log($"Output: {_crcOutput.Value.Trim()} ({crc.Length} bytes)");
}
}
@@ -1,53 +0,0 @@
using LibProsperoPkg.Gui.Services;
using LibProsperoPkg.Gui.ViewModels.Fields;
using LibProsperoPkg.License;
using System;
using System.IO;
namespace LibProsperoPkg.Gui.ViewModels.Pages;
public sealed class RifPage : ToolPageViewModel
{
private readonly TextFieldViewModel _contentId;
private readonly TextFieldViewModel _output;
private readonly TextFieldViewModel _expiry;
private readonly TextFieldViewModel _readPath;
private readonly TextFieldViewModel _appTitleId;
public RifPage(IAppHost host)
: base(host, "License records", "Create a structural license record, or summarize a license file.")
{
_contentId = Text("Content id");
_output = Text("Output file", pick: PickKind.SaveFile, suggestedName: "license.rif");
_expiry = Text("Expiry", watermark: "Optional Unix time; blank for none");
Run("Create", Create, primary: true);
_readPath = Text("License file", pick: PickKind.OpenFile);
_appTitleId = Text("Application title id", watermark: "Optional; matched when set");
Run("Summarize", Summarize);
}
private void Create(Action<string> log)
{
string expiryText = _expiry.Value.Trim();
long expiry = expiryText.Length == 0 ? ProsperoRif.NeverExpires : long.Parse(expiryText);
var rif = ProsperoRif.Create(_contentId.Value.Trim(), null, expiry);
byte[] bytes = rif.ToBytes();
File.WriteAllBytes(_output.Value.Trim(), bytes);
log($"Output: {_output.Value.Trim()} ({bytes.Length} bytes)");
}
private void Summarize(Action<string> log)
{
string? title = _appTitleId.Value.Trim();
if (title.Length == 0)
title = null;
var set = ProsperoRifSet.ReadFile(_readPath.Value.Trim());
ProsperoRifSetSummary summary = set.Summarize(title);
log($"Records: {summary.RecordCount} Has app: {summary.HasApp} Additional content: {summary.AdditionalContentCount}");
log($"App content id: {summary.AppContentId} Service id: {summary.ServiceId}");
log($"Expected size: {summary.ExpectedSize} Actual size: {summary.ActualSize}");
}
}
@@ -1,55 +0,0 @@
using LibProsperoPkg.Content;
using LibProsperoPkg.Gui.Services;
using LibProsperoPkg.Gui.ViewModels.Fields;
using System;
using System.IO;
namespace LibProsperoPkg.Gui.ViewModels.Pages;
public sealed class UcpPage : ToolPageViewModel
{
private readonly TextFieldViewModel _path;
private readonly TextFieldViewModel _repairOutput;
private readonly TextFieldViewModel _directory;
private readonly TextFieldViewModel _buildOutput;
public UcpPage(IAppHost host)
: base(host, "Content protection", "Validate, verify, build and repair content protection files.")
{
_path = Text("Protection file", pick: PickKind.OpenFile);
Run("Validate", Validate, primary: true);
Run("Verify digest", VerifyDigest);
_repairOutput = Text("Repaired output", pick: PickKind.SaveFile);
Run("Repair digest", RepairDigest);
_directory = Text("Source directory", pick: PickKind.OpenFolder);
_buildOutput = Text("Build output", pick: PickKind.SaveFile);
Run("Build from directory", BuildFromDirectory);
}
private void Validate(Action<string> log)
{
bool ok = ProsperoUcp.Validate(File.ReadAllBytes(_path.Value.Trim()), out string? error);
log("Valid: " + (ok ? "yes" : "no"));
if (!ok && error is not null)
log("Reason: " + error);
}
private void VerifyDigest(Action<string> log)
=> log("Digest: " + (ProsperoUcp.VerifyDigest(File.ReadAllBytes(_path.Value.Trim())) ? "match" : "mismatch"));
private void RepairDigest(Action<string> log)
{
byte[] repaired = ProsperoUcp.WithRepairedDigest(File.ReadAllBytes(_path.Value.Trim()));
File.WriteAllBytes(_repairOutput.Value.Trim(), repaired);
log($"Output: {_repairOutput.Value.Trim()} ({repaired.Length} bytes)");
}
private void BuildFromDirectory(Action<string> log)
{
byte[] ucp = ProsperoUcp.BuildFromDirectory(_directory.Value.Trim());
File.WriteAllBytes(_buildOutput.Value.Trim(), ucp);
log($"Output: {_buildOutput.Value.Trim()} ({ucp.Length} bytes)");
}
}
@@ -1,51 +0,0 @@
using LibProsperoPkg.Gui.Services;
using LibProsperoPkg.Gui.ViewModels.Fields;
using LibProsperoPkg.PKG;
using System;
namespace LibProsperoPkg.Gui.ViewModels.Pages;
public sealed class ValidatePage : ToolPageViewModel
{
private readonly TextFieldViewModel _path;
private readonly TextFieldViewModel _expected;
public ValidatePage(IAppHost host)
: base(host, "Validate package", "Run structural checks and report pass, warning and fail counts.")
{
_path = Text("Package file", pick: PickKind.OpenFile, filterName: "Package", extensions: ["pkg"]);
_expected = Text("Expected content id", watermark: "Optional; checked when set");
Run("Validate", Validate, primary: true);
}
private void Validate(Action<string> log)
{
string? expected = _expected.Value.Trim();
if (expected.Length == 0)
expected = null;
ProsperoAcceptanceReport report = ProsperoPkgValidator.Validate(_path.Value.Trim(), expected);
int pass = 0, warn = 0, fail = 0;
foreach (ProsperoAcceptanceCheck check in report.Checks)
{
string tag = check.Status switch
{
ProsperoCheckStatus.Pass => "pass",
ProsperoCheckStatus.Warning => "warning",
_ => "fail",
};
log($"[{tag}] {check.Name}: {check.Detail}");
switch (check.Status)
{
case ProsperoCheckStatus.Pass: pass++; break;
case ProsperoCheckStatus.Warning: warn++; break;
case ProsperoCheckStatus.Fail: fail++; break;
}
}
log($"Accepted: {(report.Accepted ? "yes" : "no")} (pass={pass}, warning={warn}, fail={fail})");
}
}
@@ -1,98 +0,0 @@
using LibProsperoPkg.Gui.Mvvm;
using LibProsperoPkg.Gui.Services;
using LibProsperoPkg.Gui.ViewModels.Fields;
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Text;
namespace LibProsperoPkg.Gui.ViewModels;
public abstract class ToolPageViewModel(IAppHost host, string title, string description) : ViewModelBase
{
private readonly IAppHost _host = host;
public string Title { get; } = title;
public string Description { get; } = description;
public ObservableCollection<FieldViewModel> Fields { get; } = [];
public ObservableCollection<ActionViewModel> Actions { get; } = [];
protected TextFieldViewModel Text(
string label,
string value = "",
string? description = null,
string? watermark = null,
PickKind pick = PickKind.None,
bool multiline = false,
string? filterName = null,
IReadOnlyList<string>? extensions = null,
string? suggestedName = null)
{
var field = new TextFieldViewModel(_host.Storage)
{
Label = label,
Description = description,
Watermark = watermark,
Multiline = multiline,
Pick = pick,
FileFilterName = filterName,
FileExtensions = extensions,
SuggestedFileName = suggestedName,
Value = value,
};
Fields.Add(field);
return field;
}
protected CheckFieldViewModel Check(string label, bool value = false, string? description = null)
{
var field = new CheckFieldViewModel { Label = label, Description = description, Value = value };
Fields.Add(field);
return field;
}
protected ChoiceFieldViewModel Choice(string label, IReadOnlyList<ChoiceOption> options, string? description = null)
{
var field = new ChoiceFieldViewModel(options) { Label = label, Description = description };
Fields.Add(field);
return field;
}
protected ChoiceFieldViewModel EnumChoice<TEnum>(string label, TEnum selected = default, string? description = null)
where TEnum : struct, Enum
{
var options = new List<ChoiceOption>();
ChoiceOption? preselected = null;
foreach (TEnum value in Enum.GetValues<TEnum>())
{
var option = new ChoiceOption(Humanize(value.ToString() ?? ""), value);
options.Add(option);
if (EqualityComparer<TEnum>.Default.Equals(value, selected))
preselected = option;
}
var field = new ChoiceFieldViewModel(options, preselected) { Label = label, Description = description };
Fields.Add(field);
return field;
}
protected void Run(string name, Action<Action<string>> operation, bool primary = false)
=> Actions.Add(new ActionViewModel(name, () => _host.RunAsync(name, operation), primary));
private static string Humanize(string value)
{
var builder = new StringBuilder(value.Length + 4);
for (int i = 0; i < value.Length; i++)
{
char c = value[i];
if (i > 0 && char.IsUpper(c) && !char.IsUpper(value[i - 1]))
builder.Append(' ');
builder.Append(c);
}
return builder.ToString();
}
}
@@ -1,56 +0,0 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="clr-namespace:LibProsperoPkg.Gui.ViewModels"
x:Class="LibProsperoPkg.Gui.Views.MainWindow"
x:DataType="vm:MainWindowViewModel"
Title="{Binding Title}"
Width="1200" Height="850"
MinWidth="1000" MinHeight="850">
<Grid RowDefinitions="*,Auto,Auto">
<Grid Grid.Row="0" ColumnDefinitions="240,*" IsEnabled="{Binding !IsBusy}">
<Border Grid.Column="0" BorderBrush="#22808080" BorderThickness="0,0,1,0" Background="#0A808080">
<DockPanel>
<TextBlock DockPanel.Dock="Top" Text="LibProsperoPkg" FontWeight="Bold" FontSize="16" Margin="16,16,16,10" />
<ListBox ItemsSource="{Binding Pages}"
SelectedItem="{Binding SelectedPage, Mode=TwoWay}"
Background="Transparent"
Margin="4">
<ListBox.ItemTemplate>
<DataTemplate x:DataType="vm:ToolPageViewModel">
<TextBlock Text="{Binding Title}" Padding="6,4" />
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</DockPanel>
</Border>
<ContentControl Grid.Column="1" Content="{Binding SelectedPage}" />
</Grid>
<Grid Grid.Row="1" RowDefinitions="Auto,170">
<Grid Grid.Row="0" ColumnDefinitions="*,Auto" Background="#12808080">
<TextBlock Grid.Column="0" Text="Log" FontWeight="SemiBold" Margin="14,6" VerticalAlignment="Center" />
<Button Grid.Column="1" Content="Clear" Command="{Binding ClearLogCommand}" Margin="6,4" Padding="12,4" />
</Grid>
<TextBox Grid.Row="1"
Text="{Binding LogText}"
IsReadOnly="True"
AcceptsReturn="True"
TextWrapping="Wrap"
VerticalContentAlignment="Top"
FontFamily="Cascadia Mono,Consolas,monospace"
FontSize="12"
ScrollViewer.VerticalScrollBarVisibility="Auto" />
</Grid>
<Grid Grid.Row="2" ColumnDefinitions="Auto,*" Background="#12808080">
<ProgressBar Grid.Column="0"
IsIndeterminate="{Binding IsBusy}"
IsVisible="{Binding IsBusy}"
Width="130" Margin="14,6" />
<TextBlock Grid.Column="1" Text="{Binding Status}" VerticalAlignment="Center" Margin="14,6" />
</Grid>
</Grid>
</Window>
@@ -1,11 +0,0 @@
using Avalonia.Controls;
namespace LibProsperoPkg.Gui.Views;
public partial class MainWindow : Window
{
public MainWindow()
{
InitializeComponent();
}
}
-17
View File
@@ -1,17 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
<assemblyIdentity version="1.6.0.0" name="LibProsperoPkg.Gui" />
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
<application>
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" />
</application>
</compatibility>
<application xmlns="urn:schemas-microsoft-com:asm.v3">
<windowsSettings>
<dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true/pm</dpiAware>
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">permonitorv2,permonitor</dpiAwareness>
</windowsSettings>
</application>
</assembly>
@@ -1,395 +0,0 @@
// LibProsperoPkg - A library for building and inspecting PS5 packages.
// Copyright (C) 2026 SvenGDK
//
// ELF64 header reader and editor. A module that is fed to the fake-self builder must carry a
// correct ELF header: 64-bit class, little-endian data, the x86-64 machine, an OS/ABI the loader
// accepts, and an executable or dynamic type. This type reads those fields and edits them in place
// without touching segment content, so a plain homebrew ELF can be normalized before MakeFself.
#nullable enable
using System;
using System.Buffers.Binary;
using System.IO;
namespace LibProsperoPkg.Content;
/// <summary>ELF identification class byte (<c>e_ident[EI_CLASS]</c>).</summary>
public enum ElfClass : byte
{
/// <summary>Invalid class.</summary>
None = 0,
/// <summary>32-bit objects.</summary>
Elf32 = 1,
/// <summary>64-bit objects.</summary>
Elf64 = 2,
}
/// <summary>ELF identification data-encoding byte (<c>e_ident[EI_DATA]</c>).</summary>
public enum ElfData : byte
{
/// <summary>Invalid encoding.</summary>
None = 0,
/// <summary>Two's-complement little-endian.</summary>
LittleEndian = 1,
/// <summary>Two's-complement big-endian.</summary>
BigEndian = 2,
}
/// <summary>ELF object-file type (<c>e_type</c>), including the module-specific values.</summary>
public enum ElfType : ushort
{
/// <summary>No file type.</summary>
None = 0x0000,
/// <summary>Relocatable file.</summary>
Relocatable = 0x0001,
/// <summary>Executable file.</summary>
Executable = 0x0002,
/// <summary>Shared object (dynamic) file.</summary>
Dynamic = 0x0003,
/// <summary>Core file.</summary>
Core = 0x0004,
/// <summary>Module executable.</summary>
ModuleExecutable = 0xFE00,
/// <summary>Replay executable.</summary>
ModuleReplayExecutable = 0xFE01,
/// <summary>Relocatable module executable.</summary>
ModuleRelocatableExecutable = 0xFE04,
/// <summary>Stub library.</summary>
ModuleStubLibrary = 0xFE0C,
/// <summary>Address-space-randomized executable.</summary>
ModuleDynamicExecutable = 0xFE10,
/// <summary>Dynamic shared library.</summary>
ModuleDynamic = 0xFE18,
}
/// <summary>OS/ABI identification byte (<c>e_ident[EI_OSABI]</c>).</summary>
public static class ElfOsAbi
{
/// <summary>System V (0x00).</summary>
public const byte SystemV = 0x00;
/// <summary>HP-UX (0x01).</summary>
public const byte HpUx = 0x01;
/// <summary>NetBSD (0x02).</summary>
public const byte NetBsd = 0x02;
/// <summary>GNU / Linux (0x03).</summary>
public const byte Gnu = 0x03;
/// <summary>Solaris (0x06).</summary>
public const byte Solaris = 0x06;
/// <summary>AIX (0x07).</summary>
public const byte Aix = 0x07;
/// <summary>IRIX (0x08).</summary>
public const byte Irix = 0x08;
/// <summary>FreeBSD (0x09). The value module SELF images carry.</summary>
public const byte FreeBsd = 0x09;
/// <summary>OpenBSD (0x0C).</summary>
public const byte OpenBsd = 0x0C;
/// <summary>OpenVMS (0x0D).</summary>
public const byte OpenVms = 0x0D;
/// <summary>FenixOS (0x10).</summary>
public const byte FenixOs = 0x10;
}
/// <summary>Machine identifier (<c>e_machine</c>).</summary>
public static class ElfMachine
{
/// <summary>No machine.</summary>
public const ushort None = 0x0000;
/// <summary>AMD x86-64 (0x3E). The machine module images use.</summary>
public const ushort X86_64 = 0x003E;
/// <summary>ARM AArch64 (0xB7).</summary>
public const ushort AArch64 = 0x00B7;
}
/// <summary>
/// Reader and in-place editor for the 64-bit ELF header (the first 0x40 bytes of an ELF file).
/// </summary>
/// <remarks>
/// Field layout (little-endian scalars):
/// <list type="bullet">
/// <item><c>e_ident</c> at 0x00, 16 bytes: magic <c>7F 45 4C 46</c>, class (0x04), data (0x05),
/// header version (0x06), OS/ABI (0x07), ABI version (0x08), then padding.</item>
/// <item><c>e_type</c> (0x10, u16), <c>e_machine</c> (0x12, u16), <c>e_version</c> (0x14, u32).</item>
/// <item><c>e_entry</c> (0x18, u64), <c>e_phoff</c> (0x20, u64), <c>e_shoff</c> (0x28, u64).</item>
/// <item><c>e_flags</c> (0x30, u32), <c>e_ehsize</c> (0x34, u16), <c>e_phentsize</c> (0x36, u16),
/// <c>e_phnum</c> (0x38, u16), <c>e_shentsize</c> (0x3A, u16), <c>e_shnum</c> (0x3C, u16),
/// <c>e_shstrndx</c> (0x3E, u16).</item>
/// </list>
/// The editing methods change only the header bytes they name; program headers, section headers
/// and segment data are left untouched.
/// </remarks>
public sealed class ProsperoElfHeader
{
/// <summary>Size in bytes of a 64-bit ELF header.</summary>
public const int HeaderSize = 0x40;
private const byte EiClass = 0x04;
private const byte EiData = 0x05;
private const byte EiVersion = 0x06;
private const byte EiOsAbi = 0x07;
private const byte EiAbiVersion = 0x08;
private const int OffType = 0x10;
private const int OffMachine = 0x12;
private const int OffVersion = 0x14;
private const int OffEntry = 0x18;
private const int OffPhOff = 0x20;
private const int OffShOff = 0x28;
private const int OffFlags = 0x30;
private const int OffEhSize = 0x34;
private const int OffPhEntSize = 0x36;
private const int OffPhNum = 0x38;
private const int OffShEntSize = 0x3A;
private const int OffShNum = 0x3C;
private const int OffShStrNdx = 0x3E;
private ProsperoElfHeader()
{
}
/// <summary>Identification class byte.</summary>
public ElfClass Class { get; private init; }
/// <summary>Identification data-encoding byte.</summary>
public ElfData Data { get; private init; }
/// <summary>Identification header-version byte.</summary>
public byte IdentVersion { get; private init; }
/// <summary>OS/ABI byte. See <see cref="ElfOsAbi"/> for named values.</summary>
public byte OsAbi { get; private init; }
/// <summary>ABI-version byte.</summary>
public byte AbiVersion { get; private init; }
/// <summary>Object-file type.</summary>
public ElfType Type { get; private init; }
/// <summary>Machine identifier. See <see cref="ElfMachine"/> for named values.</summary>
public ushort Machine { get; private init; }
/// <summary>File version word.</summary>
public uint Version { get; private init; }
/// <summary>Entry-point virtual address.</summary>
public ulong Entry { get; private init; }
/// <summary>Program-header table file offset.</summary>
public ulong ProgramHeaderOffset { get; private init; }
/// <summary>Section-header table file offset.</summary>
public ulong SectionHeaderOffset { get; private init; }
/// <summary>Processor-specific flags.</summary>
public uint Flags { get; private init; }
/// <summary>ELF header size.</summary>
public ushort HeaderSizeField { get; private init; }
/// <summary>Program-header entry size.</summary>
public ushort ProgramHeaderEntrySize { get; private init; }
/// <summary>Program-header entry count.</summary>
public ushort ProgramHeaderCount { get; private init; }
/// <summary>Section-header entry size.</summary>
public ushort SectionHeaderEntrySize { get; private init; }
/// <summary>Section-header entry count.</summary>
public ushort SectionHeaderCount { get; private init; }
/// <summary>Section-name string-table index.</summary>
public ushort SectionNameStringIndex { get; private init; }
/// <summary>Whether the type is an executable or module-executable variant.</summary>
public bool IsExecutable =>
Type is ElfType.Executable or ElfType.ModuleExecutable or ElfType.ModuleReplayExecutable
or ElfType.ModuleRelocatableExecutable or ElfType.ModuleDynamicExecutable;
/// <summary>Whether the type is a dynamic/shared object or module-dynamic library.</summary>
public bool IsDynamic =>
Type is ElfType.Dynamic or ElfType.ModuleDynamic or ElfType.ModuleStubLibrary;
/// <summary>Whether the type is one of the module-specific values.</summary>
public bool IsModuleType => (ushort)Type >= 0xFE00 && (ushort)Type <= 0xFEFF;
/// <summary>
/// Whether the header is shaped the way a module the fake-self builder accepts expects it:
/// 64-bit, little-endian, the x86-64 machine, an executable or dynamic type, and an OS/ABI of
/// FreeBSD or System V.
/// </summary>
public bool IsModuleReady =>
Class == ElfClass.Elf64 &&
Data == ElfData.LittleEndian &&
Machine == ElfMachine.X86_64 &&
(IsExecutable || IsDynamic) &&
OsAbi is ElfOsAbi.FreeBsd or ElfOsAbi.SystemV;
/// <summary>Returns whether the buffer begins with the ELF magic and is large enough for a header.</summary>
public static bool IsElf(ReadOnlySpan<byte> data) =>
data.Length >= HeaderSize &&
data[0] == 0x7F && data[1] == (byte)'E' && data[2] == (byte)'L' && data[3] == (byte)'F';
/// <summary>Parses a 64-bit ELF header from the start of a buffer.</summary>
/// <param name="data">A buffer beginning with an ELF header.</param>
/// <exception cref="InvalidDataException">The buffer is not a 64-bit little-endian ELF header.</exception>
public static ProsperoElfHeader Read(ReadOnlySpan<byte> data)
{
if (!IsElf(data))
throw new InvalidDataException("Buffer does not begin with an ELF header.");
if (data[EiClass] != (byte)ElfClass.Elf64)
throw new InvalidDataException("Only 64-bit ELF files are supported.");
if (data[EiData] != (byte)ElfData.LittleEndian)
throw new InvalidDataException("Only little-endian ELF files are supported.");
return new ProsperoElfHeader
{
Class = (ElfClass)data[EiClass],
Data = (ElfData)data[EiData],
IdentVersion = data[EiVersion],
OsAbi = data[EiOsAbi],
AbiVersion = data[EiAbiVersion],
Type = (ElfType)BinaryPrimitives.ReadUInt16LittleEndian(data[OffType..]),
Machine = BinaryPrimitives.ReadUInt16LittleEndian(data[OffMachine..]),
Version = BinaryPrimitives.ReadUInt32LittleEndian(data[OffVersion..]),
Entry = BinaryPrimitives.ReadUInt64LittleEndian(data[OffEntry..]),
ProgramHeaderOffset = BinaryPrimitives.ReadUInt64LittleEndian(data[OffPhOff..]),
SectionHeaderOffset = BinaryPrimitives.ReadUInt64LittleEndian(data[OffShOff..]),
Flags = BinaryPrimitives.ReadUInt32LittleEndian(data[OffFlags..]),
HeaderSizeField = BinaryPrimitives.ReadUInt16LittleEndian(data[OffEhSize..]),
ProgramHeaderEntrySize = BinaryPrimitives.ReadUInt16LittleEndian(data[OffPhEntSize..]),
ProgramHeaderCount = BinaryPrimitives.ReadUInt16LittleEndian(data[OffPhNum..]),
SectionHeaderEntrySize = BinaryPrimitives.ReadUInt16LittleEndian(data[OffShEntSize..]),
SectionHeaderCount = BinaryPrimitives.ReadUInt16LittleEndian(data[OffShNum..]),
SectionNameStringIndex = BinaryPrimitives.ReadUInt16LittleEndian(data[OffShStrNdx..]),
};
}
/// <summary>Reads the ELF header from a file.</summary>
/// <param name="path">Path to an ELF file.</param>
public static ProsperoElfHeader ReadFile(string path)
{
ArgumentNullException.ThrowIfNull(path);
byte[] head = new byte[HeaderSize];
using FileStream fs = File.OpenRead(path);
int read = fs.ReadAtLeast(head, HeaderSize, throwOnEndOfStream: false);
if (read < HeaderSize)
throw new InvalidDataException("File is smaller than an ELF header.");
return Read(head);
}
/// <summary>Writes the OS/ABI byte in place.</summary>
/// <param name="elf">A buffer beginning with a 64-bit ELF header; modified in place.</param>
/// <param name="osAbi">The OS/ABI value to write. See <see cref="ElfOsAbi"/>.</param>
public static void SetOsAbi(byte[] elf, byte osAbi)
{
Require64BitElf(elf);
elf[EiOsAbi] = osAbi;
}
/// <summary>Writes the ABI-version byte in place.</summary>
/// <param name="elf">A buffer beginning with a 64-bit ELF header; modified in place.</param>
/// <param name="abiVersion">The ABI-version value to write.</param>
public static void SetAbiVersion(byte[] elf, byte abiVersion)
{
Require64BitElf(elf);
elf[EiAbiVersion] = abiVersion;
}
/// <summary>Writes the object-file type in place.</summary>
/// <param name="elf">A buffer beginning with a 64-bit ELF header; modified in place.</param>
/// <param name="type">The type value to write.</param>
public static void SetType(byte[] elf, ElfType type)
{
Require64BitElf(elf);
BinaryPrimitives.WriteUInt16LittleEndian(elf.AsSpan(OffType), (ushort)type);
}
/// <summary>Writes the machine identifier in place.</summary>
/// <param name="elf">A buffer beginning with a 64-bit ELF header; modified in place.</param>
/// <param name="machine">The machine value to write. See <see cref="ElfMachine"/>.</param>
public static void SetMachine(byte[] elf, ushort machine)
{
Require64BitElf(elf);
BinaryPrimitives.WriteUInt16LittleEndian(elf.AsSpan(OffMachine), machine);
}
/// <summary>
/// Normalizes an ELF header for the module loader without changing segment content: sets the
/// machine to x86-64 and, when the current OS/ABI is System V or GNU, sets it to FreeBSD. A type
/// that is already executable or dynamic (standard or module-specific) is kept; a
/// <see cref="ElfType.None"/> type is set to <see cref="ElfType.Executable"/>. Returns the
/// fields that changed.
/// </summary>
/// <param name="elf">A buffer beginning with a 64-bit ELF header; modified in place.</param>
/// <returns>A summary of which fields were changed.</returns>
public static ElfNormalizeResult NormalizeForModule(byte[] elf)
{
Require64BitElf(elf);
var before = Read(elf);
bool machineChanged = before.Machine != ElfMachine.X86_64;
if (machineChanged)
SetMachine(elf, ElfMachine.X86_64);
bool osAbiChanged = before.OsAbi is ElfOsAbi.SystemV or ElfOsAbi.Gnu;
if (osAbiChanged)
SetOsAbi(elf, ElfOsAbi.FreeBsd);
bool typeChanged = before.Type == ElfType.None;
if (typeChanged)
SetType(elf, ElfType.Executable);
return new ElfNormalizeResult(machineChanged, osAbiChanged, typeChanged);
}
/// <summary>Returns a one-line description of the header for logging and inspection.</summary>
public string Describe() =>
$"ELF64 {Data} osabi=0x{OsAbi:X2} type={Type} machine=0x{Machine:X4} " +
$"phnum={ProgramHeaderCount} entry=0x{Entry:X}";
private static void Require64BitElf(byte[] elf)
{
ArgumentNullException.ThrowIfNull(elf);
if (!IsElf(elf))
throw new ArgumentException("Buffer does not begin with an ELF header.", nameof(elf));
if (elf[EiClass] != (byte)ElfClass.Elf64)
throw new ArgumentException("Only 64-bit ELF files are supported.", nameof(elf));
if (elf[EiData] != (byte)ElfData.LittleEndian)
throw new ArgumentException("Only little-endian ELF files are supported.", nameof(elf));
}
}
/// <summary>Records which header fields <see cref="ProsperoElfHeader.NormalizeForModule"/> changed.</summary>
/// <param name="MachineChanged">Whether the machine field was set to x86-64.</param>
/// <param name="OsAbiChanged">Whether the OS/ABI byte was set to FreeBSD.</param>
/// <param name="TypeChanged">Whether the type field was set to executable.</param>
public readonly record struct ElfNormalizeResult(bool MachineChanged, bool OsAbiChanged, bool TypeChanged)
{
/// <summary>Whether any field changed.</summary>
public bool Changed => MachineChanged || OsAbiChanged || TypeChanged;
}
@@ -1,362 +0,0 @@
// LibProsperoPkg - A library for building and inspecting PS5 packages.
// Copyright (C) 2026 SvenGDK
//
// SELF (signed ELF) container reader and fake-self producer. A PS5 package wraps executable modules as
// SELF images: a container header, a segment table, the original ELF header and program headers, extended
// info, and plaintext segment data. The debug path builds a fake-self whose per-segment digest and
// signature areas are zero-filled and whose authority id carries the fake-authority prefix, so a debug
// console accepts the module without a real signature. The extended-info digest is SHA-256 over the
// embedded (normalized) module bytes.
#nullable enable
using System;
using System.Buffers.Binary;
using System.Collections.Generic;
using System.IO;
using System.Security.Cryptography;
namespace LibProsperoPkg.Content;
/// <summary>A decoded entry from a <see cref="ProsperoFself"/> segment table.</summary>
/// <param name="Flags">Raw 64-bit flags word.</param>
/// <param name="FileOffset">Offset of the segment data within the SELF file.</param>
/// <param name="FileSize">Stored size of the segment.</param>
/// <param name="MemSize">In-memory size of the segment.</param>
public sealed record SelfSegment(ulong Flags, ulong FileOffset, ulong FileSize, ulong MemSize)
{
/// <summary>Segment id (bits 20..35), a program-header index for data segments.</summary>
public int Id => (int)((Flags >> 20) & 0xFFFF);
/// <summary>Whether the segment is ordered.</summary>
public bool Ordered => (Flags & 0x1) != 0;
/// <summary>Whether the segment data is encrypted.</summary>
public bool Encrypted => (Flags & 0x2) != 0;
/// <summary>Whether the segment is covered by a signature/digest.</summary>
public bool Signed => (Flags & 0x4) != 0;
/// <summary>Whether the segment data is deflate-compressed.</summary>
public bool Compressed => (Flags & 0x8) != 0;
/// <summary>Whether the segment is stored in fixed-size blocks.</summary>
public bool Blocked => (Flags & 0x800) != 0;
}
/// <summary>SELF extended information (0x40 bytes) that follows the ELF program headers.</summary>
/// <param name="AuthorityId">Program authority id (PAID). A fake-self uses the 0x31.. prefix.</param>
/// <param name="ProgramType">Program type (PTYPE).</param>
/// <param name="AppVersion">Application version.</param>
/// <param name="FirmwareVersion">Firmware version.</param>
/// <param name="Digest">SHA-256 of the original ELF file.</param>
public sealed record SelfExtInfo(ulong AuthorityId, ulong ProgramType, ulong AppVersion, ulong FirmwareVersion, byte[] Digest);
/// <summary>A parsed SELF image.</summary>
/// <param name="ProgramType">Container header program/key type field.</param>
/// <param name="HeaderSize">Size of the header region.</param>
/// <param name="MetaSize">Size of the metadata footer.</param>
/// <param name="FileSize">Total file size recorded in the header.</param>
/// <param name="Segments">Decoded segment table.</param>
/// <param name="Elf">The embedded ELF header and program headers region.</param>
/// <param name="ExtInfo">Extended info, when present.</param>
public sealed record SelfImage(
uint ProgramType,
int HeaderSize,
int MetaSize,
ulong FileSize,
IReadOnlyList<SelfSegment> Segments,
byte[] Elf,
SelfExtInfo? ExtInfo);
/// <summary>Options for <see cref="ProsperoFself.MakeFself"/>.</summary>
public sealed class FselfOptions
{
/// <summary>Application version written to the extended info.</summary>
public ulong AppVersion { get; init; }
/// <summary>Firmware version written to the extended info.</summary>
public ulong FirmwareVersion { get; init; }
/// <summary>
/// Overrides the authority id. When null, the fake-authority id is written.
/// </summary>
public ulong? AuthorityId { get; init; }
/// <summary>
/// Normalizes the ELF header before building (machine to x86-64, a System V / GNU OS/ABI to
/// FreeBSD, a placeholder type to executable) so a plain homebrew ELF is accepted as a module.
/// Only the 0x40-byte header changes; the container embeds and digests that normalized module,
/// so the extended-info digest stays self-consistent. A module whose header is already correct
/// is left unchanged. Defaults to <see langword="true"/>.
/// </summary>
public bool NormalizeHeader { get; init; } = true;
}
/// <summary>
/// Reader and producer for the SELF container used by PS5 executable modules.
/// </summary>
/// <remarks>
/// Layout (little-endian scalars):
/// <list type="bullet">
/// <item>Container header, 0x20 bytes: magic <c>0xEEF51454</c>, version/mode/endian/attr bytes, program type,
/// header size, metadata size, file size, segment count, flags.</item>
/// <item>Segment table at 0x20, one 0x20-byte entry per segment: flags, file offset, file size, memory
/// size. Content segments come in pairs (a zero-filled digest segment then the data segment).</item>
/// <item>ELF header and program headers, copied verbatim.</item>
/// <item>Extended info (0x40) after the program headers: authority id, program type, versions, and the
/// SHA-256 of the input ELF.</item>
/// <item>A zero-filled metadata footer, then the plaintext segment data.</item>
/// </list>
/// </remarks>
public static class ProsperoFself
{
/// <summary>Container header magic at file offset 0x00.</summary>
public const uint Magic = 0xEEF51454;
private const int ContainerHeaderSize = 0x20;
private const int SegEntrySize = 0x20;
private const int ExtInfoSize = 0x40;
private const int ControlRegionSize = 0x30;
private const int MetaFooterBase = 0x110;
private const int DigestSegSize = 0x20;
private const int FooterMarkerOffset = 0x3F0;
private const uint DefaultProgramType = 0x00000101;
/// <summary>
/// Program authority id (PAID) a fake-self carries. One value covers an executable and a library
/// alike; it does not vary with the module type.
/// </summary>
public const ulong FakeAuthorityId = 0x3100000000000002;
private const int ElfHeaderSize = 0x40;
private const int ElfPhdrSize = 0x38;
/// <summary>Returns whether the buffer begins with a SELF container header.</summary>
public static bool IsSelf(ReadOnlySpan<byte> data) =>
data.Length >= ContainerHeaderSize && BinaryPrimitives.ReadUInt32LittleEndian(data) == Magic;
/// <summary>Returns whether the buffer begins with an ELF header.</summary>
public static bool IsElf(ReadOnlySpan<byte> data) =>
data.Length >= ElfHeaderSize && data[0] == 0x7F && data[1] == (byte)'E' && data[2] == (byte)'L' && data[3] == (byte)'F';
/// <summary>Parses a SELF image.</summary>
/// <exception cref="InvalidDataException">The buffer is not a structurally valid SELF.</exception>
public static SelfImage Parse(ReadOnlySpan<byte> data)
{
if (!Validate(data, out var error))
throw new InvalidDataException(error);
uint programType = BinaryPrimitives.ReadUInt32LittleEndian(data[0x08..]);
int headerSize = BinaryPrimitives.ReadUInt16LittleEndian(data[0x0C..]);
int metaSize = BinaryPrimitives.ReadUInt16LittleEndian(data[0x0E..]);
ulong fileSize = BinaryPrimitives.ReadUInt64LittleEndian(data[0x10..]);
int segCount = BinaryPrimitives.ReadUInt16LittleEndian(data[0x18..]);
var segments = new List<SelfSegment>(segCount);
for (int i = 0; i < segCount; i++)
{
int e = ContainerHeaderSize + i * SegEntrySize;
segments.Add(new SelfSegment(
BinaryPrimitives.ReadUInt64LittleEndian(data[e..]),
BinaryPrimitives.ReadUInt64LittleEndian(data[(e + 0x08)..]),
BinaryPrimitives.ReadUInt64LittleEndian(data[(e + 0x10)..]),
BinaryPrimitives.ReadUInt64LittleEndian(data[(e + 0x18)..])));
}
int elfStart = ContainerHeaderSize + segCount * SegEntrySize;
SelfExtInfo? extInfo = null;
byte[] elf = Array.Empty<byte>();
if (IsElf(data[elfStart..]))
{
int phnum = BinaryPrimitives.ReadUInt16LittleEndian(data[(elfStart + 0x38)..]);
int elfLen = ElfHeaderSize + phnum * ElfPhdrSize;
if (elfStart + elfLen <= data.Length)
{
elf = data.Slice(elfStart, elfLen).ToArray();
int extStart = AlignUp(elfStart + elfLen, 0x10);
if (extStart + ExtInfoSize <= headerSize && extStart + ExtInfoSize <= data.Length)
{
extInfo = new SelfExtInfo(
BinaryPrimitives.ReadUInt64LittleEndian(data[extStart..]),
BinaryPrimitives.ReadUInt64LittleEndian(data[(extStart + 0x08)..]),
BinaryPrimitives.ReadUInt64LittleEndian(data[(extStart + 0x10)..]),
BinaryPrimitives.ReadUInt64LittleEndian(data[(extStart + 0x18)..]),
data.Slice(extStart + 0x20, 0x20).ToArray());
}
}
}
return new SelfImage(programType, headerSize, metaSize, fileSize, segments, elf, extInfo);
}
/// <summary>Validates the container header and segment table of a SELF image.</summary>
public static bool Validate(ReadOnlySpan<byte> data, out string? error)
{
if (data.Length < ContainerHeaderSize) { error = "Buffer is smaller than the container header."; return false; }
if (BinaryPrimitives.ReadUInt32LittleEndian(data) != Magic) { error = "Bad container magic."; return false; }
int headerSize = BinaryPrimitives.ReadUInt16LittleEndian(data[0x0C..]);
int segCount = BinaryPrimitives.ReadUInt16LittleEndian(data[0x18..]);
long tableEnd = ContainerHeaderSize + (long)segCount * SegEntrySize;
if (tableEnd > data.Length) { error = "Segment table overruns the buffer."; return false; }
if (headerSize > data.Length) { error = "Header size exceeds the buffer."; return false; }
error = null;
return true;
}
/// <summary>Builds a debug fake-self from a plaintext ELF module.</summary>
/// <param name="elf">The input ELF file bytes.</param>
/// <param name="options">Version and authority overrides.</param>
/// <exception cref="ArgumentException">The input is not a supported ELF.</exception>
public static byte[] MakeFself(byte[] elf, FselfOptions? options = null)
{
ArgumentNullException.ThrowIfNull(elf);
options ??= new FselfOptions();
if (!IsElf(elf))
throw new ArgumentException("Input is not an ELF file.", nameof(elf));
if (elf[4] != 2)
throw new ArgumentException("Only 64-bit ELF modules are supported.", nameof(elf));
// Normalize the header on a private copy so the caller's buffer is never mutated; the
// container then embeds and digests this normalized module.
if (options.NormalizeHeader)
{
elf = (byte[])elf.Clone();
ProsperoElfHeader.NormalizeForModule(elf);
}
ushort eType = BinaryPrimitives.ReadUInt16LittleEndian(elf.AsSpan(0x10));
int phoff = (int)BinaryPrimitives.ReadUInt64LittleEndian(elf.AsSpan(0x20));
int phentSize = BinaryPrimitives.ReadUInt16LittleEndian(elf.AsSpan(0x36));
int phnum = BinaryPrimitives.ReadUInt16LittleEndian(elf.AsSpan(0x38));
if (phentSize != ElfPhdrSize)
throw new ArgumentException($"Unexpected ELF program-header size {phentSize}.", nameof(elf));
if (phoff + phnum * ElfPhdrSize > elf.Length)
throw new ArgumentException("ELF program headers overrun the file.", nameof(elf));
// The header region embeds the ELF header and its program headers as one contiguous block, so
// the program-header table must directly follow the 0x40-byte ELF header. Reject any other
// layout rather than embed the wrong bytes.
if (phoff != ElfHeaderSize)
throw new ArgumentException(
$"The ELF program-header table must follow the ELF header at 0x{ElfHeaderSize:X} (e_phoff is 0x{phoff:X}).",
nameof(elf));
var selected = SelectSegments(elf, phoff, phnum);
if (selected.Count == 0)
throw new ArgumentException("The ELF has no loadable segment content.", nameof(elf));
int segCount = selected.Count * 2;
int afterSeg = ContainerHeaderSize + segCount * SegEntrySize;
int elfHdrLen = ElfHeaderSize + phnum * ElfPhdrSize;
int extInfoStart = AlignUp(afterSeg + elfHdrLen, 0x10);
int headerSize = extInfoStart + ExtInfoSize + ControlRegionSize;
// One 0x40-byte block per segment plus the fixed group that closes the footer out. Both this and
// the header size are matched against containers a console accepts.
int metaSize = MetaFooterBase + (segCount + 8) * 0x40;
int dataStart = headerSize + metaSize;
// The container header stores headerSize and metaSize as u16 fields (0x0C / 0x0E). A module
// with enough program headers to overflow them cannot be represented; fail rather than
// silently truncate the sizes.
if (headerSize > ushort.MaxValue || metaSize > ushort.MaxValue)
throw new ArgumentException(
$"The ELF has too many segments to fake-sign (header 0x{headerSize:X}, meta 0x{metaSize:X} exceed the 16-bit container fields).",
nameof(elf));
// Assign segment file offsets: a 0x20 digest segment then the data (padded to 16) per pair.
var segOffsets = new int[segCount];
int cursor = dataStart;
for (int k = 0; k < selected.Count; k++)
{
segOffsets[k * 2] = cursor;
cursor += DigestSegSize;
segOffsets[k * 2 + 1] = cursor;
cursor = AlignUp(cursor + selected[k].FileSize, 0x10);
}
int fileSize = cursor;
var buffer = new byte[fileSize];
var span = buffer.AsSpan();
BinaryPrimitives.WriteUInt32LittleEndian(span, Magic);
span[0x04] = 0; // version
span[0x05] = 1; // mode
span[0x06] = 1; // endian
span[0x07] = 0x12; // attr
BinaryPrimitives.WriteUInt32LittleEndian(span[0x08..], DefaultProgramType);
BinaryPrimitives.WriteUInt16LittleEndian(span[0x0C..], (ushort)headerSize);
BinaryPrimitives.WriteUInt16LittleEndian(span[0x0E..], (ushort)metaSize);
BinaryPrimitives.WriteUInt64LittleEndian(span[0x10..], (ulong)fileSize);
BinaryPrimitives.WriteUInt16LittleEndian(span[0x18..], (ushort)segCount);
BinaryPrimitives.WriteUInt16LittleEndian(span[0x1A..], 0x0022); // flags
for (int k = 0; k < selected.Count; k++)
{
int digestEntry = ContainerHeaderSize + (k * 2) * SegEntrySize;
int dataEntry = ContainerHeaderSize + (k * 2 + 1) * SegEntrySize;
int dataTableIndex = k * 2 + 1;
ulong digestFlags = ((ulong)dataTableIndex << 20) | 0x10004;
WriteSegment(span, digestEntry, digestFlags, (ulong)segOffsets[k * 2], DigestSegSize, DigestSegSize);
ulong dataFlags = ((ulong)selected[k].PhdrIndex << 20) | 0x2804;
WriteSegment(span, dataEntry, dataFlags, (ulong)segOffsets[k * 2 + 1],
(ulong)selected[k].FileSize, (ulong)selected[k].FileSize);
}
elf.AsSpan(0, elfHdrLen).CopyTo(span[afterSeg..]);
ulong authorityId = options.AuthorityId ?? FakeAuthorityId;
BinaryPrimitives.WriteUInt64LittleEndian(span[extInfoStart..], authorityId);
BinaryPrimitives.WriteUInt64LittleEndian(span[(extInfoStart + 0x08)..], 1); // program type
BinaryPrimitives.WriteUInt64LittleEndian(span[(extInfoStart + 0x10)..], options.AppVersion);
BinaryPrimitives.WriteUInt64LittleEndian(span[(extInfoStart + 0x18)..], options.FirmwareVersion);
SHA256.HashData(elf).CopyTo(span[(extInfoStart + 0x20)..]);
BinaryPrimitives.WriteUInt64LittleEndian(span[(extInfoStart + ExtInfoSize)..], 3); // control block type
if (metaSize > FooterMarkerOffset + 4)
BinaryPrimitives.WriteUInt32LittleEndian(span[(headerSize + FooterMarkerOffset)..], 0x00010000);
for (int k = 0; k < selected.Count; k++)
elf.AsSpan(selected[k].FileOffset, selected[k].FileSize).CopyTo(span[segOffsets[k * 2 + 1]..]);
return buffer;
}
private readonly record struct SelectedSegment(int PhdrIndex, int FileOffset, int FileSize);
// A program header becomes a SELF content segment when it has file content and its type is a loadable
// segment or one of the module-data types the builder carries into the container.
private const uint PtLoad = 0x00000001;
private const uint PtModuleData = 0x61000000;
private const uint PtRelro = 0x61000010;
private const uint PtComment = 0x6FFFFF00;
private static List<SelectedSegment> SelectSegments(byte[] elf, int phoff, int phnum)
{
var result = new List<SelectedSegment>();
for (int i = 0; i < phnum; i++)
{
int p = phoff + i * ElfPhdrSize;
uint pType = BinaryPrimitives.ReadUInt32LittleEndian(elf.AsSpan(p));
long off = (long)BinaryPrimitives.ReadUInt64LittleEndian(elf.AsSpan(p + 0x08));
long fsz = (long)BinaryPrimitives.ReadUInt64LittleEndian(elf.AsSpan(p + 0x20));
if (fsz <= 0 || off + fsz > elf.Length)
continue;
if (pType == PtLoad || pType == PtModuleData || pType == PtRelro || pType == PtComment)
result.Add(new SelectedSegment(i, (int)off, (int)fsz));
}
return result;
}
private static void WriteSegment(Span<byte> span, int entry, ulong flags, ulong offset, ulong fileSize, ulong memSize)
{
BinaryPrimitives.WriteUInt64LittleEndian(span[entry..], flags);
BinaryPrimitives.WriteUInt64LittleEndian(span[(entry + 0x08)..], offset);
BinaryPrimitives.WriteUInt64LittleEndian(span[(entry + 0x10)..], fileSize);
BinaryPrimitives.WriteUInt64LittleEndian(span[(entry + 0x18)..], memSize);
}
private static int AlignUp(int value, int alignment) => (value + alignment - 1) & ~(alignment - 1);
}
@@ -1,254 +0,0 @@
// LibProsperoPkg - A library for building and inspecting PS5 packages.
// Copyright (C) 2026 SvenGDK
//
// Launch-readiness inspection for a debug application tree. The console launch service prepares a
// title through a fixed chain: it mounts the application workspace, runs a path/directory check, then
// hands the process to the system-core daemon which loads and starts the modules. Two conditions
// decide whether that chain completes for a self-authored or converted title on a debug-mode console:
// every executable module must be a plaintext module the loader accepts (a fake-authority SELF, or a
// raw ELF the builder fake-signs), and the metadata must be a param.json rather than the older
// param.sfo, which the launch service refuses. This type reads an application root, classifies each
// executable module, and reports whether the tree meets those conditions. It never signs, mounts, or
// launches anything; it only inspects.
#nullable enable
using System;
using System.Buffers.Binary;
using System.Collections.Generic;
using System.IO;
using System.Linq;
namespace LibProsperoPkg.Content;
/// <summary>How an executable module presents to the module loader on a debug-mode console.</summary>
public enum ModuleAuthorityKind
{
/// <summary>The file is neither an ELF nor a SELF container.</summary>
NotExecutable = 0,
/// <summary>A raw ELF module. The builder fake-signs it, so it starts on a debug-mode console.</summary>
RawElf = 1,
/// <summary>A plaintext SELF whose authority id carries the fake-authority prefix. Starts on a debug-mode console.</summary>
FakeAuthoritySelf = 2,
/// <summary>A plaintext SELF whose authority id is a genuine prefix. Relies on the console-provided module.</summary>
GenuineAuthoritySelf = 3,
/// <summary>A plaintext SELF whose authority prefix is neither fake nor a known genuine value.</summary>
UnknownAuthoritySelf = 4,
/// <summary>A signed and encrypted module. It needs the sealed key path and will not start on a debug-mode console.</summary>
SignedEncrypted = 5,
}
/// <summary>Launch classification of a single executable module.</summary>
/// <param name="Path">Path of the module, relative to the inspected root.</param>
/// <param name="Kind">How the module presents to the loader.</param>
/// <param name="AuthorityId">The SELF authority id, or 0 when the module is not a SELF.</param>
/// <param name="WillRunOnDebugConsole">Whether the module starts on a debug-mode console as packaged.</param>
/// <param name="Note">A short description of the classification.</param>
public sealed record ModuleLaunchReadiness(
string Path,
ModuleAuthorityKind Kind,
ulong AuthorityId,
bool WillRunOnDebugConsole,
string Note);
/// <summary>The launch-readiness report for an application root.</summary>
public sealed class ProsperoLaunchReadinessReport
{
/// <summary>The inspected application root.</summary>
public required string AppRoot { get; init; }
/// <summary>Classification of every executable module found under the root.</summary>
public required IReadOnlyList<ModuleLaunchReadiness> Modules { get; init; }
/// <summary>Whether a main <c>eboot.bin</c> is present at the root.</summary>
public required bool HasEboot { get; init; }
/// <summary>Whether <c>sce_sys/param.json</c> is present.</summary>
public required bool HasParamJson { get; init; }
/// <summary>Whether a <c>sce_sys/param.sfo</c> is present. The launch service refuses this metadata form.</summary>
public required bool HasParamSfo { get; init; }
/// <summary>
/// Whether the tree carries fake-authority or raw modules, which start only on a debug-mode console.
/// A tree of solely genuine-authority modules does not set this.
/// </summary>
public required bool RequiresDebugConsole { get; init; }
/// <summary>Blocking reasons that keep the tree from starting. Empty when the tree is launch-ready.</summary>
public required IReadOnlyList<string> Issues { get; init; }
/// <summary>Whether the tree meets every launch condition.</summary>
public bool IsLaunchReady => Issues.Count == 0;
}
/// <summary>
/// Reads an application root and reports whether its executable modules and metadata satisfy the
/// console launch conditions for a debug-mode console. See the file header for the model.
/// </summary>
public static class ProsperoLaunchReadiness
{
private const uint ElfMagic = 0x464C457FU; // 0x7F 'E' 'L' 'F'
private const uint SelfMagic = 0xEEF51454U; // SELF container magic on disk (fake and genuine alike)
private const ulong AuthorityMask = 0xFF00000000000000UL;
private const ulong FakeAuthorityPrefix = 0x3100000000000000UL;
private const ulong GenuineAuthorityPrefix = 0x4500000000000000UL;
// A 2 MiB window is larger than any real module header plus segment table, so a classification read
// never needs the whole of a large module file.
private const int HeaderWindow = 0x200000;
// eboot.bin is inspected explicitly by name; the recursive scan looks for the module extensions.
private static readonly string[] ModuleExtensions = { ".prx", ".sprx" };
/// <summary>Classifies a single module from its bytes.</summary>
/// <param name="path">Path recorded on the result.</param>
/// <param name="data">The module bytes. Only the header region is read.</param>
public static ModuleLaunchReadiness InspectModule(string path, ReadOnlySpan<byte> data)
{
if (data.Length < 4)
return new ModuleLaunchReadiness(path, ModuleAuthorityKind.NotExecutable, 0, false, "File is too short to classify.");
uint magic = BinaryPrimitives.ReadUInt32LittleEndian(data);
if (magic == ElfMagic)
return new ModuleLaunchReadiness(path, ModuleAuthorityKind.RawElf, 0, true,
"Raw ELF module; the builder fake-signs it, so it starts on a debug-mode console.");
// On disk a signed/encrypted module and a fake-self carry the same SELF magic; they are told
// apart by the extended-info authority id, so both route through ClassifySelf. A genuine
// (non-fake) authority or an unreadable encrypted header is reported as not launch-ready.
if (magic == SelfMagic)
return ClassifySelf(path, data);
return new ModuleLaunchReadiness(path, ModuleAuthorityKind.NotExecutable, 0, false,
"Not an ELF or SELF module.");
}
private static ModuleLaunchReadiness ClassifySelf(string path, ReadOnlySpan<byte> data)
{
SelfImage image;
try
{
image = ProsperoFself.Parse(data);
}
catch (InvalidDataException ex)
{
return new ModuleLaunchReadiness(path, ModuleAuthorityKind.UnknownAuthoritySelf, 0, false,
$"SELF header could not be parsed: {ex.Message}");
}
if (image.ExtInfo is null)
return new ModuleLaunchReadiness(path, ModuleAuthorityKind.UnknownAuthoritySelf, 0, false,
"SELF has no extended info; authority id is unavailable.");
ulong authority = image.ExtInfo.AuthorityId;
ulong prefix = authority & AuthorityMask;
if (prefix == FakeAuthorityPrefix)
return new ModuleLaunchReadiness(path, ModuleAuthorityKind.FakeAuthoritySelf, authority, true,
"Fake-authority SELF; starts on a debug-mode console.");
if (prefix == GenuineAuthorityPrefix)
return new ModuleLaunchReadiness(path, ModuleAuthorityKind.GenuineAuthoritySelf, authority, false,
"Genuine-authority SELF; relies on the console-provided module and does not start from this package on a debug-mode console.");
return new ModuleLaunchReadiness(path, ModuleAuthorityKind.UnknownAuthoritySelf, authority, false,
"SELF authority prefix is neither fake nor a known genuine value.");
}
/// <summary>
/// Inspects an application root (the folder that holds <c>eboot.bin</c> and <c>sce_sys/</c>) and
/// reports whether it meets the launch conditions for a debug-mode console.
/// </summary>
/// <param name="appRoot">The application root folder.</param>
/// <exception cref="ArgumentException"><paramref name="appRoot"/> is empty or missing.</exception>
public static ProsperoLaunchReadinessReport InspectAppRoot(string appRoot)
{
if (string.IsNullOrWhiteSpace(appRoot) || !Directory.Exists(appRoot))
throw new ArgumentException("Application root does not exist.", nameof(appRoot));
string root = Path.GetFullPath(appRoot);
var modules = new List<ModuleLaunchReadiness>();
var issues = new List<string>();
string ebootPath = Path.Combine(root, "eboot.bin");
bool hasEboot = File.Exists(ebootPath);
var moduleFiles = new List<string>();
if (hasEboot)
moduleFiles.Add(ebootPath);
moduleFiles.AddRange(Directory
.EnumerateFiles(root, "*", SearchOption.AllDirectories)
.Where(f => ModuleExtensions.Contains(Path.GetExtension(f).ToLowerInvariant()))
.OrderBy(f => f, StringComparer.OrdinalIgnoreCase));
moduleFiles = moduleFiles
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
foreach (string file in moduleFiles)
{
string rel = Path.GetRelativePath(root, file).Replace('\\', '/');
ModuleLaunchReadiness result = InspectModule(rel, ReadHeaderWindow(file));
if (result.Kind == ModuleAuthorityKind.NotExecutable)
continue;
modules.Add(result);
}
bool hasParamJson = File.Exists(Path.Combine(root, "sce_sys", "param.json"));
bool hasParamSfo = File.Exists(Path.Combine(root, "sce_sys", "param.sfo"));
bool requiresDebugConsole = modules.Any(m =>
m.Kind is ModuleAuthorityKind.FakeAuthoritySelf or ModuleAuthorityKind.RawElf);
if (!hasEboot)
issues.Add("No eboot.bin at the application root.");
var mainModule = modules.FirstOrDefault(m => m.Path == "eboot.bin");
if (hasEboot && mainModule is null)
issues.Add("eboot.bin is present but is not a loadable ELF or SELF module; it will not start.");
else if (hasEboot && mainModule is not null && !mainModule.WillRunOnDebugConsole)
issues.Add($"eboot.bin will not start on a debug-mode console: {mainModule.Note}");
var blocked = modules
.Where(m => m.Kind == ModuleAuthorityKind.SignedEncrypted)
.ToList();
foreach (var m in blocked)
issues.Add($"Module '{m.Path}' is signed and encrypted; it will not start on a debug-mode console.");
if (!hasParamJson)
issues.Add("No sce_sys/param.json; the launch service needs the param.json metadata form.");
if (hasParamSfo)
issues.Add("A sce_sys/param.sfo is present; the launch service refuses the param.sfo metadata form.");
return new ProsperoLaunchReadinessReport
{
AppRoot = root,
Modules = modules,
HasEboot = hasEboot,
HasParamJson = hasParamJson,
HasParamSfo = hasParamSfo,
RequiresDebugConsole = requiresDebugConsole,
Issues = issues,
};
}
private static byte[] ReadHeaderWindow(string path)
{
using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read);
int length = (int)Math.Min(stream.Length, HeaderWindow);
var buffer = new byte[length];
int read = 0;
while (read < length)
{
int n = stream.Read(buffer, read, length - read);
if (n == 0) break;
read += n;
}
return read == length ? buffer : buffer[..read];
}
}
@@ -1,229 +0,0 @@
// LibProsperoPkg - A library for building and inspecting PS5 packages.
// Copyright (C) 2026 SvenGDK
//
// SELF authentication-info sidecar (*.auth_info): a fixed 0x88-byte record that pairs with a SELF
// module and carries the program authority id together with the capability and attribute words the
// runtime consults for the module. It is distinct from the in-SELF extended-info authority id
// (see ProsperoFself.SelfExtInfo): a fake-self carries a 0x31.. id in its extended info while its
// paired sidecar may carry a genuine 0x45.. authority for the original module. The runtime retrieves
// the same record for a running module through a kernel query that fills a 0x88-byte buffer; this
// type reads, validates, builds, and round-trips the on-disk sidecar form of that record.
#nullable enable
using System;
using System.Buffers.Binary;
using System.Collections.Generic;
using System.IO;
namespace LibProsperoPkg.Content;
/// <summary>The authority-id category, taken from the top byte of the program authority id.</summary>
public enum ProsperoAuthorityCategory : byte
{
/// <summary>An id whose category byte is none of the known values.</summary>
Unknown = 0x00,
/// <summary>Fake authority (debug / fake-self modules), category byte 0x31.</summary>
Fake = 0x31,
/// <summary>Genuine authority carried by an <c>auth_info</c> sidecar, category byte 0x45.</summary>
Genuine = 0x45,
/// <summary>Privileged system authority, category byte 0x48.</summary>
PrivilegedSystem = 0x48,
}
/// <summary>
/// A parsed SELF authentication-info sidecar (<c>*.auth_info</c>), a fixed 0x88-byte record.
/// </summary>
/// <remarks>
/// Layout (little-endian scalars):
/// <list type="table">
/// <item><term>0x00</term><description>program authority id (<c>paid</c>), 8 bytes.</description></item>
/// <item><term>0x08</term><description>capability words, four 64-bit values (0x20 bytes).</description></item>
/// <item><term>0x28</term><description>attribute words, four 64-bit values (0x20 bytes).</description></item>
/// <item><term>0x48</term><description>reserved tail, 0x40 bytes.</description></item>
/// </list>
/// The record is structural: the capability and attribute words are grants issued for the original
/// module and are copied verbatim when a sidecar is carried alongside a module. This type never
/// synthesizes a grant; <see cref="Create"/> takes whatever material the caller supplies.
/// </remarks>
public sealed class ProsperoSelfAuthInfo
{
/// <summary>The fixed size of an authentication-info record, in bytes.</summary>
public const int Size = 0x88;
/// <summary>The number of 64-bit capability words.</summary>
public const int CapabilityWordCount = 4;
/// <summary>The number of 64-bit attribute words.</summary>
public const int AttributeWordCount = 4;
/// <summary>The size of the reserved tail, in bytes.</summary>
public const int ReservedSize = 0x40;
private const int PaidOffset = 0x00;
private const int CapabilitiesOffset = 0x08;
private const int AttributesOffset = 0x28;
private const int ReservedOffset = 0x48;
private readonly ulong[] _capabilities;
private readonly ulong[] _attributes;
private readonly byte[] _reserved;
private ProsperoSelfAuthInfo(ulong paid, ulong[] capabilities, ulong[] attributes, byte[] reserved)
{
Paid = paid;
_capabilities = capabilities;
_attributes = attributes;
_reserved = reserved;
}
/// <summary>The program authority id (<c>paid</c>) at offset 0x00.</summary>
public ulong Paid { get; }
/// <summary>The program authority id, exposed under the name used by the SELF extended info.</summary>
public ulong AuthorityId => Paid;
/// <summary>The four 64-bit capability words at offset 0x08.</summary>
public IReadOnlyList<ulong> Capabilities => _capabilities;
/// <summary>The four 64-bit attribute words at offset 0x28.</summary>
public IReadOnlyList<ulong> Attributes => _attributes;
/// <summary>The 0x40-byte reserved tail at offset 0x48.</summary>
public ReadOnlySpan<byte> Reserved => _reserved;
/// <summary>The category taken from the top byte of <see cref="Paid"/>.</summary>
public ProsperoAuthorityCategory Category
{
get
{
byte top = (byte)(Paid >> 56);
return top is (byte)ProsperoAuthorityCategory.Fake
or (byte)ProsperoAuthorityCategory.Genuine
or (byte)ProsperoAuthorityCategory.PrivilegedSystem
? (ProsperoAuthorityCategory)top
: ProsperoAuthorityCategory.Unknown;
}
}
/// <summary>Whether the authority id carries the fake-authority category byte (0x31).</summary>
public bool IsFakeAuthority => Category == ProsperoAuthorityCategory.Fake;
/// <summary>Whether the authority id carries the genuine-authority category byte (0x45).</summary>
public bool IsGenuineAuthority => Category == ProsperoAuthorityCategory.Genuine;
/// <summary>Whether the authority id carries the privileged-system category byte (0x48).</summary>
public bool IsPrivilegedSystem => Category == ProsperoAuthorityCategory.PrivilegedSystem;
/// <summary>Returns whether the buffer is large enough to hold an authentication-info record.</summary>
public static bool IsAuthInfo(ReadOnlySpan<byte> data) => data.Length >= Size;
/// <summary>Parses an authentication-info record from the start of a buffer.</summary>
/// <exception cref="InvalidDataException">The buffer is shorter than <see cref="Size"/>.</exception>
public static ProsperoSelfAuthInfo Parse(ReadOnlySpan<byte> data)
{
if (data.Length < Size)
throw new InvalidDataException($"Authentication-info record must be at least 0x{Size:X} bytes, got 0x{data.Length:X}.");
ulong paid = BinaryPrimitives.ReadUInt64LittleEndian(data[PaidOffset..]);
var capabilities = new ulong[CapabilityWordCount];
for (int i = 0; i < CapabilityWordCount; i++)
capabilities[i] = BinaryPrimitives.ReadUInt64LittleEndian(data[(CapabilitiesOffset + i * 8)..]);
var attributes = new ulong[AttributeWordCount];
for (int i = 0; i < AttributeWordCount; i++)
attributes[i] = BinaryPrimitives.ReadUInt64LittleEndian(data[(AttributesOffset + i * 8)..]);
var reserved = data.Slice(ReservedOffset, ReservedSize).ToArray();
return new ProsperoSelfAuthInfo(paid, capabilities, attributes, reserved);
}
/// <summary>Reads an authentication-info record from the current position of a stream.</summary>
/// <exception cref="InvalidDataException">The stream holds fewer than <see cref="Size"/> bytes.</exception>
public static ProsperoSelfAuthInfo Read(Stream stream)
{
ArgumentNullException.ThrowIfNull(stream);
var buffer = new byte[Size];
int read = 0;
while (read < Size)
{
int n = stream.Read(buffer, read, Size - read);
if (n == 0)
throw new InvalidDataException($"Stream ended before a 0x{Size:X}-byte authentication-info record was read.");
read += n;
}
return Parse(buffer);
}
/// <summary>Reads an authentication-info record from a <c>*.auth_info</c> file.</summary>
public static ProsperoSelfAuthInfo ReadFile(string path)
{
ArgumentNullException.ThrowIfNull(path);
using var fs = File.OpenRead(path);
return Read(fs);
}
/// <summary>Builds an authentication-info record from supplied fields.</summary>
/// <param name="paid">The program authority id.</param>
/// <param name="capabilities">Up to four 64-bit capability words; fewer are zero-extended.</param>
/// <param name="attributes">Up to four 64-bit attribute words; fewer are zero-extended.</param>
/// <param name="reserved">Up to 0x40 reserved bytes; fewer are zero-extended.</param>
/// <exception cref="ArgumentException">More words or reserved bytes are supplied than the record holds.</exception>
public static ProsperoSelfAuthInfo Create(
ulong paid,
ReadOnlySpan<ulong> capabilities = default,
ReadOnlySpan<ulong> attributes = default,
ReadOnlySpan<byte> reserved = default)
{
if (capabilities.Length > CapabilityWordCount)
throw new ArgumentException($"At most {CapabilityWordCount} capability words are allowed.", nameof(capabilities));
if (attributes.Length > AttributeWordCount)
throw new ArgumentException($"At most {AttributeWordCount} attribute words are allowed.", nameof(attributes));
if (reserved.Length > ReservedSize)
throw new ArgumentException($"At most 0x{ReservedSize:X} reserved bytes are allowed.", nameof(reserved));
var caps = new ulong[CapabilityWordCount];
capabilities.CopyTo(caps);
var attrs = new ulong[AttributeWordCount];
attributes.CopyTo(attrs);
var rsv = new byte[ReservedSize];
reserved.CopyTo(rsv);
return new ProsperoSelfAuthInfo(paid, caps, attrs, rsv);
}
/// <summary>Serializes the record to a fresh 0x88-byte buffer.</summary>
public byte[] ToBytes()
{
var buffer = new byte[Size];
var span = buffer.AsSpan();
BinaryPrimitives.WriteUInt64LittleEndian(span[PaidOffset..], Paid);
for (int i = 0; i < CapabilityWordCount; i++)
BinaryPrimitives.WriteUInt64LittleEndian(span[(CapabilitiesOffset + i * 8)..], _capabilities[i]);
for (int i = 0; i < AttributeWordCount; i++)
BinaryPrimitives.WriteUInt64LittleEndian(span[(AttributesOffset + i * 8)..], _attributes[i]);
_reserved.CopyTo(span[ReservedOffset..]);
return buffer;
}
/// <summary>Writes the record to the current position of a stream.</summary>
public void Write(Stream stream)
{
ArgumentNullException.ThrowIfNull(stream);
stream.Write(ToBytes(), 0, Size);
}
/// <summary>Writes the record to a <c>*.auth_info</c> file.</summary>
public void WriteFile(string path)
{
ArgumentNullException.ThrowIfNull(path);
using var fs = File.Create(path);
Write(fs);
}
}
@@ -1,226 +0,0 @@
// LibProsperoPkg - A library for building and inspecting PS5 packages.
// Copyright (C) 2026 SvenGDK
//
// UCP container codec. The trophy and user-data-system components of a PS5 package are shipped as
// UCP archives (sce_sys/trophy2/trophyNN.ucp and sce_sys/uds/udsNN.ucp): a flat set of named blobs
// (icons, PNG assets, JSON definition/metadata files) wrapped in a self-describing table plus a
// whole-file SHA-1 digest. The consumer reads entries through the table and re-validates the digest,
// so any self-consistent archive with a correct digest is accepted.
#nullable enable
using System;
using System.Buffers.Binary;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
namespace LibProsperoPkg.Content;
/// <summary>One named blob inside a <see cref="ProsperoUcp"/> archive.</summary>
/// <param name="Name">The entry name (no path separators; at most 32 bytes).</param>
/// <param name="Data">The raw entry bytes.</param>
public sealed record UcpEntry(string Name, byte[] Data);
/// <summary>
/// Reader and producer for the UCP archive format used by the trophy and user-data-system components
/// of a PS5 package.
/// </summary>
/// <remarks>
/// Layout (big-endian scalars):
/// <list type="bullet">
/// <item>Header, 0x60 bytes: 0x00 u32 magic <c>0xB228C60A</c>; 0x04 u32 version (1); 0x08 u64 total
/// file size; 0x10 u32 entry count; 0x14 u32 entry-record size (0x40); 0x18 u32 zero; 0x1C 20-byte
/// SHA-1 digest; 0x30..0x60 reserved zero.</item>
/// <item>Entry table at 0x60, one 0x40-byte record per entry: 32-byte name (NUL-padded), u64 offset,
/// u64 size, 16 reserved bytes.</item>
/// <item>Blob region: entries in ascending name order, each blob followed by padding to the next
/// 16-byte boundary (a full 16 bytes when the end is already aligned); the file size is padded the
/// same way. The digest covers the whole file with the digest field held zero.</item>
/// </list>
/// </remarks>
public static class ProsperoUcp
{
/// <summary>Archive magic (big-endian) at file offset 0x00.</summary>
public const uint Magic = 0xB228C60A;
/// <summary>Format version at file offset 0x04.</summary>
public const uint Version = 1;
private const int HeaderSize = 0x60;
private const int EntryRecordSize = 0x40;
private const int NameFieldSize = 0x20;
private const int CountOffset = 0x10;
private const int RecordSizeOffset = 0x14;
private const int DigestOffset = 0x1C;
private const int DigestSize = 20;
private const int BlobAlignment = 0x10;
/// <summary>Returns whether the buffer begins with a UCP header.</summary>
public static bool IsUcp(ReadOnlySpan<byte> data) =>
data.Length >= HeaderSize && BinaryPrimitives.ReadUInt32BigEndian(data) == Magic;
/// <summary>Reads the entry list from a UCP archive.</summary>
/// <exception cref="InvalidDataException">The buffer is not a structurally valid archive.</exception>
public static IReadOnlyList<UcpEntry> Read(ReadOnlySpan<byte> data)
{
if (!Validate(data, out var error))
throw new InvalidDataException(error);
int count = (int)BinaryPrimitives.ReadUInt32BigEndian(data[CountOffset..]);
var entries = new List<UcpEntry>(count);
for (int i = 0; i < count; i++)
{
int rec = HeaderSize + i * EntryRecordSize;
var nameField = data.Slice(rec, NameFieldSize);
int nul = nameField.IndexOf((byte)0);
string name = Encoding.Latin1.GetString(nul < 0 ? nameField : nameField[..nul]);
ulong off = BinaryPrimitives.ReadUInt64BigEndian(data[(rec + NameFieldSize)..]);
ulong size = BinaryPrimitives.ReadUInt64BigEndian(data[(rec + NameFieldSize + 8)..]);
entries.Add(new UcpEntry(name, data.Slice((int)off, (int)size).ToArray()));
}
return entries;
}
/// <summary>Builds a UCP archive from a set of named blobs.</summary>
/// <exception cref="ArgumentException">A name is empty, duplicated, or longer than the name field.</exception>
public static byte[] Build(IEnumerable<UcpEntry> entries)
{
ArgumentNullException.ThrowIfNull(entries);
var ordered = entries.OrderBy(e => e.Name, StringComparer.Ordinal).ToArray();
var names = new byte[ordered.Length][];
var seen = new HashSet<string>(StringComparer.Ordinal);
for (int i = 0; i < ordered.Length; i++)
{
string name = ordered[i].Name;
if (string.IsNullOrEmpty(name))
throw new ArgumentException("A UCP entry name must not be empty.", nameof(entries));
byte[] nb = Encoding.Latin1.GetBytes(name);
if (nb.Length > NameFieldSize)
throw new ArgumentException($"UCP entry name '{name}' exceeds {NameFieldSize} bytes.", nameof(entries));
if (!seen.Add(name))
throw new ArgumentException($"Duplicate UCP entry name '{name}'.", nameof(entries));
names[i] = nb;
}
// Lay out the blob region: the first blob starts right after the entry table (already
// 16-aligned); each following blob starts at the next 16-byte boundary past the previous
// blob's end. The total file size is padded the same way.
var offsets = new long[ordered.Length];
long cursor = HeaderSize + (long)ordered.Length * EntryRecordSize;
for (int i = 0; i < ordered.Length; i++)
{
offsets[i] = cursor;
cursor = AlignUpStrict(cursor + ordered[i].Data.Length);
}
long total = ordered.Length == 0 ? HeaderSize : cursor;
var buffer = new byte[total];
var span = buffer.AsSpan();
BinaryPrimitives.WriteUInt32BigEndian(span, Magic);
BinaryPrimitives.WriteUInt32BigEndian(span[4..], Version);
BinaryPrimitives.WriteUInt64BigEndian(span[8..], (ulong)total);
BinaryPrimitives.WriteUInt32BigEndian(span[CountOffset..], (uint)ordered.Length);
BinaryPrimitives.WriteUInt32BigEndian(span[RecordSizeOffset..], EntryRecordSize);
for (int i = 0; i < ordered.Length; i++)
{
int rec = HeaderSize + i * EntryRecordSize;
names[i].CopyTo(span[rec..]);
BinaryPrimitives.WriteUInt64BigEndian(span[(rec + NameFieldSize)..], (ulong)offsets[i]);
BinaryPrimitives.WriteUInt64BigEndian(span[(rec + NameFieldSize + 8)..], (ulong)ordered[i].Data.Length);
ordered[i].Data.CopyTo(span[(int)offsets[i]..]);
}
WriteDigest(buffer);
return buffer;
}
/// <summary>
/// Builds a UCP archive from the top-level files of a directory, using each file name as the entry
/// name. Subdirectories are ignored.
/// </summary>
public static byte[] BuildFromDirectory(string directory)
{
ArgumentException.ThrowIfNullOrEmpty(directory);
if (!Directory.Exists(directory))
throw new DirectoryNotFoundException($"UCP source directory not found: {directory}");
var entries = Directory.EnumerateFiles(directory)
.Select(p => new UcpEntry(Path.GetFileName(p), File.ReadAllBytes(p)));
return Build(entries);
}
/// <summary>
/// Validates the header and entry table of a UCP archive. Does not verify the digest; use
/// <see cref="VerifyDigest"/> for that.
/// </summary>
public static bool Validate(ReadOnlySpan<byte> data, out string? error)
{
if (data.Length < HeaderSize) { error = "Buffer is smaller than a UCP header."; return false; }
if (BinaryPrimitives.ReadUInt32BigEndian(data) != Magic) { error = "Bad UCP magic."; return false; }
if (BinaryPrimitives.ReadUInt32BigEndian(data[4..]) != Version) { error = "Unsupported UCP version."; return false; }
ulong total = BinaryPrimitives.ReadUInt64BigEndian(data[8..]);
if (total != (ulong)data.Length) { error = $"UCP size field ({total}) does not match buffer length ({data.Length})."; return false; }
uint recSize = BinaryPrimitives.ReadUInt32BigEndian(data[RecordSizeOffset..]);
if (recSize != EntryRecordSize) { error = $"Unexpected UCP entry-record size {recSize}."; return false; }
long count = BinaryPrimitives.ReadUInt32BigEndian(data[CountOffset..]);
long tableEnd = HeaderSize + count * EntryRecordSize;
if (tableEnd > data.Length) { error = "UCP entry table overruns the buffer."; return false; }
for (int i = 0; i < count; i++)
{
int rec = HeaderSize + i * EntryRecordSize;
ulong off = BinaryPrimitives.ReadUInt64BigEndian(data[(rec + NameFieldSize)..]);
ulong size = BinaryPrimitives.ReadUInt64BigEndian(data[(rec + NameFieldSize + 8)..]);
if (off < (ulong)tableEnd || off + size > total)
{
error = $"UCP entry {i} range [{off},{off + size}) is outside the blob region.";
return false;
}
}
error = null;
return true;
}
/// <summary>Recomputes the stored SHA-1 digest and reports whether it matches.</summary>
public static bool VerifyDigest(ReadOnlySpan<byte> data)
{
if (data.Length < HeaderSize) return false;
Span<byte> stored = stackalloc byte[DigestSize];
data.Slice(DigestOffset, DigestSize).CopyTo(stored);
byte[] copy = data.ToArray();
Array.Clear(copy, DigestOffset, DigestSize);
Span<byte> calc = stackalloc byte[DigestSize];
SHA1.HashData(copy, calc);
return calc.SequenceEqual(stored);
}
/// <summary>
/// Returns a copy of the archive with its SHA-1 digest recomputed, correcting a stale or wrong
/// digest without touching the entry data.
/// </summary>
public static byte[] WithRepairedDigest(ReadOnlySpan<byte> data)
{
if (!IsUcp(data))
throw new InvalidDataException("Buffer is not a UCP archive.");
byte[] copy = data.ToArray();
WriteDigest(copy);
return copy;
}
private static void WriteDigest(byte[] buffer)
{
Array.Clear(buffer, DigestOffset, DigestSize);
Span<byte> digest = stackalloc byte[DigestSize];
SHA1.HashData(buffer, digest);
digest.CopyTo(buffer.AsSpan(DigestOffset));
}
// Next 16-byte boundary strictly greater than value (a full 16 bytes when already aligned).
private static long AlignUpStrict(long value) => ((value / BlobAlignment) + 1) * BlobAlignment;
}
@@ -1,180 +0,0 @@
// LibProsperoPkg - A library for building and inspecting PS5 packages.
// Copyright (C) 2026 SvenGDK
//
// A read-only, seekable stream that presents an ordered set of source-stream windows as one
// contiguous stream. Used to reassemble a split disc-backup package (app_0.pkg + app_sc.pkg + ...)
// on the fly without materialising a multi-gigabyte temp file.
using System;
using System.Collections.Generic;
using System.IO;
namespace LibProsperoPkg.DiscBackup;
/// <summary>
/// Presents a sequence of <c>(source, offset, length)</c> windows as a single contiguous,
/// read-only, seekable stream. A read never crosses a segment boundary in one call (callers
/// that need a fixed count should loop, e.g. via <see cref="Stream.CopyTo(Stream)"/> or a
/// read-exactly helper).
/// </summary>
public sealed class ProsperoConcatStream : Stream
{
private sealed class Segment
{
public required Stream Source { get; init; }
public required long SourceOffset { get; init; }
public required long SegmentLength { get; init; }
public long VirtualStart { get; init; }
}
private readonly Segment[] _segments;
private readonly bool _ownsSources;
private long _position;
private bool _disposed;
/// <inheritdoc/>
public override bool CanRead => true;
/// <inheritdoc/>
public override bool CanSeek => true;
/// <inheritdoc/>
public override bool CanWrite => false;
/// <inheritdoc/>
public override long Length { get; }
/// <inheritdoc/>
public override long Position
{
get => _position;
set => Seek(value, SeekOrigin.Begin);
}
/// <summary>
/// Builds a concatenation of the supplied windows, in the order given. When
/// <paramref name="ownsSources"/> is <see langword="true"/> the source streams are disposed
/// with this stream.
/// </summary>
/// <exception cref="ArgumentException">No segments were supplied.</exception>
public ProsperoConcatStream(IEnumerable<(Stream source, long offset, long length)> segments, bool ownsSources = false)
{
ArgumentNullException.ThrowIfNull(segments);
var list = new List<Segment>();
long running = 0;
foreach ((Stream source, long offset, long length) in segments)
{
ArgumentNullException.ThrowIfNull(source);
ArgumentOutOfRangeException.ThrowIfNegative(offset);
ArgumentOutOfRangeException.ThrowIfNegative(length);
// Every read seeks its backing source by absolute position, so a non-seekable source cannot
// back a segment. Reject it here rather than failing on the first read.
if (!source.CanSeek)
throw new ArgumentException("Concat-stream sources must be seekable.", nameof(segments));
list.Add(new Segment
{
Source = source,
SourceOffset = offset,
SegmentLength = length,
VirtualStart = running,
});
running += length;
}
if (list.Count == 0)
throw new ArgumentException("At least one segment is required.", nameof(segments));
_segments = [.. list];
_ownsSources = ownsSources;
Length = running;
}
/// <inheritdoc/>
public override int Read(byte[] buffer, int offset, int count)
{
ArgumentNullException.ThrowIfNull(buffer);
ArgumentOutOfRangeException.ThrowIfNegative(offset);
ArgumentOutOfRangeException.ThrowIfNegative(count);
if (offset + count > buffer.Length)
throw new ArgumentException("The buffer is too small for the requested range.");
return Read(buffer.AsSpan(offset, count));
}
/// <inheritdoc/>
public override int Read(Span<byte> buffer)
{
ObjectDisposedException.ThrowIf(_disposed, this);
if (buffer.IsEmpty || _position >= Length)
return 0;
Segment s = _segments[FindSegment(_position)];
long withinSegment = _position - s.VirtualStart;
int toRead = (int)Math.Min(buffer.Length, s.SegmentLength - withinSegment);
if (toRead <= 0)
return 0;
s.Source.Position = s.SourceOffset + withinSegment;
int read = s.Source.Read(buffer[..toRead]);
_position += read;
return read;
}
/// <inheritdoc/>
public override long Seek(long offset, SeekOrigin origin)
{
long target = origin switch
{
SeekOrigin.Begin => offset,
SeekOrigin.Current => _position + offset,
SeekOrigin.End => Length + offset,
_ => throw new ArgumentOutOfRangeException(nameof(origin)),
};
if (target < 0)
throw new IOException("Cannot seek before the start of the stream.");
_position = target;
return _position;
}
private int FindSegment(long position)
{
int lo = 0, hi = _segments.Length - 1;
while (lo < hi)
{
int mid = (lo + hi + 1) >> 1;
if (_segments[mid].VirtualStart <= position)
lo = mid;
else
hi = mid - 1;
}
return lo;
}
/// <inheritdoc/>
public override void Flush()
{
// Read-only stream: nothing to flush.
}
/// <inheritdoc/>
public override void SetLength(long value) => throw new NotSupportedException();
/// <inheritdoc/>
public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException();
/// <inheritdoc/>
protected override void Dispose(bool disposing)
{
if (!_disposed)
{
if (disposing && _ownsSources)
{
foreach (Segment s in _segments)
s.Source.Dispose();
}
_disposed = true;
}
base.Dispose(disposing);
}
}
@@ -1,317 +0,0 @@
// LibProsperoPkg - A library for building and inspecting PS5 packages.
// Copyright (C) 2026 SvenGDK
//
// Opens a split disc-backup package (app_0.pkg + app_sc.pkg + ... described by app.json),
// reassembles the pieces on the fly, verifies the SHA-256 package digest and PlayGo chunk CRCs,
// and reads/extracts the finalized (FIH) container and its embedded CNT entries.
using LibProsperoPkg.PKG;
using System;
using System.Buffers;
using System.Collections.Generic;
using System.IO;
using System.Security.Cryptography;
namespace LibProsperoPkg.DiscBackup;
/// <summary>
/// A split disc-backup package described by an <c>app.json</c> manifest. Reassembles its pieces
/// into the single finalized image without materialising a temp file, and exposes verification
/// and container-extraction helpers.
/// </summary>
public sealed class ProsperoDiscBackup
{
private const int CopyBufferSize = 1 << 20;
private readonly (ProsperoDiscBackupPiece Piece, string Path)[] _pieces;
private ProsperoDiscBackup(string directory, ProsperoDiscBackupManifest manifest, (ProsperoDiscBackupPiece, string)[] pieces)
{
Directory = directory;
Manifest = manifest;
_pieces = pieces;
}
/// <summary>The directory that contains the manifest and its piece files.</summary>
public string Directory { get; }
/// <summary>The parsed <c>app.json</c> manifest.</summary>
public ProsperoDiscBackupManifest Manifest { get; }
/// <summary>The size in bytes of the reassembled package (from the manifest).</summary>
public long OriginalFileSize => Manifest.OriginalFileSize;
/// <summary>
/// Opens a disc backup from an <c>app.json</c> path or from a directory that contains one.
/// Piece <c>url</c>s are resolved relative to the manifest directory and must all exist.
/// </summary>
/// <exception cref="FileNotFoundException">The manifest or a piece file is missing.</exception>
/// <exception cref="InvalidDataException">The manifest lists no pieces.</exception>
public static ProsperoDiscBackup Open(string path)
{
ArgumentException.ThrowIfNullOrEmpty(path);
string manifestPath = System.IO.Directory.Exists(path) ? Path.Combine(path, "app.json") : path;
if (!File.Exists(manifestPath))
throw new FileNotFoundException("Disc-backup manifest (app.json) not found.", manifestPath);
string directory = Path.GetDirectoryName(Path.GetFullPath(manifestPath)) ?? ".";
ProsperoDiscBackupManifest manifest = ProsperoDiscBackupManifest.Read(manifestPath);
if (manifest.Pieces.Count == 0)
throw new InvalidDataException("Disc-backup manifest lists no pieces.");
var ordered = new List<(ProsperoDiscBackupPiece, string)>(manifest.Pieces.Count);
foreach (ProsperoDiscBackupPiece piece in manifest.Pieces)
ordered.Add((piece, Path.Combine(directory, piece.Url)));
ordered.Sort((a, b) => a.Item1.FileOffset.CompareTo(b.Item1.FileOffset));
// Validate the split is complete: pieces must tile the image contiguously from offset 0 with no
// gap or overlap, and (when the manifest declares it) sum to the original size. Otherwise the
// reassembled stream would be silently misaligned.
long expectedOffset = 0;
foreach ((ProsperoDiscBackupPiece piece, string piecePath) in ordered)
{
if (!File.Exists(piecePath))
throw new FileNotFoundException($"Disc-backup piece '{piece.Url}' not found.", piecePath);
if (piece.FileOffset != expectedOffset)
throw new InvalidDataException(
$"Disc-backup piece '{piece.Url}' starts at 0x{piece.FileOffset:X}, expected 0x{expectedOffset:X} (gapped or overlapping split).");
expectedOffset += piece.FileSize;
}
if (manifest.OriginalFileSize > 0 && expectedOffset != manifest.OriginalFileSize)
throw new InvalidDataException(
$"Disc-backup pieces total {expectedOffset} bytes but the manifest declares {manifest.OriginalFileSize}.");
return new ProsperoDiscBackup(directory, manifest, [.. ordered]);
}
/// <summary>The resolved absolute path of the PlayGo chunk-CRC file, or <see langword="null"/> when absent.</summary>
public string? ChunkCrcPath
{
get
{
if (string.IsNullOrEmpty(Manifest.PlaygoChunkCrcUrl)) return null;
string p = Path.Combine(Directory, Manifest.PlaygoChunkCrcUrl);
return File.Exists(p) ? p : null;
}
}
/// <summary>
/// Opens the reassembled package as a single read-only, seekable stream. The pieces are read
/// in place (no temp file); the returned stream owns the underlying piece handles.
/// </summary>
public ProsperoConcatStream OpenPackageStream()
{
var segments = new List<(Stream, long, long)>(_pieces.Length);
var opened = new List<FileStream>(_pieces.Length);
try
{
foreach ((ProsperoDiscBackupPiece piece, string piecePath) in _pieces)
{
var fs = new FileStream(piecePath, FileMode.Open, FileAccess.Read, FileShare.Read);
opened.Add(fs);
long length = piece.FileSize > 0 ? Math.Min(piece.FileSize, fs.Length) : fs.Length;
segments.Add((fs, 0, length));
}
return new ProsperoConcatStream(segments, ownsSources: true);
}
catch
{
foreach (FileStream fs in opened)
fs.Dispose();
throw;
}
}
/// <summary>Reads and parses the reassembled finalized (FIH) container and its embedded CNT.</summary>
public ProsperoPkg ReadPackage()
{
using ProsperoConcatStream stream = OpenPackageStream();
return ProsperoPkgReader.Read(stream);
}
/// <summary>
/// Projects the NpDrm content-info (title-id, drm/content type, content flags, patch kind,
/// nested-image flag) from the reassembled finalized image. The CNT metadata is carried by the tail piece
/// (<c>app_sc.pkg</c>), so this requires the full reassembled stream, not the head piece alone.
/// </summary>
public NpDrm.ProsperoNpDrmContentInfo ReadContentInfo() =>
NpDrm.ProsperoNpDrmContentInfo.FromPackage(ReadPackage());
/// <summary>Computes the uppercase-hex SHA-256 of the reassembled package.</summary>
public string ComputePackageDigest(IProgress<long>? progress = null)
{
using ProsperoConcatStream stream = OpenPackageStream();
return Sha256Hex(stream, progress);
}
/// <summary>
/// Verifies the reassembled package against <see cref="ProsperoDiscBackupManifest.PackageDigest"/>.
/// </summary>
public bool VerifyPackageDigest(IProgress<long>? progress = null) =>
!string.IsNullOrEmpty(Manifest.PackageDigest) &&
string.Equals(ComputePackageDigest(progress), Manifest.PackageDigest, StringComparison.OrdinalIgnoreCase);
/// <summary>
/// Verifies the PlayGo chunk-CRC file against
/// <see cref="ProsperoDiscBackupManifest.PlaygoChunkCrcHashValue"/> (SHA-256 of <c>app.crc</c>).
/// </summary>
public bool VerifyChunkCrcHash()
{
string? crcPath = ChunkCrcPath;
if (crcPath is null || string.IsNullOrEmpty(Manifest.PlaygoChunkCrcHashValue))
return false;
using var fs = new FileStream(crcPath, FileMode.Open, FileAccess.Read, FileShare.Read);
return string.Equals(Sha256Hex(fs, null), Manifest.PlaygoChunkCrcHashValue, StringComparison.OrdinalIgnoreCase);
}
/// <summary>Reads the parsed PlayGo chunk-CRC table.</summary>
/// <exception cref="FileNotFoundException">The chunk-CRC file is absent.</exception>
public ProsperoPlaygoChunkCrc ReadChunkCrc()
{
string crcPath = ChunkCrcPath
?? throw new FileNotFoundException("Disc-backup PlayGo chunk-CRC file not found.");
return ProsperoPlaygoChunkCrc.Read(crcPath);
}
/// <summary>
/// Recomputes every 64 KiB CRC-32C of the reassembled package and checks it against the
/// chunk-CRC table. <paramref name="mismatchChunk"/> is the first bad chunk, or -1 on success.
/// </summary>
public bool VerifyChunkCrcs(out int mismatchChunk, IProgress<long>? progress = null)
{
ProsperoPlaygoChunkCrc table = ReadChunkCrc();
using ProsperoConcatStream stream = OpenPackageStream();
return table.VerifyPackage(stream, out mismatchChunk, progress);
}
/// <summary>Reassembles the pieces into <paramref name="output"/>; returns the byte count written.</summary>
public long ReassembleTo(Stream output, IProgress<long>? progress = null)
{
ArgumentNullException.ThrowIfNull(output);
using ProsperoConcatStream stream = OpenPackageStream();
return Copy(stream, output, progress);
}
/// <summary>Reassembles the pieces into a new file at <paramref name="path"/>.</summary>
public long ReassembleTo(string path, IProgress<long>? progress = null)
{
using var output = new FileStream(path, FileMode.Create, FileAccess.Write, FileShare.None);
return ReassembleTo(output, progress);
}
/// <summary>
/// Finds the EEKPFS key entry (id <see cref="ProsperoEntryId.ImageKey"/>, 0x20) in the embedded
/// CNT, or <see langword="null"/> when absent. This is the entry the console reads to derive the
/// PFS keys; for a retail image it is encrypted.
/// </summary>
public static ProsperoPkgEntry? FindImageKeyEntry(ProsperoPkg package)
{
ArgumentNullException.ThrowIfNull(package);
foreach (ProsperoPkgEntry entry in package.Entries)
{
if (entry.Id == ProsperoEntryId.ImageKey)
return entry;
}
return null;
}
/// <summary>Extracts one CNT entry's raw bytes (as stored — encrypted entries stay encrypted).</summary>
public byte[] ExtractEntryBytes(ProsperoPkg package, ProsperoPkgEntry entry)
{
ArgumentNullException.ThrowIfNull(entry);
using var output = new MemoryStream(checked((int)entry.DataSize));
ExtractEntry(package, entry, output);
return output.ToArray();
}
/// <summary>
/// Copies one CNT entry's raw bytes into <paramref name="output"/>; returns the byte count.
/// The offset is resolved relative to the embedded CNT base (FIH+0x58) for a finalized image.
/// </summary>
public long ExtractEntry(ProsperoPkg package, ProsperoPkgEntry entry, Stream output)
{
ArgumentNullException.ThrowIfNull(package);
ArgumentNullException.ThrowIfNull(entry);
ArgumentNullException.ThrowIfNull(output);
long cntBase = package.Fih is { } fih ? (long)fih.EmbeddedCntOffset : 0;
long start = cntBase + entry.DataOffset;
using ProsperoConcatStream stream = OpenPackageStream();
if (start + entry.DataSize > stream.Length)
throw new InvalidDataException($"Entry '{entry.Name ?? entry.Id.ToString()}' extends past the package.");
stream.Position = start;
return CopyExact(stream, output, entry.DataSize);
}
private static long Copy(Stream source, Stream destination, IProgress<long>? progress)
{
byte[] buffer = ArrayPool<byte>.Shared.Rent(CopyBufferSize);
try
{
long total = 0;
int read;
while ((read = source.Read(buffer, 0, buffer.Length)) > 0)
{
destination.Write(buffer, 0, read);
total += read;
progress?.Report(total);
}
return total;
}
finally
{
ArrayPool<byte>.Shared.Return(buffer);
}
}
private static long CopyExact(Stream source, Stream destination, long count)
{
byte[] buffer = ArrayPool<byte>.Shared.Rent(CopyBufferSize);
try
{
long remaining = count;
while (remaining > 0)
{
int want = (int)Math.Min(buffer.Length, remaining);
int read = source.Read(buffer, 0, want);
if (read == 0)
throw new EndOfStreamException("Unexpected end of package while extracting an entry.");
destination.Write(buffer, 0, read);
remaining -= read;
}
return count;
}
finally
{
ArrayPool<byte>.Shared.Return(buffer);
}
}
private static string Sha256Hex(Stream stream, IProgress<long>? progress)
{
using var sha = SHA256.Create();
byte[] buffer = ArrayPool<byte>.Shared.Rent(CopyBufferSize);
try
{
long total = 0;
int read;
while ((read = stream.Read(buffer, 0, buffer.Length)) > 0)
{
sha.TransformBlock(buffer, 0, read, null, 0);
total += read;
progress?.Report(total);
}
sha.TransformFinalBlock([], 0, 0);
return Convert.ToHexString(sha.Hash!);
}
finally
{
ArrayPool<byte>.Shared.Return(buffer);
}
}
}
@@ -1,111 +0,0 @@
// LibProsperoPkg - A library for building and inspecting PS5 packages.
// Copyright (C) 2026 SvenGDK
//
// Parser for the disc-backup reassembly manifest (app.json): the split-file list plus the
// SHA-256 digests used to verify the reassembled package and the PlayGo chunk-CRC file.
using System;
using System.Collections.Generic;
using System.IO;
using System.Text.Json;
namespace LibProsperoPkg.DiscBackup;
/// <summary>One split piece of a disc-backup package.</summary>
public sealed class ProsperoDiscBackupPiece
{
/// <summary>The disc number this piece belongs to.</summary>
public int DiscNumber { get; init; }
/// <summary>The byte offset of this piece within the reassembled package.</summary>
public long FileOffset { get; init; }
/// <summary>The size in bytes of this piece.</summary>
public long FileSize { get; init; }
/// <summary>The piece file name, relative to the manifest directory (e.g. <c>app_0.pkg</c>).</summary>
public required string Url { get; init; }
}
/// <summary>
/// The parsed <c>app.json</c> manifest describing how a split disc-backup package is
/// reassembled and verified.
/// </summary>
public sealed class ProsperoDiscBackupManifest
{
/// <summary>The number of split piece files.</summary>
public int NumberOfSplitFiles { get; init; }
/// <summary>The size in bytes of the reassembled package.</summary>
public long OriginalFileSize { get; init; }
/// <summary>Hex SHA-256 of the reassembled package.</summary>
public string PackageDigest { get; init; } = "";
/// <summary>The split pieces, in manifest order.</summary>
public IReadOnlyList<ProsperoDiscBackupPiece> Pieces { get; init; } = Array.Empty<ProsperoDiscBackupPiece>();
/// <summary>Hex SHA-256 of the PlayGo chunk-CRC file (<c>app.crc</c>).</summary>
public string PlaygoChunkCrcHashValue { get; init; } = "";
/// <summary>The PlayGo chunk-CRC file name, relative to the manifest directory.</summary>
public string PlaygoChunkCrcUrl { get; init; } = "";
/// <summary>Parses a manifest from a JSON string.</summary>
/// <exception cref="InvalidDataException">The JSON root is not an object.</exception>
public static ProsperoDiscBackupManifest Parse(string json)
{
ArgumentException.ThrowIfNullOrEmpty(json);
using var doc = JsonDocument.Parse(json);
return FromRoot(doc.RootElement);
}
/// <summary>Reads and parses an <c>app.json</c> manifest file.</summary>
public static ProsperoDiscBackupManifest Read(string path)
{
byte[] bytes = File.ReadAllBytes(path);
using var doc = JsonDocument.Parse(bytes);
return FromRoot(doc.RootElement);
}
private static ProsperoDiscBackupManifest FromRoot(JsonElement root)
{
if (root.ValueKind != JsonValueKind.Object)
throw new InvalidDataException("app.json root is not a JSON object.");
var pieces = new List<ProsperoDiscBackupPiece>();
if (root.TryGetProperty("pieces", out JsonElement arr) && arr.ValueKind == JsonValueKind.Array)
{
foreach (JsonElement e in arr.EnumerateArray())
{
if (e.ValueKind != JsonValueKind.Object) continue;
pieces.Add(new ProsperoDiscBackupPiece
{
DiscNumber = Int(e, "discNumber"),
FileOffset = Long(e, "fileOffset"),
FileSize = Long(e, "fileSize"),
Url = Str(e, "url"),
});
}
}
return new ProsperoDiscBackupManifest
{
NumberOfSplitFiles = Int(root, "numberOfSplitFiles"),
OriginalFileSize = Long(root, "originalFileSize"),
PackageDigest = Str(root, "packageDigest"),
Pieces = pieces,
PlaygoChunkCrcHashValue = Str(root, "playgoChunkCrcHashValue"),
PlaygoChunkCrcUrl = Str(root, "playgoChunkCrcUrl"),
};
}
private static int Int(JsonElement o, string name) =>
o.TryGetProperty(name, out JsonElement v) && v.ValueKind == JsonValueKind.Number && v.TryGetInt32(out int i) ? i : 0;
private static long Long(JsonElement o, string name) =>
o.TryGetProperty(name, out JsonElement v) && v.ValueKind == JsonValueKind.Number && v.TryGetInt64(out long l) ? l : 0;
private static string Str(JsonElement o, string name) =>
o.TryGetProperty(name, out JsonElement v) && v.ValueKind == JsonValueKind.String ? v.GetString() ?? "" : "";
}
@@ -1,105 +0,0 @@
// LibProsperoPkg - A library for building and inspecting PS5 packages.
// Copyright (C) 2026 SvenGDK
//
// Parser and verifier for the disc-backup PlayGo chunk-CRC file (app.crc / playgo-chunk.crc).
// The file is a headerless array of little-endian CRC-32C (Castagnoli) values, one per 64 KiB
// (0x10000) chunk of the reassembled package.
using LibProsperoPkg.Util;
using System;
using System.Buffers;
using System.Buffers.Binary;
using System.Collections.Generic;
using System.IO;
namespace LibProsperoPkg.DiscBackup;
/// <summary>
/// The parsed PlayGo chunk-CRC table: one <see cref="ProsperoCrc32C"/> value per 64 KiB chunk
/// of the reassembled package, in chunk order.
/// </summary>
public sealed class ProsperoPlaygoChunkCrc
{
/// <summary>The chunk size each CRC covers (64 KiB).</summary>
public const int ChunkSize = 0x10000;
private readonly uint[] _crcs;
private ProsperoPlaygoChunkCrc(uint[] crcs) => _crcs = crcs;
/// <summary>The per-chunk CRC-32C values, in chunk order.</summary>
public IReadOnlyList<uint> ChunkCrcs => _crcs;
/// <summary>The number of chunks (CRC values) in the table.</summary>
public int Count => _crcs.Length;
/// <summary>The CRC-32C for chunk <paramref name="index"/>.</summary>
public uint this[int index] => _crcs[index];
/// <summary>Parses the headerless little-endian CRC-32C array.</summary>
/// <exception cref="InvalidDataException">The length is not a multiple of 4.</exception>
public static ProsperoPlaygoChunkCrc Parse(ReadOnlySpan<byte> data)
{
if (data.Length % sizeof(uint) != 0)
throw new InvalidDataException("A playgo-chunk.crc file must be a multiple of 4 bytes.");
var crcs = new uint[data.Length / sizeof(uint)];
for (int i = 0; i < crcs.Length; i++)
crcs[i] = BinaryPrimitives.ReadUInt32LittleEndian(data.Slice(i * sizeof(uint), sizeof(uint)));
return new ProsperoPlaygoChunkCrc(crcs);
}
/// <summary>Reads and parses an <c>app.crc</c> / <c>playgo-chunk.crc</c> file.</summary>
public static ProsperoPlaygoChunkCrc Read(string path) => Parse(File.ReadAllBytes(path));
/// <summary>
/// Verifies every chunk of <paramref name="package"/> against this table by recomputing each
/// 64 KiB CRC-32C from the current stream position. Returns <see langword="true"/> when all
/// chunks match; otherwise <paramref name="mismatchChunk"/> is the index of the first bad
/// (or missing) chunk.
/// </summary>
public bool VerifyPackage(Stream package, out int mismatchChunk, IProgress<long>? progress = null)
{
ArgumentNullException.ThrowIfNull(package);
byte[] buffer = ArrayPool<byte>.Shared.Rent(ChunkSize);
try
{
long total = 0;
for (int i = 0; i < _crcs.Length; i++)
{
int read = ReadChunk(package, buffer);
if (read == 0)
{
mismatchChunk = i;
return false;
}
if (ProsperoCrc32C.Compute(buffer.AsSpan(0, read)) != _crcs[i])
{
mismatchChunk = i;
return false;
}
total += read;
progress?.Report(total);
}
}
finally
{
ArrayPool<byte>.Shared.Return(buffer);
}
mismatchChunk = -1;
return true;
}
private static int ReadChunk(Stream stream, byte[] buffer)
{
int total = 0;
while (total < buffer.Length)
{
int read = stream.Read(buffer, total, buffer.Length - total);
if (read == 0) break;
total += read;
}
return total;
}
}
@@ -1,95 +0,0 @@
// LibProsperoPkg - A library for building and inspecting PS5 packages.
// Copyright (C) 2026 SvenGDK
//
// Folder -> GP5 project generation. Walks a prepared folder and emits the Prospero GP5
// project descriptor.
using System;
using System.IO;
namespace LibProsperoPkg.GP5;
/// <summary>
/// Turns a prepared application folder into a <see cref="Gp5Project"/>.
/// </summary>
public static class Gp5Creator
{
/// <summary>
/// The default directory-exclude mask: directories (e.g. <c>about</c>) that hold project
/// scaffolding rather than image content, so they never end up inside the image.
/// </summary>
public const string DefaultDirExclude = "about";
/// <summary>The default file-exclude mask (semicolon separated).</summary>
public const string DefaultFileExclude =
"*.gp5;*.esbak;keystone;*.dds;disc_info.dat;pfs-version.dat;ext_info.dat";
/// <summary>
/// Builds a GP5 project that references <paramref name="sourceFolder"/> as a single
/// recursively-walked <c>rootdir</c>. The path is emitted verbatim; callers that need a
/// different on-disk root can pass an override.
/// </summary>
/// <param name="sourceFolder">The prepared folder (must contain <c>sce_sys/param.json</c>).</param>
/// <param name="type">The Prospero volume type.</param>
/// <param name="passcode">The 32-character package passcode (defaults to all zeroes).</param>
/// <param name="rootDirPathOverride">
/// When supplied, written as the <c>rootdir</c> <c>src_path</c> instead of
/// <paramref name="sourceFolder"/>.
/// </param>
public static Gp5Project FromFolder(
string sourceFolder,
Gp5VolumeType type = Gp5VolumeType.prospero_app,
string passcode = "00000000000000000000000000000000",
string? rootDirPathOverride = null)
{
ArgumentException.ThrowIfNullOrWhiteSpace(sourceFolder);
if (!Directory.Exists(sourceFolder))
throw new DirectoryNotFoundException($"Source folder does not exist: {sourceFolder}");
var project = Gp5Project.Create(type, passcode);
project.RootDir = new Gp5RootDir
{
SourcePath = rootDirPathOverride ?? sourceFolder,
DirExclude = DefaultDirExclude,
FileExclude = DefaultFileExclude,
};
return project;
}
/// <summary>
/// Builds a GP5 project in the <see cref="Gp5Layout.Flat"/> style: every file under
/// <paramref name="sourceFolder"/> is listed as an explicit top-level <c>&lt;file&gt;</c> entry
/// (inside <c>&lt;files&gt;</c>), with no <c>&lt;rootdir&gt;</c> / <c>&lt;global_exclude&gt;</c>. This
/// produces a fully-resolved project that does not rely on directory walking at pack time,
/// which is convenient for a direct packaging pipeline.
/// </summary>
public static Gp5Project FromFolderExplicit(
string sourceFolder,
Gp5VolumeType type = Gp5VolumeType.prospero_app,
string passcode = "00000000000000000000000000000000")
{
ArgumentException.ThrowIfNullOrWhiteSpace(sourceFolder);
if (!Directory.Exists(sourceFolder))
throw new DirectoryNotFoundException($"Source folder does not exist: {sourceFolder}");
var root = Path.GetFullPath(sourceFolder);
var project = Gp5Project.Create(type, passcode);
foreach (var file in Directory.EnumerateFiles(root, "*", SearchOption.AllDirectories))
{
var relative = Path.GetRelativePath(root, file);
// Prospero destination paths use backslash separators (e.g. sce_sys\param.json).
var dst = relative
.Replace(Path.DirectorySeparatorChar, '\\')
.Replace(Path.AltDirectorySeparatorChar, '\\');
project.Files.Add(new Gp5File
{
SourcePath = file,
DestinationPath = dst,
});
}
return project;
}
}
@@ -1,339 +0,0 @@
// LibProsperoPkg - A library for building and inspecting PS5 packages.
// Copyright (C) 2026 SvenGDK
//
// Model of the PS5 "Prospero" GP5 project format (*.gp5). This is the XML project
// descriptor used to describe a PS5 package, with
// Prospero-specific element/attribute names. The model round-trips byte-compatibly with standard
// GP5 projects, so it can be used as the project model throughout the PS5 PKG builders.
using System;
using System.Collections.Generic;
using System.IO;
using System.Xml;
using System.Xml.Serialization;
namespace LibProsperoPkg.GP5;
/// <summary>
/// The PS5 package volume types expressed by a GP5 project.
/// </summary>
public enum Gp5VolumeType
{
/// <summary>A standalone PS5 application/game package.</summary>
prospero_app,
/// <summary>A PS5 application patch.</summary>
prospero_patch,
/// <summary>Additional content (DLC) that ships data.</summary>
prospero_ac,
/// <summary>Additional content (DLC) entitlement only, no data.</summary>
prospero_ac_nodata,
}
/// <summary>
/// The structural style a GP5 project uses to describe its contents. Two
/// equivalent layouts exist:
/// <list type="bullet">
/// <item><see cref="Normal"/> — a single <c>&lt;rootdir&gt;</c> with a <c>src_path</c> (and optional
/// exclude masks) that the tool walks recursively, preceded by <c>&lt;global_exclude&gt;</c>.</item>
/// <item><see cref="Flat"/> — an explicit top-level <c>&lt;files&gt;</c> (and optional
/// <c>&lt;folders&gt;</c>) listing that maps each source path to a package destination path, with no
/// <c>&lt;rootdir&gt;</c> / <c>&lt;global_exclude&gt;</c>.</item>
/// </list>
/// </summary>
public enum Gp5Layout
{
/// <summary>A single recursively-walked <c>&lt;rootdir src_path&gt;</c> (with <c>&lt;global_exclude&gt;</c>).</summary>
Normal,
/// <summary>An explicit top-level <c>&lt;files&gt;</c> / <c>&lt;folders&gt;</c> listing.</summary>
Flat,
}
/// <summary>
/// In-memory representation of a <c>*.gp5</c> project. Use <see cref="Create"/> to build
/// a new project, <see cref="WriteTo(Gp5Project, string)"/> to serialize it and
/// <see cref="ReadFrom(string)"/> to load one back.
/// </summary>
[XmlRoot(ElementName = "psproject")]
public sealed class Gp5Project
{
[XmlAttribute("fmt")]
public string Format { get; set; } = "gp5";
[XmlAttribute("version")]
public int Version { get; set; } = 1000;
[XmlElement(ElementName = "volume", Order = 1)]
public Gp5Volume Volume { get; set; } = new();
[XmlElement(ElementName = "global_exclude", Order = 2)]
public string GlobalExclude { get; set; } = "";
[XmlElement(ElementName = "rootdir", Order = 3)]
public Gp5RootDir RootDir { get; set; } = new();
/// <summary>
/// The explicit top-level <c>&lt;files&gt;</c> listing (flat layout). When this is non-empty the
/// project is written in the <see cref="Gp5Layout.Flat"/> style and the <c>&lt;rootdir&gt;</c> /
/// <c>&lt;global_exclude&gt;</c> elements are omitted.
/// </summary>
[XmlArray(ElementName = "files", Order = 4)]
[XmlArrayItem(ElementName = "file", Type = typeof(Gp5File))]
public List<Gp5File> Files { get; set; } = [];
/// <summary>
/// The optional explicit top-level <c>&lt;folders&gt;</c> listing (flat layout) that maps whole
/// source directories to package destination paths, parallel to <see cref="Files"/>.
/// </summary>
[XmlArray(ElementName = "folders", Order = 5)]
[XmlArrayItem(ElementName = "dir", Type = typeof(Gp5Dir))]
public List<Gp5Dir> Folders { get; set; } = [];
/// <summary>
/// The layout this project is in: <see cref="Gp5Layout.Flat"/> when it carries an explicit
/// <see cref="Files"/> / <see cref="Folders"/> listing, otherwise <see cref="Gp5Layout.Normal"/>
/// (a recursively-walked <see cref="RootDir"/>).
/// </summary>
[XmlIgnore]
public Gp5Layout Layout => (Files.Count > 0 || Folders.Count > 0) ? Gp5Layout.Flat : Gp5Layout.Normal;
// Normal-layout elements are emitted only when the project is not an explicit flat listing.
public bool ShouldSerializeGlobalExclude() => Layout == Gp5Layout.Normal;
public bool ShouldSerializeRootDir() => Layout == Gp5Layout.Normal;
public bool ShouldSerializeFiles() => Files.Count > 0;
public bool ShouldSerializeFolders() => Folders.Count > 0;
/// <summary>The XML namespaces written for a GP5 project (none).</summary>
private static readonly XmlSerializerNamespaces EmptyNamespaces =
new([XmlQualifiedName.Empty]);
/// <summary>
/// Creates a new, valid GP5 project with sensible Prospero defaults for the
/// supplied volume type.
/// </summary>
public static Gp5Project Create(Gp5VolumeType type, string passcode = "00000000000000000000000000000000")
{
var project = new Gp5Project
{
Volume = new Gp5Volume
{
VolumeTypeName = type.ToString(),
Package = new Gp5Package { Passcode = passcode },
},
};
// Only full applications carry PlayGo chunk/scenario information. Additional
// content packages omit it entirely.
if (type is Gp5VolumeType.prospero_app or Gp5VolumeType.prospero_patch)
{
project.Volume.ChunkInfo = new Gp5ChunkInfo
{
ChunkCount = 1,
ScenarioCount = 1,
Chunks = [new Gp5Chunk { Id = 0, Label = "Chunk #0" }],
Scenarios = new Gp5Scenarios
{
DefaultId = 0,
Items =
[
new Gp5Scenario
{
Id = 0,
Type = "playmode",
InitialChunkCount = 1,
Label = "Scenario #0",
Chunks = "0",
},
],
},
};
}
return project;
}
/// <summary>Serializes the project to the given stream.</summary>
public static void WriteTo(Gp5Project project, Stream stream)
{
ArgumentNullException.ThrowIfNull(project);
ArgumentNullException.ThrowIfNull(stream);
var settings = new XmlWriterSettings
{
Indent = true,
IndentChars = " ",
Encoding = new System.Text.UTF8Encoding(false),
OmitXmlDeclaration = false,
};
using var writer = XmlWriter.Create(stream, settings);
new XmlSerializer(typeof(Gp5Project)).Serialize(writer, project, EmptyNamespaces);
}
/// <summary>Serializes the project to the given file path.</summary>
public static void WriteTo(Gp5Project project, string path)
{
using var fs = File.Create(path);
WriteTo(project, fs);
}
/// <summary>Reads a GP5 project from the given stream.</summary>
public static Gp5Project ReadFrom(Stream stream)
{
ArgumentNullException.ThrowIfNull(stream);
return (Gp5Project)new XmlSerializer(typeof(Gp5Project)).Deserialize(stream)!;
}
/// <summary>Reads a GP5 project from the given file path.</summary>
public static Gp5Project ReadFrom(string path)
{
using var fs = File.OpenRead(path);
return ReadFrom(fs);
}
}
/// <summary>The <c>&lt;volume&gt;</c> element of a GP5 project.</summary>
public sealed class Gp5Volume
{
[XmlElement(ElementName = "volume_type")]
public string VolumeTypeName { get; set; } = nameof(Gp5VolumeType.prospero_app);
[XmlIgnore]
public Gp5VolumeType Type
{
get => Enum.TryParse<Gp5VolumeType>(VolumeTypeName, out var t) ? t : Gp5VolumeType.prospero_app;
set => VolumeTypeName = value.ToString();
}
[XmlElement(ElementName = "volume_id")]
public string? VolumeId { get; set; }
[XmlElement(ElementName = "volume_ts")]
public string? VolumeTimestamp { get; set; }
[XmlElement(ElementName = "package")]
public Gp5Package Package { get; set; } = new();
[XmlElement(ElementName = "chunk_info")]
public Gp5ChunkInfo? ChunkInfo { get; set; }
}
/// <summary>The <c>&lt;package&gt;</c> element. PS5 packages carry the content id in param.json, not here.</summary>
public sealed class Gp5Package
{
[XmlAttribute("content_id")]
public string? ContentId { get; set; }
[XmlAttribute("passcode")]
public string Passcode { get; set; } = "00000000000000000000000000000000";
[XmlAttribute("storage_type")]
public string? StorageType { get; set; }
[XmlAttribute("app_path")]
public string? AppPath { get; set; }
public bool ShouldSerializeContentId() => !string.IsNullOrEmpty(ContentId);
public bool ShouldSerializeStorageType() => !string.IsNullOrEmpty(StorageType);
public bool ShouldSerializeAppPath() => !string.IsNullOrEmpty(AppPath);
}
/// <summary>PlayGo chunk/scenario metadata; present only for application packages.</summary>
public sealed class Gp5ChunkInfo
{
[XmlAttribute("chunk_count")]
public int ChunkCount { get; set; }
[XmlAttribute("scenario_count")]
public int ScenarioCount { get; set; }
[XmlArray(ElementName = "chunks")]
[XmlArrayItem(ElementName = "chunk", Type = typeof(Gp5Chunk))]
public List<Gp5Chunk> Chunks { get; set; } = [];
[XmlElement(ElementName = "scenarios")]
public Gp5Scenarios Scenarios { get; set; } = new();
}
/// <summary>A single PlayGo chunk.</summary>
public sealed class Gp5Chunk
{
[XmlAttribute("id")]
public int Id { get; set; }
[XmlAttribute("label")]
public string Label { get; set; } = "";
}
/// <summary>The <c>&lt;scenarios&gt;</c> container.</summary>
public sealed class Gp5Scenarios
{
[XmlAttribute("default_id")]
public int DefaultId { get; set; }
[XmlElement(ElementName = "scenario", Type = typeof(Gp5Scenario))]
public List<Gp5Scenario> Items { get; set; } = [];
}
/// <summary>A single PlayGo scenario; the chunk list is stored as element text.</summary>
public sealed class Gp5Scenario
{
[XmlAttribute("id")]
public int Id { get; set; }
[XmlAttribute("type")]
public string Type { get; set; } = "playmode";
[XmlAttribute("initial_chunk_count")]
public int InitialChunkCount { get; set; }
[XmlAttribute("label")]
public string Label { get; set; } = "";
[XmlText]
public string Chunks { get; set; } = "0";
}
/// <summary>
/// The <c>&lt;rootdir&gt;</c> element of the <see cref="Gp5Layout.Normal"/> layout: a single source
/// directory that is walked recursively, with optional directory- and file-exclude masks.
/// The explicit per-file/per-folder listing of the flat layout lives in
/// <see cref="Gp5Project.Files"/> / <see cref="Gp5Project.Folders"/>, not here.
/// </summary>
public sealed class Gp5RootDir
{
[XmlAttribute("dir_exclude")]
public string? DirExclude { get; set; }
[XmlAttribute("file_exclude")]
public string? FileExclude { get; set; }
[XmlAttribute("src_path")]
public string? SourcePath { get; set; }
public bool ShouldSerializeSourcePath() => !string.IsNullOrEmpty(SourcePath);
public bool ShouldSerializeDirExclude() => !string.IsNullOrEmpty(DirExclude);
public bool ShouldSerializeFileExclude() => !string.IsNullOrEmpty(FileExclude);
}
/// <summary>An explicit file entry mapping a source path to a package destination path.</summary>
public sealed class Gp5File
{
[XmlAttribute("dst_path")]
public string DestinationPath { get; set; } = "";
[XmlAttribute("src_path")]
public string SourcePath { get; set; } = "";
}
/// <summary>An explicit directory entry mapping a source folder to a package destination path.</summary>
public sealed class Gp5Dir
{
[XmlAttribute("dst_path")]
public string DestinationPath { get; set; } = "";
[XmlAttribute("src_path")]
public string SourcePath { get; set; } = "";
}
@@ -1,39 +0,0 @@
-----BEGIN RSA PRIVATE KEY-----
MIIG4wIBAAKCAYEAqx29QzlJMxajXEBOLCKXuDNoXBrTVOjFuniI0bD68lqPFKoG
Uo+kZYZu1CMD0wCRC9nYQQH+VMEr/E9/nDp6yRMz/SzcyxQAdhreXC68oBFtjDBL
i0fzPEE3coSenh0YO017vJlMN+14h9SGlCNLcazLTblQcDNmGJdu1nscQBohE9Q5
iANASZ9la3rus4bAZ5jC0UTrtYS1ZXso4pCUSTF5mwsJsnGh2TcL/k+Eusx46jyR
fTANU9XFajQLKwdWCA8oMlNj65vITrkdcEaO74vUqzAvE/MAQXCVecqlTovXZCNW
7IUjChUU4AZnVoQjCB1kOZaIM6UcWy/Htu8AYj+3JYmaKWfLwUzurv6HRygClaMc
kIlZs37OsAZBgsUzZk3tY1X/MTz4KokaQtyIZV/d/nHmUOUbFJCoiM441vuFDiDR
JAjNsPDvqy/xn5qVgC1DdWDAyYbF8suyDiuJf2vLZ6Vle0ck29oss4/iPXOM8m+M
wG4PEiH+dA0ONoFxAgMBAAECggGBAI4E88UscYV2X4U8VeUpnNSjzhTLquSJATrf
uWaYRd8JrEERUIgLcf1VUvy8RvtEOB4m4uYpemXroc8aSCZpHuluB7M0HdhqtGtR
p4XIwIL1k/9LQhfKUqWK1zMzwNYn/amSiIUiknDEpknN6RhgJsilCmNqz8kfz7fP
T42xxeOqDBQCCvHJCP1RzwIimKTlzSDuV5sKYbtY9pjQXEGWj4wkBPLaeWTiDNtU
ZZ7fbqD+/cgjFvlY/Wa8QMoBgddnkPMo0g7JO/XK9qvdo/+J/qJHQ4rIJa/Ygi4T
iXD+jvsZ3dNzpc7Lv8wuBHlY/NjnrTpabDOdmPt5R+oDTXJLkDZIeo4AaUkeGtSX
4ehXlXTinu+mKtIlHYPa1zpPGqqs9x7fNRBVfY20cU/QXWPcdOrjYh0rBAbFEm/H
1qELmVY4nHVWy9pRxEtdrIe7l9ZGjaceJ9WDLvqWAEjQU6QAw6z+Krpoo6GvT0N+
oau8Mc15pRRwfWGAv/1Y2nwqRKu/QQKBwQDYT3iTjzH0VugozyiQYgTZNpn2oxlu
xydTbftoXmPEz612B4gfbz+9hr06BWLFIv0KQn0SAsN3zuNzyVHnYwcpiQDykV7l
3bE/lhS6w1/SKzS9qFv/hrzHHpiPZCLjoC7J0Y1E5MDQVF26fsZZOq7LDh0es91/
YTU79IgR+7tvpQ31NX846Afhw8P+8VLLxrLCtGdPPX1EOcjuoO8XtACiAtI+kzlK
orIPV3oGFSjxuNXIU9B/NadTyyQ3PuAFxckCgcEAyoNnf/Oec0fZD5lVxVpWV8NU
O6lmuoYQ4LEvwpbV8dHYz/J9A67O7Mx3Bl8xmZ46hDexhiQTdXWeqoyNZstfSret
ZBicXGNMfbNzcOKCJOMuy8oJsI7fZKmePmLZtKGmx16sUbGC49Vt0HHiOL1WQdme
y+KR619I+/pTQwa4fWDkQB0YS+BaI2nPOeBZ+0fDtQP0qqiC8303Yd7OXqcNhx4J
s3aqVO8zqr3yeO1osuJRZoEHfO5Rby58WQM1jlJpAoHAB3gfCsFcETrbA2W72dh4
oGOBR4H0Q93+nqPilYUE3uvo6nVyHtvBkLLRX+qFsZb2s9794JxV0ZJESmA+QsYp
niaL8NRSOY/BKhftmVFbwq8ZQB9LJfSqGhoVXIYxqjiCxRdGUIWxnr/7CJCOGtCq
7noLSV8em+JoayyTckOGAmHprHjvbrCcbRBMeUYt/LlcvNpr4tGVvMBeDtdhyii+
CNoeFmkRBmG90kfL/9/FLSubvjIetfXNVFhkZL/4Dlr5AoHAPJljsEMbSA3Y4zUU
GHE24x49J3lCl1Ak3sfGrejq7mjIAznhtOdrXiq090AnHHvfsM7lnWlQNVbT+t8C
NR9oTXh3NzuyFmdUbUz0n3P4U8dzqmGz0pR+PqYPB0YXNVkmCgTHdc6zhy/Ho5dg
hXAKzrurLAGJfrBNq7E1lxn8vO/wfUr3iUUCVBSGgSAkbPAFnTYo0aSJQwlWOEAu
6t38S1Fuv7gjsjS99jrOwubv7I+SoiS8M+MwlR+I8C3oqcT5AoHAXFDvIxTb4c8Z
ZoqTTdznYjRypS/9p2kAzgVsmnpAWlWdgU5J/PNyNhhielRoNj2QjvTuJjMUZjZq
HmYtWyVSEF2FIRG5kd55EOKaJa87FCww3zxbjf/onDWWxvVjCehBntlhVZSYL9mG
BTIBI4Z03BJK+dW0/aWebSiuAtvs4M+yw6xsvu5kIGO0jqfwaZa97E2n+BYUPNpn
afy1hEcQcaxkJL2UPorj37SpVHMeTNO4+QjMHYU7wcwKz0e7rWt7
-----END RSA PRIVATE KEY-----
@@ -1,107 +0,0 @@
// LibProsperoPkg - A library for building and inspecting PS5 packages.
// Copyright (C) 2026 SvenGDK
//
// Wired-in PS5 publishing key material, embedded as resources so the build is
// self-contained. None of these are secret retail keys: they are the published PKG-metadata
// RSA-3072 private key (used by the system software to verify a package's metadata) and the
// Prospero publishing passcode / mount-image keys.
using System;
using System.IO;
using System.Reflection;
using System.Security.Cryptography;
namespace LibProsperoPkg.Keys;
/// <summary>
/// Provides access to the published PS5 publishing key material required by the package
/// pipeline. Keys are embedded resources; <see cref="IsAvailable"/> reports whether they could
/// be loaded.
/// </summary>
public static class ProsperoKeys
{
private const string RsaPemResource = "LibProsperoPkg.Keys.Data.pkg_meta_rsa_key.pem";
private const string PasscodeResource = "LibProsperoPkg.Keys.Data.passcode.bin";
private const string MountImageResource = "LibProsperoPkg.Keys.Data.mount_image.bin";
private static readonly Lazy<RSAParameters?> _metadataRsa = new(LoadMetadataRsaParameters);
private static readonly Lazy<byte[]?> _passcodeKey = new(() => TryLoadBytes(PasscodeResource));
private static readonly Lazy<byte[]?> _mountImageKey = new(() => TryLoadBytes(MountImageResource));
/// <summary>True when every required PS5 publishing key was loaded successfully.</summary>
public static bool IsAvailable =>
_metadataRsa.Value is not null
&& _passcodeKey.Value is { Length: > 0 }
&& _mountImageKey.Value is { Length: > 0 };
/// <summary>
/// The PKG-metadata RSA-3072 private key. Returns a fresh
/// <see cref="RSA"/> instance the caller owns and must dispose.
/// </summary>
/// <exception cref="InvalidOperationException">The embedded key could not be loaded.</exception>
public static RSA CreateMetadataRsa()
{
if (_metadataRsa.Value is not { } parameters)
throw new InvalidOperationException("The PS5 PKG-metadata RSA-3072 key is unavailable.");
var rsa = RSA.Create();
rsa.ImportParameters(parameters);
return rsa;
}
/// <summary>The Prospero publishing-tool passcode key blob (from Prospero Publishing Tools).</summary>
/// <exception cref="InvalidOperationException">The embedded key could not be loaded.</exception>
public static ReadOnlySpan<byte> PasscodeKey =>
_passcodeKey.Value ?? throw new InvalidOperationException("The PS5 passcode key is unavailable.");
/// <summary>The Prospero publishing-tool mount-image key blob.</summary>
/// <exception cref="InvalidOperationException">The embedded key could not be loaded.</exception>
public static ReadOnlySpan<byte> MountImageKey =>
_mountImageKey.Value ?? throw new InvalidOperationException("The PS5 mount-image key is unavailable.");
private static RSAParameters? LoadMetadataRsaParameters()
{
try
{
var pem = LoadText(RsaPemResource);
if (pem is null) return null;
using var rsa = RSA.Create();
rsa.ImportFromPem(pem);
// Export including private parameters so we can re-import on demand without
// keeping a single shared (and disposable) instance alive.
return rsa.ExportParameters(true);
}
catch
{
return null;
}
}
private static string? LoadText(string resourceName)
{
using var stream = OpenResource(resourceName);
if (stream is null) return null;
using var reader = new StreamReader(stream);
return reader.ReadToEnd();
}
private static byte[]? TryLoadBytes(string resourceName)
{
try
{
using var stream = OpenResource(resourceName);
if (stream is null) return null;
using var ms = new MemoryStream();
stream.CopyTo(ms);
return ms.ToArray();
}
catch
{
return null;
}
}
private static Stream? OpenResource(string resourceName) =>
typeof(ProsperoKeys).GetTypeInfo().Assembly.GetManifestResourceStream(resourceName);
}
@@ -1,50 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>14.0</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>disable</ImplicitUsings>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<EnableNETAnalyzers>true</EnableNETAnalyzers>
<AnalysisLevel>latest</AnalysisLevel>
<EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild>
<!-- Documentation comments reference some symbols by short name; do not fail the build on
missing-XML-comment (CS1591) warnings. -->
<NoWarn>$(NoWarn);CS1591</NoWarn>
<AssemblyName>LibProsperoPkg</AssemblyName>
<RootNamespace>LibProsperoPkg</RootNamespace>
<PackageId>LibProsperoPkg</PackageId>
<Version>2.6.0</Version>
<Authors>SvenGDK</Authors>
<Product>LibProsperoPkg</Product>
<Description>A .NET library for reading, building and inspecting PS5 packages.</Description>
<Copyright>Copyright © SvenGDK 2026</Copyright>
<PackageLicenseExpression>GPL-3.0-or-later</PackageLicenseExpression>
<PackageReadmeFile>README.md</PackageReadmeFile>
<PackageTags>playstation;ps5;prospero;pkg;pfs;gp5;package</PackageTags>
</PropertyGroup>
<ItemGroup>
<None Include="..\..\README.md" Pack="true" PackagePath="\" />
</ItemGroup>
<ItemGroup>
<EmbeddedResource Include="Keys\Data\pkg_meta_rsa_key.pem">
<LogicalName>LibProsperoPkg.Keys.Data.pkg_meta_rsa_key.pem</LogicalName>
</EmbeddedResource>
<EmbeddedResource Include="Keys\Data\passcode.bin">
<LogicalName>LibProsperoPkg.Keys.Data.passcode.bin</LogicalName>
</EmbeddedResource>
<EmbeddedResource Include="Keys\Data\mount_image.bin">
<LogicalName>LibProsperoPkg.Keys.Data.mount_image.bin</LogicalName>
</EmbeddedResource>
<EmbeddedResource Include="PlayGo\Data\right.sprx">
<LogicalName>LibProsperoPkg.PlayGo.Data.right.sprx</LogicalName>
</EmbeddedResource>
</ItemGroup>
</Project>
@@ -1,137 +0,0 @@
// LibProsperoPkg - A library for building and inspecting PS5 packages.
// Copyright (C) 2026 SvenGDK
//
// The debug grant for a content id + passcode. A debug image derives its mount key from public
// inputs only (the content id and the passcode), so the mount path consumes no license record.
// This type computes that derived key set and states the "no rif required" decision in one place.
// It never holds or fabricates a per-device secret: an all-zero content key is not a grant, and the
// structural rif it can emit carries a zero blob. The derived debug key stays distinct from the
// sealed retail key; this type only produces the former.
#nullable enable
using LibProsperoPkg.PFS;
using System;
using System.Text;
namespace LibProsperoPkg.License;
/// <summary>
/// The full derived key set for one debug image: the EKPFS and the AES-XTS (tweak, data) pair and
/// sign key it yields for a specific superblock seed. Every value is a function of public inputs.
/// </summary>
/// <param name="Ekpfs">The 32-byte image key derived from the content id and passcode.</param>
/// <param name="TweakKey">The 16-byte AES-XTS tweak key for the image data.</param>
/// <param name="DataKey">The 16-byte AES-XTS data key for the image data.</param>
/// <param name="SignKey">The 32-byte key for the image's signed metadata blocks.</param>
public sealed record ProsperoDebugKeySet(byte[] Ekpfs, byte[] TweakKey, byte[] DataKey, byte[] SignKey);
/// <summary>
/// Expresses the debug grant for a package: the content id and passcode whose EKPFS the mount path
/// recomputes. A debug image needs no <see cref="ProsperoRif"/>, because its key is derived, not
/// granted. Use <see cref="DeriveEkpfs"/> / <see cref="DeriveKeySet"/> to obtain the key material,
/// and <see cref="ToStructuralRif"/> only when a pipeline expects a license file to be present.
/// </summary>
public sealed class ProsperoDebugLicense
{
/// <summary>Length in bytes of the derived EKPFS.</summary>
public const int EkpfsSize = 32;
/// <summary>Length in bytes of the superblock seed the image keys are bound to.</summary>
public const int SeedSize = 16;
/// <summary>Required passcode length.</summary>
public const int PasscodeLength = 32;
/// <summary>Maximum content-id length in bytes.</summary>
public const int ContentIdMaxLength = 0x24;
/// <summary>The all-zero passcode, the debug default.</summary>
public static string DefaultPasscode => new('0', PasscodeLength);
/// <summary>The package content id the derived key binds to. Also the id a structural rif carries.</summary>
public required string ContentId { get; init; }
/// <summary>The 32-character passcode. The all-zero passcode is the debug default.</summary>
public required string Passcode { get; init; }
/// <summary>
/// A debug grant never requires a license record. The mount path recomputes the EKPFS from the
/// content id and passcode, so no rif is consumed.
/// </summary>
public bool RequiresRif => false;
/// <summary>
/// Builds a debug grant for <paramref name="contentId"/>. The passcode defaults to the all-zero
/// debug passcode when omitted.
/// </summary>
/// <exception cref="ArgumentException">The content id or passcode is malformed.</exception>
public static ProsperoDebugLicense Create(string contentId, string? passcode = null)
{
string pass = passcode ?? DefaultPasscode;
var license = new ProsperoDebugLicense { ContentId = contentId, Passcode = pass };
if (!license.Validate(out string? error))
throw new ArgumentException(error, nameof(contentId));
return license;
}
/// <summary>Derives the 32-byte EKPFS from the content id and passcode.</summary>
public byte[] DeriveEkpfs() => ProsperoPfsKeys.DeriveEkpfs(ContentId, Passcode);
/// <summary>
/// Derives the AES-XTS (tweak, data) key pair for the image data from the given 16-byte
/// superblock <paramref name="seed"/>.
/// </summary>
public (byte[] TweakKey, byte[] DataKey) DeriveImageEncryptionKeys(byte[] seed) =>
ProsperoPfsKeys.DeriveImageEncryptionKeys(DeriveEkpfs(), seed);
/// <summary>Derives the 32-byte sign key for the image's signed metadata from the given seed.</summary>
public byte[] DeriveImageSignKey(byte[] seed) =>
ProsperoPfsKeys.DeriveImageSignKey(DeriveEkpfs(), seed);
/// <summary>
/// Derives the complete key set (EKPFS, tweak, data and sign keys) for an image built with the
/// given 16-byte superblock <paramref name="seed"/> in one call.
/// </summary>
/// <exception cref="ArgumentException"><paramref name="seed"/> is not exactly <see cref="SeedSize"/> bytes.</exception>
public ProsperoDebugKeySet DeriveKeySet(byte[] seed)
{
ArgumentNullException.ThrowIfNull(seed);
if (seed.Length != SeedSize)
throw new ArgumentException($"A superblock seed is exactly {SeedSize} bytes (got {seed.Length}).", nameof(seed));
byte[] ekpfs = DeriveEkpfs();
var (tweak, data) = ProsperoPfsKeys.DeriveImageEncryptionKeys(ekpfs, seed);
byte[] sign = ProsperoPfsKeys.DeriveImageSignKey(ekpfs, seed);
return new ProsperoDebugKeySet(ekpfs, tweak, data, sign);
}
/// <summary>
/// Emits a structural rif bound to this content id, with a zero key blob. The debug mount path
/// does not consume it; it exists only for pipelines that expect a license file to be present.
/// </summary>
public ProsperoRif ToStructuralRif(long expiry = ProsperoRif.NeverExpires) =>
ProsperoRif.Create(ContentId, keyBlob: null, expiry);
/// <summary>
/// Checks that the content id and passcode are well-formed. Returns <see langword="true"/> when
/// usable; otherwise <paramref name="error"/> describes the first problem.
/// </summary>
public bool Validate(out string? error)
{
if (string.IsNullOrEmpty(ContentId) || Encoding.ASCII.GetByteCount(ContentId) > ContentIdMaxLength)
{
error = $"A content id is 1..{ContentIdMaxLength} ASCII bytes.";
return false;
}
if (string.IsNullOrEmpty(Passcode) || Passcode.Length != PasscodeLength)
{
error = $"A passcode is exactly {PasscodeLength} characters.";
return false;
}
error = null;
return true;
}
/// <summary>Returns the content id and rif requirement for diagnostics.</summary>
public override string ToString() => $"DebugLicense({ContentId}, rif required={RequiresRif})";
}
@@ -1,173 +0,0 @@
// LibProsperoPkg - A library for building and inspecting PS5 packages.
// Copyright (C) 2026 SvenGDK
//
// Model for the 128-bit content key ("entitlement_key") the package builder carries alongside
// a title. The builder accepts a content key as the finalized/keyed alternative to a passcode:
// the two are mutually exclusive (a passcode drives the fake/debug key schedule, an entitlement
// key drives the finalized/keyed one). The key is referenced as "pkg/entitlement_key" and is the
// value the license record's 448-byte blob is built to deliver to the mount path.
//
// The blob-wrap that binds an entitlement key into a license record is produced with per-device
// material, so this type is a validated carrier for supplied material only: it never fabricates
// the record blob.
#nullable enable
using System;
namespace LibProsperoPkg.License;
/// <summary>
/// Selects which key schedule a package is built for. The builder rejects supplying both a
/// passcode and an entitlement key at once ("entitlement_key must not be specified"), and rejects
/// supplying neither when one is required ("The passcode should be specified").
/// </summary>
public enum ProsperoKeyMode
{
/// <summary>The fake/debug schedule: the image key is derived from a 32-character passcode.</summary>
Passcode = 0,
/// <summary>The finalized/keyed schedule: the image key is delivered by a supplied entitlement key.</summary>
EntitlementKey = 1,
}
/// <summary>
/// A 128-bit content key ("entitlement_key"). Parses, validates and formats the raw 16-byte value
/// and expresses the builder's passcode/entitlement mutual-exclusivity rule. Binding this key into
/// a <see cref="ProsperoRif"/> record requires per-device material absent from host tooling, so this
/// type only ever carries supplied material — it does not forge a license blob.
/// </summary>
public sealed class ProsperoEntitlementKey
{
/// <summary>Size in bytes of a content key (128-bit).</summary>
public const int Size = 16;
/// <summary>The raw 16-byte content key.</summary>
public required byte[] Value { get; init; }
/// <summary>True when every byte of <see cref="Value"/> is zero (an absent / placeholder key).</summary>
public bool IsZero
{
get
{
foreach (byte b in Value)
{
if (b != 0) return false;
}
return true;
}
}
/// <summary>Wraps a raw 16-byte content key. The bytes are copied.</summary>
/// <exception cref="ArgumentException"><paramref name="value"/> is not exactly <see cref="Size"/> bytes.</exception>
public static ProsperoEntitlementKey FromBytes(ReadOnlySpan<byte> value)
{
if (value.Length != Size)
throw new ArgumentException($"A content key is exactly {Size} bytes (got {value.Length}).", nameof(value));
return new ProsperoEntitlementKey { Value = value.ToArray() };
}
/// <summary>
/// Parses a content key from a 32-character hex string. Surrounding whitespace and an optional
/// <c>0x</c> prefix are ignored; the digits are case-insensitive.
/// </summary>
/// <exception cref="ArgumentNullException"><paramref name="hex"/> is null.</exception>
/// <exception cref="FormatException"><paramref name="hex"/> is not 32 hex digits.</exception>
public static ProsperoEntitlementKey ParseHex(string hex)
{
ArgumentNullException.ThrowIfNull(hex);
string s = hex.Trim();
if (s.StartsWith("0x", StringComparison.OrdinalIgnoreCase))
s = s[2..];
if (s.Length != Size * 2)
throw new FormatException($"A content key is {Size * 2} hex digits (got {s.Length}).");
byte[] value = new byte[Size];
for (int i = 0; i < Size; i++)
value[i] = (byte)((HexNibble(s[i * 2]) << 4) | HexNibble(s[(i * 2) + 1]));
return new ProsperoEntitlementKey { Value = value };
}
/// <summary>Formats the key as a lower-case 32-character hex string (no prefix).</summary>
public string ToHex() => Convert.ToHexStringLower(Value);
/// <summary>
/// Validates that the value is a well-formed, non-zero content key. Returns <see langword="true"/>
/// when usable; otherwise <paramref name="error"/> describes the problem.
/// </summary>
public bool Validate(out string? error)
{
if (Value is null || Value.Length != Size)
{
error = $"A content key must be exactly {Size} bytes.";
return false;
}
if (IsZero)
{
error = "A content key must not be all-zero.";
return false;
}
error = null;
return true;
}
/// <summary>
/// Resolves which key schedule a build targets from the two mutually-exclusive inputs, applying
/// the same rule the builder enforces. Exactly one of <paramref name="passcode"/> /
/// <paramref name="entitlementKey"/> must be present.
/// </summary>
/// <param name="passcode">The 32-character passcode, or <see langword="null"/>.</param>
/// <param name="entitlementKey">The content key, or <see langword="null"/>.</param>
/// <param name="mode">The resolved key schedule on success.</param>
/// <param name="error">The reason resolution failed, or <see langword="null"/> on success.</param>
/// <returns><see langword="true"/> when exactly one input is present and valid.</returns>
public static bool ResolveMode(
string? passcode,
ProsperoEntitlementKey? entitlementKey,
out ProsperoKeyMode mode,
out string? error)
{
bool hasPasscode = !string.IsNullOrEmpty(passcode);
bool hasKey = entitlementKey is not null && !entitlementKey.IsZero;
if (hasPasscode && hasKey)
{
mode = default;
error = "entitlement_key must not be specified when a passcode is used.";
return false;
}
if (!hasPasscode && !hasKey)
{
mode = default;
error = "A passcode or an entitlement_key must be specified.";
return false;
}
if (hasPasscode && passcode!.Length != 32)
{
mode = default;
error = "A passcode must be 32 characters long.";
return false;
}
if (hasKey && !entitlementKey!.Validate(out error))
{
mode = default;
return false;
}
mode = hasPasscode ? ProsperoKeyMode.Passcode : ProsperoKeyMode.EntitlementKey;
error = null;
return true;
}
private static int HexNibble(char c) => c switch
{
>= '0' and <= '9' => c - '0',
>= 'a' and <= 'f' => c - 'a' + 10,
>= 'A' and <= 'F' => c - 'A' + 10,
_ => throw new FormatException($"'{c}' is not a hex digit."),
};
/// <summary>Returns the hex form for diagnostics.</summary>
public override string ToString() => $"EntitlementKey({ToHex()})";
}
@@ -1,303 +0,0 @@
// LibProsperoPkg - A library for building and inspecting PS5 packages.
// Copyright (C) 2026 SvenGDK
//
// Reader, writer and creator for the PS5 per-title license file (license/rif).
// A RIF (Rights Information File) is a fixed 0x400-byte record that binds a content-id
// to an encrypted 448-byte entitlement/key blob. The fixed header is big-endian on disk;
// multi-title discs concatenate one record per sub-title.
using System;
using System.Buffers.Binary;
using System.Collections.Generic;
using System.IO;
using System.Text;
namespace LibProsperoPkg.License;
/// <summary>
/// A single PS5 <c>rif</c> license record (0x400 bytes). Parses and rebuilds every fixed
/// field (magic, version, flags, <c>QPaC</c> tag, expiry, content-id, format descriptor and
/// the raw 448-byte encrypted key blob). Use <see cref="Parse"/> / <see cref="Read"/> to read
/// one record, <see cref="ReadAll(Stream)"/> for a multi-title file, and <see cref="Create"/>
/// to build a structural record for a content-id.
/// </summary>
public sealed class ProsperoRif
{
/// <summary>Size in bytes of a single RIF record.</summary>
public const int RecordSize = 0x400;
/// <summary>Size in bytes of the content-id field (offset 0x20).</summary>
public const int ContentIdSize = 0x24;
/// <summary>Offset of the encrypted key blob within the record.</summary>
public const int KeyBlobOffset = 0x240;
/// <summary>Size in bytes of the encrypted key blob (offset 0x240 to end of record).</summary>
public const int KeyBlobSize = 0x1C0;
/// <summary>The expected header version (0x0002).</summary>
public const ushort CurrentVersion = 2;
/// <summary>The non-expiring expiry sentinel (<see cref="long.MaxValue"/>).</summary>
public const long NeverExpires = 0x7FFFFFFFFFFFFFFF;
private const int VersionField = 0x04;
private const int FlagsField = 0x06;
private const int FormatTagField = 0x14;
private const int ExpiryField = 0x18;
private const int ContentIdField = 0x20;
private const int FormatDescriptorField = 0x50;
private const int FormatDescriptorSize = 0x08;
private const int EntryCountField = 0x60;
/// <summary>Record magic: <c>52 49 46 00</c> ("RIF\0").</summary>
public static ReadOnlySpan<byte> Magic => [0x52, 0x49, 0x46, 0x00];
/// <summary>The constant format tag at offset 0x14: <c>51 50 61 43</c> ("QPaC").</summary>
public static ReadOnlySpan<byte> FormatTag => [0x51, 0x50, 0x61, 0x43];
/// <summary>The constant format descriptor at offset 0x50.</summary>
public static ReadOnlySpan<byte> DefaultFormatDescriptor => [0x01, 0x04, 0x00, 0x10, 0x00, 0x20, 0x00, 0x03];
/// <summary>Header version (offset 0x04, big-endian). Expected to be <see cref="CurrentVersion"/>.</summary>
public ushort Version { get; init; } = CurrentVersion;
/// <summary>Header flags (offset 0x06, big-endian). <c>0xFFFF</c> observed.</summary>
public ushort Flags { get; init; } = 0xFFFF;
/// <summary>Expiry / timestamp (offset 0x18, big-endian). <see cref="NeverExpires"/> = non-expiring.</summary>
public long Expiry { get; init; } = NeverExpires;
/// <summary>The 36-char content-id (offset 0x20), NUL-trimmed.</summary>
public required string ContentId { get; init; }
/// <summary>The 8-byte format descriptor (offset 0x50).</summary>
public byte[] FormatDescriptor { get; init; } = DefaultFormatDescriptor.ToArray();
/// <summary>The entry-count / flag field (offset 0x60, big-endian). <c>1</c> observed.</summary>
public ulong EntryCount { get; init; } = 1;
/// <summary>The raw 448-byte encrypted key blob (offset 0x240). Encrypted with console secrets.</summary>
public byte[] KeyBlob { get; init; } = new byte[KeyBlobSize];
/// <summary>True when <see cref="Expiry"/> is the non-expiring sentinel.</summary>
public bool IsNonExpiring => Expiry == NeverExpires;
/// <summary>True when the key blob is non-zero (a real entitlement blob is present).</summary>
public bool HasKeyBlob => !IsAllZero(KeyBlob);
/// <summary>
/// The title-id extracted from <see cref="ContentId"/> (the token between the label prefix
/// and <c>_00</c>, e.g. <c>PPSA00000</c>), or <see langword="null"/> when it cannot be parsed.
/// </summary>
public string? TitleId
{
get
{
int dash = ContentId.IndexOf('-', StringComparison.Ordinal);
if (dash < 0 || dash + 1 >= ContentId.Length) return null;
int underscore = ContentId.IndexOf('_', dash + 1);
if (underscore < 0) return null;
return ContentId.Substring(dash + 1, underscore - dash - 1);
}
}
/// <summary>
/// The service label extracted from <see cref="ContentId"/> (the token before the first
/// <c>-</c>, e.g. <c>EP0082</c> or <c>UP0001</c>), or <see langword="null"/> when it cannot be
/// parsed. This is the identifier the verify path reports as the per-record <c>ServiceID</c>.
/// </summary>
public string? ServiceLabel
{
get
{
int dash = ContentId.IndexOf('-', StringComparison.Ordinal);
return dash > 0 ? ContentId[..dash] : null;
}
}
/// <summary>Parses a single RIF record from the first <see cref="RecordSize"/> bytes of <paramref name="record"/>.</summary>
/// <exception cref="ArgumentException">The span is shorter than one record.</exception>
/// <exception cref="InvalidDataException">The record magic is not "RIF\0".</exception>
public static ProsperoRif Parse(ReadOnlySpan<byte> record)
{
if (record.Length < RecordSize)
throw new ArgumentException($"A RIF record needs {RecordSize} bytes.", nameof(record));
record = record[..RecordSize];
if (!record[..4].SequenceEqual(Magic))
throw new InvalidDataException("Not a RIF record (unexpected magic).");
return new ProsperoRif
{
Version = BinaryPrimitives.ReadUInt16BigEndian(record[VersionField..]),
Flags = BinaryPrimitives.ReadUInt16BigEndian(record[FlagsField..]),
Expiry = BinaryPrimitives.ReadInt64BigEndian(record[ExpiryField..]),
ContentId = ReadNulTrimmedAscii(record.Slice(ContentIdField, ContentIdSize)),
FormatDescriptor = record.Slice(FormatDescriptorField, FormatDescriptorSize).ToArray(),
EntryCount = BinaryPrimitives.ReadUInt64BigEndian(record[EntryCountField..]),
KeyBlob = record.Slice(KeyBlobOffset, KeyBlobSize).ToArray(),
};
}
/// <summary>Reads one RIF record from <paramref name="stream"/> at its current position.</summary>
public static ProsperoRif Read(Stream stream)
{
ArgumentNullException.ThrowIfNull(stream);
byte[] record = new byte[RecordSize];
ReadExactly(stream, record);
return Parse(record);
}
/// <summary>Reads every 0x400-byte record from a (single- or multi-title) RIF file.</summary>
public static IReadOnlyList<ProsperoRif> ReadAll(string path)
{
using var fs = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read);
return ReadAll(fs);
}
/// <summary>Reads every 0x400-byte record from a (single- or multi-title) RIF stream.</summary>
/// <exception cref="InvalidDataException">The stream length is not a whole number of records.</exception>
public static IReadOnlyList<ProsperoRif> ReadAll(Stream stream)
{
ArgumentNullException.ThrowIfNull(stream);
long remaining = stream.Length - stream.Position;
if (remaining < RecordSize || remaining % RecordSize != 0)
throw new InvalidDataException($"A RIF file must be a positive multiple of {RecordSize} bytes.");
int count = (int)(remaining / RecordSize);
var list = new List<ProsperoRif>(count);
for (int i = 0; i < count; i++)
list.Add(Read(stream));
return list;
}
/// <summary>
/// Builds a structural RIF for <paramref name="contentId"/>. The 448-byte
/// <paramref name="keyBlob"/> (if supplied) is copied verbatim; when omitted the blob is
/// left zero. A record built without a supplied blob is only valid for the fake/debug path or as a template
/// whose blob comes from an existing license — a retail entitlement blob cannot be forged.
/// </summary>
/// <exception cref="ArgumentException"><paramref name="contentId"/> is empty/too long or the blob is too large.</exception>
public static ProsperoRif Create(string contentId, byte[]? keyBlob = null, long expiry = NeverExpires)
{
ArgumentException.ThrowIfNullOrEmpty(contentId);
if (Encoding.ASCII.GetByteCount(contentId) > ContentIdSize)
throw new ArgumentException($"A content-id is at most {ContentIdSize} bytes.", nameof(contentId));
if (keyBlob is not null && keyBlob.Length > KeyBlobSize)
throw new ArgumentException($"A RIF key blob is at most {KeyBlobSize} bytes.", nameof(keyBlob));
byte[] blob = new byte[KeyBlobSize];
keyBlob?.CopyTo(blob, 0);
return new ProsperoRif
{
Version = CurrentVersion,
Flags = 0xFFFF,
Expiry = expiry,
ContentId = contentId,
FormatDescriptor = DefaultFormatDescriptor.ToArray(),
EntryCount = 1,
KeyBlob = blob,
};
}
/// <summary>Serialises this record to a new <see cref="RecordSize"/>-byte array.</summary>
public byte[] ToBytes()
{
byte[] record = new byte[RecordSize];
var span = record.AsSpan();
Magic.CopyTo(span);
BinaryPrimitives.WriteUInt16BigEndian(span[VersionField..], Version);
BinaryPrimitives.WriteUInt16BigEndian(span[FlagsField..], Flags);
FormatTag.CopyTo(span[FormatTagField..]);
BinaryPrimitives.WriteInt64BigEndian(span[ExpiryField..], Expiry);
int idBytes = Math.Min(Encoding.ASCII.GetByteCount(ContentId), ContentIdSize);
Encoding.ASCII.GetBytes(ContentId).AsSpan(0, idBytes).CopyTo(span[ContentIdField..]);
FormatDescriptor.AsSpan(0, Math.Min(FormatDescriptor.Length, FormatDescriptorSize))
.CopyTo(span[FormatDescriptorField..]);
BinaryPrimitives.WriteUInt64BigEndian(span[EntryCountField..], EntryCount);
KeyBlob.AsSpan(0, Math.Min(KeyBlob.Length, KeyBlobSize)).CopyTo(span[KeyBlobOffset..]);
return record;
}
/// <summary>Writes this record to <paramref name="stream"/> at its current position.</summary>
public void Write(Stream stream)
{
ArgumentNullException.ThrowIfNull(stream);
stream.Write(ToBytes());
}
/// <summary>Writes one or more records to <paramref name="stream"/> as a multi-title RIF.</summary>
public static void WriteAll(Stream stream, IEnumerable<ProsperoRif> records)
{
ArgumentNullException.ThrowIfNull(stream);
ArgumentNullException.ThrowIfNull(records);
foreach (ProsperoRif rif in records)
rif.Write(stream);
}
/// <summary>Writes one or more records to <paramref name="path"/> as a multi-title RIF.</summary>
public static void WriteAll(string path, IEnumerable<ProsperoRif> records)
{
using var fs = new FileStream(path, FileMode.Create, FileAccess.Write, FileShare.None);
WriteAll(fs, records);
}
/// <summary>
/// Validates the structural fields against the known-good layout. Returns <see langword="true"/>
/// when consistent; otherwise <paramref name="error"/> describes the first problem found.
/// </summary>
public bool Validate(out string? error)
{
if (Version != CurrentVersion)
{
error = $"Unexpected RIF version 0x{Version:X4} (expected 0x{CurrentVersion:X4}).";
return false;
}
if (string.IsNullOrEmpty(ContentId) || Encoding.ASCII.GetByteCount(ContentId) > ContentIdSize)
{
error = "Content-id is empty or longer than 36 bytes.";
return false;
}
if (KeyBlob.Length != KeyBlobSize)
{
error = $"Key blob must be exactly {KeyBlobSize} bytes.";
return false;
}
error = null;
return true;
}
private static bool IsAllZero(ReadOnlySpan<byte> data)
{
foreach (byte b in data)
{
if (b != 0) return false;
}
return true;
}
private static string ReadNulTrimmedAscii(ReadOnlySpan<byte> span)
{
int len = span.IndexOf((byte)0);
if (len < 0) len = span.Length;
return Encoding.ASCII.GetString(span[..len]);
}
private static void ReadExactly(Stream stream, byte[] buffer)
{
int total = 0;
while (total < buffer.Length)
{
int read = stream.Read(buffer, total, buffer.Length - total);
if (read == 0)
throw new EndOfStreamException("Unexpected end of stream while reading a RIF record.");
total += read;
}
}
}
@@ -1,190 +0,0 @@
// LibProsperoPkg - A library for building and inspecting PS5 packages.
// Copyright (C) 2026 SvenGDK
//
// Model for a multi-content license file: one or more 0x400-byte RIF records concatenated in a
// single "license/rif". A single-title disc carries one record; a multi-application disc carries
// one record per sub-title (e.g. a three-title compilation is a 0xC00-byte file of three records).
//
// The set mirrors the fields the verify path reports over a license file: the record count
// ("Number of Contents in RIF file" / n_rif), the per-record service label (ServiceID), the
// expected vs actual file size (rif_size exp/act), whether an application record is present
// (has_app) and how many of the remaining records are additional content (n_ac).
#nullable enable
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
namespace LibProsperoPkg.License;
/// <summary>
/// A summary of a <see cref="ProsperoRifSet"/> relative to a known application title, mirroring the
/// verify-path report: the record count, the resolved application record, the additional-content
/// count and the expected/actual file size.
/// </summary>
public sealed class ProsperoRifSetSummary
{
/// <summary>The number of records in the file (<c>n_rif</c>).</summary>
public required int RecordCount { get; init; }
/// <summary>True when a record for the supplied application title-id was found (<c>has_app</c>).</summary>
public required bool HasApp { get; init; }
/// <summary>The content-id of the application record, or <see langword="null"/> when absent.</summary>
public string? AppContentId { get; init; }
/// <summary>The service label (<c>ServiceID</c>) of the application record, or <see langword="null"/>.</summary>
public string? ServiceId { get; init; }
/// <summary>The count of records that are not the application record (<c>n_ac</c>).</summary>
public required int AdditionalContentCount { get; init; }
/// <summary>The expected file size for <see cref="RecordCount"/> records (<c>rif_size</c> exp).</summary>
public required long ExpectedSize { get; init; }
/// <summary>The actual file size measured (<c>rif_size</c> act), or the expected size when built in-memory.</summary>
public required long ActualSize { get; init; }
/// <summary>True when <see cref="ExpectedSize"/> equals <see cref="ActualSize"/>.</summary>
public bool SizeMatches => ExpectedSize == ActualSize;
/// <summary>Renders the one-line verify-style summary.</summary>
public override string ToString() =>
$"ServiceID={ServiceId ?? "-"}, rif_size(exp/act)={ExpectedSize:D8}/{ActualSize:D8}, " +
$"has_app={(HasApp ? "true" : "false")}, n_ac={AdditionalContentCount}, n_rif={RecordCount}";
}
/// <summary>
/// A parsed multi-content license file: an ordered collection of <see cref="ProsperoRif"/> records.
/// Provides the record count, per-record identifiers and the structural checks the verify path runs,
/// including the whole-file-size rule (a positive multiple of <see cref="ProsperoRif.RecordSize"/>).
/// </summary>
public sealed class ProsperoRifSet
{
/// <summary>The records in file order. Always at least one when parsed from a valid file.</summary>
public required IReadOnlyList<ProsperoRif> Records { get; init; }
/// <summary>The measured file size in bytes, or the computed size for an in-memory set.</summary>
public required long FileSize { get; init; }
/// <summary>The number of records (<c>n_rif</c>, "Number of Contents in RIF file").</summary>
public int Count => Records.Count;
/// <summary>The expected file size: <see cref="Count"/> × <see cref="ProsperoRif.RecordSize"/>.</summary>
public long ExpectedSize => (long)Count * ProsperoRif.RecordSize;
/// <summary>The distinct content-ids across the records, in first-seen order.</summary>
public IReadOnlyList<string> ContentIds =>
Records.Select(r => r.ContentId).Distinct(StringComparer.Ordinal).ToArray();
/// <summary>The distinct title-ids across the records (nulls dropped), in first-seen order.</summary>
public IReadOnlyList<string> TitleIds =>
Records.Select(r => r.TitleId).Where(t => t is not null).Select(t => t!).Distinct(StringComparer.Ordinal).ToArray();
/// <summary>The distinct service labels (<c>ServiceID</c>s) across the records, in first-seen order.</summary>
public IReadOnlyList<string> ServiceLabels =>
Records.Select(r => r.ServiceLabel).Where(s => s is not null).Select(s => s!).Distinct(StringComparer.Ordinal).ToArray();
/// <summary>Reads every record from a (single- or multi-content) license file on disk.</summary>
public static ProsperoRifSet ReadFile(string path)
{
ArgumentException.ThrowIfNullOrEmpty(path);
long size = new FileInfo(path).Length;
IReadOnlyList<ProsperoRif> records = ProsperoRif.ReadAll(path);
return new ProsperoRifSet { Records = records, FileSize = size };
}
/// <summary>Reads every record from a (single- or multi-content) license stream.</summary>
public static ProsperoRifSet Read(Stream stream)
{
ArgumentNullException.ThrowIfNull(stream);
// ReadAll parses from the current position to the end, so the recorded file size must measure
// that same region rather than the whole stream (which may start past position 0).
long size = stream.Length - stream.Position;
IReadOnlyList<ProsperoRif> records = ProsperoRif.ReadAll(stream);
return new ProsperoRifSet { Records = records, FileSize = size };
}
/// <summary>Builds a set from already-parsed records (for building or re-checking in memory).</summary>
/// <exception cref="ArgumentException"><paramref name="records"/> is empty.</exception>
public static ProsperoRifSet FromRecords(IEnumerable<ProsperoRif> records)
{
ArgumentNullException.ThrowIfNull(records);
var list = records.ToArray();
if (list.Length == 0)
throw new ArgumentException("A license set needs at least one record.", nameof(records));
return new ProsperoRifSet { Records = list, FileSize = (long)list.Length * ProsperoRif.RecordSize };
}
/// <summary>
/// Validates the set: a non-zero record count, the whole-file-size rule, and each record's own
/// structural checks. Returns <see langword="true"/> when consistent; otherwise
/// <paramref name="error"/> describes the first problem.
/// </summary>
public bool Validate(out string? error)
{
if (Count == 0)
{
error = "A license file must contain at least one record.";
return false;
}
if (FileSize != ExpectedSize)
{
error = $"Unexpected rif file size {FileSize} (expected {ExpectedSize} for {Count} record(s)).";
return false;
}
for (int i = 0; i < Records.Count; i++)
{
if (!Records[i].Validate(out string? recordError))
{
error = $"Record {i}: {recordError}";
return false;
}
}
error = null;
return true;
}
/// <summary>
/// Summarises the set relative to a known application title-id (typically taken from the disc
/// backup's <c>app.json</c> / <c>bd/param.json</c>). The record whose title-id matches is treated
/// as the application; the rest are counted as additional content (<c>n_ac</c>).
/// </summary>
/// <param name="appTitleId">The application title-id (e.g. <c>PPSA00000</c>), or <see langword="null"/>.</param>
public ProsperoRifSetSummary Summarize(string? appTitleId = null)
{
ProsperoRif? app = appTitleId is null
? null
: Records.FirstOrDefault(r => string.Equals(r.TitleId, appTitleId, StringComparison.OrdinalIgnoreCase));
bool hasApp = app is not null;
return new ProsperoRifSetSummary
{
RecordCount = Count,
HasApp = hasApp,
AppContentId = app?.ContentId,
ServiceId = app?.ServiceLabel ?? (Records.Count > 0 ? Records[0].ServiceLabel : null),
AdditionalContentCount = hasApp ? Count - 1 : Count,
ExpectedSize = ExpectedSize,
ActualSize = FileSize,
};
}
/// <summary>Renders a multi-line description of every record for diagnostics.</summary>
public string Describe()
{
var sb = new StringBuilder();
sb.Append("License set: ").Append(Count).Append(" record(s), ").Append(FileSize).AppendLine(" bytes");
for (int i = 0; i < Records.Count; i++)
{
ProsperoRif r = Records[i];
sb.Append(" [").Append(i).Append("] ContentId=").Append(r.ContentId)
.Append(", ServiceID=").Append(r.ServiceLabel ?? "-")
.Append(", TitleId=").Append(r.TitleId ?? "-")
.Append(", keyBlob=").AppendLine(r.HasKeyBlob ? "present" : "empty");
}
return sb.ToString();
}
}
@@ -1,337 +0,0 @@
// LibProsperoPkg - A library for building and inspecting PS5 packages.
// Copyright (C) 2026 SvenGDK
//
// Reader/writer model for the manifest.json document that ships alongside framework-based system
// applications. The document is kept as a live JsonObject so every property round-trips exactly,
// with typed accessors for the recognised keys and the nested applicationData block.
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using System.Text.Encodings.Web;
using System.Text.Json;
using System.Text.Json.Nodes;
namespace LibProsperoPkg.Metadata;
/// <summary>The recognised <c>manifest.json</c> property names.</summary>
public static class ProsperoManifestKeys
{
/// <summary><c>applicationName</c> string.</summary>
public const string ApplicationName = "applicationName";
/// <summary><c>applicationVersion</c> string.</summary>
public const string ApplicationVersion = "applicationVersion";
/// <summary><c>commitHash</c> string.</summary>
public const string CommitHash = "commitHash";
/// <summary><c>bootAnimation</c> string.</summary>
public const string BootAnimation = "bootAnimation";
/// <summary><c>titleId</c> string.</summary>
public const string TitleId = "titleId";
/// <summary><c>repositoryUrl</c> string.</summary>
public const string RepositoryUrl = "repositoryUrl";
/// <summary><c>reactNativePlaystationVersion</c> string.</summary>
public const string ReactNativePlaystationVersion = "reactNativePlaystationVersion";
/// <summary><c>applicationData</c> object.</summary>
public const string ApplicationData = "applicationData";
/// <summary><c>branchType</c> key inside <c>applicationData</c>.</summary>
public const string BranchType = "branchType";
/// <summary><c>twinTurbo</c> flag.</summary>
public const string TwinTurbo = "twinTurbo";
/// <summary><c>enableAccessibility</c> string array.</summary>
public const string EnableAccessibility = "enableAccessibility";
/// <summary><c>enableHttpCache</c> flag.</summary>
public const string EnableHttpCache = "enableHttpCache";
}
/// <summary>
/// The <c>manifest.json</c> document. Backed by a mutable <see cref="JsonObject"/>
/// (<see cref="Root"/>) so arbitrary keys are preserved on load/save, with typed accessors for the
/// recognised keys and the nested <c>applicationData</c> block. Property order is preserved on
/// round-trip; created documents use the canonical field order.
/// </summary>
public sealed class ProsperoManifest
{
/// <summary>The field order used by <see cref="Create"/> and the canonical writer.</summary>
private static readonly string[] CanonicalOrder =
[
ProsperoManifestKeys.ApplicationName,
ProsperoManifestKeys.ApplicationVersion,
ProsperoManifestKeys.CommitHash,
ProsperoManifestKeys.BootAnimation,
ProsperoManifestKeys.TitleId,
ProsperoManifestKeys.RepositoryUrl,
ProsperoManifestKeys.ReactNativePlaystationVersion,
ProsperoManifestKeys.ApplicationData,
ProsperoManifestKeys.TwinTurbo,
// Optional keys have no established corpus position; they follow the fixed core order.
ProsperoManifestKeys.EnableAccessibility,
ProsperoManifestKeys.EnableHttpCache,
];
/// <summary>Creates an empty document.</summary>
public ProsperoManifest() => Root = new JsonObject();
/// <summary>Wraps an existing JSON object as a document. The object is used directly, not copied.</summary>
public ProsperoManifest(JsonObject root) => Root = root ?? throw new ArgumentNullException(nameof(root));
/// <summary>The live document object. Mutations are reflected by <see cref="ToJson"/> / <see cref="Save"/>.</summary>
public JsonObject Root { get; }
/// <summary>Gets or sets a raw node by key. Setting <see langword="null"/> removes the key.</summary>
public JsonNode? this[string key]
{
get
{
ArgumentException.ThrowIfNullOrEmpty(key);
return Root.TryGetPropertyValue(key, out JsonNode? node) ? node : null;
}
set
{
ArgumentException.ThrowIfNullOrEmpty(key);
if (value is null) Root.Remove(key);
else Root[key] = value;
}
}
/// <summary>Returns true when the document has a property named <paramref name="key"/>.</summary>
public bool ContainsKey(string key) => Root.ContainsKey(key);
/// <summary>Removes the property named <paramref name="key"/>; returns true when it existed.</summary>
public bool Remove(string key) => Root.Remove(key);
/// <summary><c>applicationName</c> string.</summary>
public string? ApplicationName
{
get => GetString(ProsperoManifestKeys.ApplicationName);
set => SetString(ProsperoManifestKeys.ApplicationName, value);
}
/// <summary><c>applicationVersion</c> string.</summary>
public string? ApplicationVersion
{
get => GetString(ProsperoManifestKeys.ApplicationVersion);
set => SetString(ProsperoManifestKeys.ApplicationVersion, value);
}
/// <summary><c>commitHash</c> string.</summary>
public string? CommitHash
{
get => GetString(ProsperoManifestKeys.CommitHash);
set => SetString(ProsperoManifestKeys.CommitHash, value);
}
/// <summary><c>bootAnimation</c> string.</summary>
public string? BootAnimation
{
get => GetString(ProsperoManifestKeys.BootAnimation);
set => SetString(ProsperoManifestKeys.BootAnimation, value);
}
/// <summary><c>titleId</c> string.</summary>
public string? TitleId
{
get => GetString(ProsperoManifestKeys.TitleId);
set => SetString(ProsperoManifestKeys.TitleId, value);
}
/// <summary><c>repositoryUrl</c> string.</summary>
public string? RepositoryUrl
{
get => GetString(ProsperoManifestKeys.RepositoryUrl);
set => SetString(ProsperoManifestKeys.RepositoryUrl, value);
}
/// <summary><c>reactNativePlaystationVersion</c> string.</summary>
public string? ReactNativePlaystationVersion
{
get => GetString(ProsperoManifestKeys.ReactNativePlaystationVersion);
set => SetString(ProsperoManifestKeys.ReactNativePlaystationVersion, value);
}
/// <summary><c>twinTurbo</c> flag.</summary>
public bool? TwinTurbo
{
get => AsBool(Root[ProsperoManifestKeys.TwinTurbo]);
set
{
if (value is null) Root.Remove(ProsperoManifestKeys.TwinTurbo);
else Root[ProsperoManifestKeys.TwinTurbo] = value.Value;
}
}
/// <summary><c>enableHttpCache</c> flag.</summary>
public bool? EnableHttpCache
{
get => AsBool(Root[ProsperoManifestKeys.EnableHttpCache]);
set
{
if (value is null) Root.Remove(ProsperoManifestKeys.EnableHttpCache);
else Root[ProsperoManifestKeys.EnableHttpCache] = value.Value;
}
}
/// <summary>The <c>enableAccessibility</c> entries, in order.</summary>
public IReadOnlyList<string> EnableAccessibility
{
get => Root[ProsperoManifestKeys.EnableAccessibility] is JsonArray arr
? arr.Select(n => AsString(n) ?? "").ToArray()
: Array.Empty<string>();
}
/// <summary>Replaces the <c>enableAccessibility</c> array with the given entries.</summary>
public void SetEnableAccessibility(IEnumerable<string> values)
{
ArgumentNullException.ThrowIfNull(values);
var arr = new JsonArray();
foreach (string value in values)
arr.Add(value);
Root[ProsperoManifestKeys.EnableAccessibility] = arr;
}
/// <summary>The <c>applicationData.branchType</c> value.</summary>
public string? BranchType
{
get => (Root[ProsperoManifestKeys.ApplicationData] as JsonObject) is { } data
? AsString(data[ProsperoManifestKeys.BranchType])
: null;
set
{
if (value is null)
{
(Root[ProsperoManifestKeys.ApplicationData] as JsonObject)?.Remove(ProsperoManifestKeys.BranchType);
return;
}
GetOrCreateObject(ProsperoManifestKeys.ApplicationData)[ProsperoManifestKeys.BranchType] = value;
}
}
/// <summary>
/// Serializes the document. When <paramref name="canonical"/> is <see langword="true"/> the
/// recognised keys are written in the canonical field order (unrecognised keys follow, in their
/// current order); non-ASCII characters are written literally.
/// </summary>
public string ToJson(bool canonical = false, bool indented = true, int indentSize = 4)
{
ArgumentOutOfRangeException.ThrowIfNegative(indentSize);
JsonNode node = canonical ? OrderedClone(Root) : Root;
var options = new JsonSerializerOptions
{
WriteIndented = indented,
IndentSize = indentSize,
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
};
return node.ToJsonString(options);
}
/// <summary>Writes the document to <paramref name="path"/> as UTF-8 without a byte-order mark.</summary>
public void Save(string path, bool canonical = false, bool indented = true, int indentSize = 4)
{
ArgumentException.ThrowIfNullOrEmpty(path);
File.WriteAllText(path, ToJson(canonical, indented, indentSize), new UTF8Encoding(false));
}
/// <summary>Returns a deep, independent copy of the document.</summary>
public ProsperoManifest Clone() => new((JsonObject)Root.DeepClone());
/// <summary>Parses a document from JSON text.</summary>
/// <exception cref="InvalidDataException">The root is not a JSON object.</exception>
public static ProsperoManifest Parse(string json)
{
ArgumentException.ThrowIfNullOrEmpty(json);
JsonNode? node = JsonNode.Parse(json);
return node is JsonObject obj
? new ProsperoManifest(obj)
: throw new InvalidDataException("manifest.json root is not a JSON object.");
}
/// <summary>Reads and parses a <c>manifest.json</c> file.</summary>
public static ProsperoManifest Load(string path)
{
ArgumentException.ThrowIfNullOrEmpty(path);
return Parse(File.ReadAllText(path));
}
/// <summary>
/// Builds a document with the required fields in canonical order: the application name and
/// version, the title id, the framework version, an <c>applicationData.branchType</c> and the
/// <c>twinTurbo</c> flag.
/// </summary>
public static ProsperoManifest Create(
string applicationName,
string applicationVersion,
string titleId,
string reactNativePlaystationVersion,
string branchType = "release",
bool twinTurbo = true)
{
ArgumentException.ThrowIfNullOrEmpty(applicationName);
ArgumentException.ThrowIfNullOrEmpty(applicationVersion);
ArgumentException.ThrowIfNullOrEmpty(titleId);
ArgumentException.ThrowIfNullOrEmpty(reactNativePlaystationVersion);
ArgumentException.ThrowIfNullOrEmpty(branchType);
return new ProsperoManifest
{
ApplicationName = applicationName,
ApplicationVersion = applicationVersion,
TitleId = titleId,
ReactNativePlaystationVersion = reactNativePlaystationVersion,
BranchType = branchType,
TwinTurbo = twinTurbo,
};
}
// ---- Internals ----
private JsonObject GetOrCreateObject(string key)
{
if (Root[key] is JsonObject existing)
return existing;
var created = new JsonObject();
Root[key] = created;
return created;
}
private string? GetString(string key) => AsString(Root[key]);
private void SetString(string key, string? value)
{
if (value is null) Root.Remove(key);
else Root[key] = value;
}
private static string? AsString(JsonNode? node) =>
node is JsonValue value && value.TryGetValue(out string? s) ? s : null;
private static bool? AsBool(JsonNode? node) =>
node is JsonValue value && value.TryGetValue(out bool b) ? b : null;
private JsonObject OrderedClone(JsonObject source)
{
var ordered = new JsonObject();
foreach (string key in CanonicalOrder)
{
if (source.TryGetPropertyValue(key, out JsonNode? value))
ordered[key] = value?.DeepClone();
}
foreach (KeyValuePair<string, JsonNode?> kvp in source)
{
if (!ordered.ContainsKey(kvp.Key))
ordered[kvp.Key] = kvp.Value?.DeepClone();
}
return ordered;
}
}
Loaded 100 of 186 files, more files were not shown because too many files have changed in this diff. Show more