mirror of
https://github.com/manos555555/PS5-Suite.git
synced 2026-10-08 08:00:31 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
79def2a46f | ||
|
|
e5d3728d6c | ||
|
|
6cf0fdd114 |
No files matched your search
@@ -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/
|
||||
@@ -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.** 🍻
|
||||
|
||||
[](https://buymeacoffee.com/manos555555)
|
||||
[](https://x.com/manos_moyrtzis)
|
||||
|
||||
</div>
|
||||
|
||||

|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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 & 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;
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 { }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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 { }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
-2011
File diff suppressed because it is too large.
Load diff
-68
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
@@ -1,5 +0,0 @@
|
||||
bin/
|
||||
obj/
|
||||
.vs/
|
||||
*.user
|
||||
*.suo
|
||||
@@ -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>.
|
||||
@@ -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.
|
||||
@@ -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).
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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 |
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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><file></c> entry
|
||||
/// (inside <c><files></c>), with no <c><rootdir></c> / <c><global_exclude></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><rootdir></c> with a <c>src_path</c> (and optional
|
||||
/// exclude masks) that the tool walks recursively, preceded by <c><global_exclude></c>.</item>
|
||||
/// <item><see cref="Flat"/> — an explicit top-level <c><files></c> (and optional
|
||||
/// <c><folders></c>) listing that maps each source path to a package destination path, with no
|
||||
/// <c><rootdir></c> / <c><global_exclude></c>.</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
public enum Gp5Layout
|
||||
{
|
||||
/// <summary>A single recursively-walked <c><rootdir src_path></c> (with <c><global_exclude></c>).</summary>
|
||||
Normal,
|
||||
|
||||
/// <summary>An explicit top-level <c><files></c> / <c><folders></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><files></c> listing (flat layout). When this is non-empty the
|
||||
/// project is written in the <see cref="Gp5Layout.Flat"/> style and the <c><rootdir></c> /
|
||||
/// <c><global_exclude></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><folders></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><volume></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><package></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><scenarios></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><rootdir></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; } = "";
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
@@ -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
Reference in new issue
Block a user