Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5b65f0dd32 | ||
|
|
a6199c8d51 |
No files matched your search
@@ -0,0 +1,46 @@
|
||||
# Build outputs
|
||||
bin/
|
||||
obj/
|
||||
dist/
|
||||
publish/
|
||||
publish_*/
|
||||
out/
|
||||
*.user
|
||||
*.suo
|
||||
msbuild.binlog
|
||||
|
||||
# Release binaries (uploaded as GitHub Release assets, not tracked in git)
|
||||
release/
|
||||
*.apk
|
||||
*.aab
|
||||
PS5UploadSuite-*.exe
|
||||
PS5Upload_Standalone_*/
|
||||
|
||||
# Runtime settings/logs (machine-local)
|
||||
ps5suite_settings.json
|
||||
ps5upload_settings.json
|
||||
ps5_favorites.json
|
||||
ps5_profiles.json
|
||||
ps5suite_*.log
|
||||
*.tmp.png
|
||||
|
||||
# AI/tooling junk
|
||||
.aider*
|
||||
.freebuff/
|
||||
.aider.tags.cache.v4/
|
||||
|
||||
# OS files
|
||||
Thumbs.db
|
||||
desktop.ini
|
||||
.DS_Store
|
||||
|
||||
# internal test/debug artifacts
|
||||
publish-*/
|
||||
payload/appdb_*.db
|
||||
payload/port9113_killer.*
|
||||
lib-prospero-pkg/test-stream/realbuild-out/
|
||||
|
||||
# legacy clients (WPF v5/v6, old MAUI app, empty stub template)
|
||||
client/
|
||||
mobile/
|
||||
client-android/
|
||||
@@ -1,403 +1,217 @@
|
||||
# 🚀 PS5 Upload Suite - High-Speed File Transfer
|
||||
# 🎮 PS5 Suite v7.1.5 — Complete PS5 Management Platform
|
||||
|
||||
**By Manos**
|
||||
|
||||
**Version 4.2.1 - Bugfix Release**
|
||||
All-in-one management suite for jailbroken PS5 consoles. One app — on Windows, Linux or macOS (Android build in progress) — 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, memory tools, fan control, kernel log and a remote shell.
|
||||
|
||||
Custom high-speed file transfer system for PS5 with etaHEN. Achieves **104+ MB/s** upload speeds for large files using parallel chunked uploads!
|
||||

|
||||
|
||||
⭐ **NEW in v4.2.1 (Bugfix):**
|
||||
- 🖥️ **Fixed UI scrolling** - Connection panel no longer eats half the screen
|
||||
- 🔢 **Fixed file counting** - Chunked files now count correctly as completed
|
||||
- 📊 **Fixed frozen progress** - Progress updates no longer freeze during heavy uploads
|
||||
## 📸 Screenshots
|
||||
|
||||
⭐ **v4.2.0 Features:**
|
||||
- 🎮 **Mount Games Button** - Mount uploaded games directly from the client
|
||||
- 🔍 **Multi-Path Scanning** - Scans internal storage, USB drives (0-3), and M.2 SSD
|
||||
- 🔄 **Duplicate Detection** - Skips already-mounted games automatically
|
||||
- 📊 **Mount Summary** - Detailed results with PS5 notifications
|
||||
- 🧹 **Auto-Cleanup** - Removes mount entries for deleted games
|
||||
|
||||
📱 **Android Mobile Client available!**
|
||||
|
||||

|
||||

|
||||
| | |
|
||||
|---|---|
|
||||
|  |  |
|
||||
| **File Transfer** — dual PC/PS5 browser, storage bar | **Games** — list, mount/launch context menu |
|
||||
|  |  |
|
||||
| **Saves & Media** — decrypted mount/backup/restore | **System Info** — live sensors, per-core CPU, RAM |
|
||||
|  |  |
|
||||
| **Shell** — remote terminal on the console | **Debug Log** — every operation, timestamped |
|
||||
|
||||
---
|
||||
|
||||
## 📦 Downloads
|
||||
## 📥 What's in this release
|
||||
|
||||
Download the latest release from the [Releases](https://github.com/manos555555/PS5-Upload-Suite/releases) page:
|
||||
|
||||
### 1. PS5 Server Payload
|
||||
- **File:** `ps5_upload_server.elf`
|
||||
- **Port:** 9113
|
||||
- High-speed custom binary protocol
|
||||
- 16MB socket buffers for maximum throughput
|
||||
- Multi-threaded client handling
|
||||
|
||||
### 2. Windows GUI Client
|
||||
- **File:** `PS5Upload.exe`
|
||||
- Self-contained executable (no .NET installation required)
|
||||
- Modern dark theme UI
|
||||
- Drag & drop file/folder upload
|
||||
- Real-time upload progress with sliding window speed tracking
|
||||
- Transfer History with success/failed tracking and retry
|
||||
- PS5 storage display (free/used/total)
|
||||
- Game mounting with one-click Mount Games button
|
||||
|
||||
### 3. 📱 Android Mobile Client
|
||||
- **File:** `PS5UploadMobile.apk`
|
||||
- Upload/download files from your phone
|
||||
- Multi-PS5 Profiles support
|
||||
- Browse PS5 filesystem
|
||||
- Favorites for quick navigation
|
||||
| Asset | Platform |
|
||||
|---|---|
|
||||
| `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) |
|
||||
| `ps5_suite_server.elf` | **Required** on-console server component |
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Quick Start
|
||||
## 🚀 Setup (2 minutes)
|
||||
|
||||
### Step 1: Load PS5 Payload
|
||||
1. Load a jailbreak / payload loader on the PS5 — tested with **etaHEN** and **Kstuff** (any payload loader that accepts ELFs works).
|
||||
2. Open the app → expand **⚙️ Payload Settings** → **📂 Browse** → pick `payload/ps5_suite_server.elf` → **📤 Send Payload** (default port 9020).
|
||||
3. The payload installs itself and starts the suite server (ports 9113–9116).
|
||||
4. Press **🔍** to auto-discover the console on your LAN, or type its IP → **🔌 Connect**.
|
||||
5. Done. Future payload updates go through **🔄 Self-Update Payload** (pushes the new ELF through the running server) or **⬆️ Update from GitHub**.
|
||||
|
||||
1. Download `ps5_upload_server.elf` from the [Releases](https://github.com/manos555555/PS5-Upload-Suite/releases) page
|
||||
|
||||
2. Copy it to your PS5: `/data/etaHEN/payloads/ps5_upload_server.elf`
|
||||
|
||||
3. Load the payload with elfldr
|
||||
|
||||
4. You should see notification: `PS5 Upload Server: 192.168.0.XXX:9113 - By Manos`
|
||||
|
||||
### Step 2: Run Windows Client
|
||||
|
||||
1. Run `PS5Upload.exe` on your PC
|
||||
|
||||
2. Enter your PS5's IP address (e.g., `192.168.0.160`)
|
||||
|
||||
3. Click **Connect**
|
||||
|
||||
4. Browse files/folders or drag & drop
|
||||
|
||||
5. Click **Upload to PS5**
|
||||
💾 Save your console(s) as named **connection profiles** for one-click reconnect.
|
||||
|
||||
---
|
||||
|
||||
## 🎨 Features
|
||||
## 📂 EVERYTHING the program does — feature by feature
|
||||
|
||||
### PS5 Server Payload
|
||||
✅ **High-Speed Protocol** - Custom binary protocol for maximum speed
|
||||
✅ **8MB Buffer** - Large buffer for high throughput
|
||||
✅ **16MB Socket Buffers** - Maximum network throughput
|
||||
✅ **Direct Disk I/O** - No temp files, direct write syscalls
|
||||
✅ **Multi-threaded** - Handle multiple clients simultaneously
|
||||
✅ **Per-File Mutex** - Safe parallel uploads without corruption
|
||||
✅ **5-Minute Socket Timeout** - No connection drops on large files
|
||||
✅ **Game Mounting** - nullfs mount, DRM patching, metadata copy
|
||||
✅ **Shell Commands** - Built-in shell with ls, cd, cat, mkdir, rm, etc.
|
||||
✅ **Filesystem Indexing** - In-memory index for instant search
|
||||
### 🔌 Top bar / Connection
|
||||
|
||||
### Windows Client
|
||||
✅ **Modern UI** - Dark theme, maximized window
|
||||
✅ **Drag & Drop** - Files and folders
|
||||
✅ **Browse PS5** - Navigate filesystem with favorites
|
||||
✅ **Real-time Speed** - Sliding window algorithm for accurate speed display
|
||||
✅ **Parallel Uploads** - 4 parallel chunked connections for 104+ MB/s
|
||||
✅ **Transfer History** - Track all uploads/downloads with success/failed status
|
||||
✅ **Retry Failed Transfers** - Right-click to retry failed uploads
|
||||
✅ **Duplicate File Dialog** - Handle file conflicts during upload
|
||||
✅ **Folder Upload** - Recursive directory upload
|
||||
✅ **Download Files** - Download from PS5 to PC
|
||||
✅ **💾 Storage Display** - Real-time PS5 storage info (free/used/total)
|
||||
✅ **🔍 Smart Search** - Full filesystem indexing with instant search
|
||||
- Wildcard support: `*.pkg`, `*loader*`, `game*.bin`
|
||||
- Size filters: `size:>1GB`, `size:<100MB`
|
||||
- Case-insensitive matching
|
||||
- Search both filename and full path
|
||||
- Double-click to navigate to folder
|
||||
✅ **💻 Shell Terminal** - Execute commands directly on PS5
|
||||
- Run system commands remotely
|
||||
- Real-time output display
|
||||
- Command history
|
||||
- Working directory support
|
||||
✅ **⭐ Favorites/Bookmarks** - Quick navigation to saved paths
|
||||
✅ **👥 Multi-PS5 Profiles** - Save and switch between multiple PS5 consoles
|
||||
✅ **🎮 Mount Games** - Mount uploaded games directly from the client
|
||||
- Scans all game paths: internal, USB drives (0-3), M.2 SSD
|
||||
- Duplicate detection across storage locations
|
||||
- Auto-cleanup of deleted game mounts
|
||||
- PS5 notifications with mount progress
|
||||
- Automatic game registration via game_mounter.elf
|
||||
- **Auto-discover (🔍)** — broadcasts on the LAN and finds the PS5 automatically.
|
||||
- **Profiles** — save multiple consoles by name, delete them, reconnect instantly.
|
||||
- **Storage Info panel** — free/total space on the console's drive, **🔄 Refresh Storage** button.
|
||||
- **Payload Settings** — pick the ELF, set the injection port (default 9020), **Send Payload**, **Self-Update Payload** (updates the running server in place), **Update from GitHub** (checks for new suite releases).
|
||||
- **Buy Me a Coffee** link in the header. ❤️
|
||||
|
||||
---
|
||||
|
||||
## 📊 Performance
|
||||
### 📁 FILE TRANSFER (main screen — dual browser)
|
||||
|
||||
### v3.1.0 Optimized Results:
|
||||
- ✅ **104+ MB/s** upload speed for large files (parallel chunked uploads)
|
||||
- ✅ **4 parallel chunk connections** for maximum throughput
|
||||
- ✅ **500MB chunks** - optimal size for PS5 disk performance
|
||||
- ✅ **Real-time progress** - Speed/ETA updates during upload
|
||||
- ✅ **Zero chunk failures** - Fixed race conditions
|
||||
- ✅ **Fully responsive UI** throughout upload
|
||||
- ✅ **No memory leaks** - stable operation
|
||||
Two side-by-side panes: your PC on the left, the PS5 on the right.
|
||||
|
||||
### v4.1.0 Real-World Test (Astrobot):
|
||||
- ✅ **156,253 files** uploaded successfully in **53 minutes**
|
||||
- ✅ **1,724 directories** created
|
||||
- ✅ **Zero crashes** - stable memory usage throughout
|
||||
- ✅ **Smooth UI** - no stuttering with massive file counts
|
||||
**Local PC pane**
|
||||
- Browse all local drives/folders like a file manager.
|
||||
- **📄 Files** — multi-select files to queue. **📁 Folder** — queue an entire folder (recursive).
|
||||
- **🌐 NAS** — add a network/SMB path (`\\server\share\folder`) directly — uploads straight from your NAS to the PS5.
|
||||
- Right-click a local `.pkg` → **📦 Install on PS5** — *streams the package from the PC to the console's installer over HTTP; no 20 GB copy to the PS5 drive needed.*
|
||||
- **🗑️ Clear** empties the upload queue.
|
||||
|
||||
### Previous Results (v1.3.0):
|
||||
- ✅ **42,801 files** uploaded successfully
|
||||
- ✅ **60-150 MB/s** sustained throughput
|
||||
|
||||
| Network | Expected Upload Speed | Expected Download Speed |
|
||||
|---------|----------------------|------------------------|
|
||||
| **Gigabit Ethernet** | 88-110 MB/s (aggregate) | 100-120 MB/s |
|
||||
| **WiFi 6 (5GHz)** | 32-70 MB/s (aggregate) | 60-80 MB/s |
|
||||
| **WiFi 5 (5GHz)** | 20-50 MB/s (aggregate) | 40-60 MB/s |
|
||||
|
||||
**Note:** Upload speeds depend on network quality and PS5 disk write performance. WiFi has ~40-60% overhead compared to Ethernet.
|
||||
**PS5 remote pane**
|
||||
- Full filesystem browser: path bar + **Go**, ⭐ **favorites** (saved per console), live **search/filter** box.
|
||||
- Right-click menu: **⬇️ Download**, **📝 Rename**, **📋 Copy**, **✂️ Move**, **🗑️ Delete**, **🗑️ Delete Selected** (multi-select).
|
||||
- Downloads ask where to save on the PC; folders download recursively.
|
||||
|
||||
**Transfer engine**
|
||||
- **📤 Upload** — parallel chunked transfers; ~**280 MB/s over Ethernet** on fast LANs (2.5GbE), saturates gigabit wired links easily. Wi-Fi will be significantly slower — use a wired connection on both PC and PS5 for full speed.
|
||||
- Duplicate files on the PS5 trigger a dialog: skip / overwrite / rename.
|
||||
- Live progress bar with per-file %, overall speed (MB/s) and ETA. **⏹ STOP** cancels cleanly.
|
||||
- **🎮 Mount Games** — mounts uploaded game dumps so they appear in the PS5's game list.
|
||||
- **Transfer History** — every completed/failed transfer logged; **🔄 Retry** failed items, **🗑️ Remove**/**Clear All**.
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ Troubleshooting
|
||||
### 🎮 GAMES (sidebar)
|
||||
|
||||
### Connection Failed
|
||||
- Make sure PS5 payload is running
|
||||
- Check PS5 IP address is correct
|
||||
- Verify both devices are on same network
|
||||
- Check firewall settings
|
||||
|
||||
### Slow Upload Speed
|
||||
- Use wired Ethernet connection (not WiFi)
|
||||
- Close other network applications
|
||||
- Check PS5 disk health
|
||||
|
||||
### Upload Fails
|
||||
- Check PS5 has enough free space
|
||||
- Verify destination path exists
|
||||
- Check file permissions
|
||||
- Lists every installed + mounted title: name, title ID, region, version, size, path.
|
||||
- **▶ Launch Game** — boots the title directly.
|
||||
- **ℹ️ View Details** — full info window (param.sfo metadata, content ID, category, versions).
|
||||
- **📥 Mount Game** / **🗑️ Unmount Game** — mount/unmount game images on the fly.
|
||||
- **📂 Open Path** — jumps the PS5 browser straight into that game's folder.
|
||||
- **📥 Mount All** batch-mounts everything in the list.
|
||||
|
||||
---
|
||||
|
||||
## 🔒 Security Notes
|
||||
### 💾 SAVES & MEDIA → Saves
|
||||
|
||||
- Server only accepts connections from local network
|
||||
- No authentication required (local network only)
|
||||
- SHUTDOWN command only works from localhost
|
||||
- Enumerates all save data on the console (per user, per title) with size/type badges.
|
||||
- **🔓 Mount (decrypt)** — mounts the save decrypted on the PS5 so its files are readable.
|
||||
- **📂 Browse** — opens the Save Browser window into the mounted save's files.
|
||||
- **🔒 Unmount & save** — unmounts and re-seals the save.
|
||||
- **📥 Backup (decrypted)** — downloads the decrypted save contents to PC.
|
||||
- **📥 Backup (raw image)** — downloads the raw encrypted save image 1:1.
|
||||
- **📤 Restore** — pushes a backup back onto the console.
|
||||
- **🗑️ Delete save** / **📂 Copy path**.
|
||||
|
||||
---
|
||||
|
||||
## 📝 What's New in v4.2.0
|
||||
### 📷 SAVES & MEDIA → Screenshots
|
||||
|
||||
### 🎮 Game Mounter Integration
|
||||
- **Mount Games Button** - New green "🎮 Mount Games" button in the client UI
|
||||
- **Multi-Path Scanning** - Scans 6 locations: `/data/etaHEN/games`, USB drives (0-3), M.2 SSD (`/mnt/ext0/games`)
|
||||
- **Duplicate Detection** - If the same game exists on multiple drives, it's mounted only once
|
||||
- **Auto-Cleanup** - Automatically unmounts entries for games that have been deleted
|
||||
- **PS5 Notifications** - Real-time progress notifications on PS5 screen during mounting
|
||||
- **Detailed Summary** - Shows new mounts, already mounted, duplicates skipped, and failures
|
||||
- **Auto Registration** - Automatically runs `game_mounter.elf` for home screen registration when new games are detected
|
||||
|
||||
### 🔧 Payload Improvements
|
||||
- **New Protocol Command** - `CMD_MOUNT_GAMES` (0x30) for triggering game mount from client
|
||||
- **nullfs Mounting** - Games mounted via nullfs to `/system_ex/app/`
|
||||
- **DRM Patching** - Automatic DRM type fix in param.json
|
||||
- **sce_sys Copy** - Copies game metadata to `/user/app/` and `/user/appmeta/`
|
||||
- **mount.lnk Tracking** - Creates mount link files for persistent mount state
|
||||
|
||||
### ⚡ Client Improvements
|
||||
- **Real-Time Speed Display** - Sliding window algorithm for accurate real-time upload speed
|
||||
- **Smoother ETA** - Blended speed calculation (60% real-time + 40% average) with better formatting
|
||||
- **Duplicate File Dialog** - New dialog for handling file conflicts during upload
|
||||
- **Move Dialog Refactor** - Reusable `ShowPathPickerDialogAsync()` replaces inline code
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
- **Fixed IOVEC_ENTRY(NULL) crash** - `strlen(NULL)` with `-O3` optimization caused payload crash
|
||||
- **Fixed libSceAppInstUtil crash** - Library not available in etaHEN payload context, switched to standalone registration
|
||||
- **Removed system() calls** - Replaced `system("rm -rf ...")` with safe recursive delete to prevent hangs
|
||||
- **Fixed Shell Terminal hardcoded IP** - Shell was connecting to hardcoded IP instead of configured PS5 address
|
||||
- **Fixed ETA jumping** - ETA no longer jumps erratically during uploads
|
||||
- **Fixed speed dropping to 0** - Sliding window prevents speed display from showing 0 during pauses
|
||||
- **Fixed files remaining counter** - Prevented negative values in remaining files display
|
||||
- Grid of all captured screenshots & video clips on the PS5 with **thumbnails**, filename, size, date.
|
||||
- **⬇ Download** selected (or batch to a chosen folder), **🗑️ Delete**, **📋 Copy Path**.
|
||||
- Double-click/preview to view the image full-size.
|
||||
|
||||
---
|
||||
|
||||
## 📝 What's New in v3.2.0
|
||||
### 🖥️ SYSTEM INFO (live hardware monitor)
|
||||
|
||||
### 🎯 Improvements:
|
||||
Everything is read **live from the console** — nothing hardcoded; values the console can't report show as unavailable.
|
||||
|
||||
#### 1. 💾 Accurate Storage Display
|
||||
- **Storage now matches PS5 UI** - Shows ~848 GB instead of raw 872 GB
|
||||
- **Accounts for reserved space** - Same calculation as PS5 system
|
||||
|
||||
#### 2. 📱 Mobile App Improvements
|
||||
- **Multi-select mode** - Toggle button to select multiple files/folders
|
||||
- **Folder browser** - Visual folder picker for Copy/Move destinations
|
||||
- **Sorted file list** - Folders always displayed at top
|
||||
- **Exit confirmation** - Prompt when pressing back button
|
||||
- **Delete folders** - Now supports folder deletion (not just files)
|
||||
- **Batch operations** - Delete/download multiple items at once
|
||||
|
||||
#### 3. 🐛 Bug Fixes
|
||||
- **Fixed empty folder deletion** - No more "Unexpected response" error
|
||||
- **Fixed Copy/Move crash** - No longer crashes when selecting destination
|
||||
- **Fixed multi-select count** - Correct item count in delete confirmation
|
||||
- **Fixed folder navigation** - Can enter folders after actions complete
|
||||
- **System Information** — model (CFI-xxxx), serial number, architecture, OS version, CPU cores, physical RAM.
|
||||
- **Extended Info** — firmware version, product code, uptime this boot, power-cycle count, BD drive presence, thermal alert status.
|
||||
- **Live Sensors** (● LIVE, auto-refresh) — 🔥 CPU temp, 🌡️ SoC temp, ⚡ CPU frequency, 🔋 SoC power draw (watts).
|
||||
- **CPU Usage** — per-core usage bars (all 8 cores) + total.
|
||||
- **RAM** — used/free memory.
|
||||
- **Storage** — free/total per mount point.
|
||||
- **Modules** — **🔄 Load** lists all loaded kernel/user modules.
|
||||
|
||||
---
|
||||
|
||||
## 📝 What's New in v3.1.0
|
||||
### 🔧 TOOLS → Shell
|
||||
|
||||
### 🚀 Performance Improvements:
|
||||
|
||||
#### 1. ⚡ Parallel Chunked Uploads (104 MB/s!)
|
||||
- **Large files (>100MB)** are now split into 500MB chunks
|
||||
- **4 parallel connections** upload chunks simultaneously
|
||||
- **Result:** ~104 MB/s upload speed (2x faster than before!)
|
||||
- **Fixed race condition:** First chunk creates file before others start
|
||||
- **Fixed closure bug:** Correct offset/size capture for parallel tasks
|
||||
|
||||
#### 2. 📊 Real-time Progress Display
|
||||
- **Speed display** now updates in real-time during chunked uploads
|
||||
- **ETA display** shows accurate time remaining
|
||||
- **Progress bar** correctly shows 0-100% for chunked files
|
||||
- No more speed dropping to 0 during large file uploads
|
||||
|
||||
#### 3. 💾 Storage Display Improvements
|
||||
- **Changed label** to "Data Storage (/data)" for clarity
|
||||
- **Uses statfs with f_bavail** for more accurate available space
|
||||
- **Real-time updates** every 5 seconds when connected
|
||||
- Run shell commands directly on the PS5 — type, Enter, output streams back.
|
||||
- Scrollable session history, **🗑️ Clear**, **💾 Save Log** to a PC file.
|
||||
|
||||
---
|
||||
|
||||
## 📝 What's New in v3.0.0
|
||||
### 🔧 TOOLS → Search
|
||||
|
||||
### 🐛 Critical Bug Fixes:
|
||||
|
||||
#### 1. 🔧 Path Normalization Fix
|
||||
- **Fixed:** Double-slash paths (`//mnt/ext1/...`) that caused directory creation to fail
|
||||
- **Affected:** All file operations (upload, download, delete, rename, copy, move)
|
||||
- **Solution:** Added `NormalizePath()` function in both client and server
|
||||
- **Result:** 100% reliable directory creation and file uploads
|
||||
|
||||
#### 2. 📱 Android Mobile Client
|
||||
- **NEW:** Full-featured Android app for PS5 file management
|
||||
- **Multi-PS5 Profiles** - Save and switch between multiple PS5 consoles
|
||||
- Upload files from phone to PS5
|
||||
- Download files from PS5 with Share option
|
||||
- Browse PS5 filesystem
|
||||
- Favorites for quick navigation
|
||||
- Debug Log with Copy to clipboard
|
||||
- Transfer History tracking
|
||||
|
||||
#### 3. 🛠️ Server Improvements
|
||||
- Path normalization in all handler functions
|
||||
- More robust error handling
|
||||
- Improved stability for parallel uploads
|
||||
- **🔄 Start Index** — the payload crawls the PS5 filesystem and builds a searchable index.
|
||||
- Instant search across **every** file/folder on the console — results open straight in the browser.
|
||||
- **🔃 Refresh** rebuilds, **🗑️ Clear** wipes the index.
|
||||
|
||||
---
|
||||
|
||||
## 📝 What's New in v2.1.0
|
||||
### 🔧 TOOLS → Fan Control
|
||||
|
||||
### 🚀 Performance Optimizations:
|
||||
|
||||
#### 1. ⚡ Massive Upload Speed Boost (88-110 MB/s)
|
||||
- **Server-side:** Replaced `fwrite()` with direct `write()` syscalls
|
||||
- **Client-side:** 8 parallel large file uploads (optimal for PS5 disk)
|
||||
- **Result:** 80-110 MB/s aggregate upload speed on Gigabit Ethernet
|
||||
- **Peak bursts:** Up to 2.05 GB/s when hitting disk cache
|
||||
- **Per-file:** 11-14 MB/s sustained per connection
|
||||
|
||||
#### 2. 📊 Transfer History
|
||||
- Complete history of all uploads and downloads
|
||||
- Success/Failed status tracking with error messages
|
||||
- Speed statistics (average, min, max)
|
||||
- Export to CSV/JSON for analysis
|
||||
- Persistent storage across sessions
|
||||
|
||||
#### 3. 🔄 Auto-Clear History on Startup
|
||||
- Optional checkbox to clear history automatically
|
||||
- Useful for keeping UI clean between sessions
|
||||
- Setting saved in `ps5_upload_settings.json`
|
||||
|
||||
#### 4. 🖥️ Maximized Window UI
|
||||
- Application opens in full-screen mode by default
|
||||
- Better visibility for large file transfers
|
||||
- Can be resized/restored as needed
|
||||
|
||||
### Technical Improvements:
|
||||
- ✅ **16MB socket buffers** (up from 4MB) for maximum throughput
|
||||
- ✅ **Per-file mutex locking** - Parallel writes without race conditions
|
||||
- ✅ **File pre-allocation** - Reduces disk fragmentation for large files
|
||||
- ✅ **Direct syscalls** - Bypasses stdio buffering overhead
|
||||
- Reads the current fan threshold + live temperature.
|
||||
- **🌡️ Set Threshold** — custom temperature target.
|
||||
- Presets: **❄️ Cool (50 °C)**, **⚖️ Balanced (60 °C)**, **🔇 Quiet (70 °C)**.
|
||||
|
||||
---
|
||||
|
||||
## 📝 What's New in v2.0.0
|
||||
### 🔧 TOOLS → Memory
|
||||
|
||||
### 🎉 4 Major New Features:
|
||||
|
||||
#### 1. 📥 Download Files (PS5 → PC)
|
||||
- Right-click any file → "⬇️ Download to PC"
|
||||
- Save file dialog for destination selection
|
||||
- Real-time progress tracking with speed display
|
||||
- Optimized with sendfile for maximum speed
|
||||
|
||||
#### 2. 🔍 File Search
|
||||
- Search box in PS5 Files panel
|
||||
- Real-time filtering as you type
|
||||
- Case-insensitive search
|
||||
- Quick "Clear" button to reset
|
||||
|
||||
#### 3. ⭐ Favorites/Bookmarks
|
||||
- Save frequently used PS5 paths
|
||||
- Quick dropdown navigation
|
||||
- Add/Remove favorite paths
|
||||
- Persistent storage in JSON
|
||||
|
||||
#### 4. 🎮 Multi-PS5 Support
|
||||
- Save multiple PS5 profiles (IP + name)
|
||||
- Quick switch between different PS5 consoles
|
||||
- Dropdown profile selector
|
||||
- Persistent profile storage
|
||||
|
||||
### Previous Stability (v1.3.0):
|
||||
- ✅ **Zero connection drops** - 5 minute socket timeout + aggressive keepalive
|
||||
- ✅ **Fully responsive UI** - Async updates + throttled logging
|
||||
- ✅ **No memory leaks** - Proper resource disposal
|
||||
- ✅ **Tested:** 42,801 files uploaded with 100% success rate
|
||||
- **Process list** of running PS5 processes (**↻ refresh**).
|
||||
- **🔍 First Scan / 🔎 Next Scan** — cheat-style value scanning in a process's memory.
|
||||
- **📖 Read** (hex dump, 256 B) and **✏️ Write** at arbitrary addresses — full debugging/patching toolkit.
|
||||
|
||||
---
|
||||
|
||||
## 📝 License
|
||||
### 🔧 TOOLS → Kernel Log
|
||||
|
||||
This software is provided as-is for personal use.
|
||||
- Live kernel log viewer — choose **Last 32 KB / Last 128 KB / Full buffer**.
|
||||
- **Auto (3s)** checkbox for continuous refresh, **🔄 Refresh** manual.
|
||||
- **📋 Copy** to clipboard, **💾 Save…** to file.
|
||||
|
||||
---
|
||||
|
||||
## 👤 Author
|
||||
### 🔧 TOOLS → PKG
|
||||
|
||||
**Manos**
|
||||
- **Install PKG** — three ways:
|
||||
- **📋 Browse PS5** — pick a `.pkg` already on the console.
|
||||
- **💻 Browse PC** — pick a local `.pkg`; it's **streamed over HTTP** (ranged requests, persistent keep-alive connections, 512 KB relay) straight into the PS5 installer. Verified with a 22.7 GB package.
|
||||
- Via the local-pane right-click "Install on PS5".
|
||||
- **🔄 Check Status** — queries the console's installer for progress/result.
|
||||
- Staged `.pkg` files on the PS5 are **auto-deleted** after successful install.
|
||||
- PS5 notifications show the real package name (not a generic label).
|
||||
|
||||
Created with ❤️ for the PS5 homebrew community
|
||||
- **Convert to FPKG** — turn an extracted game folder into an installable fake package **entirely on the PC**:
|
||||
- Point at the source folder (with `sce_sys`), choose output, done.
|
||||
- Auto-fills title ID / content ID / version from `param.json`.
|
||||
- Optional **fake-signing** of `eboot.bin`, `*.elf`, `*.prx`, `*.sprx` (originals restored automatically after the build).
|
||||
- Builds the full PS5 container: inner PFS image, encrypted+signed outer PFS, CNT entry table, all SHA3-256 digests, RSA-3072 signatures, FIH finalization — powered by the bundled **LibProsperoPkg** engine.
|
||||
- Output verified against real package structure (field-accurate, validator-tested).
|
||||
|
||||
---
|
||||
|
||||
## 🙏 Credits
|
||||
### 🧾 DEBUG LOG (sidebar)
|
||||
|
||||
- PS5 SDK
|
||||
- etaHEN
|
||||
- Inspired by ps5upload by PhantomPtr
|
||||
- PS5 homebrew community
|
||||
- Timestamped log of **every** operation the app performs — connections, transfers, errors, PS5 responses.
|
||||
- **📋 Copy Log**, **🗑️ Clear Log**; a `ps5suite_*.log` file is also written automatically next to the executable for post-mortem debugging.
|
||||
- **Failed transfers** section with per-item **🔄 Retry** and **🗑️ Remove**.
|
||||
|
||||
---
|
||||
|
||||
## 📞 Support
|
||||
## 🔧 Technical details
|
||||
|
||||
For issues or questions, please open an issue on GitHub.
|
||||
| Component | Technology |
|
||||
|---|---|
|
||||
| Client (Windows/Linux/macOS/Android) | C# / .NET 10, **Avalonia UI** — one shared codebase everywhere |
|
||||
| On-console payload | C, built with the **PS5 Payload SDK** (`prospero-clang`) |
|
||||
| FPKG engine | **LibProsperoPkg** (bundled) — CNT/PFS/PFSC/PFSv3/FIH build, read & verify |
|
||||
| Transfer protocol | Raw TCP, chunked + parallel streams |
|
||||
| Suite server ports | 9113–9116 |
|
||||
| Local PKG streamer ports | 18990–19009 |
|
||||
| Payload proxy port | 13801+ |
|
||||
|
||||
- All heavy work (transfers, FPKG builds) runs on background threads — the UI never freezes.
|
||||
- The Android app shares **the same UI + protocol code as the desktop EXE** (source included in the repo — APK build in progress).
|
||||
-
|
||||
---
|
||||
|
||||
**Enjoy blazing-fast file transfers on your PS5!** 🚀
|
||||
## ⚠️ Disclaimer
|
||||
|
||||
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.
|
||||
|
||||
**Enjoy. — Manos**
|
||||
@@ -0,0 +1,33 @@
|
||||
using Android.App;
|
||||
using Android.Content.PM;
|
||||
using Avalonia;
|
||||
using Avalonia.Android;
|
||||
|
||||
namespace PS5Upload.Android;
|
||||
|
||||
[Application]
|
||||
public class Application : AvaloniaAndroidApplication<App>
|
||||
{
|
||||
public Application(nint javaReference, global::Android.Runtime.JniHandleOwnership transfer)
|
||||
: base(javaReference, transfer)
|
||||
{
|
||||
}
|
||||
|
||||
protected override AppBuilder CustomizeAppBuilder(AppBuilder builder)
|
||||
{
|
||||
return base.CustomizeAppBuilder(builder)
|
||||
.WithInterFont()
|
||||
.LogToTrace();
|
||||
}
|
||||
}
|
||||
|
||||
[Activity(
|
||||
Label = "PS5 Suite",
|
||||
Theme = "@style/MyTheme.NoActionBar",
|
||||
Icon = "@mipmap/ic_launcher",
|
||||
MainLauncher = true,
|
||||
LaunchMode = LaunchMode.SingleTop,
|
||||
ConfigurationChanges = ConfigChanges.Orientation | ConfigChanges.ScreenSize | ConfigChanges.UiMode | ConfigChanges.ScreenLayout | ConfigChanges.SmallestScreenSize)]
|
||||
public class MainActivity : AvaloniaMainActivity
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net10.0-android</TargetFramework>
|
||||
<SupportedOSPlatformVersion>23</SupportedOSPlatformVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>14</LangVersion>
|
||||
<ImplicitUsings>disable</ImplicitUsings>
|
||||
<AvaloniaUseCompiledBindingsByDefault>false</AvaloniaUseCompiledBindingsByDefault>
|
||||
<ApplicationId>com.manos.ps5suite</ApplicationId>
|
||||
<ApplicationVersion>1</ApplicationVersion>
|
||||
<ApplicationDisplayVersion>7.1.5</ApplicationDisplayVersion>
|
||||
<ApplicationTitle>PS5 Suite</ApplicationTitle>
|
||||
<AssemblyName>PS5Suite</AssemblyName>
|
||||
<RootNamespace>PS5Upload</RootNamespace>
|
||||
<AndroidPackageFormat>apk</AndroidPackageFormat>
|
||||
<AndroidEnableProfiledAot>false</AndroidEnableProfiledAot>
|
||||
<!-- The UI relies on runtime reflection bindings ({Binding ...} without x:DataType);
|
||||
trimming would strip bound properties and blank out the lists. -->
|
||||
<PublishTrimmed>false</PublishTrimmed>
|
||||
<RunAOTCompilation>false</RunAOTCompilation>
|
||||
<Authors>Manos</Authors>
|
||||
<Description>PS5 Suite - Complete PS5 Management Platform</Description>
|
||||
</PropertyGroup>
|
||||
|
||||
<!-- Reuse the full desktop UI/codebase so the Android app has feature parity -->
|
||||
<ItemGroup>
|
||||
<Compile Include="..\client-avalonia\**\*.cs"
|
||||
Exclude="..\client-avalonia\Program.cs;..\client-avalonia\bin\**;..\client-avalonia\obj\**" />
|
||||
<AvaloniaXaml Include="..\client-avalonia\**\*.axaml"
|
||||
Exclude="..\client-avalonia\bin\**;..\client-avalonia\obj\**" />
|
||||
<AvaloniaResource Include="..\client-avalonia\ps5_logo.png" Link="ps5_logo.png" />
|
||||
<AvaloniaResource Include="..\client-avalonia\ps5_logo.ico" Link="ps5_logo.ico" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Avalonia" Version="12.1.3" />
|
||||
<PackageReference Include="Avalonia.Android" Version="12.1.3" />
|
||||
<PackageReference Include="Avalonia.Themes.Fluent" Version="12.1.3" />
|
||||
<PackageReference Include="Avalonia.Fonts.Inter" Version="12.1.3" />
|
||||
<PackageReference Include="Avalonia.Controls.DataGrid" Version="12.1.2" />
|
||||
<Reference Include="LibProsperoPkg">
|
||||
<HintPath>..\client-avalonia\libs\LibProsperoPkg.dll</HintPath>
|
||||
</Reference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<AndroidResource Include="Resources\**\*.xml" />
|
||||
<AndroidResource Include="Resources\**\*.png" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android" android:installLocation="auto">
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
|
||||
<application android:label="PS5 Suite" android:icon="@mipmap/ic_launcher" android:theme="@style/MyTheme.Main" android:usesCleartextTraffic="true" />
|
||||
<uses-feature android:glEsVersion="0x00020000" android:required="true" />
|
||||
</manifest>
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_background" />
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground" />
|
||||
<monochrome android:drawable="@mipmap/ic_launcher_foreground" />
|
||||
</adaptive-icon>
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_background" />
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground" />
|
||||
<monochrome android:drawable="@mipmap/ic_launcher_foreground" />
|
||||
</adaptive-icon>
|
||||
|
After Width: | Height: | Size: 9.9 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 4.5 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 107 KiB |
|
After Width: | Height: | Size: 65 KiB |
|
After Width: | Height: | Size: 183 KiB |
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#1E1E1E</color>
|
||||
</resources>
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<resources>
|
||||
<style name="MyTheme.Main" parent="android:Theme.Material.NoActionBar">
|
||||
<item name="android:windowBackground">@android:color/black</item>
|
||||
<item name="android:statusBarColor">#1E1E1E</item>
|
||||
<item name="android:navigationBarColor">#1E1E1E</item>
|
||||
<item name="android:windowLightStatusBar">false</item>
|
||||
</style>
|
||||
<style name="MyTheme.NoActionBar" parent="MyTheme.Main" />
|
||||
</resources>
|
||||
@@ -0,0 +1,9 @@
|
||||
<Application xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
x:Class="PS5Upload.App"
|
||||
RequestedThemeVariant="Dark">
|
||||
<Application.Styles>
|
||||
<FluentTheme />
|
||||
<StyleInclude Source="avares://Avalonia.Controls.DataGrid/Themes/Fluent.xaml"/>
|
||||
</Application.Styles>
|
||||
</Application>
|
||||
@@ -0,0 +1,63 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Threading.Tasks;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls.ApplicationLifetimes;
|
||||
using Avalonia.Markup.Xaml;
|
||||
|
||||
namespace PS5Upload
|
||||
{
|
||||
public partial class App : Application
|
||||
{
|
||||
private static readonly string LogFilePath = Path.Combine(
|
||||
AppDomain.CurrentDomain.BaseDirectory,
|
||||
$"ps5suite_{DateTime.Now:yyyy-MM-dd_HH-mm-ss}.log");
|
||||
|
||||
private static readonly object _logLock = new object();
|
||||
|
||||
public override void Initialize()
|
||||
{
|
||||
AvaloniaXamlLoader.Load(this);
|
||||
LogToFile("=== Application Started ===");
|
||||
}
|
||||
|
||||
public override void OnFrameworkInitializationCompleted()
|
||||
{
|
||||
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
|
||||
{
|
||||
desktop.MainWindow = new MainWindow();
|
||||
}
|
||||
else if (ApplicationLifetime is ISingleViewApplicationLifetime singleView)
|
||||
{
|
||||
#if ANDROID
|
||||
// On Android there is no real Window host — the single view embeds controls.
|
||||
// Build the MainWindow (for its code-behind + dialog ownership) but host its
|
||||
// content directly in the single view; a TopLevel can't be a child control.
|
||||
var window = new MainWindow();
|
||||
if (window.Content is Avalonia.Controls.Control content)
|
||||
{
|
||||
window.Content = null;
|
||||
singleView.MainView = content;
|
||||
}
|
||||
#else
|
||||
singleView.MainView = new MainWindow();
|
||||
#endif
|
||||
}
|
||||
|
||||
base.OnFrameworkInitializationCompleted();
|
||||
}
|
||||
|
||||
public static void LogToFile(string message)
|
||||
{
|
||||
try
|
||||
{
|
||||
lock (_logLock)
|
||||
{
|
||||
string timestamp = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss.fff");
|
||||
File.AppendAllText(LogFilePath, $"[{timestamp}] {message}\n");
|
||||
}
|
||||
}
|
||||
catch { /* Ignore logging errors */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
<Window xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
x:Class="PS5Upload.DuplicateFileDialog"
|
||||
Title="Duplicate File Found"
|
||||
Width="500" Height="280"
|
||||
Icon="/ps5_logo.ico"
|
||||
WindowStartupLocation="CenterOwner"
|
||||
Background="#1E1E1E"
|
||||
CanResize="False">
|
||||
<Grid Margin="20">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="*"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<TextBlock Grid.Row="0" Text="⚠️ A file with this name already exists on the PS5:"
|
||||
Foreground="White" FontSize="14" Margin="0,0,0,10"/>
|
||||
|
||||
<TextBlock Grid.Row="1" x:Name="FileNameText" Text=""
|
||||
Foreground="#FFD700" FontWeight="Bold" FontSize="13" Margin="0,0,0,10"/>
|
||||
|
||||
<StackPanel Grid.Row="2" Margin="0,0,0,15">
|
||||
<TextBlock x:Name="LocalSizeText" Foreground="#AAAAAA" FontSize="12"/>
|
||||
<TextBlock x:Name="RemoteSizeText" Foreground="#AAAAAA" FontSize="12"/>
|
||||
</StackPanel>
|
||||
|
||||
<TextBlock Grid.Row="3" Text="What would you like to do?"
|
||||
Foreground="White" Margin="0,0,0,10"/>
|
||||
|
||||
<StackPanel Grid.Row="4" Orientation="Horizontal" HorizontalAlignment="Center" Spacing="8">
|
||||
<Button Content="Replace" Width="90" Click="ReplaceButton_Click"/>
|
||||
<Button Content="Skip" Width="90" Click="SkipButton_Click"/>
|
||||
<Button Content="Replace All" Width="100" Click="ReplaceAllButton_Click"/>
|
||||
<Button Content="Skip All" Width="90" Click="SkipAllButton_Click"/>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Window>
|
||||
@@ -0,0 +1,47 @@
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Interactivity;
|
||||
|
||||
namespace PS5Upload
|
||||
{
|
||||
public partial class DuplicateFileDialog : Window
|
||||
{
|
||||
public enum FileAction { Replace, Skip, ReplaceAll, SkipAll }
|
||||
public FileAction UserAction { get; private set; } = FileAction.Skip;
|
||||
|
||||
public DuplicateFileDialog()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
|
||||
public DuplicateFileDialog(string fileName, long localSize, long remoteSize) : this()
|
||||
{
|
||||
FileNameText.Text = fileName;
|
||||
LocalSizeText.Text = $"Local file size: {FileUtils.FormatFileSize(localSize)}";
|
||||
RemoteSizeText.Text = $"Remote file size: {FileUtils.FormatFileSize(remoteSize)}";
|
||||
}
|
||||
|
||||
private void ReplaceButton_Click(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
UserAction = FileAction.Replace;
|
||||
Close();
|
||||
}
|
||||
|
||||
private void SkipButton_Click(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
UserAction = FileAction.Skip;
|
||||
Close();
|
||||
}
|
||||
|
||||
private void ReplaceAllButton_Click(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
UserAction = FileAction.ReplaceAll;
|
||||
Close();
|
||||
}
|
||||
|
||||
private void SkipAllButton_Click(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
UserAction = FileAction.SkipAll;
|
||||
Close();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
<Window xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
x:Class="PS5Upload.GameDetailsWindow"
|
||||
Title="Game Details"
|
||||
Height="720" Width="860"
|
||||
Icon="/ps5_logo.ico"
|
||||
Background="#1E1E1E"
|
||||
WindowStartupLocation="CenterOwner"
|
||||
CanResize="True">
|
||||
<Grid>
|
||||
<!-- Cover Art Background -->
|
||||
<Image x:Name="BackgroundCoverImage"
|
||||
Stretch="UniformToFill"
|
||||
Opacity="0.25"/>
|
||||
|
||||
<!-- Gradient overlay -->
|
||||
<Border>
|
||||
<Border.Background>
|
||||
<LinearGradientBrush StartPoint="0%,0%" EndPoint="0%,100%">
|
||||
<GradientStop Color="#CC1E1E1E" Offset="0"/>
|
||||
<GradientStop Color="#E61E1E1E" Offset="0.5"/>
|
||||
<GradientStop Color="#FF1E1E1E" Offset="1"/>
|
||||
</LinearGradientBrush>
|
||||
</Border.Background>
|
||||
</Border>
|
||||
|
||||
<Grid Margin="20">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="*"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<!-- Header: icon + title -->
|
||||
<Border Grid.Row="0" Background="#CC2D2D30" CornerRadius="6" Padding="15">
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
<ColumnDefinition Width="*"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<Border Grid.Column="0" Width="128" Height="128" CornerRadius="8"
|
||||
Background="#3E3E42" ClipToBounds="True" Margin="0,0,20,0">
|
||||
<Grid>
|
||||
<TextBlock Text="🎮" FontSize="64"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center"/>
|
||||
<Image x:Name="GameIconImage" Stretch="UniformToFill"/>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<StackPanel Grid.Column="1" VerticalAlignment="Center">
|
||||
<TextBlock x:Name="GameNameText" Text="Game Name"
|
||||
FontSize="22" FontWeight="Bold" Foreground="White"
|
||||
TextTrimming="CharacterEllipsis"/>
|
||||
<StackPanel Orientation="Horizontal" Margin="0,6,0,0">
|
||||
<TextBlock x:Name="TitleIdText" Text="TITLE_ID"
|
||||
FontSize="14" FontWeight="SemiBold" Foreground="#007ACC"/>
|
||||
<TextBlock Text=" • " Foreground="#666666" FontSize="14"/>
|
||||
<TextBlock x:Name="RegionText" Text="Region"
|
||||
FontSize="14" Foreground="#FFC107"/>
|
||||
<TextBlock Text=" • " Foreground="#666666" FontSize="14"/>
|
||||
<TextBlock x:Name="StatusText" Text="Status"
|
||||
FontSize="14" Foreground="#28A745"/>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<!-- Details panel -->
|
||||
<ScrollViewer Grid.Row="1" VerticalScrollBarVisibility="Auto" Margin="0,15,0,15">
|
||||
<StackPanel>
|
||||
<Border Background="#CC2D2D30" CornerRadius="6" Padding="15" Margin="0,0,0,10">
|
||||
<StackPanel>
|
||||
<TextBlock Text="📊 Size & Storage" FontSize="14" FontWeight="Bold"
|
||||
Foreground="#9CDCFE" Margin="0,0,0,8"/>
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="140"/>
|
||||
<ColumnDefinition Width="*"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
</Grid.RowDefinitions>
|
||||
<TextBlock Grid.Row="0" Grid.Column="0" Text="Total size:" Foreground="#999999" Margin="0,0,0,4"/>
|
||||
<TextBlock Grid.Row="0" Grid.Column="1" x:Name="TotalSizeText" Text="-" Foreground="White" FontWeight="SemiBold"/>
|
||||
<TextBlock Grid.Row="1" Grid.Column="0" Text="eboot.bin:" Foreground="#999999" Margin="0,0,0,4"/>
|
||||
<TextBlock Grid.Row="1" Grid.Column="1" x:Name="EbootSizeText" Text="-" Foreground="White"/>
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<Border Background="#CC2D2D30" CornerRadius="6" Padding="15" Margin="0,0,0,10">
|
||||
<StackPanel>
|
||||
<TextBlock Text="📁 Paths" FontSize="14" FontWeight="Bold" Foreground="#9CDCFE" Margin="0,0,0,8"/>
|
||||
<TextBlock Text="Source path:" Foreground="#999999"/>
|
||||
<TextBlock x:Name="SourcePathText" Text="-" Foreground="White" TextWrapping="Wrap" Margin="0,2,0,8"/>
|
||||
<TextBlock Text="Install date:" Foreground="#999999"/>
|
||||
<TextBlock x:Name="InstallDateText" Text="-" Foreground="White" Margin="0,2,0,0"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<Border Background="#CC2D2D30" CornerRadius="6" Padding="15" Margin="0,0,0,10">
|
||||
<StackPanel>
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<TextBlock Grid.Column="0" Text="🌐 Online Info (PSN Store)" FontSize="14" FontWeight="Bold"
|
||||
Foreground="#9CDCFE" VerticalAlignment="Center"/>
|
||||
<Button Grid.Column="1" x:Name="FetchPsnInfoButton"
|
||||
Content="🔍 Fetch from Internet"
|
||||
Click="FetchPsnInfoButton_Click"
|
||||
Background="#007ACC" Foreground="White"
|
||||
Padding="10,4" BorderThickness="0"/>
|
||||
</Grid>
|
||||
<TextBlock x:Name="PsnStatusText" Text="Click the button above to fetch online info"
|
||||
Foreground="#888888" FontStyle="Italic" Margin="0,6,0,0" TextWrapping="Wrap"/>
|
||||
<StackPanel x:Name="PsnInfoPanel" IsVisible="False" Margin="0,8,0,0">
|
||||
<TextBlock x:Name="PsnTitleText" FontSize="15" FontWeight="Bold"
|
||||
Foreground="White" Margin="0,0,0,4" TextWrapping="Wrap"/>
|
||||
<TextBlock x:Name="PsnPublisherText" Foreground="#9CDCFE" Margin="0,0,0,4"/>
|
||||
<TextBlock x:Name="PsnReleaseDateText" Foreground="#FFC107" Margin="0,0,0,6"/>
|
||||
<ScrollViewer MaxHeight="180" VerticalScrollBarVisibility="Auto" HorizontalScrollBarVisibility="Disabled">
|
||||
<TextBlock x:Name="PsnDescriptionText" Foreground="#CCCCCC" TextWrapping="Wrap"/>
|
||||
</ScrollViewer>
|
||||
</StackPanel>
|
||||
<Button x:Name="OpenInBrowserButton"
|
||||
Content="🌍 Search on PSN Store (browser)"
|
||||
Click="OpenInBrowserButton_Click"
|
||||
Background="#3E3E42" Foreground="White"
|
||||
Padding="10,4" BorderThickness="0"
|
||||
HorizontalAlignment="Left" Margin="0,10,0,0"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<Border Background="#CC2D2D30" CornerRadius="6" Padding="15">
|
||||
<StackPanel>
|
||||
<TextBlock Text="📜 param.json" FontSize="14" FontWeight="Bold"
|
||||
Foreground="#9CDCFE" Margin="0,0,0,8"/>
|
||||
<TextBox x:Name="ParamJsonText"
|
||||
IsReadOnly="True"
|
||||
TextWrapping="Wrap"
|
||||
Background="#1E1E1E"
|
||||
Foreground="#CCCCCC"
|
||||
BorderThickness="1"
|
||||
BorderBrush="#3E3E42"
|
||||
Padding="8"
|
||||
FontFamily="Consolas"
|
||||
FontSize="11"
|
||||
MinHeight="120"
|
||||
MaxHeight="240"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
|
||||
<!-- Footer buttons -->
|
||||
<StackPanel Grid.Row="2" Orientation="Horizontal" HorizontalAlignment="Right">
|
||||
<Button Content="Close" Click="CloseButton_Click"
|
||||
Background="#007ACC" Foreground="White"
|
||||
Padding="20,8" BorderThickness="0"/>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Window>
|
||||
@@ -0,0 +1,318 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Net.Http;
|
||||
using System.Threading.Tasks;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.Media;
|
||||
using Avalonia.Media.Imaging;
|
||||
using Avalonia.Threading;
|
||||
|
||||
namespace PS5Upload
|
||||
{
|
||||
public partial class GameDetailsWindow : Window
|
||||
{
|
||||
private readonly PS5MountedGame _game;
|
||||
private readonly PS5Protocol? _protocol;
|
||||
private readonly string _paramJsonRaw;
|
||||
private static readonly HttpClient _http = CreateHttpClient();
|
||||
|
||||
private static HttpClient CreateHttpClient()
|
||||
{
|
||||
var client = new HttpClient();
|
||||
client.DefaultRequestHeaders.UserAgent.ParseAdd("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36");
|
||||
client.Timeout = TimeSpan.FromSeconds(8);
|
||||
return client;
|
||||
}
|
||||
|
||||
public GameDetailsWindow()
|
||||
{
|
||||
InitializeComponent();
|
||||
_game = new PS5MountedGame();
|
||||
_paramJsonRaw = "";
|
||||
}
|
||||
|
||||
public GameDetailsWindow(PS5MountedGame game, Dictionary<string, string> details, PS5Protocol? protocol = null) : this()
|
||||
{
|
||||
_game = game;
|
||||
_protocol = protocol;
|
||||
_paramJsonRaw = details.TryGetValue("param_json", out var pj) ? pj : "";
|
||||
|
||||
if (game.Icon != null) GameIconImage.Source = game.Icon;
|
||||
GameNameText.Text = details.TryGetValue("name", out var n) ? n : game.Name;
|
||||
TitleIdText.Text = game.TitleId;
|
||||
RegionText.Text = details.TryGetValue("region", out var r) ? r : game.Region;
|
||||
bool isActive = details.TryGetValue("is_active", out var act) && act == "1";
|
||||
StatusText.Text = isActive ? "✓ Mounted" : "✗ Not Mounted";
|
||||
StatusText.Foreground = isActive
|
||||
? new SolidColorBrush(Color.FromRgb(0x28, 0xA7, 0x45))
|
||||
: new SolidColorBrush(Color.FromRgb(0xDC, 0x35, 0x45));
|
||||
|
||||
if (details.TryGetValue("total_size", out var ts) && ulong.TryParse(ts, out ulong totalBytes))
|
||||
TotalSizeText.Text = FormatSize(totalBytes);
|
||||
if (details.TryGetValue("eboot_size", out var es) && ulong.TryParse(es, out ulong ebootBytes))
|
||||
EbootSizeText.Text = FormatSize(ebootBytes);
|
||||
|
||||
SourcePathText.Text = details.TryGetValue("path", out var p) ? p : game.Path;
|
||||
InstallDateText.Text = details.TryGetValue("install_date", out var d) ? d : "Unknown";
|
||||
|
||||
ParamJsonText.Text = details.TryGetValue("param_json", out var param) ? FormatJson(param) : "(not available)";
|
||||
|
||||
_ = LoadCoverArtAsync();
|
||||
}
|
||||
|
||||
private async Task LoadCoverArtAsync()
|
||||
{
|
||||
if (_protocol == null || !_protocol.IsConnected) return;
|
||||
try
|
||||
{
|
||||
var picBytes = await _protocol.GetGamePicAsync(_game.TitleId, 0);
|
||||
if (picBytes == null || picBytes.Length == 0) return;
|
||||
await Dispatcher.UIThread.InvokeAsync(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
using var ms = new MemoryStream(picBytes);
|
||||
var bitmap = new Bitmap(ms);
|
||||
BackgroundCoverImage.Source = bitmap;
|
||||
}
|
||||
catch { }
|
||||
});
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
private void OpenInBrowserButton_Click(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
try
|
||||
{
|
||||
string query = !string.IsNullOrWhiteSpace(_game.Name) && _game.Name != "Unknown" ? _game.Name : _game.TitleId;
|
||||
string url = $"https://store.playstation.com/en-gb/search/{Uri.EscapeDataString(query)}";
|
||||
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo { FileName = url, UseShellExecute = true });
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
private async void FetchPsnInfoButton_Click(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
FetchPsnInfoButton.IsEnabled = false;
|
||||
PsnStatusText.Text = "Fetching from PSN Store...";
|
||||
PsnInfoPanel.IsVisible = false;
|
||||
|
||||
try
|
||||
{
|
||||
var info = await FetchPsnInfoAsync(_game.TitleId);
|
||||
if (info == null)
|
||||
{
|
||||
PsnStatusText.Text = "❌ Not found on PSN Store. Try the browser search button below.";
|
||||
return;
|
||||
}
|
||||
PsnTitleText.Text = info.Title;
|
||||
PsnPublisherText.Text = string.IsNullOrEmpty(info.Publisher) ? "" : $"🏢 {info.Publisher}";
|
||||
PsnReleaseDateText.Text = string.IsNullOrEmpty(info.ReleaseDate) ? "" : $"📅 Released: {info.ReleaseDate}";
|
||||
PsnDescriptionText.Text = info.Description ?? "";
|
||||
PsnInfoPanel.IsVisible = true;
|
||||
PsnStatusText.Text = $"✓ Found on PSN Store ({info.Source})";
|
||||
}
|
||||
catch (Exception ex) { PsnStatusText.Text = $"❌ Error: {ex.Message}"; }
|
||||
finally { FetchPsnInfoButton.IsEnabled = true; }
|
||||
}
|
||||
|
||||
private class PsnGameInfo
|
||||
{
|
||||
public string Title { get; set; } = "";
|
||||
public string Publisher { get; set; } = "";
|
||||
public string ReleaseDate { get; set; } = "";
|
||||
public string Description { get; set; } = "";
|
||||
public string Source { get; set; } = "";
|
||||
}
|
||||
|
||||
private async Task<PsnGameInfo?> FetchPsnInfoAsync(string titleId)
|
||||
{
|
||||
var idsToTry = new List<string> { titleId + "_00" };
|
||||
var alternateIds = ExtractAlternateTitleIds(_paramJsonRaw);
|
||||
foreach (var altId in alternateIds)
|
||||
if (!idsToTry.Contains(altId)) idsToTry.Add(altId);
|
||||
|
||||
string[] chihiroRegions = { "US/en", "GB/en", "DE/de", "FR/fr", "JP/ja", "IT/it", "ES/es" };
|
||||
foreach (var tryId in idsToTry)
|
||||
{
|
||||
foreach (var region in chihiroRegions)
|
||||
{
|
||||
try
|
||||
{
|
||||
var parts = region.Split('/');
|
||||
string url = $"https://store.playstation.com/store/api/chihiro/00_09_000/titlecontainer/{parts[0]}/{parts[1]}/999/{tryId}";
|
||||
var response = await _http.GetAsync(url);
|
||||
if (!response.IsSuccessStatusCode) continue;
|
||||
string json = await response.Content.ReadAsStringAsync();
|
||||
if (json.Length < 50) continue;
|
||||
var info = ParsePsnJson(json);
|
||||
if (info != null) { string tag = tryId == titleId + "_00" ? "" : $" via alt ID {tryId}"; info.Source = $"Chihiro API ({region}){tag}"; return info; }
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
}
|
||||
|
||||
string[] valkyrieRegions = { "gb/GB", "us/US", "de/DE", "fr/FR", "ja/JP", "it/IT", "es/ES" };
|
||||
foreach (var region in valkyrieRegions)
|
||||
{
|
||||
try
|
||||
{
|
||||
string url = $"https://store.playstation.com/valkyrie-api/{region.Split('/')[0]}/{region.Split('/')[1]}/19/resolve/{titleId}_00";
|
||||
var response = await _http.GetAsync(url);
|
||||
if (!response.IsSuccessStatusCode) continue;
|
||||
string json = await response.Content.ReadAsStringAsync();
|
||||
if (json.Length < 50) continue;
|
||||
var info = ParseValkyrieJson(json);
|
||||
if (info != null) { info.Source = $"Valkyrie API ({region})"; return info; }
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
string gameName = _game.Name;
|
||||
if (!string.IsNullOrWhiteSpace(gameName) && gameName != "Unknown")
|
||||
{
|
||||
try
|
||||
{
|
||||
string encoded = Uri.EscapeDataString(gameName);
|
||||
string url = $"https://store.playstation.com/valkyrie-api/en/US/19/tumbler-search/{encoded}?suggested_size=5&mode=game";
|
||||
var response = await _http.GetAsync(url);
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
string json = await response.Content.ReadAsStringAsync();
|
||||
var info = ParseValkyrieSearchJson(json, gameName);
|
||||
if (info != null) { info.Source = "Valkyrie Search (by name)"; return info; }
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static PsnGameInfo? ParseValkyrieJson(string json)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var doc = System.Text.Json.JsonDocument.Parse(json);
|
||||
var root = doc.RootElement;
|
||||
if (!root.TryGetProperty("included", out var included) || included.ValueKind != System.Text.Json.JsonValueKind.Array) return null;
|
||||
foreach (var item in included.EnumerateArray())
|
||||
{
|
||||
if (!item.TryGetProperty("attributes", out var attrs)) continue;
|
||||
var info = new PsnGameInfo();
|
||||
if (attrs.TryGetProperty("name", out var name)) info.Title = name.GetString() ?? "";
|
||||
if (attrs.TryGetProperty("long-description", out var ld)) info.Description = StripHtml(ld.GetString() ?? "");
|
||||
else if (attrs.TryGetProperty("description", out var sd)) info.Description = StripHtml(sd.GetString() ?? "");
|
||||
if (attrs.TryGetProperty("release-date", out var rd)) { string raw = rd.GetString() ?? ""; info.ReleaseDate = DateTime.TryParse(raw, out DateTime dt) ? dt.ToString("yyyy-MM-dd") : raw; }
|
||||
if (attrs.TryGetProperty("provider-name", out var pub)) info.Publisher = pub.GetString() ?? "";
|
||||
else if (attrs.TryGetProperty("publisher-name", out var pub2)) info.Publisher = pub2.GetString() ?? "";
|
||||
if (!string.IsNullOrWhiteSpace(info.Title)) return info;
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
return null;
|
||||
}
|
||||
|
||||
private static PsnGameInfo? ParseValkyrieSearchJson(string json, string searchName)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var doc = System.Text.Json.JsonDocument.Parse(json);
|
||||
var root = doc.RootElement;
|
||||
if (!root.TryGetProperty("included", out var included) || included.ValueKind != System.Text.Json.JsonValueKind.Array) return null;
|
||||
PsnGameInfo? bestMatch = null;
|
||||
foreach (var item in included.EnumerateArray())
|
||||
{
|
||||
if (!item.TryGetProperty("type", out var type) || type.GetString() != "game") continue;
|
||||
if (!item.TryGetProperty("attributes", out var attrs) || !attrs.TryGetProperty("name", out var nameProp)) continue;
|
||||
string name = nameProp.GetString() ?? "";
|
||||
if (string.IsNullOrWhiteSpace(name)) continue;
|
||||
if (string.Equals(name, searchName, StringComparison.OrdinalIgnoreCase) || name.Contains(searchName, StringComparison.OrdinalIgnoreCase) || searchName.Contains(name, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var info = new PsnGameInfo { Title = name };
|
||||
if (attrs.TryGetProperty("long-description", out var ld)) info.Description = StripHtml(ld.GetString() ?? "");
|
||||
if (attrs.TryGetProperty("release-date", out var rd)) { string raw = rd.GetString() ?? ""; if (DateTime.TryParse(raw, out DateTime dt)) info.ReleaseDate = dt.ToString("yyyy-MM-dd"); }
|
||||
if (attrs.TryGetProperty("provider-name", out var pub)) info.Publisher = pub.GetString() ?? "";
|
||||
if (string.Equals(name, searchName, StringComparison.OrdinalIgnoreCase)) return info;
|
||||
bestMatch ??= info;
|
||||
}
|
||||
}
|
||||
return bestMatch;
|
||||
}
|
||||
catch { }
|
||||
return null;
|
||||
}
|
||||
|
||||
private static PsnGameInfo? ParsePsnJson(string json)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var doc = System.Text.Json.JsonDocument.Parse(json);
|
||||
var root = doc.RootElement;
|
||||
var info = new PsnGameInfo();
|
||||
if (root.TryGetProperty("name", out var name)) info.Title = name.GetString() ?? "";
|
||||
else if (root.TryGetProperty("title_name", out var tn)) info.Title = tn.GetString() ?? "";
|
||||
if (root.TryGetProperty("provider_name", out var pub)) info.Publisher = pub.GetString() ?? "";
|
||||
else if (root.TryGetProperty("publisher_name", out var pub2)) info.Publisher = pub2.GetString() ?? "";
|
||||
if (root.TryGetProperty("release_date", out var rd)) { string rawDate = rd.GetString() ?? ""; info.ReleaseDate = DateTime.TryParse(rawDate, out DateTime dt) ? dt.ToString("yyyy-MM-dd") : rawDate; }
|
||||
if (root.TryGetProperty("long_desc", out var ld)) info.Description = StripHtml(ld.GetString() ?? "");
|
||||
else if (root.TryGetProperty("short_desc", out var sd)) info.Description = StripHtml(sd.GetString() ?? "");
|
||||
if (!string.IsNullOrWhiteSpace(info.Title)) return info;
|
||||
}
|
||||
catch { }
|
||||
return null;
|
||||
}
|
||||
|
||||
private static List<string> ExtractAlternateTitleIds(string paramJson)
|
||||
{
|
||||
var ids = new List<string>();
|
||||
if (string.IsNullOrWhiteSpace(paramJson)) return ids;
|
||||
var regex = new System.Text.RegularExpressions.Regex(@"(?:[A-Z]{2}\d{4}-)?(PPSA\d{5}_\d{2}|CUSA\d{5}_\d{2})");
|
||||
var matches = regex.Matches(paramJson);
|
||||
foreach (System.Text.RegularExpressions.Match m in matches)
|
||||
if (m.Groups.Count >= 2) { string id = m.Groups[1].Value; if (!ids.Contains(id)) ids.Add(id); }
|
||||
return ids;
|
||||
}
|
||||
|
||||
private static string StripHtml(string html)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(html)) return "";
|
||||
html = System.Text.RegularExpressions.Regex.Replace(html, @"<br\s*/?>", "\n", System.Text.RegularExpressions.RegexOptions.IgnoreCase);
|
||||
html = System.Text.RegularExpressions.Regex.Replace(html, @"</p>", "\n\n", System.Text.RegularExpressions.RegexOptions.IgnoreCase);
|
||||
html = System.Text.RegularExpressions.Regex.Replace(html, @"<[^>]+>", "");
|
||||
html = System.Net.WebUtility.HtmlDecode(html);
|
||||
html = System.Text.RegularExpressions.Regex.Replace(html, @"[ \t]+", " ");
|
||||
html = System.Text.RegularExpressions.Regex.Replace(html, @"\n{3,}", "\n\n");
|
||||
return html.Trim();
|
||||
}
|
||||
|
||||
private static string FormatSize(ulong bytes)
|
||||
{
|
||||
double mb = bytes / (1024.0 * 1024.0);
|
||||
return mb < 1024 ? $"{mb:F1} MB ({bytes:N0} bytes)" : $"{mb / 1024.0:F2} GB ({bytes:N0} bytes)";
|
||||
}
|
||||
|
||||
private static string FormatJson(string raw)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(raw)) return "(empty)";
|
||||
var sb = new System.Text.StringBuilder();
|
||||
int depth = 0; bool inString = false;
|
||||
foreach (char c in raw)
|
||||
{
|
||||
if (c == '"') inString = !inString;
|
||||
if (!inString)
|
||||
{
|
||||
if (c == '{' || c == '[') { sb.Append(c); sb.Append('\n'); depth++; sb.Append(new string(' ', depth * 2)); continue; }
|
||||
if (c == '}' || c == ']') { sb.Append('\n'); depth = Math.Max(0, depth - 1); sb.Append(new string(' ', depth * 2)); sb.Append(c); continue; }
|
||||
if (c == ',') { sb.Append(c); sb.Append('\n'); sb.Append(new string(' ', depth * 2)); continue; }
|
||||
}
|
||||
sb.Append(c);
|
||||
}
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private void CloseButton_Click(object? sender, RoutedEventArgs e) => Close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,788 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.IO.Compression;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Input;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.Media;
|
||||
using Avalonia.Platform.Storage;
|
||||
using Avalonia.Threading;
|
||||
|
||||
namespace PS5Upload
|
||||
{
|
||||
public partial class MainWindow
|
||||
{
|
||||
// ============================================================
|
||||
// SETTINGS
|
||||
// ============================================================
|
||||
private void LoadSettings()
|
||||
{
|
||||
try
|
||||
{
|
||||
// Renamed with the app (PS5 Suite); fall back to the legacy file.
|
||||
const string settingsFile = "ps5suite_settings.json";
|
||||
string effective = File.Exists(settingsFile) ? settingsFile
|
||||
: File.Exists("ps5upload_settings.json") ? "ps5upload_settings.json" : settingsFile;
|
||||
if (File.Exists(effective))
|
||||
{
|
||||
string json = File.ReadAllText(effective);
|
||||
var settings = System.Text.Json.JsonSerializer.Deserialize<Dictionary<string, object>>(json);
|
||||
if (settings != null)
|
||||
{
|
||||
if (settings.ContainsKey("AutoSendPayload"))
|
||||
_autoSendPayload = settings["AutoSendPayload"].ToString() == "True";
|
||||
if (settings.ContainsKey("PayloadPath"))
|
||||
_payloadPath = settings["PayloadPath"].ToString() ?? "";
|
||||
if (settings.ContainsKey("PayloadPort"))
|
||||
int.TryParse(settings["PayloadPort"].ToString(), out _payloadPort);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex) { Log($"⚠️ Failed to load settings: {ex.Message}"); }
|
||||
}
|
||||
|
||||
private void SaveSettings()
|
||||
{
|
||||
try
|
||||
{
|
||||
const string settingsFile = "ps5suite_settings.json";
|
||||
var settings = new Dictionary<string, object>
|
||||
{
|
||||
["AutoSendPayload"] = _autoSendPayload,
|
||||
["PayloadPath"] = _payloadPath,
|
||||
["PayloadPort"] = _payloadPort
|
||||
};
|
||||
string json = System.Text.Json.JsonSerializer.Serialize(settings, new System.Text.Json.JsonSerializerOptions { WriteIndented = true });
|
||||
File.WriteAllText(settingsFile, json);
|
||||
}
|
||||
catch (Exception ex) { Log($"⚠️ Failed to save settings: {ex.Message}"); }
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// CONNECTION
|
||||
// ============================================================
|
||||
private async Task AutoSendPayloadOnStartup()
|
||||
{
|
||||
await Task.Delay(1000);
|
||||
if (string.IsNullOrEmpty(_ps5IpAddress)) { Log("⚠️ Auto-send payload: No PS5 IP address configured"); return; }
|
||||
if (string.IsNullOrEmpty(_payloadPath) || !File.Exists(_payloadPath)) { Log("⚠️ Auto-send payload: Payload file not found"); return; }
|
||||
|
||||
Log($"📤 Auto-sending payload to {_ps5IpAddress}:{_payloadPort}...");
|
||||
var progress = new InlineProgress<long>(bytes => { });
|
||||
bool success = await PS5Protocol.SendPayloadAsync(_ps5IpAddress, _payloadPath, _payloadPort, progress);
|
||||
if (success)
|
||||
{
|
||||
Log($"✅ Payload sent successfully ({new FileInfo(_payloadPath).Length} bytes)");
|
||||
await Task.Delay(2000);
|
||||
await ConnectToPS5Async();
|
||||
}
|
||||
else
|
||||
Log("❌ Failed to send payload");
|
||||
}
|
||||
|
||||
private async Task ConnectToPS5Async()
|
||||
{
|
||||
if (await _protocol.ConnectAsync(_ps5IpAddress))
|
||||
{
|
||||
Log("✅ Connected to PS5 successfully");
|
||||
await Dispatcher.UIThread.InvokeAsync(() =>
|
||||
{
|
||||
ConnectButton.Content = "🟢 Disconnect";
|
||||
ConnectButton.Background = new SolidColorBrush(Colors.Green);
|
||||
UploadButton.IsEnabled = true;
|
||||
MountGamesButton.IsEnabled = true;
|
||||
});
|
||||
await LoadPS5DirectoryAsync(_currentPS5Path);
|
||||
}
|
||||
else
|
||||
Log("❌ Failed to connect to PS5");
|
||||
}
|
||||
|
||||
private CancellationTokenSource? _connectCts;
|
||||
private bool _connecting;
|
||||
|
||||
private async void AutoDetectButton_Click(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
Log("🔍 Auto-discovering PS5 on the network...");
|
||||
AutoDetectButton.IsEnabled = false;
|
||||
try
|
||||
{
|
||||
var found = await PS5Protocol.DiscoverPS5Async(3000);
|
||||
if (found == null)
|
||||
{
|
||||
Log("❌ No PS5 answered — is the payload running?");
|
||||
await ShowMessageAsync("No PS5 found on the network.\nMake sure the payload is running.", "Auto-Discovery");
|
||||
return;
|
||||
}
|
||||
IpAddressTextBox.Text = found.Value.ip;
|
||||
Log($"🔍 Found PS5 at {found.Value.ip}:{found.Value.port}");
|
||||
}
|
||||
catch (Exception ex) { Log($"❌ Discovery failed: {ex.Message}"); }
|
||||
finally { AutoDetectButton.IsEnabled = true; }
|
||||
}
|
||||
|
||||
private async void ConnectButton_Click(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
// While a connect attempt is running the button becomes Cancel
|
||||
if (_connecting)
|
||||
{
|
||||
Log("⏹ Connect cancelled by user");
|
||||
_connectCts?.Cancel();
|
||||
return;
|
||||
}
|
||||
|
||||
string ipAddress = IpAddressTextBox.Text?.Trim() ?? "";
|
||||
|
||||
// Empty IP => auto-discover: broadcast a UDP probe; the running
|
||||
// payload answers with its IP + actual bound port.
|
||||
if (string.IsNullOrEmpty(ipAddress))
|
||||
{
|
||||
Log("🔍 Auto-discovering PS5 on the network...");
|
||||
try
|
||||
{
|
||||
var found = await PS5Protocol.DiscoverPS5Async(2500);
|
||||
if (found == null)
|
||||
{
|
||||
Log("❌ No PS5 answered the discovery probe");
|
||||
await ShowMessageAsync("No PS5 found on the network.\nMake sure the payload is running, or enter the IP manually.", "Auto-Discovery");
|
||||
return;
|
||||
}
|
||||
ipAddress = found.Value.ip;
|
||||
Log($"🔍 Found PS5 at {ipAddress}:{found.Value.port}");
|
||||
await Dispatcher.UIThread.InvokeAsync(() => IpAddressTextBox.Text = ipAddress);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log($"❌ Discovery failed: {ex.Message}");
|
||||
await ShowMessageAsync($"Discovery failed:\n{ex.Message}", "Auto-Discovery");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
_ps5IpAddress = ipAddress;
|
||||
|
||||
if (_protocol.IsConnected)
|
||||
{
|
||||
ConnectButton.IsEnabled = false;
|
||||
Log("🔌 Disconnecting from PS5...");
|
||||
Log("🔌 Disconnecting from PS5...");
|
||||
StopHwAutoRefresh();
|
||||
_protocol.Disconnect();
|
||||
_shellActive = false;
|
||||
_shellCurrentDir = "/data";
|
||||
|
||||
await Dispatcher.UIThread.InvokeAsync(() =>
|
||||
{
|
||||
ConnectButton.Content = "🔴 Disconnected";
|
||||
ConnectButton.Background = new SolidColorBrush(Colors.DarkRed);
|
||||
UploadButton.IsEnabled = false;
|
||||
MountGamesButton.IsEnabled = false;
|
||||
_ps5Files.Clear();
|
||||
});
|
||||
Log("✅ Disconnected from PS5");
|
||||
|
||||
await Task.Delay(1000);
|
||||
await Dispatcher.UIThread.InvokeAsync(() =>
|
||||
{
|
||||
ConnectButton.Content = "🔵 Connect";
|
||||
ConnectButton.Background = new SolidColorBrush(Color.FromRgb(0, 122, 204));
|
||||
});
|
||||
ConnectButton.IsEnabled = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
Log($"Connecting to PS5 at {ipAddress}...");
|
||||
_connecting = true;
|
||||
_connectCts = new CancellationTokenSource();
|
||||
await Dispatcher.UIThread.InvokeAsync(() =>
|
||||
{
|
||||
ConnectButton.Content = "⏹ Cancel";
|
||||
ConnectButton.Background = new SolidColorBrush(Colors.DarkOrange);
|
||||
});
|
||||
try
|
||||
{
|
||||
if (await _protocol.ConnectAsync(ipAddress, 9113, _connectCts.Token))
|
||||
{
|
||||
Log("✅ Connected to PS5 successfully");
|
||||
await Dispatcher.UIThread.InvokeAsync(() =>
|
||||
{
|
||||
ConnectButton.Content = "🟢 Disconnect";
|
||||
ConnectButton.Background = new SolidColorBrush(Colors.DarkGreen);
|
||||
UploadButton.IsEnabled = true;
|
||||
MountGamesButton.IsEnabled = true;
|
||||
});
|
||||
await OpenShellAsync();
|
||||
await LoadPS5DirectoryAsync(_currentPS5Path);
|
||||
await RefreshStorageInfoAsync();
|
||||
}
|
||||
else
|
||||
{
|
||||
Log("❌ Failed to connect to PS5");
|
||||
await ShowMessageAsync("Failed to connect to PS5. Make sure the payload is running.", "Connection Error");
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
Log("⏹ Connect cancelled");
|
||||
}
|
||||
finally
|
||||
{
|
||||
_connecting = false;
|
||||
_connectCts?.Dispose();
|
||||
_connectCts = null;
|
||||
bool stillConnected = _protocol.IsConnected;
|
||||
await Dispatcher.UIThread.InvokeAsync(() =>
|
||||
{
|
||||
if (!stillConnected)
|
||||
{
|
||||
ConnectButton.Content = "🔵 Connect";
|
||||
ConnectButton.Background = new SolidColorBrush(Color.FromRgb(0, 122, 204));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// PS5 DIRECTORY BROWSING
|
||||
// ============================================================
|
||||
private async Task LoadPS5DirectoryAsync(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!_protocol.IsConnected)
|
||||
{
|
||||
Log($"⚠️ Connection lost (LastError: {_protocol.LastError}), reconnecting...");
|
||||
if (!await _protocol.ConnectAsync(_ps5IpAddress))
|
||||
{
|
||||
await ShowMessageAsync("Failed to reconnect to PS5", "Connection Error");
|
||||
return;
|
||||
}
|
||||
Log("✅ Reconnected successfully");
|
||||
}
|
||||
var sw = System.Diagnostics.Stopwatch.StartNew();
|
||||
_currentPS5Path = path;
|
||||
CurrentPathTextBox.Text = path;
|
||||
|
||||
var entries = await _protocol.ListDirAsync(path);
|
||||
Log($"🔍 DEBUG: ListDir returned {entries.Count()} entries for path: {path}");
|
||||
foreach (var entry in entries.Take(5))
|
||||
Log($" - {(entry.IsDirectory ? "📁" : "📄")} {entry.Name} ({entry.Size} bytes)");
|
||||
if (entries.Count() > 5)
|
||||
Log($" ... and {entries.Count() - 5} more");
|
||||
|
||||
var items = new List<PS5FileItem>();
|
||||
if (path != "/")
|
||||
{
|
||||
string? parentPath = Path.GetDirectoryName(path);
|
||||
if (string.IsNullOrEmpty(parentPath)) parentPath = "/";
|
||||
else parentPath = parentPath.Replace("\\", "/");
|
||||
items.Add(new PS5FileItem { Name = "..", FullPath = parentPath, Icon = "📁", IsDirectory = true, Size = 0 });
|
||||
}
|
||||
|
||||
foreach (var entry in entries)
|
||||
{
|
||||
items.Add(new PS5FileItem
|
||||
{
|
||||
Name = entry.Name,
|
||||
IsDirectory = entry.IsDirectory,
|
||||
Size = entry.Size,
|
||||
FullPath = $"{path}/{entry.Name}".Replace("//", "/"),
|
||||
Icon = entry.IsDirectory ? "📁" : "📄"
|
||||
});
|
||||
}
|
||||
|
||||
var sortedItems = items.OrderBy(i => i.IsDirectory ? 0 : 1).ThenBy(i => i.Name, StringComparer.OrdinalIgnoreCase).ToList();
|
||||
|
||||
_ps5Files.Clear();
|
||||
foreach (var item in sortedItems) _ps5Files.Add(item);
|
||||
|
||||
var t4 = sw.ElapsedMilliseconds;
|
||||
Log("📂 Loaded " + entries.Count().ToString() + " items (Total: " + t4.ToString() + "ms)");
|
||||
ApplySearchFilter();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
if (_protocol.IsConnected)
|
||||
{
|
||||
Log($"❌ Failed to load directory: {ex.Message}");
|
||||
await ShowMessageAsync($"Failed to load directory: {ex.Message}", "Error");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void ApplySearchFilter()
|
||||
{
|
||||
_ps5FilesFiltered.Clear();
|
||||
if (string.IsNullOrWhiteSpace(_searchQuery))
|
||||
{
|
||||
foreach (var file in _ps5Files) _ps5FilesFiltered.Add(file);
|
||||
}
|
||||
else
|
||||
{
|
||||
string query = _searchQuery.ToLower();
|
||||
foreach (var file in _ps5Files)
|
||||
if (file.Name.ToLower().Contains(query))
|
||||
_ps5FilesFiltered.Add(file);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// LOCAL FILES
|
||||
// ============================================================
|
||||
private async void BrowseFilesButton_Click(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
var topLevel = TopLevel.GetTopLevel(this);
|
||||
if (topLevel == null) return;
|
||||
var files = await topLevel.StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions { Title = "Select files to upload", AllowMultiple = true });
|
||||
foreach (var file in files)
|
||||
{
|
||||
var path = file.TryGetLocalPath();
|
||||
if (path != null)
|
||||
{
|
||||
FileInfo info = new FileInfo(path);
|
||||
|
||||
// Check if it's a ZIP file - extract and add contents
|
||||
if (info.Extension.Equals(".zip", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
await ExtractAndAddZipContents(path, info.Name);
|
||||
}
|
||||
else
|
||||
{
|
||||
_localFiles.Add(new LocalFileItem { Name = info.Name, FullPath = path, Icon = "📄", IsDirectory = false, Size = info.Length });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async void BrowseFolderButton_Click(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
var topLevel = TopLevel.GetTopLevel(this);
|
||||
if (topLevel == null) return;
|
||||
var folders = await topLevel.StorageProvider.OpenFolderPickerAsync(new FolderPickerOpenOptions { Title = "Select folder to upload", AllowMultiple = false });
|
||||
if (folders.Count > 0)
|
||||
{
|
||||
var path = folders[0].TryGetLocalPath();
|
||||
if (path != null)
|
||||
{
|
||||
DirectoryInfo dirInfo = new DirectoryInfo(path);
|
||||
_localFiles.Add(new LocalFileItem { Name = dirInfo.Name, FullPath = path, Icon = "📁", IsDirectory = true, Size = 0 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void ClearLocalFiles_Click(object? sender, RoutedEventArgs e) => _localFiles.Clear();
|
||||
|
||||
private void LocalFilesListBox_DragOver(object? sender, DragEventArgs e) => e.DragEffects = DragDropEffects.Copy;
|
||||
|
||||
private void LocalFilesListBox_Drop(object? sender, DragEventArgs e)
|
||||
{
|
||||
// Avalonia 12: Use TryGetFiles() on DataTransfer
|
||||
if (e.DataTransfer.TryGetFiles() is { } files)
|
||||
{
|
||||
foreach (var item in files)
|
||||
{
|
||||
var path = item.Path.LocalPath;
|
||||
if (path == null) continue;
|
||||
|
||||
if (File.Exists(path))
|
||||
{
|
||||
FileInfo info = new FileInfo(path);
|
||||
|
||||
// Check if it's a ZIP file - extract and add contents
|
||||
if (info.Extension.Equals(".zip", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
_ = ExtractAndAddZipContents(path, info.Name);
|
||||
}
|
||||
else
|
||||
{
|
||||
_localFiles.Add(new LocalFileItem { Name = info.Name, FullPath = path, Icon = "📄", IsDirectory = false, Size = info.Length });
|
||||
}
|
||||
}
|
||||
else if (Directory.Exists(path))
|
||||
{
|
||||
DirectoryInfo dirInfo = new DirectoryInfo(path);
|
||||
_localFiles.Add(new LocalFileItem { Name = dirInfo.Name, FullPath = path, Icon = "📁", IsDirectory = true, Size = 0 });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// ZIP ARCHIVE SUPPORT (ps5upload-style)
|
||||
// Extracts .zip files and adds contents to upload queue
|
||||
// ============================================================
|
||||
private async Task ExtractAndAddZipContents(string zipPath, string zipName)
|
||||
{
|
||||
try
|
||||
{
|
||||
Log($"📦 Extracting ZIP: {zipName}");
|
||||
|
||||
// Create temp extraction directory
|
||||
string tempDir = Path.Combine(Path.GetTempPath(), "ps5suite_" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(tempDir);
|
||||
|
||||
// Extract ZIP
|
||||
await Task.Run(() => System.IO.Compression.ZipFile.ExtractToDirectory(zipPath, tempDir));
|
||||
|
||||
// Add all extracted files
|
||||
var files = Directory.GetFiles(tempDir, "*", SearchOption.AllDirectories);
|
||||
var dirs = Directory.GetDirectories(tempDir, "*", SearchOption.AllDirectories);
|
||||
|
||||
Log($"📦 Found {files.Length} files, {dirs.Length} folders in ZIP");
|
||||
|
||||
// Add root directory as a folder item
|
||||
string zipBaseName = Path.GetFileNameWithoutExtension(zipName);
|
||||
_localFiles.Add(new LocalFileItem {
|
||||
Name = zipBaseName,
|
||||
FullPath = tempDir,
|
||||
Icon = "📁",
|
||||
IsDirectory = true,
|
||||
Size = 0
|
||||
});
|
||||
|
||||
Log($"✅ ZIP extracted to temp folder - added '{zipBaseName}' to upload queue");
|
||||
Log($"⚠️ Will upload extracted contents, not the .zip file");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log($"❌ Failed to extract ZIP: {ex.Message}");
|
||||
// Fall back to adding the zip file itself
|
||||
FileInfo info = new FileInfo(zipPath);
|
||||
_localFiles.Add(new LocalFileItem { Name = info.Name, FullPath = zipPath, Icon = "📄", IsDirectory = false, Size = info.Length });
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// PS5 FILE OPERATIONS
|
||||
// ============================================================
|
||||
private void PS5FilesListBox_DoubleTapped(object? sender, TappedEventArgs e)
|
||||
{
|
||||
if (PS5FilesListBox.SelectedItem is PS5FileItem item && item.IsDirectory)
|
||||
_ = LoadPS5DirectoryAsync(item.FullPath);
|
||||
}
|
||||
|
||||
private void ClearSearchButton_Click(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
SearchTextBox.Text = "";
|
||||
_searchQuery = "";
|
||||
ApplySearchFilter();
|
||||
}
|
||||
|
||||
private void GoButton_Click(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
string path = CurrentPathTextBox.Text?.Trim() ?? "";
|
||||
if (!string.IsNullOrEmpty(path)) _ = LoadPS5DirectoryAsync(path);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// NAS/SMB SUPPORT (ps5upload-style)
|
||||
// Adds network paths (\\server\share\folder) to upload queue
|
||||
// ============================================================
|
||||
private async void AddNasPath_Click(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
try
|
||||
{
|
||||
string? nasPath = await ShowInputDialogAsync(
|
||||
"Add NAS/Network Path",
|
||||
"Enter UNC path (\\\\server\\share\\folder):",
|
||||
"\\\\NAS\\games");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(nasPath)) return;
|
||||
|
||||
nasPath = nasPath.Trim();
|
||||
|
||||
// Validate UNC path format
|
||||
if (!nasPath.StartsWith("\\\\") && !nasPath.StartsWith("//"))
|
||||
{
|
||||
await ShowMessageAsync(
|
||||
"Invalid path format.\n\n" +
|
||||
"Use UNC format: \\\\server\\share\\folder\n" +
|
||||
"Example: \\\\NAS\\games\\PS4\\MyGame",
|
||||
"Invalid Path");
|
||||
return;
|
||||
}
|
||||
|
||||
// Normalize path (convert // to \\ for Windows)
|
||||
if (nasPath.StartsWith("//"))
|
||||
{
|
||||
nasPath = "\\\\" + nasPath.Substring(2).Replace("/", "\\");
|
||||
}
|
||||
|
||||
Log($"🌐 Checking NAS path: {nasPath}");
|
||||
|
||||
// Check if path exists
|
||||
if (Directory.Exists(nasPath))
|
||||
{
|
||||
// It's a directory - add it
|
||||
DirectoryInfo dirInfo = new DirectoryInfo(nasPath);
|
||||
_localFiles.Add(new LocalFileItem {
|
||||
Name = dirInfo.Name,
|
||||
FullPath = nasPath,
|
||||
Icon = "🌐",
|
||||
IsDirectory = true,
|
||||
Size = 0
|
||||
});
|
||||
Log($"✅ Added NAS folder: {dirInfo.Name} ({nasPath})");
|
||||
}
|
||||
else if (File.Exists(nasPath))
|
||||
{
|
||||
// It's a file - add it
|
||||
FileInfo fileInfo = new FileInfo(nasPath);
|
||||
|
||||
// Check if it's a ZIP file
|
||||
if (fileInfo.Extension.Equals(".zip", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
await ExtractAndAddZipContents(nasPath, fileInfo.Name);
|
||||
}
|
||||
else
|
||||
{
|
||||
_localFiles.Add(new LocalFileItem {
|
||||
Name = fileInfo.Name,
|
||||
FullPath = nasPath,
|
||||
Icon = "🌐",
|
||||
IsDirectory = false,
|
||||
Size = fileInfo.Length
|
||||
});
|
||||
Log($"✅ Added NAS file: {fileInfo.Name} ({FormatFileSize(fileInfo.Length)})");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
Log($"❌ NAS path not found: {nasPath}");
|
||||
await ShowMessageAsync(
|
||||
$"Cannot access: {nasPath}\n\n" +
|
||||
"Make sure:\n" +
|
||||
"• The network share is accessible\n" +
|
||||
"• You have permission to read it\n" +
|
||||
"• The path is correct",
|
||||
"NAS Path Not Found");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log($"❌ NAS error: {ex.Message}");
|
||||
await ShowMessageAsync($"Error accessing NAS path: {ex.Message}", "Error");
|
||||
}
|
||||
}
|
||||
|
||||
private async void RenameMenuItem_Click(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (PS5FilesListBox.SelectedItem is PS5FileItem item)
|
||||
{
|
||||
string? newName = await ShowInputDialogAsync("Rename", "New name:", item.Name);
|
||||
if (!string.IsNullOrWhiteSpace(newName))
|
||||
{
|
||||
try
|
||||
{
|
||||
string newPath = Path.Combine(Path.GetDirectoryName(item.FullPath)?.Replace("\\", "/") ?? "", newName).Replace("\\", "/");
|
||||
await _protocol.RenameAsync(item.FullPath, newPath);
|
||||
Log($"✅ Renamed {item.Name} to {newName}");
|
||||
await LoadPS5DirectoryAsync(_currentPS5Path);
|
||||
}
|
||||
catch (Exception ex) { await ShowMessageAsync($"Rename failed: {ex.Message}", "Error"); Log($"❌ Rename failed: {ex.Message}"); }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async void CopyMenuItem_Click(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (PS5FilesListBox.SelectedItem is PS5FileItem item)
|
||||
{
|
||||
string? destPath = await ShowInputDialogAsync("Copy To", "Destination path:", _currentPS5Path + "/" + item.Name);
|
||||
if (destPath != null)
|
||||
{
|
||||
try { await _protocol.CopyFileAsync(item.FullPath, destPath); Log($"✅ Copied {item.Name} to {destPath}"); await LoadPS5DirectoryAsync(_currentPS5Path); }
|
||||
catch (Exception ex) { await ShowMessageAsync($"Copy failed: {ex.Message}", "Error"); Log($"❌ Copy failed: {ex.Message}"); }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async void MoveMenuItem_Click(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (PS5FilesListBox.SelectedItem is PS5FileItem item)
|
||||
{
|
||||
string? destPath = await ShowInputDialogAsync("Move To", "Destination path:", _currentPS5Path + "/" + item.Name);
|
||||
if (destPath != null)
|
||||
{
|
||||
try { await _protocol.RenameAsync(item.FullPath, destPath); Log($"✅ Moved {item.Name} to {destPath}"); await LoadPS5DirectoryAsync(_currentPS5Path); }
|
||||
catch (Exception ex) { await ShowMessageAsync($"Move failed: {ex.Message}", "Error"); Log($"❌ Move failed: {ex.Message}"); }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async void DeleteMenuItem_Click(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (PS5FilesListBox.SelectedItem is PS5FileItem item)
|
||||
{
|
||||
if (await ShowConfirmAsync($"Delete {item.Name}?"))
|
||||
{
|
||||
try
|
||||
{
|
||||
if (item.IsDirectory) { Log($"🗑️ Deleting folder: {item.Name}"); await _protocol.DeleteDirAsync(item.FullPath); Log($"✅ Folder deletion complete: {item.Name}"); }
|
||||
else { Log($"🗑️ Deleting file: {item.Name}"); await _protocol.DeleteFileAsync(item.FullPath); Log($"✅ File deletion complete: {item.Name}"); }
|
||||
await Task.Delay(1500);
|
||||
await LoadPS5DirectoryAsync(_currentPS5Path);
|
||||
}
|
||||
catch (Exception ex) { await ShowMessageAsync($"Delete failed: {ex.Message}", "Error"); Log($"❌ Delete failed: {ex.Message}"); }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async void DeleteSelectedMenuItem_Click(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
var selectedItems = (PS5FilesListBox.SelectedItems ?? Array.Empty<object>()).Cast<PS5FileItem>().ToList();
|
||||
if (selectedItems.Count == 0) { await ShowMessageAsync("No items selected."); return; }
|
||||
|
||||
int folderCount = selectedItems.Count(i => i.IsDirectory);
|
||||
int fileCount = selectedItems.Count(i => !i.IsDirectory);
|
||||
string message = $"Delete {selectedItems.Count} items?\n\n";
|
||||
if (folderCount > 0) message += $"📁 {folderCount} folders\n";
|
||||
if (fileCount > 0) message += $"📄 {fileCount} files";
|
||||
|
||||
if (!await ShowConfirmAsync(message, "Confirm Bulk Delete")) return;
|
||||
|
||||
Log($"🗑️ Starting bulk delete of {selectedItems.Count} items...");
|
||||
int successCount = 0, failCount = 0;
|
||||
foreach (var item in selectedItems)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (item.IsDirectory) await _protocol.DeleteDirAsync(item.FullPath);
|
||||
else await _protocol.DeleteFileAsync(item.FullPath);
|
||||
successCount++;
|
||||
Log($"✅ Deleted: {item.Name}");
|
||||
await Task.Delay(300);
|
||||
}
|
||||
catch (Exception ex) { Log($"❌ Failed to delete {item.Name}: {ex.Message}"); failCount++; }
|
||||
}
|
||||
Log($"🗑️ Bulk delete complete: {successCount} succeeded, {failCount} failed");
|
||||
await Task.Delay(500);
|
||||
await LoadPS5DirectoryAsync(_currentPS5Path);
|
||||
await ShowMessageAsync($"Bulk delete complete!\n\n✅ {successCount} deleted\n❌ {failCount} failed");
|
||||
}
|
||||
|
||||
private async void DownloadMenuItem_Click(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (PS5FilesListBox.SelectedItem is PS5FileItem item)
|
||||
{
|
||||
var topLevel = TopLevel.GetTopLevel(this);
|
||||
if (topLevel == null) return;
|
||||
|
||||
if (item.IsDirectory)
|
||||
{
|
||||
var folders = await topLevel.StorageProvider.OpenFolderPickerAsync(new FolderPickerOpenOptions { Title = $"Select destination for '{item.Name}' folder" });
|
||||
if (folders.Count == 0) return;
|
||||
var folderPath = folders[0].TryGetLocalPath();
|
||||
if (folderPath == null) return;
|
||||
string localBasePath = Path.Combine(folderPath, item.Name);
|
||||
|
||||
try
|
||||
{
|
||||
Log($"⬇️ Downloading folder: {item.FullPath} → {localBasePath}");
|
||||
_uploadCancellation = new CancellationTokenSource();
|
||||
var ct = _uploadCancellation.Token;
|
||||
int dlCallCount = 0;
|
||||
var progress = new InlineProgress<DownloadFolderProgress>(p =>
|
||||
{
|
||||
if (++dlCallCount % 8 != 0 && p.Phase != "Complete") return;
|
||||
Dispatcher.UIThread.Post(() =>
|
||||
{
|
||||
if (p.Phase == "Scanning")
|
||||
TotalProgressText.Text = $"Scanning: {p.FilesCompleted} files found... ({p.CurrentFile})";
|
||||
else if (p.Phase == "Downloading")
|
||||
{
|
||||
double percent = p.TotalBytes > 0 ? (double)p.BytesDownloaded / p.TotalBytes * 100 : 0;
|
||||
TotalProgressBar.Value = percent;
|
||||
TotalProgressText.Text = $"Downloading: {p.FilesCompleted}/{p.TotalFiles} files ({FormatFileSize(p.BytesDownloaded)}/{FormatFileSize(p.TotalBytes)}) [{percent:F1}%]";
|
||||
if (!string.IsNullOrEmpty(p.CurrentFile)) UploadFileNameText.Text = $"⬇️ {p.CurrentFile}";
|
||||
}
|
||||
else if (p.Phase == "Complete")
|
||||
{
|
||||
TotalProgressBar.Value = 100;
|
||||
TotalProgressText.Text = $"Complete: {p.FilesCompleted}/{p.TotalFiles} files ({FormatFileSize(p.BytesDownloaded)})";
|
||||
}
|
||||
});
|
||||
});
|
||||
var (downloaded, failed, totalBytes) = await _protocol.DownloadFolderAsync(item.FullPath, localBasePath, _ps5IpAddress, progress, ct);
|
||||
Log($"✅ Folder download complete: {downloaded} files ({FormatFileSize(totalBytes)}), {failed} failed");
|
||||
await ShowMessageAsync($"Folder downloaded!\n\nFiles: {downloaded} downloaded, {failed} failed\nSize: {FormatFileSize(totalBytes)}\nSaved to: {localBasePath}", "Download Complete");
|
||||
}
|
||||
catch (Exception ex) { Log($"❌ Folder download error: {ex.Message}"); await ShowMessageAsync($"Folder download error: {ex.Message}", "Error"); }
|
||||
finally { _uploadCancellation = null; TotalProgressBar.Value = 0; TotalProgressText.Text = ""; UploadFileNameText.Text = ""; }
|
||||
return;
|
||||
}
|
||||
|
||||
// Single file download
|
||||
var saveFile = await topLevel.StorageProvider.SaveFilePickerAsync(new FilePickerSaveOptions { Title = "Save Downloaded File", SuggestedFileName = item.Name });
|
||||
if (saveFile != null)
|
||||
{
|
||||
var savePath = saveFile.TryGetLocalPath();
|
||||
if (savePath == null) return;
|
||||
try
|
||||
{
|
||||
Log($"⬇️ Downloading: {item.Name} ({FormatFileSize(item.Size)})");
|
||||
int sfCallCount = 0;
|
||||
var progress = new InlineProgress<UploadProgress>(p =>
|
||||
{
|
||||
if (++sfCallCount % 8 != 0 && p.BytesSent != p.TotalBytes) return;
|
||||
Dispatcher.UIThread.Post(() =>
|
||||
{
|
||||
double percent = p.TotalBytes > 0 ? (double)p.BytesSent / p.TotalBytes * 100 : 0;
|
||||
TotalProgressBar.Value = percent;
|
||||
TotalProgressText.Text = $"Downloading: {FormatFileSize(p.BytesSent)}/{FormatFileSize(p.TotalBytes)} ({percent:F1}%) @ {FormatFileSize((long)p.SpeedBytesPerSecond)}/s";
|
||||
});
|
||||
});
|
||||
bool success = await _protocol.DownloadFileAsync(item.FullPath, savePath, progress);
|
||||
if (success) { Log($"✅ Downloaded: {item.Name} → {savePath}"); await ShowMessageAsync($"File downloaded successfully!\n\nSaved to: {savePath}", "Success"); }
|
||||
else { Log($"❌ Download failed: {item.Name}"); await ShowMessageAsync("Download failed", "Error"); }
|
||||
}
|
||||
catch (Exception ex) { Log($"❌ Download error: {ex.Message}"); await ShowMessageAsync($"Download error: {ex.Message}", "Error"); }
|
||||
finally { TotalProgressBar.Value = 0; TotalProgressText.Text = ""; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// LOG BUTTONS
|
||||
// ============================================================
|
||||
private void ClearLogButton_Click(object? sender, RoutedEventArgs e) { LogTextBox.Text = ""; Log("Log cleared"); }
|
||||
|
||||
private async void CopyLogButton_Click(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
try
|
||||
{
|
||||
var clipboard = TopLevel.GetTopLevel(this)?.Clipboard;
|
||||
if (clipboard != null)
|
||||
{
|
||||
var item = new Avalonia.Input.DataTransferItem();
|
||||
item.Set(Avalonia.Input.DataFormat.Text, LogTextBox.Text ?? "");
|
||||
var data = new Avalonia.Input.DataTransfer();
|
||||
data.Add(item);
|
||||
await clipboard.SetDataAsync(data);
|
||||
Log("📋 Log copied to clipboard!");
|
||||
}
|
||||
}
|
||||
catch (Exception ex) { Log($"⚠️ Clipboard error: {ex.Message}"); }
|
||||
}
|
||||
|
||||
private async Task<PS5StorageInfo?> FetchStorageInfoWithFallbackAsync()
|
||||
{
|
||||
bool uploadsIdle = (_uploadCancellation == null || _uploadCancellation.IsCancellationRequested) && _activeTaskCount == 0;
|
||||
if (_protocol.IsConnected && uploadsIdle)
|
||||
return await _protocol.ListStorageAsync();
|
||||
|
||||
var tempProtocol = new PS5Protocol();
|
||||
try
|
||||
{
|
||||
if (!await tempProtocol.ConnectAsync(_ps5IpAddress)) { Log("❌ Storage refresh: failed to open temporary connection"); return null; }
|
||||
return await tempProtocol.ListStorageAsync();
|
||||
}
|
||||
finally { tempProtocol.Dispose(); }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Threading.Tasks;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.Platform.Storage;
|
||||
using Avalonia.Threading;
|
||||
|
||||
namespace PS5Upload
|
||||
{
|
||||
// Kernel log viewer (sysctl kern.msgbuf).
|
||||
public partial class MainWindow
|
||||
{
|
||||
private DispatcherTimer? _klogTimer;
|
||||
private bool _klogBusy;
|
||||
|
||||
private int KlogTailBytes => KlogTailSelect.SelectedIndex switch
|
||||
{
|
||||
0 => 32 * 1024,
|
||||
1 => 128 * 1024,
|
||||
_ => 0 // full buffer
|
||||
};
|
||||
|
||||
private async void KlogRefresh_Click(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (_klogBusy) return;
|
||||
if (!_protocol.IsConnected) { KlogStatus.Text = "Not connected"; return; }
|
||||
_klogBusy = true;
|
||||
KlogStatus.Text = "Reading kern.msgbuf…";
|
||||
var txt = await _protocol.GetKernelLogAsync(KlogTailBytes);
|
||||
_klogBusy = false;
|
||||
if (txt == null)
|
||||
{
|
||||
KlogStatus.Text = $"❌ {_protocol.LastError}";
|
||||
return;
|
||||
}
|
||||
KlogText.Text = txt;
|
||||
KlogStatus.Text = $"{txt.Length:N0} bytes · {DateTime.Now:HH:mm:ss}";
|
||||
}
|
||||
|
||||
private void KlogAutoRefresh_Changed(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (KlogAutoRefresh.IsChecked == true)
|
||||
{
|
||||
_klogTimer ??= new DispatcherTimer { Interval = TimeSpan.FromSeconds(3) };
|
||||
_klogTimer.Tick -= KlogTimer_Tick;
|
||||
_klogTimer.Tick += KlogTimer_Tick;
|
||||
_klogTimer.Start();
|
||||
KlogRefresh_Click(sender, e);
|
||||
}
|
||||
else
|
||||
{
|
||||
_klogTimer?.Stop();
|
||||
}
|
||||
}
|
||||
|
||||
private async void KlogTimer_Tick(object? sender, EventArgs e)
|
||||
{
|
||||
if (_klogBusy || !_protocol.IsConnected) return;
|
||||
_klogBusy = true;
|
||||
var txt = await _protocol.GetKernelLogAsync(KlogTailBytes);
|
||||
_klogBusy = false;
|
||||
if (txt != null)
|
||||
{
|
||||
KlogText.Text = txt;
|
||||
KlogStatus.Text = $"{txt.Length:N0} bytes · {DateTime.Now:HH:mm:ss} (auto)";
|
||||
}
|
||||
}
|
||||
|
||||
private async void KlogCopy_Click(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
var cb = TopLevel.GetTopLevel(this)?.Clipboard;
|
||||
if (cb != null && !string.IsNullOrEmpty(KlogText.Text))
|
||||
{
|
||||
var item = new Avalonia.Input.DataTransferItem();
|
||||
item.Set(Avalonia.Input.DataFormat.Text, KlogText.Text);
|
||||
var data = new Avalonia.Input.DataTransfer();
|
||||
data.Add(item);
|
||||
await cb.SetDataAsync(data);
|
||||
KlogStatus.Text = "Copied to clipboard";
|
||||
}
|
||||
}
|
||||
|
||||
private async void KlogSave_Click(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (string.IsNullOrEmpty(KlogText.Text)) { KlogStatus.Text = "Nothing to save — refresh first"; return; }
|
||||
var top = TopLevel.GetTopLevel(this);
|
||||
if (top == null) return;
|
||||
var file = await top.StorageProvider.SaveFilePickerAsync(new FilePickerSaveOptions
|
||||
{
|
||||
Title = "Save kernel log",
|
||||
SuggestedFileName = $"klog_{DateTime.Now:yyyyMMdd_HHmmss}.txt"
|
||||
});
|
||||
var dest = file?.TryGetLocalPath();
|
||||
if (dest == null) return;
|
||||
try
|
||||
{
|
||||
await File.WriteAllTextAsync(dest, KlogText.Text);
|
||||
KlogStatus.Text = $"Saved to {Path.GetFileName(dest)}";
|
||||
}
|
||||
catch (Exception ex) { KlogStatus.Text = $"Save failed: {ex.Message}"; }
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Interactivity;
|
||||
|
||||
namespace PS5Upload
|
||||
{
|
||||
// Memory editor — ptrace/PT_IO read-write-search on PS5 processes.
|
||||
public partial class MainWindow
|
||||
{
|
||||
private List<(int pid, string name)> _memProcs = new();
|
||||
private readonly List<ulong> _memResults = new();
|
||||
private int _memPid = -1;
|
||||
private int _memPatternLen = 4;
|
||||
private bool _memScanning;
|
||||
|
||||
// ---------- process list ----------
|
||||
|
||||
private async void MemRefreshProcesses_Click(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (!_protocol.IsConnected) { MemStatus.Text = "Not connected"; return; }
|
||||
MemStatus.Text = "Loading processes...";
|
||||
_memProcs = await _protocol.GetProcessListAsync();
|
||||
ApplyMemProcFilter();
|
||||
MemStatus.Text = _memProcs.Count > 0
|
||||
? $"{_memProcs.Count} processes — select one"
|
||||
: $"Process list failed: {_protocol.LastError}";
|
||||
}
|
||||
|
||||
private void MemProcessFilter_KeyUp(object? sender, Avalonia.Input.KeyEventArgs e) => ApplyMemProcFilter();
|
||||
|
||||
private void ApplyMemProcFilter()
|
||||
{
|
||||
var f = (MemProcessFilter.Text ?? "").Trim();
|
||||
MemProcessList.ItemsSource = _memProcs
|
||||
.Where(p => f.Length == 0 || p.name.Contains(f, StringComparison.OrdinalIgnoreCase) || p.pid.ToString().Contains(f))
|
||||
.Select(p => $"{p.pid,6} {p.name}")
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private async void MemProcessList_SelectionChanged(object? sender, SelectionChangedEventArgs e)
|
||||
{
|
||||
var s = MemProcessList.SelectedItem?.ToString();
|
||||
if (s == null) return;
|
||||
var first = s.Trim().Split(' ', StringSplitOptions.RemoveEmptyEntries).FirstOrDefault() ?? "";
|
||||
if (!int.TryParse(first, out int pid)) return;
|
||||
|
||||
_memPid = pid;
|
||||
_memResults.Clear();
|
||||
MemResultsList.ItemsSource = null;
|
||||
MemNextScanButton.IsEnabled = false;
|
||||
MemStatus.Text = $"Loading regions for pid {pid}...";
|
||||
|
||||
var txt = await _protocol.MemRegionsAsync(pid);
|
||||
if (txt == null) { MemStatus.Text = $"vmmap failed: {_protocol.LastError}"; return; }
|
||||
var regions = txt.Split('\n', StringSplitOptions.RemoveEmptyEntries).ToList();
|
||||
MemRegionsList.ItemsSource = regions;
|
||||
MemStatus.Text = $"pid {pid}: {regions.Count} regions — pick one or scan a range";
|
||||
}
|
||||
|
||||
private void MemRegionsList_SelectionChanged(object? sender, SelectionChangedEventArgs e)
|
||||
{
|
||||
var s = MemRegionsList.SelectedItem?.ToString();
|
||||
if (s == null) return;
|
||||
// "start-end|prot|size|path"
|
||||
var range = s.Split('|')[0].Split('-');
|
||||
if (range.Length == 2)
|
||||
{
|
||||
MemScanStart.Text = "0x" + range[0];
|
||||
MemScanEnd.Text = "0x" + range[1];
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- search ----------
|
||||
|
||||
private byte[]? EncodeSearchValue()
|
||||
{
|
||||
var type = (MemValueType.SelectedItem as ComboBoxItem)?.Content?.ToString() ?? "u32";
|
||||
var v = (MemValueInput.Text ?? "").Trim();
|
||||
try
|
||||
{
|
||||
switch (type)
|
||||
{
|
||||
case "u8": return new[] { ParseMemNumber<byte>(v, byte.TryParse) };
|
||||
case "u16": return BitConverter.GetBytes(ParseMemNumber<ushort>(v, ushort.TryParse));
|
||||
case "u32": return BitConverter.GetBytes(ParseMemNumber<uint>(v, uint.TryParse));
|
||||
case "u64": return BitConverter.GetBytes(ParseMemNumber<ulong>(v, ulong.TryParse));
|
||||
case "float": return BitConverter.GetBytes(float.Parse(v, CultureInfo.InvariantCulture));
|
||||
case "bytes": return Convert.FromHexString(v.Replace(" ", "").Replace("0x", ""));
|
||||
default: return null;
|
||||
}
|
||||
}
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
private delegate bool TryNum<T>(string s, NumberStyles st, IFormatProvider fp, out T val);
|
||||
private static T ParseMemNumber<T>(string v, TryNum<T> tryParse) where T : struct
|
||||
{
|
||||
v = v.Trim();
|
||||
if (v.StartsWith("0x", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (tryParse(v[2..], NumberStyles.HexNumber, CultureInfo.InvariantCulture, out T r)) return r;
|
||||
}
|
||||
if (tryParse(v, NumberStyles.Integer, CultureInfo.InvariantCulture, out T d)) return d;
|
||||
if (tryParse(v, NumberStyles.HexNumber, CultureInfo.InvariantCulture, out T h)) return h;
|
||||
throw new FormatException(v);
|
||||
}
|
||||
|
||||
private static ulong ParseMemAddr(string? text)
|
||||
{
|
||||
var v = (text ?? "").Trim();
|
||||
if (v.StartsWith("0x", StringComparison.OrdinalIgnoreCase)) v = v[2..];
|
||||
if (ulong.TryParse(v, NumberStyles.HexNumber, CultureInfo.InvariantCulture, out ulong hex)) return hex;
|
||||
return ulong.TryParse(v, out ulong dec) ? dec : 0;
|
||||
}
|
||||
|
||||
private async void MemFirstScan_Click(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (_memScanning) return;
|
||||
if (_memPid <= 0) { MemStatus.Text = "Select a process first"; return; }
|
||||
var pattern = EncodeSearchValue();
|
||||
if (pattern == null || pattern.Length == 0) { MemStatus.Text = "Invalid value — check type/input"; return; }
|
||||
|
||||
ulong start = ParseMemAddr(MemScanStart.Text), end = ParseMemAddr(MemScanEnd.Text);
|
||||
if (end <= start) { MemStatus.Text = "Bad range — set start/end or pick a region"; return; }
|
||||
|
||||
_memPatternLen = pattern.Length;
|
||||
_memScanning = true;
|
||||
MemFirstScanButton.IsEnabled = false;
|
||||
MemStatus.Text = $"Scanning 0x{start:X}–0x{end:X} ({(end - start) / 1024 / 1024}MB)…";
|
||||
|
||||
var txt = await _protocol.MemSearchAsync(_memPid, start, end, pattern);
|
||||
_memScanning = false;
|
||||
MemFirstScanButton.IsEnabled = true;
|
||||
|
||||
if (txt == null) { MemStatus.Text = $"Scan failed: {_protocol.LastError}"; return; }
|
||||
var lines = txt.Split('\n', StringSplitOptions.RemoveEmptyEntries);
|
||||
_memResults.Clear();
|
||||
bool truncated = false, timedOut = false;
|
||||
foreach (var l in lines)
|
||||
{
|
||||
if (l == "TRUNCATED") { truncated = true; continue; }
|
||||
if (l == "TIMEOUT") { timedOut = true; continue; }
|
||||
if (l == "NO_MATCHES") break;
|
||||
var a = ParseMemAddr(l);
|
||||
if (a != 0) _memResults.Add(a);
|
||||
}
|
||||
MemResultsList.ItemsSource = _memResults.Select(a => $"0x{a:X}").ToList();
|
||||
MemStatus.Text = $"{_memResults.Count} match(es)" +
|
||||
(truncated ? " — hit 1024 cap, narrow with Next Scan" : "") +
|
||||
(timedOut ? " — scan timed out, partial results" : "");
|
||||
MemNextScanButton.IsEnabled = _memResults.Count > 0;
|
||||
}
|
||||
|
||||
// Next Scan = client-side re-read of each candidate against the new value
|
||||
private async void MemNextScan_Click(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (_memScanning || _memResults.Count == 0) return;
|
||||
var pattern = EncodeSearchValue();
|
||||
if (pattern == null || pattern.Length != _memPatternLen)
|
||||
{
|
||||
MemStatus.Text = $"Next Scan needs the same type ({_memPatternLen} bytes)";
|
||||
return;
|
||||
}
|
||||
|
||||
_memScanning = true;
|
||||
MemNextScanButton.IsEnabled = false;
|
||||
var kept = new List<ulong>();
|
||||
for (int i = 0; i < _memResults.Count; i++)
|
||||
{
|
||||
var bytes = await _protocol.MemReadAsync(_memPid, _memResults[i], pattern.Length);
|
||||
if (bytes != null && bytes.Length >= pattern.Length && bytes.AsSpan(0, pattern.Length).SequenceEqual(pattern))
|
||||
kept.Add(_memResults[i]);
|
||||
if (i % 64 == 0) MemStatus.Text = $"Re-checking {i}/{_memResults.Count}…";
|
||||
}
|
||||
_memResults.Clear();
|
||||
_memResults.AddRange(kept);
|
||||
MemResultsList.ItemsSource = _memResults.Select(a => $"0x{a:X}").ToList();
|
||||
MemStatus.Text = $"{_memResults.Count} match(es) after filter";
|
||||
_memScanning = false;
|
||||
MemNextScanButton.IsEnabled = _memResults.Count > 0;
|
||||
}
|
||||
|
||||
// ---------- read / write ----------
|
||||
|
||||
private void MemResultsList_SelectionChanged(object? sender, SelectionChangedEventArgs e)
|
||||
{
|
||||
var s = MemResultsList.SelectedItem?.ToString();
|
||||
if (s == null) return;
|
||||
MemAddrInput.Text = s;
|
||||
MemRead_Click(sender, e);
|
||||
}
|
||||
|
||||
private async void MemRead_Click(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (_memPid <= 0) { MemStatus.Text = "Select a process first"; return; }
|
||||
ulong addr = ParseMemAddr(MemAddrInput.Text);
|
||||
if (addr == 0) { MemStatus.Text = "Enter an address (0x…)"; return; }
|
||||
var data = await _protocol.MemReadAsync(_memPid, addr, 256);
|
||||
if (data == null) { MemStatus.Text = $"Read failed: {_protocol.LastError}"; return; }
|
||||
MemDumpText.Text = MemHexDump(data, addr);
|
||||
MemStatus.Text = $"Read {data.Length}B @ 0x{addr:X}";
|
||||
}
|
||||
|
||||
private async void MemWrite_Click(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (_memPid <= 0) { MemStatus.Text = "Select a process first"; return; }
|
||||
ulong addr = ParseMemAddr(MemAddrInput.Text);
|
||||
if (addr == 0) { MemStatus.Text = "Enter an address (0x…)"; return; }
|
||||
byte[] bytes;
|
||||
try { bytes = Convert.FromHexString((MemWriteHex.Text ?? "").Replace(" ", "").Replace("0x", "")); }
|
||||
catch { MemStatus.Text = "Invalid hex data"; return; }
|
||||
if (bytes.Length == 0) { MemStatus.Text = "Nothing to write"; return; }
|
||||
|
||||
var (ok, msg) = await _protocol.MemWriteAsync(_memPid, addr, bytes);
|
||||
MemStatus.Text = ok ? $"✅ {msg}" : $"❌ {msg}";
|
||||
if (ok) MemRead_Click(sender, e); // refresh the dump
|
||||
}
|
||||
|
||||
private static string MemHexDump(byte[] data, ulong baseAddr)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
for (int i = 0; i < data.Length; i += 16)
|
||||
{
|
||||
sb.Append($"{baseAddr + (ulong)i:X8} ");
|
||||
for (int j = 0; j < 16; j++)
|
||||
sb.Append(i + j < data.Length ? $"{data[i + j]:X2} " : " ");
|
||||
sb.Append(' ');
|
||||
for (int j = 0; j < 16 && i + j < data.Length; j++)
|
||||
{
|
||||
byte b = data[i + j];
|
||||
sb.Append(b >= 32 && b < 127 ? (char)b : '.');
|
||||
}
|
||||
sb.AppendLine();
|
||||
}
|
||||
return sb.ToString();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,801 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.Media;
|
||||
using Avalonia.Media.Imaging;
|
||||
using Avalonia.Platform.Storage;
|
||||
using Avalonia.Threading;
|
||||
|
||||
namespace PS5Upload
|
||||
{
|
||||
// Games, Saves, Screenshots, Hardware tabs
|
||||
public partial class MainWindow
|
||||
{
|
||||
// ============================================================
|
||||
// SAVES TAB
|
||||
// ============================================================
|
||||
private async void RefreshSavesButton_Click(object? sender, RoutedEventArgs e) => await RefreshSavesAsync();
|
||||
|
||||
private async Task RefreshSavesAsync()
|
||||
{
|
||||
if (!_protocol.IsConnected) { Log("❌ Not connected to PS5"); return; }
|
||||
Log("💾 Scanning save images...");
|
||||
try
|
||||
{
|
||||
var saves = await _protocol.ListSaveFilesAsync();
|
||||
_currentSaves = saves;
|
||||
var mounted = await _protocol.GetGameListAsync();
|
||||
var nameByTitle = mounted.ToDictionary(g => g.TitleId, g => g.Name, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var save in saves)
|
||||
{
|
||||
save.GameName = nameByTitle.TryGetValue(save.TitleId, out var gn) ? gn : save.TitleId;
|
||||
if (_iconCache.TryGetValue(save.TitleId, out var cached)) save.Icon = cached;
|
||||
}
|
||||
|
||||
await Dispatcher.UIThread.InvokeAsync(() =>
|
||||
{
|
||||
SavesListBox.ItemsSource = saves;
|
||||
SaveCountText.Text = $" ({saves.Count} saves)";
|
||||
});
|
||||
Log($"💾 Found {saves.Count} save images");
|
||||
|
||||
// Fetch icons in background on a dedicated connection so bulk
|
||||
// icon downloads never serialize behind the main command lock.
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
using var iconProto = new PS5Protocol();
|
||||
if (!await iconProto.ConnectAsync(_ps5IpAddress)) return;
|
||||
foreach (var s2 in saves)
|
||||
{
|
||||
if (s2.Icon != null) continue;
|
||||
try
|
||||
{
|
||||
var bytes = await iconProto.GetGameIconAsync(s2.TitleId);
|
||||
if (bytes != null && bytes.Length > 0)
|
||||
{
|
||||
await Dispatcher.UIThread.InvokeAsync(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
using var ms = new MemoryStream(bytes);
|
||||
var bmp = new Bitmap(ms);
|
||||
_iconCache[s2.TitleId] = bmp;
|
||||
s2.Icon = bmp;
|
||||
}
|
||||
catch { }
|
||||
});
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
if (!_protocol.IsConnected) break;
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
});
|
||||
|
||||
await UpdateSaveMountStatusAsync();
|
||||
}
|
||||
catch (Exception ex) { Log($"❌ Error fetching saves: {ex.Message}"); }
|
||||
}
|
||||
|
||||
private async Task UpdateSaveMountStatusAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
var (mounted, src, local, isPs4) = await _protocol.SaveMountStatusAsync();
|
||||
await Dispatcher.UIThread.InvokeAsync(() =>
|
||||
{
|
||||
SaveMountStatusText.Text = mounted
|
||||
? $"🔓 Mounted (decrypted): {src} → /data/save_mnt{(isPs4 ? " [PS4 save]" : "")}"
|
||||
: "";
|
||||
BrowseSaveButton.IsEnabled = mounted;
|
||||
UnmountSaveButton.IsEnabled = mounted;
|
||||
});
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
// ---------- Mount / Browse / Unmount ----------
|
||||
|
||||
private async void MountSaveButton_Click(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (SavesListBox.SelectedItem is PS5SaveFile save) await MountSaveAsync(save);
|
||||
else await ShowMessageAsync("Select a save first.");
|
||||
}
|
||||
|
||||
private async void MountSaveMenuItem_Click(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (SavesListBox.SelectedItem is PS5SaveFile save) await MountSaveAsync(save);
|
||||
}
|
||||
|
||||
private async Task MountSaveAsync(PS5SaveFile save)
|
||||
{
|
||||
if (!_protocol.IsConnected) { await ShowMessageAsync("Not connected to PS5.", "Error"); return; }
|
||||
Log($"🔓 Mounting {save.SaveName} ({save.TitleId}, {(save.IsPs4 ? "PS4" : "PS5")})...");
|
||||
var (ok, msg) = await _protocol.SaveMountAsync(save.Path, m => Log($" {m}"));
|
||||
if (ok)
|
||||
{
|
||||
Log($"✅ Save mounted decrypted at /data/save_mnt");
|
||||
}
|
||||
else
|
||||
{
|
||||
Log($"❌ Mount failed: {msg}");
|
||||
await ShowMessageAsync($"Mount failed:\n{msg}", "Error");
|
||||
}
|
||||
await UpdateSaveMountStatusAsync();
|
||||
}
|
||||
|
||||
private async void BrowseSaveButton_Click(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (!_protocol.IsConnected) { await ShowMessageAsync("Not connected to PS5.", "Error"); return; }
|
||||
var (mounted, src, local, isPs4) = await _protocol.SaveMountStatusAsync();
|
||||
if (!mounted)
|
||||
{
|
||||
await ShowMessageAsync("No save is mounted.\nSelect a save and press Mount first.", "Save Manager");
|
||||
return;
|
||||
}
|
||||
var win = new SaveBrowserWindow(_protocol, PS5Protocol.SaveMountPoint, _ps5IpAddress);
|
||||
bool unmount = await win.ShowDialog<bool>(this);
|
||||
if (unmount) await UnmountSaveAsync();
|
||||
else await UpdateSaveMountStatusAsync();
|
||||
}
|
||||
|
||||
private async void UnmountSaveButton_Click(object? sender, RoutedEventArgs e) => await UnmountSaveAsync();
|
||||
|
||||
private async Task UnmountSaveAsync()
|
||||
{
|
||||
if (!_protocol.IsConnected) return;
|
||||
Log("🔒 Unmounting save (writing changes back to the image)...");
|
||||
var (ok, msg) = await _protocol.SaveUnmountAsync(m => Log($" {m}"));
|
||||
if (ok) Log($"✅ Save unmounted. {msg}");
|
||||
else
|
||||
{
|
||||
Log($"❌ Unmount failed: {msg}");
|
||||
await ShowMessageAsync($"Unmount failed:\n{msg}", "Error");
|
||||
}
|
||||
await UpdateSaveMountStatusAsync();
|
||||
}
|
||||
|
||||
// ---------- Backup / Restore (decrypted via mount) ----------
|
||||
|
||||
// Ensures the given save image is mounted. Returns (ok, weMountedIt).
|
||||
private async Task<(bool ok, bool weMounted)> EnsureSaveMountedAsync(PS5SaveFile save)
|
||||
{
|
||||
var (mounted, src, _, _) = await _protocol.SaveMountStatusAsync();
|
||||
if (mounted && src == save.Path) return (true, false);
|
||||
if (mounted)
|
||||
{
|
||||
await ShowMessageAsync($"A different save is mounted:\n{src}\n\nUnmount it first.", "Save Manager");
|
||||
return (false, false);
|
||||
}
|
||||
Log($"🔓 Mounting {save.SaveName} for transfer...");
|
||||
var (ok, msg) = await _protocol.SaveMountAsync(save.Path, m => Log($" {m}"));
|
||||
if (!ok)
|
||||
{
|
||||
Log($"❌ Mount failed: {msg}");
|
||||
await ShowMessageAsync($"Mount failed:\n{msg}", "Error");
|
||||
}
|
||||
await UpdateSaveMountStatusAsync();
|
||||
return (ok, ok);
|
||||
}
|
||||
|
||||
private async void BackupSaveButton_Click(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (SavesListBox.SelectedItem is PS5SaveFile save) await BackupSaveAsync(save);
|
||||
else await ShowMessageAsync("Select a save first.");
|
||||
}
|
||||
|
||||
private async void BackupSaveMenuItem_Click(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (SavesListBox.SelectedItem is PS5SaveFile save) await BackupSaveAsync(save);
|
||||
}
|
||||
|
||||
// Decrypted backup: mount -> download plaintext contents -> unmount.
|
||||
private async Task BackupSaveAsync(PS5SaveFile save)
|
||||
{
|
||||
if (!_protocol.IsConnected) { await ShowMessageAsync("Not connected to PS5.", "Error"); return; }
|
||||
|
||||
var topLevel = TopLevel.GetTopLevel(this);
|
||||
if (topLevel == null) return;
|
||||
var folders = await topLevel.StorageProvider.OpenFolderPickerAsync(new FolderPickerOpenOptions
|
||||
{
|
||||
Title = "Choose backup destination folder"
|
||||
});
|
||||
if (folders.Count == 0) return;
|
||||
var parentDir = folders[0].TryGetLocalPath();
|
||||
if (parentDir == null) return;
|
||||
|
||||
var (mountedOk, weMounted) = await EnsureSaveMountedAsync(save);
|
||||
if (!mountedOk) return;
|
||||
|
||||
string backupDir = Path.Combine(parentDir,
|
||||
$"{save.TitleId}_{save.SaveName}_{DateTime.Now:yyyyMMdd_HHmmss}_decrypted");
|
||||
Directory.CreateDirectory(backupDir);
|
||||
|
||||
Log($"📥 Backing up decrypted save contents to {backupDir}...");
|
||||
try
|
||||
{
|
||||
var result = await _protocol.DownloadFolderAsync(PS5Protocol.SaveMountPoint, backupDir, _ps5IpAddress, null, CancellationToken.None);
|
||||
Log($"✅ Backup complete: {result.filesDownloaded} files, {result.filesFailed} failed, {FormatFileSize(result.totalBytes)} total");
|
||||
await ShowMessageAsync($"Backup complete (decrypted)!\n\nLocation: {backupDir}\nFiles: {result.filesDownloaded}\nFailed: {result.filesFailed}\nSize: {FormatFileSize(result.totalBytes)}", "Backup Complete");
|
||||
}
|
||||
catch (Exception ex) { Log($"❌ Backup failed: {ex.Message}"); await ShowMessageAsync($"Backup failed:\n{ex.Message}", "Error"); }
|
||||
|
||||
if (weMounted) await UnmountSaveAsync();
|
||||
}
|
||||
|
||||
// Raw backup: copy the encrypted image file (+ .bin companion) as-is.
|
||||
private async void BackupSaveRawMenuItem_Click(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (SavesListBox.SelectedItem is not PS5SaveFile save) return;
|
||||
if (!_protocol.IsConnected) { await ShowMessageAsync("Not connected to PS5.", "Error"); return; }
|
||||
|
||||
var topLevel = TopLevel.GetTopLevel(this);
|
||||
if (topLevel == null) return;
|
||||
var folders = await topLevel.StorageProvider.OpenFolderPickerAsync(new FolderPickerOpenOptions
|
||||
{
|
||||
Title = "Choose destination for raw save image"
|
||||
});
|
||||
if (folders.Count == 0) return;
|
||||
var parentDir = folders[0].TryGetLocalPath();
|
||||
if (parentDir == null) return;
|
||||
|
||||
string local = Path.Combine(parentDir, save.SaveName);
|
||||
Log($"📥 Downloading raw save image {save.SaveName}...");
|
||||
try
|
||||
{
|
||||
bool ok = await _protocol.DownloadFileAsync(save.Path, local);
|
||||
if (save.HasBinKey)
|
||||
{
|
||||
try { await _protocol.DownloadFileAsync(save.Path + ".bin", local + ".bin"); } catch { }
|
||||
}
|
||||
Log(ok ? $"✅ Raw image saved: {local}" : "❌ Raw download failed");
|
||||
if (ok) await ShowMessageAsync($"Raw save image saved:\n{local}\n\n(encrypted - usable for same-console restore)", "Backup Complete");
|
||||
}
|
||||
catch (Exception ex) { Log($"❌ Raw backup failed: {ex.Message}"); }
|
||||
}
|
||||
|
||||
private async void RestoreSaveButton_Click(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (SavesListBox.SelectedItem is PS5SaveFile save) await RestoreSaveAsync(save);
|
||||
else await ShowMessageAsync("Select a save first.");
|
||||
}
|
||||
|
||||
private async void RestoreSaveMenuItem_Click(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (SavesListBox.SelectedItem is PS5SaveFile save) await RestoreSaveAsync(save);
|
||||
}
|
||||
|
||||
// Decrypted restore: mount -> upload plaintext files into the mounted
|
||||
// save -> unmount (the payload writes the image back automatically).
|
||||
private async Task RestoreSaveAsync(PS5SaveFile save)
|
||||
{
|
||||
if (!_protocol.IsConnected) { await ShowMessageAsync("Not connected to PS5.", "Error"); return; }
|
||||
var topLevel = TopLevel.GetTopLevel(this);
|
||||
if (topLevel == null) return;
|
||||
|
||||
var folders = await topLevel.StorageProvider.OpenFolderPickerAsync(new FolderPickerOpenOptions
|
||||
{
|
||||
Title = "Select the backup folder containing the save files"
|
||||
});
|
||||
if (folders.Count == 0) return;
|
||||
var backupFolder = folders[0].TryGetLocalPath();
|
||||
if (backupFolder == null || !Directory.Exists(backupFolder)) { await ShowMessageAsync("Invalid folder.", "Error"); return; }
|
||||
|
||||
if (!await ShowConfirmAsync($"Restore decrypted files into save {save.SaveName} ({save.TitleId})?\n\nFrom: {backupFolder}\n\nThe save will be mounted, files overwritten, then written back."))
|
||||
return;
|
||||
|
||||
var (mountedOk, weMounted) = await EnsureSaveMountedAsync(save);
|
||||
if (!mountedOk) return;
|
||||
|
||||
string mnt = PS5Protocol.SaveMountPoint;
|
||||
Log("📤 Restoring files into mounted save...");
|
||||
int uploaded = 0, failed = 0; long totalBytes = 0;
|
||||
try
|
||||
{
|
||||
var remoteDirs = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var localFile in Directory.EnumerateFiles(backupFolder, "*", SearchOption.AllDirectories))
|
||||
{
|
||||
string rel = Path.GetRelativePath(backupFolder, localFile).Replace('\\', '/');
|
||||
string? remoteDir = Path.GetDirectoryName(rel)?.Replace('\\', '/');
|
||||
if (string.IsNullOrEmpty(remoteDir)) continue;
|
||||
string parts = mnt;
|
||||
foreach (var seg in remoteDir.Split('/', StringSplitOptions.RemoveEmptyEntries))
|
||||
{
|
||||
parts += "/" + seg;
|
||||
if (remoteDirs.Add(parts))
|
||||
{
|
||||
try { await _protocol.CreateDirAsync(parts); } catch { }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var localFile in Directory.EnumerateFiles(backupFolder, "*", SearchOption.AllDirectories))
|
||||
{
|
||||
string rel = Path.GetRelativePath(backupFolder, localFile).Replace('\\', '/');
|
||||
string remote = mnt + "/" + rel;
|
||||
try { bool ok = await _protocol.UploadFileAsync(localFile, remote); if (ok) { uploaded++; totalBytes += new FileInfo(localFile).Length; } else failed++; }
|
||||
catch { failed++; }
|
||||
}
|
||||
Log($"✅ Restore upload done: {uploaded} uploaded, {failed} failed, {FormatFileSize(totalBytes)} - writing image back...");
|
||||
}
|
||||
catch (Exception ex) { Log($"❌ Restore failed: {ex.Message}"); await ShowMessageAsync($"Restore failed:\n{ex.Message}", "Error"); }
|
||||
|
||||
if (weMounted) await UnmountSaveAsync();
|
||||
else await UpdateSaveMountStatusAsync();
|
||||
}
|
||||
|
||||
// ---------- Delete ----------
|
||||
|
||||
private async void DeleteSaveButton_Click(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (SavesListBox.SelectedItem is PS5SaveFile save) await DeleteSaveAsync(save);
|
||||
else await ShowMessageAsync("Select a save first.");
|
||||
}
|
||||
|
||||
private async void DeleteSaveMenuItem_Click(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (SavesListBox.SelectedItem is PS5SaveFile save) await DeleteSaveAsync(save);
|
||||
}
|
||||
|
||||
private async Task DeleteSaveAsync(PS5SaveFile save)
|
||||
{
|
||||
if (!_protocol.IsConnected) { await ShowMessageAsync("Not connected to PS5.", "Error"); return; }
|
||||
if (!await ShowConfirmAsync($"Delete save {save.SaveName} ({save.TitleId}) from the console?\n\n{save.Path}\n\nThis cannot be undone."))
|
||||
return;
|
||||
var (ok, msg) = await _protocol.SaveDeleteAsync(save.Path);
|
||||
if (ok) { Log($"🗑 Deleted {save.SaveName}"); await RefreshSavesAsync(); }
|
||||
else { Log($"❌ Delete failed: {msg}"); await ShowMessageAsync($"Delete failed:\n{msg}", "Error"); }
|
||||
}
|
||||
|
||||
private async void CopySavePathMenuItem_Click(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (SavesListBox.SelectedItem is PS5SaveFile save)
|
||||
{
|
||||
try
|
||||
{
|
||||
var cb = TopLevel.GetTopLevel(this)?.Clipboard;
|
||||
if (cb != null)
|
||||
{
|
||||
var item = new Avalonia.Input.DataTransferItem();
|
||||
item.Set(Avalonia.Input.DataFormat.Text, save.Path);
|
||||
var data = new Avalonia.Input.DataTransfer();
|
||||
data.Add(item);
|
||||
await cb.SetDataAsync(data);
|
||||
Log($"📋 Copied: {save.Path}");
|
||||
}
|
||||
}
|
||||
catch (Exception ex) { Log($"❌ Clipboard: {ex.Message}"); }
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// GAMES TAB
|
||||
// ============================================================
|
||||
private async void RefreshGameListButton_Click(object? sender, RoutedEventArgs e) => await RefreshGameListAsync();
|
||||
|
||||
private async Task RefreshGameListAsync()
|
||||
{
|
||||
if (!_protocol.IsConnected) { Log("❌ Not connected to PS5"); return; }
|
||||
Log("🎮 Fetching mounted games list...");
|
||||
try
|
||||
{
|
||||
var games = await _protocol.GetGameListAsync();
|
||||
await Dispatcher.UIThread.InvokeAsync(() =>
|
||||
{
|
||||
MountedGamesListBox.ItemsSource = games;
|
||||
GameCountText.Text = $" ({games.Count} games)";
|
||||
});
|
||||
|
||||
if (games.Count > 0)
|
||||
{
|
||||
Log($"🎮 Found {games.Count} mounted games:");
|
||||
foreach (var game in games) Log($" • {game.TitleId} - {game.Name} [{game.Region}]");
|
||||
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
// Dedicated connection: keeps bulk icon fetches off the
|
||||
// shared main connection (no more serialized UI stalls).
|
||||
using var iconProto = new PS5Protocol();
|
||||
if (!await iconProto.ConnectAsync(_ps5IpAddress)) return;
|
||||
|
||||
foreach (var game in games)
|
||||
{
|
||||
if (_iconCache.TryGetValue(game.TitleId, out var cached)) { await Dispatcher.UIThread.InvokeAsync(() => game.Icon = cached); continue; }
|
||||
try
|
||||
{
|
||||
var iconBytes = await iconProto.GetGameIconAsync(game.TitleId);
|
||||
if (iconBytes != null && iconBytes.Length > 0)
|
||||
{
|
||||
await Dispatcher.UIThread.InvokeAsync(() =>
|
||||
{
|
||||
try { using var ms = new MemoryStream(iconBytes); var bmp = new Bitmap(ms); _iconCache[game.TitleId] = bmp; game.Icon = bmp; } catch { }
|
||||
});
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
if (!_protocol.IsConnected) break;
|
||||
}
|
||||
});
|
||||
}
|
||||
else Log("🎮 No mounted games found");
|
||||
}
|
||||
catch (Exception ex) { Log($"❌ Error fetching game list: {ex.Message}"); }
|
||||
}
|
||||
|
||||
private async void MountedGamesListBox_DoubleTapped(object? sender, Avalonia.Input.TappedEventArgs e)
|
||||
{
|
||||
if (MountedGamesListBox.SelectedItem is PS5MountedGame game) await ShowGameDetailsAsync(game);
|
||||
}
|
||||
|
||||
private async void ViewGameDetailsMenuItem_Click(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (MountedGamesListBox.SelectedItem is PS5MountedGame game) await ShowGameDetailsAsync(game);
|
||||
}
|
||||
|
||||
private async Task ShowGameDetailsAsync(PS5MountedGame game)
|
||||
{
|
||||
if (!_protocol.IsConnected) { Log("❌ Not connected to PS5"); return; }
|
||||
Log($"ℹ️ Fetching details for {game.TitleId}...");
|
||||
var details = await _protocol.GetGameDetailsAsync(game.TitleId);
|
||||
if (details == null) { await ShowMessageAsync("Failed to fetch game details.", "Error"); return; }
|
||||
var dlg = new GameDetailsWindow(game, details, _protocol);
|
||||
await dlg.ShowDialog(this);
|
||||
}
|
||||
|
||||
private async void UnmountGameMenuItem_Click(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (MountedGamesListBox.SelectedItem is PS5MountedGame game)
|
||||
{
|
||||
if (await ShowConfirmAsync($"Unmount game {game.TitleId}?\n\n{game.Name}\n\nThis will remove the game from the PS5 home screen."))
|
||||
{
|
||||
Log($"🗑️ Unmounting {game.TitleId}...");
|
||||
var (success, message) = await _protocol.UnmountGameAsync(game.TitleId, msg => Log($" {msg}"));
|
||||
if (success) { Log($"✅ {message}"); await RefreshGameListAsync(); }
|
||||
else Log($"❌ Failed: {message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async void OpenGamePathMenuItem_Click(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (MountedGamesListBox.SelectedItem is PS5MountedGame game)
|
||||
{
|
||||
string gamePath = game.Path;
|
||||
if (!string.IsNullOrEmpty(gamePath))
|
||||
{
|
||||
Log($"📂 Navigating to {gamePath}");
|
||||
_currentPS5Path = gamePath;
|
||||
NavFiles.IsChecked = true; // sidebar selection state
|
||||
NavButton_Click(NavFiles, e); // switch to the File Transfer page
|
||||
await LoadPS5DirectoryAsync(gamePath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async void LaunchGameMenuItem_Click(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (MountedGamesListBox.SelectedItem is not PS5MountedGame game) return;
|
||||
if (!_protocol.IsConnected) { Log("❌ Not connected to PS5"); return; }
|
||||
Log($"▶ Launching {game.TitleId} ({game.Name})...");
|
||||
var (success, message) = await _protocol.LaunchGameAsync(game.TitleId);
|
||||
if (success) Log($"✅ {message}"); else Log($"❌ Launch failed: {message}");
|
||||
}
|
||||
|
||||
private async void MountGameMenuItem_Click(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (MountedGamesListBox.SelectedItem is not PS5MountedGame game) return;
|
||||
if (!_protocol.IsConnected) { Log("❌ Not connected to PS5"); return; }
|
||||
Log($"📥 Mounting {game.TitleId} ({game.Name})...");
|
||||
var result = await _protocol.MountGameAsync(game.TitleId,
|
||||
onProgress: msg => Dispatcher.UIThread.Post(() => Log($" {msg}")));
|
||||
Log(result ?? $"❌ Mount failed: {_protocol.LastError}");
|
||||
await RefreshGameListAsync();
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// HARDWARE TAB
|
||||
// ============================================================
|
||||
private async void HwAutoTimer_Tick(object? sender, EventArgs e)
|
||||
{
|
||||
if (_protocol == null || !_protocol.IsConnected) { StopHwAutoRefresh(); return; }
|
||||
if (!PageSystem.IsVisible) { StopHwAutoRefresh(); return; }
|
||||
if (_activeTaskCount > 0) return;
|
||||
try { await RefreshHardwareAsync(); }
|
||||
catch { }
|
||||
}
|
||||
|
||||
private async Task RefreshHardwareAsync()
|
||||
{
|
||||
if (_protocol == null || !_protocol.IsConnected) { Log("❌ Not connected to PS5"); return; }
|
||||
if (Interlocked.CompareExchange(ref _hwBusyFlag, 1, 0) != 0) { Log("⏳ Hardware refresh already in progress"); return; }
|
||||
try
|
||||
{
|
||||
// Static info — fetched once per session; none of it can change
|
||||
// while the console is running, so it stays out of the refresh loop.
|
||||
if (!_hwStaticLoaded)
|
||||
{
|
||||
var hw = await _protocol.GetHardwareInfoAsync();
|
||||
if (hw != null)
|
||||
{
|
||||
HwModelText.Text = string.IsNullOrWhiteSpace(hw.Model) ? "PlayStation 5" : hw.Model;
|
||||
HwSerialText.Text = string.IsNullOrWhiteSpace(hw.Serial) ? "—" : hw.Serial;
|
||||
HwMachineText.Text = string.IsNullOrWhiteSpace(hw.HwMachine) ? "—" : hw.HwMachine;
|
||||
HwOsText.Text = string.IsNullOrWhiteSpace(hw.OsVersion) ? "—" : hw.OsVersion;
|
||||
HwCpuCoresText.Text = hw.NumCpu > 0 ? $"{hw.NumCpu} cores" : "—";
|
||||
HwPhysMemText.Text = hw.PhysMem > 0 ? $"{hw.PhysMem / (1024.0 * 1024.0 * 1024.0):0.0} GB" : "—";
|
||||
}
|
||||
|
||||
var ext = await _protocol.GetExtendedInfoAsync();
|
||||
if (ext != null)
|
||||
{
|
||||
HwFirmwareText.Text = !string.IsNullOrWhiteSpace(ext.FirmwareVersion) ? ext.FirmwareVersion : "—";
|
||||
HwProductCodeText.Text = !string.IsNullOrWhiteSpace(ext.ProductCode) ? ext.ProductCode : "—";
|
||||
HwTotalUptimeText.Text = ext.TotalOperatingTimeSec > 0
|
||||
? $"{ext.TotalOperatingTimeSec / 3600}h {(ext.TotalOperatingTimeSec % 3600) / 60}m"
|
||||
: "—";
|
||||
HwBootCountText.Text = ext.BootCount > 0 ? $"{ext.BootCount} cycles" : "—";
|
||||
HwBdDriveText.Text = ext.BdDrivePower >= 0 ? (ext.BdDrivePower == 1 ? "On" : "Off") : "—";
|
||||
HwThermalAlertText.Text = ext.ThermalAlert == 0 ? "Normal" : $"Alert ({ext.ThermalAlert})";
|
||||
if (ext.ThermalAlert != 0) HwThermalAlertText.Foreground = new Avalonia.Media.SolidColorBrush(Avalonia.Media.Color.Parse("#FF6B35"));
|
||||
else HwThermalAlertText.Foreground = new Avalonia.Media.SolidColorBrush(Avalonia.Media.Color.Parse("#28A745"));
|
||||
}
|
||||
|
||||
_hwStaticLoaded = true;
|
||||
}
|
||||
|
||||
// Memory info — dynamic, refreshed every tick
|
||||
var mem = await _protocol.GetMemoryInfoAsync();
|
||||
if (mem != null)
|
||||
{
|
||||
// Physical RAM is static — fill it once from this (more reliable) source
|
||||
if (mem.PhysicalTotal > 0 && HwPhysMemText.Text == "—")
|
||||
HwPhysMemText.Text = FormatBytes(mem.PhysicalTotal);
|
||||
HwMemDirectTotalText.Text = FormatBytes(mem.PhysicalTotal > 0 ? mem.PhysicalTotal : mem.DirectTotal);
|
||||
HwMemDirectAvailText.Text = FormatBytes(mem.DirectUsed);
|
||||
HwMemDirectUsedText.Text = FormatBytes(mem.FreeMemory);
|
||||
HwMemFlexibleText.Text = FormatBytes(mem.FlexibleAvailable);
|
||||
HwMemFreeText.Text = FormatBytes(mem.GpuPoolFree);
|
||||
HwMemUsageBar.Value = Math.Clamp(mem.DirectUsedPercent, 0, 100);
|
||||
HwMemUsageText.Text = $"{mem.DirectUsedPercent}%";
|
||||
}
|
||||
|
||||
await RefreshHardwareSensorsAsync();
|
||||
}
|
||||
catch (Exception ex) { Log($"❌ Hardware refresh failed: {ex.Message}"); HwStatusText.Text = $"Error: {ex.Message}"; }
|
||||
finally { Interlocked.Exchange(ref _hwBusyFlag, 0); }
|
||||
}
|
||||
|
||||
private string FormatBytes(ulong bytes)
|
||||
{
|
||||
if (bytes == 0) return "—";
|
||||
if (bytes < 1024) return $"{bytes} B";
|
||||
if (bytes < 1024 * 1024) return $"{bytes / 1024.0:0.0} KB";
|
||||
if (bytes < 1024 * 1024 * 1024) return $"{bytes / (1024.0 * 1024.0):0.0} MB";
|
||||
return $"{bytes / (1024.0 * 1024.0 * 1024.0):0.00} GB";
|
||||
}
|
||||
|
||||
private async Task RefreshHardwareSensorsAsync()
|
||||
{
|
||||
if (_protocol == null || !_protocol.IsConnected) return;
|
||||
try
|
||||
{
|
||||
// Temperature sensors
|
||||
var t = await _protocol.GetTemperatureInfoAsync();
|
||||
if (t != null)
|
||||
{
|
||||
HwCpuTempText.Text = $"{t.CpuTemp}°C";
|
||||
HwCpuTempBar.Value = Math.Clamp(t.CpuTemp, 0, 100);
|
||||
HwSocTempText.Text = $"{t.SocTemp}°C";
|
||||
HwSocTempBar.Value = Math.Clamp(t.SocTemp, 0, 100);
|
||||
HwCpuFreqText.Text = t.CpuFreqMhz > 0 ? $"{t.CpuFreqMhz} MHz" : "—";
|
||||
HwCpuFreqBar.Value = Math.Clamp(t.CpuFreqMhz, 0, 3500);
|
||||
double watts = t.SocPowerMw / 1000.0;
|
||||
HwSocPowerText.Text = t.SocPowerMw > 0 ? $"{watts:0.0} W" : "—";
|
||||
HwSocPowerBar.Value = Math.Clamp(watts, 0, 250);
|
||||
}
|
||||
|
||||
// CPU usage per core
|
||||
var cpu = await _protocol.GetCpuUsageAsync();
|
||||
if (cpu != null)
|
||||
{
|
||||
HwCpu0Text.Text = $"{cpu.CoreUsage[0]}%"; HwCpu0Bar.Value = cpu.CoreUsage[0];
|
||||
HwCpu1Text.Text = $"{cpu.CoreUsage[1]}%"; HwCpu1Bar.Value = cpu.CoreUsage[1];
|
||||
HwCpu2Text.Text = $"{cpu.CoreUsage[2]}%"; HwCpu2Bar.Value = cpu.CoreUsage[2];
|
||||
HwCpu3Text.Text = $"{cpu.CoreUsage[3]}%"; HwCpu3Bar.Value = cpu.CoreUsage[3];
|
||||
HwCpu4Text.Text = $"{cpu.CoreUsage[4]}%"; HwCpu4Bar.Value = cpu.CoreUsage[4];
|
||||
HwCpu5Text.Text = $"{cpu.CoreUsage[5]}%"; HwCpu5Bar.Value = cpu.CoreUsage[5];
|
||||
HwCpu6Text.Text = $"{cpu.CoreUsage[6]}%"; HwCpu6Bar.Value = cpu.CoreUsage[6];
|
||||
HwCpu7Text.Text = $"{cpu.CoreUsage[7]}%"; HwCpu7Bar.Value = cpu.CoreUsage[7];
|
||||
HwCpuAvgText.Text = $"{cpu.Average}%";
|
||||
}
|
||||
|
||||
HwStatusText.Text = $"Last updated: {DateTime.Now:HH:mm:ss}";
|
||||
}
|
||||
catch (Exception ex) { HwStatusText.Text = $"Error: {ex.Message}"; }
|
||||
}
|
||||
|
||||
private async void LoadModulesButton_Click(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (_protocol == null || !_protocol.IsConnected) { Log("❌ Not connected to PS5"); return; }
|
||||
try
|
||||
{
|
||||
Log("📦 Loading modules list...");
|
||||
var modules = await _protocol.GetModuleListAsync();
|
||||
ModulesListBox.ItemsSource = modules;
|
||||
HwModuleCountText.Text = $" ({modules.Count})";
|
||||
Log($"✅ Found {modules.Count} modules");
|
||||
}
|
||||
catch (Exception ex) { Log($"❌ Failed to load modules: {ex.Message}"); }
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// SCREENSHOTS TAB
|
||||
// ============================================================
|
||||
private async void RefreshScreenshotsButton_Click(object? sender, RoutedEventArgs e) => await RefreshScreenshotsAsync();
|
||||
|
||||
private async Task RefreshScreenshotsAsync()
|
||||
{
|
||||
if (_protocol == null || !_protocol.IsConnected) { Log("❌ Not connected to PS5"); return; }
|
||||
Log("📷 Fetching screenshots list...");
|
||||
try
|
||||
{
|
||||
var shots = await _protocol.ListScreenshotsAsync();
|
||||
_currentScreenshots = shots;
|
||||
foreach (var s in shots)
|
||||
if (_screenshotThumbCache.TryGetValue(s.FullPath, out var cached)) s.Thumbnail = cached;
|
||||
|
||||
ScreenshotsListBox.ItemsSource = null;
|
||||
ScreenshotsListBox.ItemsSource = shots;
|
||||
ScreenshotsCountText.Text = $"({shots.Count} items)";
|
||||
Log($"✅ Found {shots.Count} screenshots");
|
||||
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
string cacheDir = Path.Combine(Path.GetTempPath(), "PS5SuiteCache", "ss_thumbs");
|
||||
Directory.CreateDirectory(cacheDir);
|
||||
|
||||
// Dedicated connection: thumbnail downloads used to run on
|
||||
// the main connection and block every other UI operation.
|
||||
using var thumbProto = new PS5Protocol();
|
||||
if (!await thumbProto.ConnectAsync(_ps5IpAddress)) return;
|
||||
|
||||
foreach (var shot in shots)
|
||||
{
|
||||
if (shot.Thumbnail != null) continue;
|
||||
if (!_protocol.IsConnected) break;
|
||||
// Stable 64-bit FNV-1a hash instead of GetHashCode():
|
||||
// string hashing is randomized per process, so cache
|
||||
// names changed between runs (and could collide).
|
||||
string hash = FnvHash64(shot.FullPath).ToString("X16");
|
||||
string ext = Path.GetExtension(shot.FileName).ToLowerInvariant();
|
||||
if (string.IsNullOrEmpty(ext)) ext = ".jpg";
|
||||
string localCache = Path.Combine(cacheDir, hash + ext);
|
||||
try
|
||||
{
|
||||
if (!File.Exists(localCache) || new FileInfo(localCache).Length == 0)
|
||||
{
|
||||
bool ok = await thumbProto.DownloadFileAsync(shot.FullPath, localCache);
|
||||
if (!ok) continue;
|
||||
}
|
||||
await Dispatcher.UIThread.InvokeAsync(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
var bmp = new Bitmap(localCache);
|
||||
_screenshotThumbCache[shot.FullPath] = bmp;
|
||||
shot.Thumbnail = bmp;
|
||||
}
|
||||
catch { }
|
||||
});
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
});
|
||||
}
|
||||
catch (Exception ex) { Log($"❌ Screenshots fetch failed: {ex.Message}"); }
|
||||
}
|
||||
|
||||
private async void DownloadScreenshotButton_Click(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (_protocol == null || !_protocol.IsConnected) { Log("❌ Not connected to PS5"); return; }
|
||||
var selected = (ScreenshotsListBox.SelectedItems ?? Array.Empty<object>()).Cast<PS5Screenshot>().ToList();
|
||||
if (selected.Count == 0) { Log("⚠️ No screenshots selected"); return; }
|
||||
|
||||
var topLevel = TopLevel.GetTopLevel(this);
|
||||
if (topLevel == null) return;
|
||||
var folders = await topLevel.StorageProvider.OpenFolderPickerAsync(new FolderPickerOpenOptions { Title = "Select folder to save screenshots" });
|
||||
if (folders.Count == 0) return;
|
||||
var targetFolder = folders[0].TryGetLocalPath();
|
||||
if (targetFolder == null) return;
|
||||
|
||||
int ok = 0, fail = 0;
|
||||
foreach (var shot in selected)
|
||||
{
|
||||
string localPath = Path.Combine(targetFolder, shot.FileName);
|
||||
Log($"⬇ Downloading {shot.FileName} ({shot.SizeDisplay})...");
|
||||
try { bool success = await _protocol.DownloadFileAsync(shot.FullPath, localPath); if (success) ok++; else { fail++; Log($"❌ Failed: {shot.FileName}"); } }
|
||||
catch (Exception ex) { fail++; Log($"❌ {shot.FileName}: {ex.Message}"); }
|
||||
}
|
||||
Log($"✅ Downloaded {ok} screenshot(s), {fail} failed → {targetFolder}");
|
||||
}
|
||||
|
||||
private async void DeleteScreenshotButton_Click(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (_protocol == null || !_protocol.IsConnected) { Log("❌ Not connected to PS5"); return; }
|
||||
var selected = (ScreenshotsListBox.SelectedItems ?? Array.Empty<object>()).Cast<PS5Screenshot>().ToList();
|
||||
if (selected.Count == 0) { Log("⚠️ No screenshots selected"); return; }
|
||||
|
||||
if (!await ShowConfirmAsync($"Delete {selected.Count} screenshot(s) from PS5?\n\nThis cannot be undone."))
|
||||
return;
|
||||
|
||||
int ok = 0, fail = 0;
|
||||
foreach (var shot in selected)
|
||||
{
|
||||
try { var (success, _) = await _protocol.DeleteScreenshotAsync(shot.FullPath); if (success) ok++; else fail++; }
|
||||
catch { fail++; }
|
||||
}
|
||||
Log($"🗑️ Deleted {ok} screenshot(s), {fail} failed");
|
||||
await RefreshScreenshotsAsync();
|
||||
}
|
||||
|
||||
private async void ScreenshotsListBox_DoubleTapped(object? sender, Avalonia.Input.TappedEventArgs e)
|
||||
{
|
||||
if (ScreenshotsListBox.SelectedItem is not PS5Screenshot shot) return;
|
||||
if (_protocol == null || !_protocol.IsConnected) return;
|
||||
string tempPath = Path.Combine(Path.GetTempPath(), shot.FileName);
|
||||
Log($"⬇ Opening preview: {shot.FileName}...");
|
||||
try
|
||||
{
|
||||
bool ok = await _protocol.DownloadFileAsync(shot.FullPath, tempPath);
|
||||
if (ok) System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo { FileName = tempPath, UseShellExecute = true });
|
||||
else Log($"❌ Failed to download preview");
|
||||
}
|
||||
catch (Exception ex) { Log($"❌ Preview failed: {ex.Message}"); }
|
||||
}
|
||||
|
||||
// Stable 64-bit FNV-1a hash for cache file names
|
||||
private static long FnvHash64(string s)
|
||||
{
|
||||
unchecked
|
||||
{
|
||||
ulong hash = 14695981039346656037UL;
|
||||
foreach (char c in s)
|
||||
{
|
||||
hash ^= c;
|
||||
hash *= 1099511628211UL;
|
||||
}
|
||||
return (long)hash;
|
||||
}
|
||||
}
|
||||
|
||||
private async void CopyScreenshotPathMenuItem_Click(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (ScreenshotsListBox.SelectedItem is PS5Screenshot shot)
|
||||
{
|
||||
try
|
||||
{
|
||||
var cb = TopLevel.GetTopLevel(this)?.Clipboard;
|
||||
if (cb != null)
|
||||
{
|
||||
var item = new Avalonia.Input.DataTransferItem();
|
||||
item.Set(Avalonia.Input.DataFormat.Text, shot.FullPath);
|
||||
var data = new Avalonia.Input.DataTransfer();
|
||||
data.Add(item);
|
||||
await cb.SetDataAsync(data);
|
||||
}
|
||||
Log($"📋 Copied path: {shot.FullPath}");
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,613 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.Threading;
|
||||
|
||||
namespace PS5Upload
|
||||
{
|
||||
public partial class MainWindow
|
||||
{
|
||||
// IProgress that runs Report() inline on the caller's thread instead
|
||||
// of marshaling to the captured SynchronizationContext. Progress<T>
|
||||
// was flooding the UI thread with a callback per send-buffer flush ×
|
||||
// 27+ parallel lanes — the window froze (wait cursor, dead tabs).
|
||||
private sealed class InlineProgress<T> : IProgress<T>
|
||||
{
|
||||
private readonly Action<T> _action;
|
||||
public InlineProgress(Action<T> action) => _action = action;
|
||||
public void Report(T value) => _action(value);
|
||||
}
|
||||
|
||||
private void UiUpdateTimer_Tick(object? sender, EventArgs e)
|
||||
{
|
||||
int completed;
|
||||
lock (_progressLock) { completed = _completedFiles; }
|
||||
UpdateUploadStats(completed, _activeTaskCount);
|
||||
FlushLog();
|
||||
FlushPendingHistory();
|
||||
}
|
||||
|
||||
// Move queued transfer-history items into the bound collections in
|
||||
// one batch (bounded at 2000 entries so the list can't grow forever).
|
||||
private readonly List<TransferHistoryItem> _pendingHistory = new();
|
||||
private readonly object _pendingHistoryLock = new();
|
||||
private void FlushPendingHistory()
|
||||
{
|
||||
List<TransferHistoryItem>? pending = null;
|
||||
lock (_pendingHistoryLock)
|
||||
{
|
||||
if (_pendingHistory.Count > 0)
|
||||
{
|
||||
pending = new List<TransferHistoryItem>(_pendingHistory);
|
||||
_pendingHistory.Clear();
|
||||
}
|
||||
}
|
||||
if (pending == null) return;
|
||||
foreach (var it in pending)
|
||||
{
|
||||
var target = it.Status.StartsWith("❌") ? _failedTransfers : _completedTransfers;
|
||||
target.Add(it);
|
||||
while (target.Count > 2000) target.RemoveAt(0);
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateUploadStats(int completedFiles, int activeTaskCount)
|
||||
{
|
||||
Dispatcher.UIThread.Post(() =>
|
||||
{
|
||||
int remainingFiles = Math.Max(0, _totalFilesToUpload - completedFiles);
|
||||
FilesRemainingText.Text = $"Files: {completedFiles} / {_totalFilesToUpload} ({remainingFiles} remaining)";
|
||||
|
||||
double totalPercent = _totalBytesToUpload > 0 ? (double)_totalBytesUploaded / _totalBytesToUpload * 100 : 0;
|
||||
TotalProgressBar.Value = totalPercent;
|
||||
TotalProgressText.Text = $"Total: {completedFiles} / {_totalFilesToUpload} files ({FormatFileSize(_totalBytesUploaded)} / {FormatFileSize(_totalBytesToUpload)}) [{totalPercent:F1}%]";
|
||||
|
||||
var elapsed = DateTime.Now - _uploadStartTime;
|
||||
long currentBytes = Interlocked.Read(ref _totalBytesUploaded);
|
||||
var now = DateTime.Now;
|
||||
_speedWindowBytes[_speedWindowIndex] = currentBytes;
|
||||
_speedWindowTimes[_speedWindowIndex] = now;
|
||||
_speedWindowIndex = (_speedWindowIndex + 1) % SpeedWindowSize;
|
||||
if (_speedWindowCount < SpeedWindowSize) _speedWindowCount++;
|
||||
|
||||
double realtimeSpeed = 0;
|
||||
if (_speedWindowCount >= 2)
|
||||
{
|
||||
int oldestIndex = (_speedWindowIndex - _speedWindowCount + SpeedWindowSize) % SpeedWindowSize;
|
||||
long bytesDelta = currentBytes - _speedWindowBytes[oldestIndex];
|
||||
double timeDelta = (now - _speedWindowTimes[oldestIndex]).TotalSeconds;
|
||||
if (timeDelta > 0.1) realtimeSpeed = bytesDelta / timeDelta;
|
||||
}
|
||||
|
||||
if (_currentSpeed <= 0) _currentSpeed = realtimeSpeed;
|
||||
else if (realtimeSpeed > 0) _currentSpeed = (_currentSpeed * 0.7) + (realtimeSpeed * 0.3);
|
||||
|
||||
double avgSpeed = elapsed.TotalSeconds > 0 ? currentBytes / elapsed.TotalSeconds : 0;
|
||||
double etaSpeed = (_currentSpeed * 0.6) + (avgSpeed * 0.4);
|
||||
long remainingBytes = _totalBytesToUpload - currentBytes;
|
||||
TimeSpan rawETA = etaSpeed > 0 ? TimeSpan.FromSeconds(remainingBytes / etaSpeed) : TimeSpan.Zero;
|
||||
|
||||
if (_smoothedETA == TimeSpan.Zero) _smoothedETA = rawETA;
|
||||
else if (rawETA.TotalSeconds > 0)
|
||||
{
|
||||
double smoothedSeconds = (_smoothedETA.TotalSeconds * (1 - ETASmoothingFactor)) + (rawETA.TotalSeconds * ETASmoothingFactor);
|
||||
_smoothedETA = TimeSpan.FromSeconds(Math.Max(0, smoothedSeconds));
|
||||
}
|
||||
TimeSpan eta = _smoothedETA;
|
||||
|
||||
UploadSpeedText.Text = $"Speed: {FormatFileSize((long)_currentSpeed)}/s (avg {FormatFileSize((long)avgSpeed)}/s) | {activeTaskCount} active";
|
||||
string etaStr = eta.TotalHours >= 1 ? $"{(int)eta.TotalHours}h {eta.Minutes:D2}m {eta.Seconds:D2}s"
|
||||
: eta.TotalMinutes >= 1 ? $"{(int)eta.TotalMinutes}m {eta.Seconds:D2}s" : $"{eta.Seconds}s";
|
||||
UploadETAText.Text = $"ETA: {etaStr} | Elapsed: {elapsed:hh\\:mm\\:ss}";
|
||||
if (activeTaskCount > 0) UploadFileNameText.Text = $"Uploading {activeTaskCount} files in parallel...";
|
||||
|
||||
// Per-file bar — the progress callbacks only update these
|
||||
// fields now; the timer paints them (no per-report UI posts).
|
||||
long fb = Interlocked.Read(ref _currentFileBytes);
|
||||
long ft = Interlocked.Read(ref _currentFileTotalBytes);
|
||||
double fpct = ft > 0 ? (double)fb / ft * 100 : 0;
|
||||
UploadProgressBar.Value = fpct;
|
||||
UploadProgressText.Text = $"{FormatFileSize(fb)} / {FormatFileSize(ft)} ({fpct:F1}%)";
|
||||
});
|
||||
}
|
||||
|
||||
private async void UploadButton_Click(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (_localFiles.Count == 0) { await ShowMessageAsync("No files selected for upload"); return; }
|
||||
if (!_protocol.IsConnected) { await ShowMessageAsync("Not connected to PS5", "Error"); return; }
|
||||
|
||||
UploadButton.IsEnabled = false;
|
||||
CancelButton.IsEnabled = true;
|
||||
ProgressPanel.IsVisible = true;
|
||||
_uploadCancellation = new CancellationTokenSource();
|
||||
|
||||
Log("========== UPLOAD STARTED ==========");
|
||||
var localFilesCopy = _localFiles.ToList();
|
||||
string currentPath = _currentPS5Path;
|
||||
UploadFileNameText.Text = "Preparing upload...";
|
||||
TotalProgressText.Text = "Collecting files...";
|
||||
|
||||
Log("Collecting files...");
|
||||
var allFiles = await Task.Run(() =>
|
||||
{
|
||||
var files = new List<(string localPath, string remotePath)>();
|
||||
foreach (var item in localFilesCopy)
|
||||
{
|
||||
string targetBasePath = item.RemotePathOverride ?? (currentPath + "/" + item.Name);
|
||||
if (item.IsDirectory) CollectFilesFromDirectory(item.FullPath, targetBasePath, files);
|
||||
else files.Add((item.FullPath, targetBasePath));
|
||||
}
|
||||
return files;
|
||||
});
|
||||
|
||||
Log("Checking for existing files...");
|
||||
var filesToUpload = await FilterDuplicateFilesAsync(allFiles);
|
||||
if (filesToUpload.Count == 0)
|
||||
{
|
||||
Log("⚠️ No files to upload (all skipped)");
|
||||
await ShowMessageAsync("No files to upload. All files were skipped.");
|
||||
UploadButton.IsEnabled = true; CancelButton.IsEnabled = false; ProgressPanel.IsVisible = false;
|
||||
return;
|
||||
}
|
||||
allFiles = filesToUpload;
|
||||
|
||||
_totalFilesToUpload = allFiles.Count;
|
||||
_totalBytesToUpload = await Task.Run(() => { long total = 0; foreach (var f in allFiles) { try { total += new FileInfo(f.localPath).Length; } catch { } } return total; });
|
||||
_totalBytesUploaded = 0; _completedFiles = 0; _uploadStartTime = DateTime.Now;
|
||||
_fileProgressBytes.Clear(); _fileChunkProgressBytes.Clear(); _chunkLogLastBytes.Clear();
|
||||
_smoothedETA = TimeSpan.Zero; _speedWindowIndex = 0; _speedWindowCount = 0; _currentSpeed = 0;
|
||||
_smallFileBatchRemainder = 0; _smallFileCompletedTotal = 0; _smallFileBatchBytes = 0; _smallFileTotalBytes = 0;
|
||||
_activeLargeUploads = 0; _activeHugeUploads = 0;
|
||||
DrainConnectionPool();
|
||||
Log($"📊 Total: {_totalFilesToUpload} files, {FormatFileSize(_totalBytesToUpload)}");
|
||||
TotalProgressText.Text = $"Total: 0 / {_totalFilesToUpload} files ({FormatFileSize(0)} / {FormatFileSize(_totalBytesToUpload)})";
|
||||
TotalProgressBar.Value = 0;
|
||||
|
||||
try
|
||||
{
|
||||
// Create directories
|
||||
Log("Creating directories...");
|
||||
var directories = allFiles.Select(f => Path.GetDirectoryName(f.remotePath)?.Replace("\\", "/"))
|
||||
.Where(d => !string.IsNullOrEmpty(d) && d != _currentPS5Path).Distinct().OrderBy(d => d?.Length ?? 0).ToList();
|
||||
|
||||
if (!_protocol.IsConnected)
|
||||
{
|
||||
Log("⚠️ Connection lost before directory creation, reconnecting...");
|
||||
if (!await _protocol.ConnectAsync(_ps5IpAddress)) throw new Exception("Failed to reconnect to PS5 before directory creation");
|
||||
Log("✅ Reconnected successfully");
|
||||
}
|
||||
|
||||
int dirCreated = 0, dirTotal = directories.Count;
|
||||
int dirLogInterval = Math.Max(1, dirTotal / 10);
|
||||
foreach (var dir in directories)
|
||||
{
|
||||
if (string.IsNullOrEmpty(dir)) continue;
|
||||
if (dirCreated % dirLogInterval == 0 || dirCreated == 0)
|
||||
Dispatcher.UIThread.Post(() => UploadFileNameText.Text = $"Creating directories... ({dirCreated}/{dirTotal})");
|
||||
try
|
||||
{
|
||||
using var dirCts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
|
||||
var dirTask = _protocol.CreateDirAsync(dir);
|
||||
var doneTask = await Task.WhenAny(dirTask, Task.Delay(-1, dirCts.Token));
|
||||
if (doneTask != dirTask) throw new TimeoutException($"Timeout creating directory: {dir}");
|
||||
await dirTask;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log($"⚠️ Failed to create dir {dir}: {ex.Message}, retrying...");
|
||||
try { _protocol.Disconnect(); await Task.Delay(500); if (await _protocol.ConnectAsync(_ps5IpAddress)) await _protocol.CreateDirAsync(dir); else throw new Exception("Reconnect failed"); }
|
||||
catch (Exception retryEx) { Log($"❌ Directory creation failed permanently: {dir} - {retryEx.Message}"); }
|
||||
}
|
||||
dirCreated++;
|
||||
}
|
||||
Log($"✅ Created {dirCreated}/{dirTotal} directories");
|
||||
|
||||
if (!_protocol.IsConnected)
|
||||
{
|
||||
Log("⚠️ Connection lost after directory creation, reconnecting...");
|
||||
if (!await _protocol.ConnectAsync(_ps5IpAddress)) throw new Exception("Failed to reconnect to PS5 after directory creation");
|
||||
Log("✅ Reconnected successfully");
|
||||
}
|
||||
|
||||
_uiUpdateTimer.Start();
|
||||
|
||||
// Pre-warm connection pool
|
||||
int preWarmCount = Math.Min(MaxParallelUploads - 1, allFiles.Count);
|
||||
Log($"🚀 Starting parallel upload with {MaxParallelUploads} connections (pre-warming {preWarmCount})...");
|
||||
var preWarmTasks = new List<Task<PS5Protocol?>>();
|
||||
for (int i = 0; i < preWarmCount; i++)
|
||||
preWarmTasks.Add(Task.Run(async () => { try { var c = new PS5Protocol(); if (await c.ConnectAsync(_ps5IpAddress)) { Interlocked.Increment(ref _currentPoolConnections); return c; } c.Dispose(); } catch { } return (PS5Protocol?)null; }));
|
||||
await Task.WhenAll(preWarmTasks);
|
||||
int pooled = 0;
|
||||
foreach (var t in preWarmTasks) { if (t.Result != null) { _connectionPool.Enqueue(t.Result); pooled++; } }
|
||||
Log($"✅ {pooled} connections ready");
|
||||
|
||||
// Parallel upload loop
|
||||
var fileQueue = new Queue<(string localPath, string remotePath)>(allFiles);
|
||||
var activeTasks = new List<Task>();
|
||||
var taskToConnection = new Dictionary<Task, PS5Protocol>();
|
||||
var taskToFilePath = new Dictionary<Task, string>();
|
||||
var taskToRemotePath = new Dictionary<Task, string>();
|
||||
var fileChunkCounts = new Dictionary<string, int>();
|
||||
var fileChunksCompleted = new Dictionary<string, int>();
|
||||
var completedFilesSet = new HashSet<string>();
|
||||
var failedFiles = new Dictionary<string, int>();
|
||||
var taskIsLargeFile = new Dictionary<Task, bool>();
|
||||
var taskIsHugeFile = new Dictionary<Task, bool>();
|
||||
const int MAX_RETRIES = 3;
|
||||
|
||||
UploadFileNameText.Text = $"Parallel upload: {MaxParallelUploads} connections";
|
||||
|
||||
while (fileQueue.Count > 0 || activeTasks.Count > 0)
|
||||
{
|
||||
if (_uploadCancellation.Token.IsCancellationRequested) { Log("⚠️ Upload cancelled by user"); break; }
|
||||
|
||||
while (fileQueue.Count > 0 && activeTasks.Count < MaxParallelUploads)
|
||||
{
|
||||
var (localPath, remotePath) = fileQueue.Dequeue();
|
||||
long fileSize = new FileInfo(localPath).Length;
|
||||
bool isLargeFile = fileSize > LargeFileThresholdBytes;
|
||||
bool isHugeFile = fileSize >= HugeFileThresholdBytes;
|
||||
|
||||
if (isLargeFile && Volatile.Read(ref _activeLargeUploads) >= MaxParallelLargeFiles) { fileQueue.Enqueue((localPath, remotePath)); break; }
|
||||
if (isHugeFile && Volatile.Read(ref _activeHugeUploads) >= MaxParallelHugeFiles) { fileQueue.Enqueue((localPath, remotePath)); break; }
|
||||
|
||||
PS5Protocol connection;
|
||||
try { connection = await AcquireUploadConnectionAsync(); }
|
||||
catch (Exception ex) { Log($"❌ Unable to acquire connection: {ex.Message}. Requeueing {Path.GetFileName(localPath)}"); fileQueue.Enqueue((localPath, remotePath)); await Task.Delay(1000); break; }
|
||||
|
||||
if (isLargeFile) Interlocked.Increment(ref _activeLargeUploads);
|
||||
if (isHugeFile) Interlocked.Increment(ref _activeHugeUploads);
|
||||
|
||||
var task = UploadFileParallelAsync(connection, localPath, remotePath, _uploadCancellation.Token);
|
||||
activeTasks.Add(task); taskToConnection[task] = connection; taskToFilePath[task] = localPath; taskToRemotePath[task] = remotePath;
|
||||
taskIsLargeFile[task] = isLargeFile; taskIsHugeFile[task] = isHugeFile;
|
||||
fileChunkCounts[localPath] = 1; fileChunksCompleted[localPath] = 0;
|
||||
}
|
||||
|
||||
if (activeTasks.Count > 0)
|
||||
{
|
||||
var completedTask = await Task.WhenAny(activeTasks);
|
||||
bool taskSucceeded = false;
|
||||
try { await completedTask; taskSucceeded = true; }
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log($"❌ Task exception: {ex.Message}");
|
||||
if (taskToFilePath.TryGetValue(completedTask, out var fp) && taskToRemotePath.TryGetValue(completedTask, out var rp))
|
||||
{
|
||||
if (!failedFiles.ContainsKey(fp)) failedFiles[fp] = 0;
|
||||
failedFiles[fp]++;
|
||||
if (failedFiles[fp] <= MAX_RETRIES)
|
||||
{
|
||||
Log($"🔄 Requeueing ({failedFiles[fp]}/{MAX_RETRIES}): {Path.GetFileName(fp)}");
|
||||
try { await _protocol.DeleteFileAsync(rp); } catch { }
|
||||
fileQueue.Enqueue((fp, rp)); fileChunksCompleted[fp] = 0;
|
||||
await Task.Delay(3000);
|
||||
}
|
||||
else Log($"❌ Max retries exceeded: {Path.GetFileName(fp)}");
|
||||
}
|
||||
Dispatcher.UIThread.Post(() => UploadFileNameText.Text = $"Upload error: {ex.Message}");
|
||||
}
|
||||
|
||||
if (taskToFilePath.TryGetValue(completedTask, out var filePath))
|
||||
{
|
||||
if (taskSucceeded)
|
||||
{
|
||||
lock (_progressLock)
|
||||
{
|
||||
fileChunksCompleted[filePath]++;
|
||||
if (fileChunksCompleted[filePath] >= fileChunkCounts[filePath] && !completedFilesSet.Contains(filePath))
|
||||
{
|
||||
completedFilesSet.Add(filePath);
|
||||
int completed = Interlocked.Increment(ref _completedFiles);
|
||||
Log($"✅ File {completed}/{_totalFilesToUpload} completed");
|
||||
}
|
||||
}
|
||||
}
|
||||
taskToFilePath.Remove(completedTask); taskToRemotePath.Remove(completedTask);
|
||||
}
|
||||
|
||||
activeTasks.Remove(completedTask);
|
||||
if (taskToConnection.TryGetValue(completedTask, out var completedConn))
|
||||
{
|
||||
taskToConnection.Remove(completedTask);
|
||||
if (taskIsLargeFile.TryGetValue(completedTask, out var wasLarge) && wasLarge) { Interlocked.Decrement(ref _activeLargeUploads); taskIsLargeFile.Remove(completedTask); }
|
||||
if (taskIsHugeFile.TryGetValue(completedTask, out var wasHuge) && wasHuge) { Interlocked.Decrement(ref _activeHugeUploads); taskIsHugeFile.Remove(completedTask); }
|
||||
if (taskSucceeded && completedConn.IsConnected) ReleaseUploadConnection(completedConn); else DestroyConnection(completedConn);
|
||||
}
|
||||
_activeTaskCount = activeTasks.Count;
|
||||
// No UpdateUploadStats here — the 500ms UI timer
|
||||
// refreshes stats; a UI post per completed file was
|
||||
// another flood source on 100k+ uploads.
|
||||
}
|
||||
else if (fileQueue.Count > 0) { Log($"⚠️ No active tasks but {fileQueue.Count} files remain - retrying..."); await Task.Delay(500); }
|
||||
}
|
||||
|
||||
Log("🔄 Upload loop finished"); FlushSmallFileBatch(); _uiUpdateTimer.Stop();
|
||||
foreach (var conn in taskToConnection.Values.ToList()) { try { conn.Disconnect(); conn.Dispose(); } catch { } }
|
||||
taskToConnection.Clear(); DrainConnectionPool(); Log("✅ Cleanup complete");
|
||||
|
||||
if (!_uploadCancellation.Token.IsCancellationRequested)
|
||||
{
|
||||
Log($"🎉 Upload completed! {_totalFilesToUpload} files, {FormatFileSize(_totalBytesUploaded)}");
|
||||
await ShowMessageAsync($"Upload completed!\n\n{_totalFilesToUpload} files uploaded\nTotal: {FormatFileSize(_totalBytesUploaded)}", "Success");
|
||||
_localFiles.Clear();
|
||||
}
|
||||
else { Log("⚠️ Upload cancelled"); await ShowMessageAsync("Upload cancelled by user", "Cancelled"); }
|
||||
|
||||
// Reconnect if needed
|
||||
if (!_protocol.IsConnected) { try { if (await _protocol.ConnectAsync(_ps5IpAddress)) { Log("✅ Reconnected"); await LoadPS5DirectoryAsync(_currentPS5Path); } } catch { } }
|
||||
else { Log("✅ Main connection still active"); await LoadPS5DirectoryAsync(_currentPS5Path); }
|
||||
}
|
||||
catch (OperationCanceledException) { Log("❌ Upload cancelled"); await ShowMessageAsync("Upload cancelled by user", "Cancelled"); if (!_protocol.IsConnected) { try { if (await _protocol.ConnectAsync(_ps5IpAddress)) await LoadPS5DirectoryAsync(_currentPS5Path); } catch { } } else await LoadPS5DirectoryAsync(_currentPS5Path); }
|
||||
catch (Exception ex) { Log($"❌ Upload failed: {ex.Message}\n{ex.StackTrace}"); await ShowMessageAsync($"Upload failed: {ex.Message}", "Error"); }
|
||||
finally
|
||||
{
|
||||
Log("========== UPLOAD FINISHED ==========");
|
||||
FlushLog();
|
||||
_uiUpdateTimer.Stop();
|
||||
FlushPendingHistory();
|
||||
ProgressPanel.IsVisible = false;
|
||||
UploadButton.IsEnabled = true; CancelButton.IsEnabled = false;
|
||||
_uploadCancellation?.Dispose(); _uploadCancellation = null;
|
||||
await LoadPS5DirectoryAsync(_currentPS5Path);
|
||||
}
|
||||
}
|
||||
|
||||
private void CancelButton_Click(object? sender, RoutedEventArgs e) { _uploadCancellation?.Cancel(); CancelButton.IsEnabled = false; }
|
||||
|
||||
private async Task UploadFileParallelAsync(PS5Protocol connection, string localPath, string remotePath, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
string fileName = Path.GetFileName(localPath);
|
||||
FileInfo fileInfo = new FileInfo(localPath);
|
||||
|
||||
if (fileInfo.Length > ChunkThresholdBytes)
|
||||
{
|
||||
int maxParallelChunks = fileInfo.Length >= HugeFileThresholdBytes ? MaxParallelChunksForHugeFile : MaxParallelChunksForLargeFile;
|
||||
long chunkSize = fileInfo.Length >= HugeFileThresholdBytes ? HugeFileChunkSizeBytes : DefaultChunkSizeBytes;
|
||||
|
||||
// Scale chunk size so medium files still get parallel lanes:
|
||||
// a 140MB file shouldn't fall back to 1 chunk / 1 lane while
|
||||
// a 10GB file grabs 10 workers.
|
||||
long scaledChunk = fileInfo.Length / maxParallelChunks;
|
||||
long minChunk = 64L * 1024 * 1024;
|
||||
if (scaledChunk < minChunk) scaledChunk = minChunk;
|
||||
if (scaledChunk < chunkSize) chunkSize = scaledChunk;
|
||||
|
||||
long totalChunks = (fileInfo.Length + chunkSize - 1) / chunkSize;
|
||||
int workerCount = (int)Math.Min(totalChunks, Math.Max(1, maxParallelChunks));
|
||||
Log($"⬆️ Uploading (chunked): {fileName} ({FormatFileSize(fileInfo.Length)}) {workerCount} lanes");
|
||||
_fileProgressBytes[localPath] = 0;
|
||||
_fileChunkProgressBytes[localPath] = new ConcurrentDictionary<long, long>();
|
||||
_chunkLogLastBytes[localPath] = 0;
|
||||
|
||||
int nextChunkIndex = -1;
|
||||
var chunk0Ready = new SemaphoreSlim(0, 1);
|
||||
bool chunk0Released = false;
|
||||
var chunk0ReleaseLock = new object();
|
||||
|
||||
// Release the chunk-0 gate exactly once, no matter whether
|
||||
// chunk 0 succeeded or failed. Without this, a failed first
|
||||
// chunk deadlocked every other worker forever.
|
||||
void ReleaseChunk0Once()
|
||||
{
|
||||
lock (chunk0ReleaseLock)
|
||||
{
|
||||
if (!chunk0Released)
|
||||
{
|
||||
chunk0Released = true;
|
||||
chunk0Ready.Release();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var workerTasks = new List<Task>(workerCount);
|
||||
for (int wid = 0; wid < workerCount; wid++) workerTasks.Add(RunWorker(wid));
|
||||
await Task.WhenAll(workerTasks);
|
||||
|
||||
async Task RunWorker(int workerId)
|
||||
{
|
||||
PS5Protocol? wConn = null; bool owns = workerId != 0;
|
||||
try
|
||||
{
|
||||
wConn = owns ? await AcquireUploadConnectionAsync() : connection;
|
||||
while (true)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
int ci = Interlocked.Increment(ref nextChunkIndex);
|
||||
if (ci >= totalChunks) break;
|
||||
if (ci > 0) { await chunk0Ready.WaitAsync(cancellationToken); chunk0Ready.Release(); }
|
||||
Action? cb = ci == 0 ? ReleaseChunk0Once : null;
|
||||
try
|
||||
{
|
||||
await DoChunk(wConn!, ci, cb);
|
||||
}
|
||||
finally
|
||||
{
|
||||
// Guarantee the gate opens even if chunk 0 throws
|
||||
if (ci == 0) ReleaseChunk0Once();
|
||||
}
|
||||
}
|
||||
}
|
||||
finally { if (owns && wConn != null) { if (wConn.IsConnected) ReleaseUploadConnection(wConn); else DestroyConnection(wConn); } }
|
||||
}
|
||||
|
||||
async Task DoChunk(PS5Protocol wConn, int chunkIndex, Action? readyCallback)
|
||||
{
|
||||
long offset = chunkIndex * chunkSize;
|
||||
long size = Math.Min(chunkSize, fileInfo.Length - offset);
|
||||
long humanIdx = chunkIndex + 1;
|
||||
var prog = MakeChunkProgress(offset, size, humanIdx, totalChunks);
|
||||
bool ok = await wConn.UploadFileAsync(localPath, remotePath, prog, cancellationToken, offset, size, readyCallback);
|
||||
if (!ok) throw new Exception($"Chunk {humanIdx}/{totalChunks} failed for {fileName}");
|
||||
if (_fileChunkProgressBytes.TryGetValue(localPath, out var cm)) { cm[offset] = size; long agg = cm.Values.Sum(); long prev = _fileProgressBytes.GetOrAdd(localPath, 0); long d = agg - prev; if (d > 0) { Interlocked.Add(ref _totalBytesUploaded, d); _fileProgressBytes[localPath] = agg; } _currentFileBytes = agg; _currentFileTotalBytes = fileInfo.Length; }
|
||||
}
|
||||
|
||||
IProgress<UploadProgress> MakeChunkProgress(long chunkOffset, long chunkLength, long chunkNumber, long totalChunkCount)
|
||||
{
|
||||
int callCount = 0;
|
||||
// Runs on the worker thread — counters + display fields
|
||||
// only, no UI posts (the 500ms timer paints the bar).
|
||||
return new InlineProgress<UploadProgress>(p =>
|
||||
{
|
||||
callCount++;
|
||||
long sent = p.BytesSent - chunkOffset; if (sent < 0) sent = 0; if (sent > chunkLength) sent = chunkLength;
|
||||
var map = _fileChunkProgressBytes.GetOrAdd(localPath, _ => new ConcurrentDictionary<long, long>());
|
||||
map[chunkOffset] = sent;
|
||||
if (callCount % 10 != 0 && sent != chunkLength) return;
|
||||
long agg = map.Values.Sum();
|
||||
long prev = _fileProgressBytes.GetOrAdd(localPath, 0); long delta = agg - prev;
|
||||
if (delta != 0) { Interlocked.Add(ref _totalBytesUploaded, delta); _fileProgressBytes[localPath] = agg; }
|
||||
_currentFileName = fileName;
|
||||
Interlocked.Exchange(ref _currentFileBytes, agg);
|
||||
Interlocked.Exchange(ref _currentFileTotalBytes, fileInfo.Length);
|
||||
});
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
int callCount = 0;
|
||||
var progress = new InlineProgress<UploadProgress>(p =>
|
||||
{
|
||||
callCount++;
|
||||
if (callCount % 10 != 0 && p.BytesSent != p.TotalBytes) return;
|
||||
_currentFileName = fileName;
|
||||
Interlocked.Exchange(ref _currentFileBytes, p.BytesSent);
|
||||
Interlocked.Exchange(ref _currentFileTotalBytes, fileInfo.Length);
|
||||
long prev = _fileProgressBytes.GetOrAdd(localPath, 0); long add = p.BytesSent - prev;
|
||||
if (add > 0) { Interlocked.Add(ref _totalBytesUploaded, add); _fileProgressBytes[localPath] = p.BytesSent; }
|
||||
});
|
||||
bool success = await connection.UploadFileAsync(localPath, remotePath, progress, cancellationToken);
|
||||
if (!success)
|
||||
{
|
||||
string detail = connection.LastError;
|
||||
throw new Exception(string.IsNullOrEmpty(detail) ? $"Upload failed for {fileName}" : $"Upload failed for {fileName}: {detail}");
|
||||
}
|
||||
long prevB = _fileProgressBytes.GetOrAdd(localPath, 0); long d2 = fileInfo.Length - prevB;
|
||||
if (d2 > 0) { Interlocked.Add(ref _totalBytesUploaded, d2); _fileProgressBytes[localPath] = fileInfo.Length; }
|
||||
}
|
||||
|
||||
_fileProgressBytes.TryRemove(localPath, out _); _fileChunkProgressBytes.TryRemove(localPath, out _); _chunkLogLastBytes.TryRemove(localPath, out _);
|
||||
if (fileInfo.Length < 10 * 1024 * 1024) TrackSmallFileCompletion(fileName, fileInfo.Length); else Log($"✅ Upload complete: {fileName}");
|
||||
// Batch into pending list — the UI timer drains them. A post +
|
||||
// collection-change + layout pass per file froze the window
|
||||
// on 100k-file uploads.
|
||||
lock (_pendingHistoryLock)
|
||||
_pendingHistory.Add(new TransferHistoryItem { FileName = fileName, Status = "✅ Completed", Size = FormatFileSize(fileInfo.Length), Timestamp = DateTime.Now });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
string fn = Path.GetFileName(localPath);
|
||||
Log($"❌ Exception uploading {fn}: {ex.Message}");
|
||||
_fileProgressBytes.TryRemove(localPath, out _); _fileChunkProgressBytes.TryRemove(localPath, out _); _chunkLogLastBytes.TryRemove(localPath, out _);
|
||||
lock (_pendingHistoryLock)
|
||||
_pendingHistory.Add(new TransferHistoryItem { FileName = fn, Status = "❌ Failed", Size = FormatFileSize(new FileInfo(localPath).Length), Timestamp = DateTime.Now, LocalPath = localPath, RemotePath = remotePath });
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<PS5Protocol> AcquireUploadConnectionAsync()
|
||||
{
|
||||
while (_connectionPool.TryDequeue(out var p)) { if (p.IsConnected) return p; DestroyConnection(p); }
|
||||
var c = new PS5Protocol();
|
||||
for (int i = 0; i < 3; i++)
|
||||
{
|
||||
if (await c.ConnectAsync(_ps5IpAddress))
|
||||
{
|
||||
// Verify the session was actually accepted. When the server
|
||||
// is over MAX_CLIENT_SESSIONS it replies RESP_ERROR and
|
||||
// closes — a bare TCP connect still "succeeds" and poisons
|
||||
// the first command with the stale error frame.
|
||||
try { if (await c.PingAsync()) { Interlocked.Increment(ref _currentPoolConnections); return c; } }
|
||||
catch { }
|
||||
Log($"⚠️ Connection rejected by PS5 (session cap?) - retrying ({i + 1}/3)...");
|
||||
c.Disconnect();
|
||||
}
|
||||
await Task.Delay(1000);
|
||||
}
|
||||
c.Dispose(); throw new InvalidOperationException("Unable to acquire upload connection");
|
||||
}
|
||||
|
||||
private void ReleaseUploadConnection(PS5Protocol c) { if (!c.IsConnected) { DestroyConnection(c); return; } _connectionPool.Enqueue(c); }
|
||||
private void DestroyConnection(PS5Protocol c) { try { c.Disconnect(); c.Dispose(); } catch { } finally { Interlocked.Decrement(ref _currentPoolConnections); } }
|
||||
private void DrainConnectionPool() { while (_connectionPool.TryDequeue(out var c)) DestroyConnection(c); }
|
||||
|
||||
private void CollectFilesFromDirectory(string localDir, string remoteDir, List<(string localPath, string remotePath)> files)
|
||||
{
|
||||
foreach (string file in Directory.GetFiles(localDir)) { FileInfo info = new(file); files.Add((file, remoteDir + "/" + info.Name)); }
|
||||
foreach (string dir in Directory.GetDirectories(localDir)) { DirectoryInfo info = new(dir); CollectFilesFromDirectory(dir, remoteDir + "/" + info.Name, files); }
|
||||
}
|
||||
|
||||
private void TrackSmallFileCompletion(string fileName, long fileSize)
|
||||
{
|
||||
lock (_smallFileLogLock) { _smallFileCompletedTotal++; _smallFileTotalBytes += fileSize; _smallFileBatchRemainder++; _smallFileBatchBytes += fileSize; if (_smallFileBatchRemainder >= SmallFileLogBatchSize) { Log($"✅ {SmallFileLogBatchSize} small files completed (batch {FormatFileSize(_smallFileBatchBytes)}, total {FormatFileSize(_smallFileTotalBytes)})"); _smallFileBatchRemainder = 0; _smallFileBatchBytes = 0; } }
|
||||
}
|
||||
|
||||
private void FlushSmallFileBatch()
|
||||
{
|
||||
lock (_smallFileLogLock) { if (_smallFileBatchRemainder > 0) { Log($"✅ {_smallFileBatchRemainder} small files completed (batch {FormatFileSize(_smallFileBatchBytes)}, total {FormatFileSize(_smallFileTotalBytes)})"); _smallFileBatchRemainder = 0; _smallFileBatchBytes = 0; } }
|
||||
}
|
||||
|
||||
private async Task<List<(string localPath, string remotePath)>> FilterDuplicateFilesAsync(List<(string localPath, string remotePath)> allFiles)
|
||||
{
|
||||
var filesToUpload = new List<(string localPath, string remotePath)>();
|
||||
_duplicateAction = DuplicateAction.Ask;
|
||||
var filesByDir = allFiles.GroupBy(f => Path.GetDirectoryName(f.remotePath)?.Replace("\\", "/") ?? "").ToList();
|
||||
Log($"🔍 Checking {filesByDir.Count} directories for duplicates...");
|
||||
|
||||
bool uploadsIdle = (_uploadCancellation == null || _uploadCancellation.IsCancellationRequested) && _activeTaskCount == 0;
|
||||
PS5Protocol? dupConn = null; bool disposeDup = false;
|
||||
if (!uploadsIdle) { dupConn = new PS5Protocol(); if (await dupConn.ConnectAsync(_ps5IpAddress)) disposeDup = true; else dupConn = null; }
|
||||
|
||||
int dirIndex = 0; const int LogInterval = 50;
|
||||
foreach (var dirGroup in filesByDir)
|
||||
{
|
||||
dirIndex++; string remoteDir = dirGroup.Key;
|
||||
Dictionary<string, long> existingFiles;
|
||||
try
|
||||
{
|
||||
if (dirIndex == 1 || dirIndex % LogInterval == 0 || dirIndex == filesByDir.Count) Log($"📂 Checking dir ({dirIndex}/{filesByDir.Count}): {remoteDir}");
|
||||
var proto = dupConn ?? _protocol;
|
||||
var entries = await proto.ListDirAsync(remoteDir);
|
||||
existingFiles = entries.Where(en => !en.IsDirectory).ToDictionary(en => en.Name, en => en.Size);
|
||||
}
|
||||
catch { filesToUpload.AddRange(dirGroup); continue; }
|
||||
|
||||
foreach (var file in dirGroup)
|
||||
{
|
||||
string? fn = Path.GetFileName(file.remotePath);
|
||||
if (existingFiles.ContainsKey(fn!))
|
||||
{
|
||||
if (_duplicateAction == DuplicateAction.ReplaceAll) { try { await _protocol.DeleteFileAsync(file.remotePath); } catch { } filesToUpload.Add(file); }
|
||||
else if (_duplicateAction == DuplicateAction.SkipAll) { /* skip */ }
|
||||
else
|
||||
{
|
||||
long localSize = new FileInfo(file.localPath).Length;
|
||||
long remoteSize = existingFiles[fn!];
|
||||
var dlg = new DuplicateFileDialog(fn!, localSize, remoteSize);
|
||||
await dlg.ShowDialog(this);
|
||||
switch (dlg.UserAction)
|
||||
{
|
||||
case DuplicateFileDialog.FileAction.Replace: try { await _protocol.DeleteFileAsync(file.remotePath); } catch { } filesToUpload.Add(file); break;
|
||||
case DuplicateFileDialog.FileAction.Skip: break;
|
||||
case DuplicateFileDialog.FileAction.ReplaceAll: _duplicateAction = DuplicateAction.ReplaceAll; try { await _protocol.DeleteFileAsync(file.remotePath); } catch { } filesToUpload.Add(file); break;
|
||||
case DuplicateFileDialog.FileAction.SkipAll: _duplicateAction = DuplicateAction.SkipAll; break;
|
||||
}
|
||||
}
|
||||
}
|
||||
else filesToUpload.Add(file);
|
||||
}
|
||||
}
|
||||
if (disposeDup) dupConn?.Dispose();
|
||||
return filesToUpload;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,428 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Input;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.Media;
|
||||
using Avalonia.Media.Imaging;
|
||||
using Avalonia.Platform.Storage;
|
||||
using Avalonia.Threading;
|
||||
using System.Text;
|
||||
|
||||
namespace PS5Upload
|
||||
{
|
||||
public partial class MainWindow : Window
|
||||
{
|
||||
private PS5Protocol _protocol = new PS5Protocol();
|
||||
private ObservableCollection<LocalFileItem> _localFiles = new();
|
||||
private ObservableCollection<PS5FileItem> _ps5Files = new();
|
||||
private ObservableCollection<PS5FileItem> _ps5FilesFiltered = new();
|
||||
private string _currentPS5Path = "/data";
|
||||
private CancellationTokenSource? _uploadCancellation;
|
||||
private string _ps5IpAddress = "";
|
||||
private string _searchQuery = "";
|
||||
|
||||
// Multi-PS5 Support
|
||||
private Dictionary<string, string> _ps5Profiles = new();
|
||||
private const string ProfilesFileName = "ps5_profiles.json";
|
||||
|
||||
// Favorites/Bookmarks
|
||||
private List<string> _favoritePaths = new();
|
||||
private const string FavoritesFileName = "ps5_favorites.json";
|
||||
|
||||
// Transfer History
|
||||
private ObservableCollection<TransferHistoryItem> _completedTransfers = new();
|
||||
private ObservableCollection<TransferHistoryItem> _failedTransfers = new();
|
||||
|
||||
// Parallel upload settings - optimized for maximum throughput
|
||||
private const int MaxParallelUploads = 28; // Increased from 24 for better saturation
|
||||
private const long SmallFileThresholdBytes = 5L * 1024 * 1024;
|
||||
private const long LargeFileThresholdBytes = 100L * 1024 * 1024;
|
||||
private const int MaxParallelLargeFiles = 8; // Increased from 6
|
||||
private const long HugeFileThresholdBytes = 20L * 1024 * 1024 * 1024;
|
||||
private const int MaxParallelHugeFiles = 4; // Increased from 3
|
||||
// Per-connection throughput is the bottleneck (each lane runs its own TCP
|
||||
// stream), so big files get MANY lanes instead of 3-4. The server accepts
|
||||
// 32 sessions and the pool pre-warms 27, so 16 lanes are always available.
|
||||
private const int MaxParallelChunksForLargeFile = 10; // Increased from 8
|
||||
private const int MaxParallelChunksForHugeFile = 16; // Increased from 12
|
||||
private const long ChunkLogIntervalBytes = 512L * 1024 * 1024;
|
||||
private const long SmallFileLogIntervalBytes = 100L * 1024 * 1024;
|
||||
private const long ChunkThresholdBytes = LargeFileThresholdBytes;
|
||||
private const long MinChunkSizeBytes = 128L * 1024 * 1024;
|
||||
private const long DefaultChunkSizeBytes = 512L * 1024 * 1024;
|
||||
private const long HugeFileChunkSizeBytes = 2048L * 1024 * 1024; // Increased from 1536MB to 2GB
|
||||
|
||||
// Total upload tracking
|
||||
private int _totalFilesToUpload = 0;
|
||||
private long _totalBytesToUpload = 0;
|
||||
private long _totalBytesUploaded = 0;
|
||||
private int _completedFiles = 0;
|
||||
private DateTime _uploadStartTime;
|
||||
private readonly object _progressLock = new();
|
||||
private ConcurrentDictionary<string, long> _fileProgressBytes = new();
|
||||
private ConcurrentDictionary<string, ConcurrentDictionary<long, long>> _fileChunkProgressBytes = new();
|
||||
private ConcurrentDictionary<string, long> _chunkLogLastBytes = new();
|
||||
private TimeSpan _smoothedETA = TimeSpan.Zero;
|
||||
private const double ETASmoothingFactor = 0.15;
|
||||
|
||||
// Sliding window for real-time speed
|
||||
private const int SpeedWindowSize = 10;
|
||||
private long[] _speedWindowBytes = new long[SpeedWindowSize];
|
||||
private DateTime[] _speedWindowTimes = new DateTime[SpeedWindowSize];
|
||||
private int _speedWindowIndex = 0;
|
||||
private int _speedWindowCount = 0;
|
||||
private double _currentSpeed = 0;
|
||||
|
||||
// Current file progress
|
||||
private string _currentFileName = "";
|
||||
private long _currentFileBytes = 0;
|
||||
private long _currentFileTotalBytes = 0;
|
||||
|
||||
// Real-time UI update timer
|
||||
private DispatcherTimer _uiUpdateTimer;
|
||||
private int _activeTaskCount = 0;
|
||||
|
||||
// Connection pooling
|
||||
private readonly ConcurrentQueue<PS5Protocol> _connectionPool = new();
|
||||
private int _currentPoolConnections = 0;
|
||||
private int _activeLargeUploads = 0;
|
||||
private int _activeHugeUploads = 0;
|
||||
|
||||
// Small file logging batches
|
||||
private readonly object _smallFileLogLock = new();
|
||||
private int _smallFileBatchRemainder = 0;
|
||||
private int _smallFileCompletedTotal = 0;
|
||||
private const int SmallFileLogBatchSize = 50;
|
||||
private long _smallFileBatchBytes = 0;
|
||||
private long _smallFileTotalBytes = 0;
|
||||
|
||||
// Log throttling
|
||||
private int _logCounter = 0;
|
||||
private const int MaxLogLines = 1000;
|
||||
private const int MaxLogChars = 256 * 1024;
|
||||
private readonly StringBuilder _logBuf = new();
|
||||
private readonly object _logBufLock = new();
|
||||
private long _lastLogFlushTicks = 0;
|
||||
|
||||
// Duplicate file handling
|
||||
private enum DuplicateAction { Ask, Replace, Skip, ReplaceAll, SkipAll }
|
||||
private DuplicateAction _duplicateAction = DuplicateAction.Ask;
|
||||
|
||||
// Shell Terminal
|
||||
private bool _shellActive = false;
|
||||
private ObservableCollection<string> _shellOutput = new();
|
||||
private string _shellCurrentDir = "/data";
|
||||
|
||||
// Auto-send payload
|
||||
private bool _autoSendPayload = false;
|
||||
private string _payloadPath = "";
|
||||
private int _payloadPort = 9021;
|
||||
|
||||
// Icon caches
|
||||
private readonly Dictionary<string, IImage> _iconCache = new();
|
||||
private readonly Dictionary<string, IImage> _screenshotThumbCache = new();
|
||||
|
||||
// Saves & Screenshots
|
||||
private List<PS5SaveFile> _currentSaves = new();
|
||||
private List<PS5Screenshot> _currentScreenshots = new();
|
||||
|
||||
// Hardware
|
||||
private DispatcherTimer? _hwAutoTimer;
|
||||
private int _hwBusyFlag;
|
||||
private bool _hwStaticLoaded;
|
||||
private bool _isRefreshingStorage = false;
|
||||
|
||||
public MainWindow()
|
||||
{
|
||||
InitializeComponent();
|
||||
LocalFilesListBox.ItemsSource = _localFiles;
|
||||
PS5FilesListBox.ItemsSource = _ps5FilesFiltered;
|
||||
ShellOutputListBox.ItemsSource = _shellOutput;
|
||||
CompletedTransfersListBox.ItemsSource = _completedTransfers;
|
||||
FailedTransfersListBox.ItemsSource = _failedTransfers;
|
||||
|
||||
_uiUpdateTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(500) };
|
||||
_uiUpdateTimer.Tick += UiUpdateTimer_Tick;
|
||||
|
||||
_protocol.OnProgressMessage += (message) => Dispatcher.UIThread.Post(() => Log(message));
|
||||
|
||||
// Drag-drop for local files
|
||||
LocalFilesListBox.AddHandler(DragDrop.DropEvent, LocalFilesListBox_Drop);
|
||||
LocalFilesListBox.AddHandler(DragDrop.DragOverEvent, LocalFilesListBox_DragOver);
|
||||
|
||||
// Search text changed - use PropertyChanged event
|
||||
SearchTextBox.PropertyChanged += (s, args) =>
|
||||
{
|
||||
if (args.Property == TextBox.TextProperty)
|
||||
{
|
||||
_searchQuery = SearchTextBox.Text?.Trim() ?? "";
|
||||
ApplySearchFilter();
|
||||
}
|
||||
};
|
||||
|
||||
Log("Application started");
|
||||
LoadProfiles();
|
||||
LoadFavorites();
|
||||
LoadSettings();
|
||||
|
||||
if (AutoSendPayloadCheckBox != null) AutoSendPayloadCheckBox.IsChecked = _autoSendPayload;
|
||||
if (PayloadPathTextBox != null) PayloadPathTextBox.Text = _payloadPath;
|
||||
if (PayloadPortTextBox != null) PayloadPortTextBox.Text = _payloadPort.ToString();
|
||||
|
||||
if (_autoSendPayload && !string.IsNullOrEmpty(_payloadPath) && !string.IsNullOrEmpty(_ps5IpAddress))
|
||||
_ = AutoSendPayloadOnStartup();
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// HELPER DIALOGS
|
||||
// ============================================================
|
||||
private async Task ShowMessageAsync(string message, string title = "Info")
|
||||
{
|
||||
var dlg = new Window
|
||||
{
|
||||
Title = title, Width = 450, Height = 200,
|
||||
WindowStartupLocation = WindowStartupLocation.CenterOwner,
|
||||
Background = new SolidColorBrush(Color.FromRgb(30, 30, 30))
|
||||
};
|
||||
var panel = new StackPanel { Margin = new Thickness(20) };
|
||||
panel.Children.Add(new TextBlock { Text = message, Foreground = Brushes.White, TextWrapping = Avalonia.Media.TextWrapping.Wrap, Margin = new Thickness(0, 0, 0, 20) });
|
||||
var btn = new Button { Content = "OK", HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Center, Width = 80 };
|
||||
btn.Click += (s, ev) => dlg.Close();
|
||||
panel.Children.Add(btn);
|
||||
dlg.Content = panel;
|
||||
await dlg.ShowDialog(this);
|
||||
}
|
||||
|
||||
private async Task<bool> ShowConfirmAsync(string message, string title = "Confirm")
|
||||
{
|
||||
bool result = false;
|
||||
var dlg = new Window
|
||||
{
|
||||
Title = title, Width = 450, Height = 200,
|
||||
WindowStartupLocation = WindowStartupLocation.CenterOwner,
|
||||
Background = new SolidColorBrush(Color.FromRgb(30, 30, 30))
|
||||
};
|
||||
var panel = new StackPanel { Margin = new Thickness(20) };
|
||||
panel.Children.Add(new TextBlock { Text = message, Foreground = Brushes.White, TextWrapping = Avalonia.Media.TextWrapping.Wrap, Margin = new Thickness(0, 0, 0, 20) });
|
||||
var btnPanel = new StackPanel { Orientation = Avalonia.Layout.Orientation.Horizontal, HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Center, Spacing = 10 };
|
||||
var yesBtn = new Button { Content = "Yes", Width = 80 };
|
||||
var noBtn = new Button { Content = "No", Width = 80 };
|
||||
yesBtn.Click += (s, ev) => { result = true; dlg.Close(); };
|
||||
noBtn.Click += (s, ev) => { result = false; dlg.Close(); };
|
||||
btnPanel.Children.Add(yesBtn);
|
||||
btnPanel.Children.Add(noBtn);
|
||||
panel.Children.Add(btnPanel);
|
||||
dlg.Content = panel;
|
||||
await dlg.ShowDialog(this);
|
||||
return result;
|
||||
}
|
||||
|
||||
private async Task<string?> ShowInputDialogAsync(string title, string label, string defaultValue = "")
|
||||
{
|
||||
string? result = null;
|
||||
var dlg = new Window
|
||||
{
|
||||
Title = title, Width = 400, Height = 180,
|
||||
WindowStartupLocation = WindowStartupLocation.CenterOwner,
|
||||
Background = new SolidColorBrush(Color.FromRgb(30, 30, 30))
|
||||
};
|
||||
var grid = new Grid { Margin = new Thickness(20) };
|
||||
grid.RowDefinitions.Add(new RowDefinition(GridLength.Auto));
|
||||
grid.RowDefinitions.Add(new RowDefinition(GridLength.Auto));
|
||||
grid.RowDefinitions.Add(new RowDefinition(GridLength.Auto));
|
||||
var lbl = new TextBlock { Text = label, Foreground = Brushes.White, Margin = new Thickness(0, 0, 0, 10) };
|
||||
Grid.SetRow(lbl, 0);
|
||||
var textBox = new TextBox { Text = defaultValue, Margin = new Thickness(0, 0, 0, 15) };
|
||||
Grid.SetRow(textBox, 1);
|
||||
var buttonPanel = new StackPanel { Orientation = Avalonia.Layout.Orientation.Horizontal, HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Right, Spacing = 10 };
|
||||
Grid.SetRow(buttonPanel, 2);
|
||||
var okButton = new Button { Content = "OK", Width = 80 };
|
||||
okButton.Click += (s, args) => { result = textBox.Text; dlg.Close(); };
|
||||
var cancelButton = new Button { Content = "Cancel", Width = 80 };
|
||||
cancelButton.Click += (s, args) => { dlg.Close(); };
|
||||
buttonPanel.Children.Add(okButton);
|
||||
buttonPanel.Children.Add(cancelButton);
|
||||
grid.Children.Add(lbl);
|
||||
grid.Children.Add(textBox);
|
||||
grid.Children.Add(buttonPanel);
|
||||
dlg.Content = grid;
|
||||
await dlg.ShowDialog(this);
|
||||
return result;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// NAVIGATION
|
||||
// ============================================================
|
||||
private void NavButton_Click(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
// Hide all pages
|
||||
PageFiles.IsVisible = false;
|
||||
PageGames.IsVisible = false;
|
||||
PageMedia.IsVisible = false;
|
||||
PageSystem.IsVisible = false;
|
||||
PageTools.IsVisible = false;
|
||||
PageDebug.IsVisible = false;
|
||||
|
||||
// Show selected page
|
||||
if (sender == NavFiles) PageFiles.IsVisible = true;
|
||||
else if (sender == NavGames) PageGames.IsVisible = true;
|
||||
else if (sender == NavMedia) PageMedia.IsVisible = true;
|
||||
else if (sender == NavSystem)
|
||||
{
|
||||
PageSystem.IsVisible = true;
|
||||
StartHwAutoRefresh();
|
||||
_ = RefreshHardwareAsync();
|
||||
}
|
||||
else if (sender == NavTools) PageTools.IsVisible = true;
|
||||
else if (sender == NavDebug) PageDebug.IsVisible = true;
|
||||
|
||||
if (sender != NavSystem) StopHwAutoRefresh();
|
||||
}
|
||||
|
||||
private void StartHwAutoRefresh()
|
||||
{
|
||||
_hwAutoTimer ??= new DispatcherTimer { Interval = TimeSpan.FromSeconds(2) };
|
||||
_hwAutoTimer.Tick -= HwAutoTimer_Tick;
|
||||
_hwAutoTimer.Tick += HwAutoTimer_Tick;
|
||||
if (!_hwAutoTimer.IsEnabled) _hwAutoTimer.Start();
|
||||
}
|
||||
|
||||
private void StopHwAutoRefresh() { _hwAutoTimer?.Stop(); _hwStaticLoaded = false; }
|
||||
|
||||
// ============================================================
|
||||
// LOGGING
|
||||
// ============================================================
|
||||
private void Log(string message)
|
||||
{
|
||||
if (_uploadCancellation != null && _uploadCancellation.Token.IsCancellationRequested)
|
||||
{
|
||||
if (!message.Contains("Upload cancelled") && !message.Contains("Cleanup complete") &&
|
||||
!message.Contains("UPLOAD FINISHED") && !message.Contains("Checking main connection"))
|
||||
return;
|
||||
}
|
||||
|
||||
bool isImportant = message.Contains("❌") || message.Contains("⚠️") ||
|
||||
message.Contains("Exception") || message.Contains("Error") ||
|
||||
(message.Contains("File") && message.Contains("completed")) ||
|
||||
message.Contains("Starting parallel") || message.Contains("🚀") ||
|
||||
message.Contains("Upload complete") || message.Contains("finished");
|
||||
|
||||
if (message.Contains("File") && message.Contains("/"))
|
||||
{
|
||||
_logCounter++;
|
||||
if (_logCounter % 100 == 0) isImportant = true;
|
||||
}
|
||||
|
||||
if (isImportant) App.LogToFile(message);
|
||||
|
||||
if (message.Contains("📊") || message.Contains("⬆️ Uploading:") ||
|
||||
message.Contains("⏳ Waiting") || message.Contains("✅ Task completed") ||
|
||||
message.Contains("✅ Task awaited") || message.Contains("🔍 Task index") ||
|
||||
message.Contains("🧹 Cleaning up") || message.Contains("📤 Starting upload") ||
|
||||
(message.Contains("✅ Connection") && message.Contains("established")))
|
||||
{
|
||||
_logCounter++;
|
||||
if (_logCounter % 50 != 0) return;
|
||||
}
|
||||
|
||||
// Buffer and flush to the TextBox at most ~4x/sec — during a
|
||||
// 100k-file upload, one Dispatcher.Post + full-text Split('\n')
|
||||
// per log line saturated the UI thread (frozen tabs, wait cursor).
|
||||
string timestamp = DateTime.Now.ToString("HH:mm:ss.fff");
|
||||
lock (_logBufLock)
|
||||
{
|
||||
_logBuf.Append('[').Append(timestamp).Append("] ").Append(message).Append('\n');
|
||||
}
|
||||
long nowTicks = DateTime.Now.Ticks;
|
||||
if (nowTicks - Interlocked.Read(ref _lastLogFlushTicks) >= TimeSpan.TicksPerSecond / 4)
|
||||
FlushLog();
|
||||
}
|
||||
|
||||
// Drain the buffered log lines into the TextBox (single UI post per
|
||||
// flush, single text rewrite). Safe to call from any thread.
|
||||
private void FlushLog()
|
||||
{
|
||||
string chunk;
|
||||
lock (_logBufLock)
|
||||
{
|
||||
if (_logBuf.Length == 0) return;
|
||||
chunk = _logBuf.ToString();
|
||||
_logBuf.Clear();
|
||||
}
|
||||
Interlocked.Exchange(ref _lastLogFlushTicks, DateTime.Now.Ticks);
|
||||
Dispatcher.UIThread.Post(() =>
|
||||
{
|
||||
string t = (LogTextBox.Text ?? "") + chunk;
|
||||
if (t.Length > MaxLogChars)
|
||||
t = t.Substring(t.Length - MaxLogChars);
|
||||
LogTextBox.Text = t;
|
||||
LogTextBox.CaretIndex = t.Length;
|
||||
});
|
||||
}
|
||||
|
||||
private static string FormatFileSize(long bytes)
|
||||
{
|
||||
string[] sizes = { "B", "KB", "MB", "GB", "TB" };
|
||||
double len = bytes;
|
||||
int order = 0;
|
||||
while (len >= 1024 && order < sizes.Length - 1) { order++; len /= 1024; }
|
||||
return $"{len:0.##} {sizes[order]}";
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// DATA CLASSES
|
||||
// ============================================================
|
||||
public class LocalFileItem
|
||||
{
|
||||
public string Name { get; set; } = "";
|
||||
public string FullPath { get; set; } = "";
|
||||
public string Icon { get; set; } = "";
|
||||
public bool IsDirectory { get; set; }
|
||||
public long Size { get; set; }
|
||||
public string? RemotePathOverride { get; set; }
|
||||
public string SizeFormatted => IsDirectory ? "" : FileUtils.FormatFileSize(Size);
|
||||
}
|
||||
|
||||
public class PS5FileItem
|
||||
{
|
||||
public string Name { get; set; } = "";
|
||||
public string FullPath { get; set; } = "";
|
||||
public string Icon { get; set; } = "";
|
||||
public bool IsDirectory { get; set; }
|
||||
public long Size { get; set; }
|
||||
public string SizeFormatted => IsDirectory ? "" : FileUtils.FormatFileSize(Size);
|
||||
}
|
||||
|
||||
public static class FileUtils
|
||||
{
|
||||
public static string FormatFileSize(long bytes)
|
||||
{
|
||||
string[] sizes = { "B", "KB", "MB", "GB", "TB" };
|
||||
double len = bytes;
|
||||
int order = 0;
|
||||
while (len >= 1024 && order < sizes.Length - 1) { order++; len /= 1024; }
|
||||
return $"{len:0.##} {sizes[order]}";
|
||||
}
|
||||
}
|
||||
|
||||
public class TransferHistoryItem
|
||||
{
|
||||
public string FileName { get; set; } = "";
|
||||
public string Status { get; set; } = "";
|
||||
public string Size { get; set; } = "";
|
||||
public DateTime Timestamp { get; set; }
|
||||
public string FormattedTimestamp => Timestamp.ToString("yyyy-MM-dd HH:mm:ss");
|
||||
public string LocalPath { get; set; } = "";
|
||||
public string RemotePath { get; set; } = "";
|
||||
public override string ToString() => $"{FileName} - {Status} ({Size}) [{FormattedTimestamp}]";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<OutputType>WinExe</OutputType>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>14</LangVersion>
|
||||
<BuiltInComInteropSupport>true</BuiltInComInteropSupport>
|
||||
<ApplicationManifest>app.manifest</ApplicationManifest>
|
||||
<AvaloniaUseCompiledBindingsByDefault>false</AvaloniaUseCompiledBindingsByDefault>
|
||||
<AssemblyName>PS5Suite</AssemblyName>
|
||||
<RootNamespace>PS5Upload</RootNamespace>
|
||||
<ApplicationIcon>ps5_logo.ico</ApplicationIcon>
|
||||
<Version>7.0.0</Version>
|
||||
<Authors>Manos</Authors>
|
||||
<Description>PS5 Suite - Complete PS5 Management Platform</Description>
|
||||
<Copyright>Copyright © 2026 Manos</Copyright>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<AvaloniaResource Include="ps5_logo.ico" />
|
||||
<AvaloniaResource Include="ps5_logo.png" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Avalonia" Version="12.1.3" />
|
||||
<PackageReference Include="Avalonia.Desktop" Version="12.1.3" />
|
||||
<PackageReference Include="Avalonia.Themes.Fluent" Version="12.1.3" />
|
||||
<PackageReference Include="Avalonia.Fonts.Inter" Version="12.1.3" />
|
||||
<PackageReference Include="Avalonia.Controls.DataGrid" Version="12.1.2" />
|
||||
<PackageReference Condition="'$(Configuration)' == 'Debug'" Include="Avalonia.Diagnostics" Version="11.3.22" />
|
||||
<Reference Include="LibProsperoPkg">
|
||||
<HintPath>libs\LibProsperoPkg.dll</HintPath>
|
||||
</Reference>
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,220 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.IO;
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace PS5Upload
|
||||
{
|
||||
/// <summary>
|
||||
/// Minimal HTTP/1.1 file server for on-the-fly PKG installs. The PS5 payload's
|
||||
/// localhost responder (:13801/remote) proxies ranged GETs to this server, so the
|
||||
/// console installer streams the PKG straight from the PC — nothing is staged on
|
||||
/// the console's disk. Implemented with raw TcpListener (no HTTP.sys URL ACL needed).
|
||||
/// Supports HEAD and GET with Range (what the install daemon uses).
|
||||
/// </summary>
|
||||
public sealed class PkgHttpServer : IDisposable
|
||||
{
|
||||
private readonly TcpListener _listener;
|
||||
private readonly ConcurrentDictionary<string, string> _files = new(); // URL path -> local file
|
||||
private readonly CancellationTokenSource _cts = new();
|
||||
|
||||
public int Port { get; }
|
||||
|
||||
public PkgHttpServer()
|
||||
{
|
||||
TcpListener? l = null;
|
||||
int port = 18990;
|
||||
for (; port < 19010; port++)
|
||||
{
|
||||
try { l = new TcpListener(IPAddress.Any, port); l.Start(); break; }
|
||||
catch (SocketException) { }
|
||||
}
|
||||
if (l == null) throw new IOException("No free port for PKG HTTP server");
|
||||
_listener = l;
|
||||
Port = port;
|
||||
_ = AcceptLoopAsync();
|
||||
}
|
||||
|
||||
/// <summary>Register a local file; returns the URL path (escaped) to request it by.
|
||||
/// Keys are stored UNescaped because requests are unescaped before lookup.</summary>
|
||||
public string AddFile(string localPath)
|
||||
{
|
||||
var name = Path.GetFileName(localPath);
|
||||
_files["/" + name] = localPath;
|
||||
return "/" + Uri.EscapeDataString(name);
|
||||
}
|
||||
|
||||
/// <summary>Optional per-request logger (hits, misses, ranges) wired to the app log.</summary>
|
||||
public Action<string>? OnRequest;
|
||||
|
||||
public void RemoveFile(string urlPath) => _files.TryRemove(urlPath, out _);
|
||||
|
||||
private async Task AcceptLoopAsync()
|
||||
{
|
||||
while (!_cts.IsCancellationRequested)
|
||||
{
|
||||
TcpClient c;
|
||||
try { c = await _listener.AcceptTcpClientAsync(); }
|
||||
catch { break; }
|
||||
_ = Task.Run(() => HandleAsync(c));
|
||||
}
|
||||
}
|
||||
|
||||
private int _reqCount;
|
||||
|
||||
private async Task HandleAsync(TcpClient c)
|
||||
{
|
||||
try
|
||||
{
|
||||
using (c)
|
||||
{
|
||||
c.ReceiveTimeout = 15000;
|
||||
c.SendTimeout = 60000;
|
||||
c.NoDelay = true;
|
||||
c.SendBufferSize = 4 * 1024 * 1024; // bigger socket buffer → fewer context switches
|
||||
var ns = c.GetStream();
|
||||
var hdr = new byte[8192];
|
||||
int filled = 0; // bytes currently in hdr (pipelined leftovers carry over)
|
||||
|
||||
// Keep-alive loop: the payload proxy reuses this connection for
|
||||
// sequential range requests — one TCP setup serves the whole PKG.
|
||||
while (!_cts.IsCancellationRequested)
|
||||
{
|
||||
// Read request headers (cap 8KB), with a 15s idle timeout.
|
||||
int hend = -1;
|
||||
using (var idle = CancellationTokenSource.CreateLinkedTokenSource(_cts.Token))
|
||||
{
|
||||
idle.CancelAfter(15000);
|
||||
try
|
||||
{
|
||||
while (hend < 0 && filled < hdr.Length)
|
||||
{
|
||||
hend = IndexOf(hdr, filled, "\r\n\r\n");
|
||||
if (hend >= 0) break;
|
||||
int r = await ns.ReadAsync(hdr, filled, hdr.Length - filled, idle.Token);
|
||||
if (r <= 0) return;
|
||||
filled += r;
|
||||
}
|
||||
hend = IndexOf(hdr, filled, "\r\n\r\n");
|
||||
}
|
||||
catch (OperationCanceledException) { return; }
|
||||
catch (IOException) { return; }
|
||||
}
|
||||
if (hend < 0) return; // oversized/garbage request
|
||||
var req = Encoding.ASCII.GetString(hdr, 0, hend);
|
||||
// Shift leftover bytes (next request) to buffer start.
|
||||
int consumed = hend + 4;
|
||||
filled -= consumed;
|
||||
if (filled > 0) Buffer.BlockCopy(hdr, consumed, hdr, 0, filled);
|
||||
|
||||
var parts = req.Split("\r\n")[0].Split(' ');
|
||||
if (parts.Length < 2) return;
|
||||
var method = parts[0].ToUpperInvariant();
|
||||
var urlPath = Uri.UnescapeDataString(parts[1].Split('?')[0]);
|
||||
bool clientClose = req.IndexOf("Connection: close", StringComparison.OrdinalIgnoreCase) >= 0;
|
||||
|
||||
if (method != "GET" && method != "HEAD" || !_files.TryGetValue(urlPath, out var localPath))
|
||||
{
|
||||
OnRequest?.Invoke($"404 {method} {urlPath}");
|
||||
await WriteAsciiAsync(ns, "HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\nConnection: close\r\n\r\n");
|
||||
return;
|
||||
}
|
||||
|
||||
var fi = new FileInfo(localPath);
|
||||
long total = fi.Length, start = 0, end = total - 1;
|
||||
bool partial = false;
|
||||
|
||||
// Parse "Range: bytes=a-b" / "bytes=a-" / "bytes=-n".
|
||||
var rangeIdx = req.IndexOf("\r\nRange:", StringComparison.OrdinalIgnoreCase);
|
||||
if (rangeIdx < 0) rangeIdx = req.StartsWith("Range:", StringComparison.OrdinalIgnoreCase) ? 0 : -1;
|
||||
if (rangeIdx >= 0)
|
||||
{
|
||||
var lineEnd = req.IndexOf("\r\n", rangeIdx + 1);
|
||||
var spec = req.Substring(rangeIdx + 2 + 6, (lineEnd < 0 ? req.Length : lineEnd) - rangeIdx - 2 - 6).Trim();
|
||||
if (spec.StartsWith("bytes=", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var se = spec.Substring(6).Split('-');
|
||||
if (se.Length == 2)
|
||||
{
|
||||
if (se[0].Length == 0 && long.TryParse(se[1], out var suffix))
|
||||
{
|
||||
start = Math.Max(0, total - suffix); // suffix range: last N bytes
|
||||
end = total - 1;
|
||||
}
|
||||
else
|
||||
{
|
||||
long.TryParse(se[0], out start);
|
||||
if (se[1].Length > 0 && long.TryParse(se[1], out var e2))
|
||||
end = Math.Min(e2, total - 1);
|
||||
}
|
||||
if (start < 0) start = 0;
|
||||
if (start > end || start >= total) { start = 0; end = total - 1; }
|
||||
partial = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
long len = Math.Max(0, end - start + 1);
|
||||
// Log the first few requests + every 64th — the daemon fires
|
||||
// hundreds of range GETs, don't spam the UI with each one.
|
||||
int rn = Interlocked.Increment(ref _reqCount);
|
||||
if (rn <= 8 || (rn & 63) == 0)
|
||||
OnRequest?.Invoke($"{method} {urlPath} bytes={start}-{end}/{total} [#{rn}]");
|
||||
var sb = new StringBuilder();
|
||||
sb.Append(partial ? "HTTP/1.1 206 Partial Content\r\n" : "HTTP/1.1 200 OK\r\n");
|
||||
sb.Append("Content-Type: application/octet-stream\r\nAccept-Ranges: bytes\r\n");
|
||||
sb.Append($"Content-Length: {len}\r\n");
|
||||
if (partial) sb.Append($"Content-Range: bytes {start}-{end}/{total}\r\n");
|
||||
sb.Append(clientClose ? "Connection: close\r\n\r\n" : "Connection: keep-alive\r\n\r\n");
|
||||
await WriteAsciiAsync(ns, sb.ToString());
|
||||
|
||||
if (method == "GET")
|
||||
{
|
||||
using var fs = new FileStream(localPath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
|
||||
fs.Seek(start, SeekOrigin.Begin);
|
||||
var buf = new byte[1024 * 1024];
|
||||
long remain = len;
|
||||
while (remain > 0)
|
||||
{
|
||||
int rd = await fs.ReadAsync(buf, 0, (int)Math.Min(buf.Length, remain), _cts.Token);
|
||||
if (rd <= 0) break;
|
||||
await ns.WriteAsync(buf, 0, rd, _cts.Token);
|
||||
remain -= rd;
|
||||
}
|
||||
}
|
||||
if (clientClose) return;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch { /* connection aborted by installer — fine */ }
|
||||
}
|
||||
|
||||
private static int IndexOf(byte[] buf, int len, string needle)
|
||||
{
|
||||
var pat = Encoding.ASCII.GetBytes(needle);
|
||||
for (int i = 0; i + pat.Length <= len; i++)
|
||||
{
|
||||
bool m = true;
|
||||
for (int j = 0; j < pat.Length; j++) if (buf[i + j] != pat[j]) { m = false; break; }
|
||||
if (m) return i;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
private static async Task WriteAsciiAsync(NetworkStream ns, string s)
|
||||
{
|
||||
var b = Encoding.ASCII.GetBytes(s);
|
||||
await ns.WriteAsync(b, 0, b.Length);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_cts.Cancel();
|
||||
try { _listener.Stop(); } catch { }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
<Window xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
x:Class="PS5Upload.PkgPickerWindow"
|
||||
Title="Select PKG on PS5"
|
||||
Width="640" Height="480"
|
||||
Icon="/ps5_logo.ico"
|
||||
WindowStartupLocation="CenterOwner"
|
||||
Background="#1E1E1E">
|
||||
<Grid Margin="15">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="*"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<!-- Path bar -->
|
||||
<Grid Grid.Row="0" Margin="0,0,0,10">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<Button Grid.Column="0" Content="⬆ Up" Click="Up_Click" Padding="10,6"
|
||||
Background="#3E3E42" Margin="0,0,8,0"/>
|
||||
<TextBox Grid.Column="1" x:Name="PathTextBox" Background="#2D2D30"
|
||||
BorderBrush="#3E3E42" Padding="8" Foreground="White"
|
||||
KeyDown="PathTextBox_KeyDown"/>
|
||||
<Button Grid.Column="2" Content="🔄" Click="Refresh_Click" Padding="10,6"
|
||||
Background="#3E3E42" Margin="8,0,0,0"/>
|
||||
</Grid>
|
||||
|
||||
<!-- File list -->
|
||||
<Border Grid.Row="1" BorderBrush="#3E3E42" BorderThickness="1" CornerRadius="4">
|
||||
<ListBox x:Name="FilesListBox" Background="#252526" Foreground="White"
|
||||
SelectionMode="Multiple" DoubleTapped="FilesListBox_DoubleTapped">
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<TextBlock Text="{Binding Icon}" Margin="0,0,8,0" Width="22"/>
|
||||
<TextBlock Text="{Binding Name}" Foreground="{Binding NameColor}"/>
|
||||
<TextBlock Text="{Binding SizeText}" Foreground="#808080" Margin="10,0,0,0"/>
|
||||
</StackPanel>
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
</ListBox>
|
||||
</Border>
|
||||
|
||||
<!-- Filter + status -->
|
||||
<StackPanel Grid.Row="2" Orientation="Horizontal" Margin="0,8,0,8">
|
||||
<CheckBox x:Name="OnlyPkgCheckBox" Content="Show only .pkg files"
|
||||
Foreground="#AAAAAA" IsChecked="True" IsCheckedChanged="OnlyPkg_Changed"/>
|
||||
<TextBlock x:Name="StatusText" Foreground="#808080" Margin="20,3,0,0"/>
|
||||
</StackPanel>
|
||||
|
||||
<!-- Buttons -->
|
||||
<StackPanel Grid.Row="3" Orientation="Horizontal" HorizontalAlignment="Right" Spacing="10">
|
||||
<Button Content="Select" Click="Select_Click" Padding="18,8"
|
||||
Background="#007ACC" FontWeight="SemiBold"/>
|
||||
<Button Content="Cancel" Click="Cancel_Click" Padding="18,8"
|
||||
Background="#3E3E42"/>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Window>
|
||||
@@ -0,0 +1,136 @@
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Input;
|
||||
using Avalonia.Interactivity;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace PS5Upload
|
||||
{
|
||||
/// <summary>
|
||||
/// Modal PS5 file browser used by Tools → PKG → "Browse PS5".
|
||||
/// Lets the user navigate the console filesystem and pick one or more
|
||||
/// .pkg files without leaving the Tools tab.
|
||||
/// </summary>
|
||||
public partial class PkgPickerWindow : Window
|
||||
{
|
||||
private readonly PS5Protocol? _protocol;
|
||||
private string _currentPath = "/user/data";
|
||||
private List<FileEntry> _entries = new();
|
||||
|
||||
/// <summary>Full PS5 paths of the selected files (null when cancelled).</summary>
|
||||
public List<string>? SelectedPaths { get; private set; }
|
||||
|
||||
private class Row
|
||||
{
|
||||
public string Name { get; set; } = "";
|
||||
public string FullPath { get; set; } = "";
|
||||
public bool IsDirectory { get; set; }
|
||||
public bool IsPkg { get; set; }
|
||||
public long Size { get; set; }
|
||||
public string Icon => IsDirectory ? "📁" : (IsPkg ? "📦" : "📄");
|
||||
public string SizeText => IsDirectory ? "" : FileUtils.FormatFileSize(Size);
|
||||
public string NameColor => IsDirectory ? "#8FD4FF" : (IsPkg ? "#FFFFFF" : "#808080");
|
||||
}
|
||||
|
||||
public PkgPickerWindow()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
|
||||
public PkgPickerWindow(PS5Protocol protocol, string startPath) : this()
|
||||
{
|
||||
_protocol = protocol;
|
||||
if (!string.IsNullOrWhiteSpace(startPath))
|
||||
_currentPath = startPath.TrimEnd('/');
|
||||
Opened += async (_, _) => await LoadDir(_currentPath);
|
||||
}
|
||||
|
||||
private async Task LoadDir(string path)
|
||||
{
|
||||
StatusText.Text = "Loading…";
|
||||
PathTextBox.Text = path;
|
||||
try
|
||||
{
|
||||
if (_protocol == null) { StatusText.Text = "Not connected"; return; }
|
||||
_entries = (await _protocol.ListDirAsync(path)).ToList();
|
||||
_currentPath = path;
|
||||
RebuildList();
|
||||
StatusText.Text = $"{_entries.Count(e => e.IsDirectory)} folders, {_entries.Count(e => !e.IsDirectory)} files";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
StatusText.Text = $"Error: {ex.Message}";
|
||||
}
|
||||
}
|
||||
|
||||
private void RebuildList()
|
||||
{
|
||||
bool onlyPkg = OnlyPkgCheckBox.IsChecked == true;
|
||||
var rows = _entries
|
||||
.Where(e => e.IsDirectory || !onlyPkg ||
|
||||
e.Name.EndsWith(".pkg", StringComparison.OrdinalIgnoreCase))
|
||||
.OrderByDescending(e => e.IsDirectory)
|
||||
.ThenBy(e => e.Name, StringComparer.OrdinalIgnoreCase)
|
||||
.Select(e => new Row
|
||||
{
|
||||
Name = e.Name,
|
||||
FullPath = (_currentPath == "/" ? "" : _currentPath) + "/" + e.Name,
|
||||
IsDirectory = e.IsDirectory,
|
||||
IsPkg = e.Name.EndsWith(".pkg", StringComparison.OrdinalIgnoreCase),
|
||||
Size = e.Size,
|
||||
})
|
||||
.ToList();
|
||||
FilesListBox.ItemsSource = rows;
|
||||
}
|
||||
|
||||
private async void FilesListBox_DoubleTapped(object? sender, TappedEventArgs e)
|
||||
{
|
||||
if (FilesListBox.SelectedItem is Row row && row.IsDirectory)
|
||||
await LoadDir(row.FullPath);
|
||||
}
|
||||
|
||||
private async void Up_Click(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
string parent = _currentPath.TrimEnd('/');
|
||||
int idx = parent.LastIndexOf('/');
|
||||
parent = idx <= 0 ? "/" : parent[..idx];
|
||||
await LoadDir(parent);
|
||||
}
|
||||
|
||||
private async void Refresh_Click(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
await LoadDir(_currentPath);
|
||||
}
|
||||
|
||||
private async void PathTextBox_KeyDown(object? sender, KeyEventArgs e)
|
||||
{
|
||||
if (e.Key == Key.Enter && !string.IsNullOrWhiteSpace(PathTextBox.Text))
|
||||
await LoadDir(PathTextBox.Text.Trim());
|
||||
}
|
||||
|
||||
private void OnlyPkg_Changed(object? sender, RoutedEventArgs e) => RebuildList();
|
||||
|
||||
private void Select_Click(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
var sel = FilesListBox.SelectedItems?.Cast<Row>()
|
||||
.Where(r => !r.IsDirectory)
|
||||
.Select(r => r.FullPath)
|
||||
.ToList();
|
||||
if (sel == null || sel.Count == 0)
|
||||
{
|
||||
StatusText.Text = "Select at least one file";
|
||||
return;
|
||||
}
|
||||
SelectedPaths = sel;
|
||||
Close();
|
||||
}
|
||||
|
||||
private void Cancel_Click(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
SelectedPaths = null;
|
||||
Close();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
using System;
|
||||
using Avalonia;
|
||||
|
||||
namespace PS5Upload;
|
||||
|
||||
class Program
|
||||
{
|
||||
[STAThread]
|
||||
public static void Main(string[] args) => BuildAvaloniaApp()
|
||||
.StartWithClassicDesktopLifetime(args);
|
||||
|
||||
public static AppBuilder BuildAvaloniaApp()
|
||||
=> AppBuilder.Configure<App>()
|
||||
.UsePlatformDetect()
|
||||
.WithInterFont()
|
||||
.LogToTrace();
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
<Window xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
x:Class="PS5Upload.SaveBrowserWindow"
|
||||
Title="Mounted Save — Decrypted Contents"
|
||||
Width="780" Height="560"
|
||||
Background="#1E1E1E"
|
||||
WindowStartupLocation="CenterOwner">
|
||||
|
||||
<Grid Margin="12">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="*"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<!-- Header -->
|
||||
<StackPanel Grid.Row="0" Margin="0,0,0,8">
|
||||
<TextBlock Text="🔓 Mounted Save (decrypted)" FontSize="15" FontWeight="Bold" Foreground="#4EC9B0"/>
|
||||
<TextBlock x:Name="PathText" Text="/data/save_mnt" FontSize="11" Foreground="#9CDCFE" Margin="0,2,0,0"/>
|
||||
</StackPanel>
|
||||
|
||||
<!-- Toolbar -->
|
||||
<WrapPanel Grid.Row="1" Margin="0,0,0,8">
|
||||
<Button x:Name="UpButton" Content="⬅ Up" Padding="10,5" Margin="0,0,6,0" Click="UpButton_Click"/>
|
||||
<Button x:Name="RefreshButton" Content="🔄 Refresh" Padding="10,5" Margin="0,0,6,0" Click="RefreshButton_Click"/>
|
||||
<Button x:Name="DownloadFileButton" Content="⬇ Download" Padding="10,5" Margin="0,0,6,0" Background="#28A745" Click="DownloadFileButton_Click"/>
|
||||
<Button x:Name="UploadFileButton" Content="⬆ Upload" Padding="10,5" Margin="0,0,6,0" Background="#0E639C" Click="UploadFileButton_Click"/>
|
||||
<Button x:Name="NewFolderButton" Content="📁 New Folder" Padding="10,5" Margin="0,0,6,0" Click="NewFolderButton_Click"/>
|
||||
<Button x:Name="DeleteEntryButton" Content="🗑️ Delete" Padding="10,5" Background="#DC3545" Click="DeleteEntryButton_Click"/>
|
||||
</WrapPanel>
|
||||
|
||||
<!-- File list -->
|
||||
<ListBox Grid.Row="2" x:Name="FileListBox" Background="#252526" BorderThickness="0" DoubleTapped="FileListBox_DoubleTapped">
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<TextBlock Grid.Column="0" Text="{Binding IconText}" FontSize="15" Margin="4,4,8,4" VerticalAlignment="Center"/>
|
||||
<TextBlock Grid.Column="1" Text="{Binding Name}" FontSize="13" VerticalAlignment="Center"/>
|
||||
<TextBlock Grid.Column="2" Text="{Binding SizeDisplay}" FontSize="11" Foreground="#9CDCFE" VerticalAlignment="Center" Margin="10,0,4,0"/>
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
</ListBox>
|
||||
|
||||
<!-- Status + close -->
|
||||
<Grid Grid.Row="3" Margin="0,8,0,0">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<TextBlock Grid.Column="0" x:Name="StatusText" Text="" FontSize="11" Foreground="#888888" VerticalAlignment="Center" TextWrapping="Wrap"/>
|
||||
<StackPanel Grid.Column="1" Orientation="Horizontal">
|
||||
<Button x:Name="CloseStayMountedButton" Content="Close (stay mounted)" Padding="12,5" Margin="0,0,6,0" Click="CloseStayMountedButton_Click"/>
|
||||
<Button x:Name="UnmountCloseButton" Content="🔒 Unmount & Close" Padding="12,5" Background="#007ACC" Click="UnmountCloseButton_Click"/>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Window>
|
||||
@@ -0,0 +1,220 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Input;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.Platform.Storage;
|
||||
|
||||
namespace PS5Upload
|
||||
{
|
||||
// Modal browser for the decrypted save mounted at /data/save_mnt.
|
||||
// Reuses the regular remote-file commands (LIST_DIR / DOWNLOAD / UPLOAD /
|
||||
// DELETE / MKDIR) — the mount point behaves like any other directory.
|
||||
public partial class SaveBrowserWindow : Window
|
||||
{
|
||||
private readonly PS5Protocol _protocol;
|
||||
private readonly string _rootPath;
|
||||
private readonly string _ps5Ip;
|
||||
private string _currentPath;
|
||||
|
||||
public class SaveEntryItem
|
||||
{
|
||||
public string Name { get; set; } = "";
|
||||
public bool IsDirectory { get; set; }
|
||||
public long Size { get; set; }
|
||||
public string IconText => IsDirectory ? "📁" : "📄";
|
||||
public string SizeDisplay
|
||||
{
|
||||
get
|
||||
{
|
||||
if (IsDirectory) return "";
|
||||
double mb = Size / (1024.0 * 1024.0);
|
||||
if (mb < 1) return $"{Size / 1024.0:F1} KB";
|
||||
if (mb < 1024) return $"{mb:F1} MB";
|
||||
return $"{mb / 1024.0:F2} GB";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public SaveBrowserWindow()
|
||||
{
|
||||
InitializeComponent();
|
||||
_protocol = null!;
|
||||
_ps5Ip = string.Empty;
|
||||
_rootPath = string.Empty;
|
||||
_currentPath = string.Empty;
|
||||
}
|
||||
|
||||
public SaveBrowserWindow(PS5Protocol protocol, string rootPath, string ps5Ip)
|
||||
{
|
||||
InitializeComponent();
|
||||
_protocol = protocol;
|
||||
_ps5Ip = ps5Ip;
|
||||
_rootPath = rootPath.TrimEnd('/');
|
||||
_currentPath = _rootPath;
|
||||
Opened += async (_, __) => await RefreshAsync();
|
||||
}
|
||||
|
||||
private void SetStatus(string s) => StatusText.Text = s;
|
||||
|
||||
private async Task RefreshAsync()
|
||||
{
|
||||
SetStatus("Loading…");
|
||||
try
|
||||
{
|
||||
var entries = await _protocol.ListDirAsync(_currentPath);
|
||||
var items = entries
|
||||
.OrderByDescending(e => e.IsDirectory)
|
||||
.ThenBy(e => e.Name, StringComparer.OrdinalIgnoreCase)
|
||||
.Select(e => new SaveEntryItem { Name = e.Name, IsDirectory = e.IsDirectory, Size = e.Size })
|
||||
.ToList();
|
||||
FileListBox.ItemsSource = items;
|
||||
PathText.Text = _currentPath;
|
||||
UpButton.IsEnabled = _currentPath.Length > _rootPath.Length;
|
||||
SetStatus($"{items.Count} item(s)");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
SetStatus($"List failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private async void RefreshButton_Click(object? sender, RoutedEventArgs e) => await RefreshAsync();
|
||||
|
||||
private async void UpButton_Click(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (_currentPath.Length <= _rootPath.Length) return;
|
||||
int idx = _currentPath.LastIndexOf('/');
|
||||
_currentPath = idx <= 0 ? _rootPath : _currentPath.Substring(0, idx);
|
||||
if (_currentPath.Length < _rootPath.Length) _currentPath = _rootPath;
|
||||
await RefreshAsync();
|
||||
}
|
||||
|
||||
private async void FileListBox_DoubleTapped(object? sender, TappedEventArgs e)
|
||||
{
|
||||
if (FileListBox.SelectedItem is SaveEntryItem item && item.IsDirectory)
|
||||
{
|
||||
_currentPath = _currentPath.TrimEnd('/') + "/" + item.Name;
|
||||
await RefreshAsync();
|
||||
}
|
||||
}
|
||||
|
||||
private async void DownloadFileButton_Click(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (FileListBox.SelectedItem is not SaveEntryItem item) { SetStatus("Select a file first."); return; }
|
||||
var top = TopLevel.GetTopLevel(this);
|
||||
if (top == null) return;
|
||||
string remote = _currentPath.TrimEnd('/') + "/" + item.Name;
|
||||
|
||||
if (item.IsDirectory)
|
||||
{
|
||||
var folders = await top.StorageProvider.OpenFolderPickerAsync(new FolderPickerOpenOptions { Title = "Choose destination folder" });
|
||||
if (folders.Count == 0) return;
|
||||
var dest = folders[0].TryGetLocalPath();
|
||||
if (dest == null) return;
|
||||
SetStatus($"Downloading folder {item.Name}…");
|
||||
try
|
||||
{
|
||||
var r = await _protocol.DownloadFolderAsync(remote, Path.Combine(dest, item.Name), _ps5Ip, null, System.Threading.CancellationToken.None);
|
||||
SetStatus($"Downloaded {r.filesDownloaded} files ({r.totalBytes / 1024} KB), {r.filesFailed} failed");
|
||||
}
|
||||
catch (Exception ex) { SetStatus($"Download failed: {ex.Message}"); }
|
||||
}
|
||||
else
|
||||
{
|
||||
var file = await top.StorageProvider.SaveFilePickerAsync(new FilePickerSaveOptions
|
||||
{
|
||||
Title = "Save file as",
|
||||
SuggestedFileName = item.Name
|
||||
});
|
||||
if (file == null) return;
|
||||
var dest = file.TryGetLocalPath();
|
||||
if (dest == null) return;
|
||||
SetStatus($"Downloading {item.Name}…");
|
||||
try
|
||||
{
|
||||
bool ok = await _protocol.DownloadFileAsync(remote, dest);
|
||||
SetStatus(ok ? $"Saved to {dest}" : "Download failed");
|
||||
}
|
||||
catch (Exception ex) { SetStatus($"Download failed: {ex.Message}"); }
|
||||
}
|
||||
}
|
||||
|
||||
private async void UploadFileButton_Click(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
var top = TopLevel.GetTopLevel(this);
|
||||
if (top == null) return;
|
||||
var files = await top.StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions
|
||||
{
|
||||
Title = "Select file(s) to upload into the save",
|
||||
AllowMultiple = true
|
||||
});
|
||||
if (files.Count == 0) return;
|
||||
|
||||
int done = 0, failed = 0;
|
||||
foreach (var f in files)
|
||||
{
|
||||
var local = f.TryGetLocalPath();
|
||||
if (local == null) { failed++; continue; }
|
||||
string remote = _currentPath.TrimEnd('/') + "/" + Path.GetFileName(local);
|
||||
SetStatus($"Uploading {Path.GetFileName(local)}…");
|
||||
try
|
||||
{
|
||||
bool ok = await _protocol.UploadFileAsync(local, remote);
|
||||
if (ok) done++; else failed++;
|
||||
}
|
||||
catch { failed++; }
|
||||
}
|
||||
SetStatus($"Uploaded {done}, failed {failed}");
|
||||
await RefreshAsync();
|
||||
}
|
||||
|
||||
private async void NewFolderButton_Click(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
var dlg = new Window
|
||||
{
|
||||
Title = "New Folder",
|
||||
Width = 360, Height = 140,
|
||||
Background = Avalonia.Media.Brush.Parse("#1E1E1E"),
|
||||
WindowStartupLocation = WindowStartupLocation.CenterOwner
|
||||
};
|
||||
var nameBox = new TextBox { PlaceholderText = "Folder name", Margin = new Avalonia.Thickness(10) };
|
||||
var okBtn = new Button { Content = "Create", HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Center, Padding = new Avalonia.Thickness(20, 6) };
|
||||
var panel = new StackPanel { Spacing = 8 };
|
||||
panel.Children.Add(nameBox);
|
||||
panel.Children.Add(okBtn);
|
||||
dlg.Content = panel;
|
||||
okBtn.Click += (_, __) => dlg.Close(nameBox.Text);
|
||||
var name = await dlg.ShowDialog<string?>(this);
|
||||
if (string.IsNullOrWhiteSpace(name)) return;
|
||||
try
|
||||
{
|
||||
bool ok = await _protocol.CreateDirAsync(_currentPath.TrimEnd('/') + "/" + name.Trim());
|
||||
SetStatus(ok ? $"Created {name}" : "Create failed");
|
||||
}
|
||||
catch (Exception ex) { SetStatus($"Create failed: {ex.Message}"); }
|
||||
await RefreshAsync();
|
||||
}
|
||||
|
||||
private async void DeleteEntryButton_Click(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (FileListBox.SelectedItem is not SaveEntryItem item) { SetStatus("Select an entry first."); return; }
|
||||
string remote = _currentPath.TrimEnd('/') + "/" + item.Name;
|
||||
SetStatus($"Deleting {item.Name}…");
|
||||
try
|
||||
{
|
||||
bool ok = await _protocol.DeleteFileAsync(remote);
|
||||
SetStatus(ok ? $"Deleted {item.Name}" : "Delete failed");
|
||||
}
|
||||
catch (Exception ex) { SetStatus($"Delete failed: {ex.Message}"); }
|
||||
await RefreshAsync();
|
||||
}
|
||||
|
||||
private void CloseStayMountedButton_Click(object? sender, RoutedEventArgs e) => Close(false);
|
||||
|
||||
private void UnmountCloseButton_Click(object? sender, RoutedEventArgs e) => Close(true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
|
||||
<assemblyIdentity version="1.0.0.0" name="PS5Suite.Desktop"/>
|
||||
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
|
||||
<application>
|
||||
<!-- Windows 10 and Windows 11 -->
|
||||
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" />
|
||||
</application>
|
||||
</compatibility>
|
||||
</assembly>
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"sdk": {
|
||||
"version": "10.0.401"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Builds standalone single-file executables of PS5Suite for all major platforms.
|
||||
|
||||
.DESCRIPTION
|
||||
Produces self-contained, single-file binaries (no .NET runtime required on target machine) for:
|
||||
- Windows x64 -> dist/win-x64/PS5Suite.exe
|
||||
- Linux x64 -> dist/linux-x64/PS5Suite
|
||||
- Linux ARM64 -> dist/linux-arm64/PS5Suite
|
||||
- macOS x64 (Intel) -> dist/osx-x64/PS5Suite
|
||||
- macOS ARM64 (M1+) -> dist/osx-arm64/PS5Suite
|
||||
|
||||
.PARAMETER Targets
|
||||
Optional list of RIDs to build. Defaults to all 5.
|
||||
Example: .\publish.ps1 -Targets win-x64,linux-x64
|
||||
|
||||
.PARAMETER Clean
|
||||
Wipe the dist/ folder before building.
|
||||
|
||||
.EXAMPLE
|
||||
.\publish.ps1
|
||||
.\publish.ps1 -Targets win-x64
|
||||
.\publish.ps1 -Clean
|
||||
#>
|
||||
|
||||
param(
|
||||
[string[]] $Targets = @('win-x64', 'linux-x64', 'linux-arm64', 'osx-x64', 'osx-arm64'),
|
||||
[switch] $Clean
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$ProjectRoot = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||
$Project = Join-Path $ProjectRoot 'PS5Suite.csproj'
|
||||
$DistRoot = Join-Path $ProjectRoot 'dist'
|
||||
|
||||
if (-not (Test-Path $Project)) {
|
||||
Write-Error "Project file not found: $Project"
|
||||
exit 1
|
||||
}
|
||||
|
||||
if ($Clean -and (Test-Path $DistRoot)) {
|
||||
Write-Host "Cleaning $DistRoot ..." -ForegroundColor Yellow
|
||||
Remove-Item $DistRoot -Recurse -Force
|
||||
}
|
||||
|
||||
New-Item -ItemType Directory -Path $DistRoot -Force | Out-Null
|
||||
|
||||
$summary = @()
|
||||
|
||||
foreach ($rid in $Targets) {
|
||||
$outDir = Join-Path $DistRoot $rid
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "============================================================" -ForegroundColor Cyan
|
||||
Write-Host " Publishing $rid" -ForegroundColor Cyan
|
||||
Write-Host "============================================================" -ForegroundColor Cyan
|
||||
|
||||
$args = @(
|
||||
'publish', $Project,
|
||||
'-c', 'Release',
|
||||
'-r', $rid,
|
||||
'--self-contained', 'true',
|
||||
'-p:PublishSingleFile=true',
|
||||
'-p:IncludeNativeLibrariesForSelfExtract=true',
|
||||
'-p:EnableCompressionInSingleFile=true',
|
||||
'-p:DebugType=None',
|
||||
'-p:DebugSymbols=false',
|
||||
'-o', $outDir
|
||||
)
|
||||
|
||||
& dotnet @args
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Error "Publish failed for $rid"
|
||||
$summary += [pscustomobject]@{ RID = $rid; Status = 'FAILED'; Path = '-'; SizeMB = '-' }
|
||||
continue
|
||||
}
|
||||
|
||||
# Locate the produced binary
|
||||
$exeName = if ($rid -like 'win-*') { 'PS5Suite.exe' } else { 'PS5Suite' }
|
||||
$exePath = Join-Path $outDir $exeName
|
||||
|
||||
if (Test-Path $exePath) {
|
||||
$sizeMB = [math]::Round((Get-Item $exePath).Length / 1MB, 2)
|
||||
Write-Host (" -> {0} ({1} MB)" -f $exePath, $sizeMB) -ForegroundColor Green
|
||||
$summary += [pscustomobject]@{ RID = $rid; Status = 'OK'; Path = $exePath; SizeMB = $sizeMB }
|
||||
} else {
|
||||
Write-Warning "Binary not found at expected path: $exePath"
|
||||
$summary += [pscustomobject]@{ RID = $rid; Status = 'NO BINARY'; Path = $outDir; SizeMB = '-' }
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "============================================================" -ForegroundColor Cyan
|
||||
Write-Host " Build summary" -ForegroundColor Cyan
|
||||
Write-Host "============================================================" -ForegroundColor Cyan
|
||||
$summary | Format-Table -AutoSize
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "Notes:" -ForegroundColor Yellow
|
||||
Write-Host " - Linux/macOS users may need: chmod +x ./PS5Suite"
|
||||
Write-Host " - macOS may also need: xattr -dr com.apple.quarantine ./PS5Suite"
|
||||
Write-Host " - All builds are self-contained; no .NET runtime needed on the target system."
|
||||
@@ -1,8 +0,0 @@
|
||||
<Application x:Class="PS5Upload.App"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
StartupUri="MainWindow.xaml">
|
||||
<Application.Resources>
|
||||
|
||||
</Application.Resources>
|
||||
</Application>
|
||||
@@ -1,61 +0,0 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Threading.Tasks;
|
||||
using System.Windows;
|
||||
using System.Windows.Threading;
|
||||
|
||||
namespace PS5Upload
|
||||
{
|
||||
public partial class App : Application
|
||||
{
|
||||
private static readonly string LogFilePath = Path.Combine(
|
||||
AppDomain.CurrentDomain.BaseDirectory,
|
||||
$"ps5upload_{DateTime.Now:yyyy-MM-dd_HH-mm-ss}.log");
|
||||
|
||||
private static readonly object _logLock = new object();
|
||||
|
||||
public App()
|
||||
{
|
||||
// Global exception handlers
|
||||
this.DispatcherUnhandledException += App_DispatcherUnhandledException;
|
||||
AppDomain.CurrentDomain.UnhandledException += CurrentDomain_UnhandledException;
|
||||
TaskScheduler.UnobservedTaskException += TaskScheduler_UnobservedTaskException;
|
||||
|
||||
LogToFile("=== Application Started ===");
|
||||
}
|
||||
|
||||
private void App_DispatcherUnhandledException(object sender, DispatcherUnhandledExceptionEventArgs e)
|
||||
{
|
||||
LogToFile($"DISPATCHER EXCEPTION: {e.Exception}");
|
||||
e.Handled = true; // Prevent crash, but log it
|
||||
|
||||
MessageBox.Show($"Error: {e.Exception.Message}\n\nCheck log file: {LogFilePath}",
|
||||
"PS5 Upload Error", MessageBoxButton.OK, MessageBoxImage.Error);
|
||||
}
|
||||
|
||||
private void CurrentDomain_UnhandledException(object sender, UnhandledExceptionEventArgs e)
|
||||
{
|
||||
var ex = e.ExceptionObject as Exception;
|
||||
LogToFile($"UNHANDLED EXCEPTION: {ex}");
|
||||
}
|
||||
|
||||
private void TaskScheduler_UnobservedTaskException(object? sender, UnobservedTaskExceptionEventArgs e)
|
||||
{
|
||||
LogToFile($"TASK EXCEPTION: {e.Exception}");
|
||||
e.SetObserved(); // Prevent crash
|
||||
}
|
||||
|
||||
public static void LogToFile(string message)
|
||||
{
|
||||
try
|
||||
{
|
||||
lock (_logLock)
|
||||
{
|
||||
string timestamp = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss.fff");
|
||||
File.AppendAllText(LogFilePath, $"[{timestamp}] {message}\n");
|
||||
}
|
||||
}
|
||||
catch { /* Ignore logging errors */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
<Window x:Class="PS5Upload.DuplicateFileDialog"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
Title="File Already Exists"
|
||||
Height="320"
|
||||
Width="550"
|
||||
WindowStartupLocation="CenterOwner"
|
||||
ResizeMode="NoResize"
|
||||
Background="#1E1E1E">
|
||||
|
||||
<Window.Resources>
|
||||
<Style TargetType="Button">
|
||||
<Setter Property="Background" Value="#2D2D30"/>
|
||||
<Setter Property="Foreground" Value="White"/>
|
||||
<Setter Property="BorderBrush" Value="#3F3F46"/>
|
||||
<Setter Property="Padding" Value="15,8"/>
|
||||
<Setter Property="Margin" Value="5"/>
|
||||
<Setter Property="FontSize" Value="13"/>
|
||||
<Setter Property="Cursor" Value="Hand"/>
|
||||
<Style.Triggers>
|
||||
<Trigger Property="IsMouseOver" Value="True">
|
||||
<Setter Property="Background" Value="#3E3E42"/>
|
||||
</Trigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
</Window.Resources>
|
||||
|
||||
<Grid Margin="20">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="*"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<!-- Icon and Message -->
|
||||
<StackPanel Grid.Row="0" Orientation="Horizontal" Margin="0,0,0,15">
|
||||
<TextBlock Text="⚠️" FontSize="32" Margin="0,0,15,0"/>
|
||||
<TextBlock x:Name="MessageTextBlock"
|
||||
Text="This file already exists on PS5:"
|
||||
Foreground="White"
|
||||
FontSize="14"
|
||||
VerticalAlignment="Center"/>
|
||||
</StackPanel>
|
||||
|
||||
<!-- File Name -->
|
||||
<TextBlock Grid.Row="1"
|
||||
x:Name="FileNameTextBlock"
|
||||
Foreground="#4EC9B0"
|
||||
FontSize="13"
|
||||
FontWeight="Bold"
|
||||
Margin="0,0,0,10"
|
||||
TextWrapping="Wrap"/>
|
||||
|
||||
<!-- File Info -->
|
||||
<StackPanel Grid.Row="2" Margin="0,0,0,20">
|
||||
<TextBlock x:Name="LocalSizeTextBlock"
|
||||
Foreground="#9CDCFE"
|
||||
FontSize="12"
|
||||
Margin="0,0,0,5"/>
|
||||
<TextBlock x:Name="RemoteSizeTextBlock"
|
||||
Foreground="#9CDCFE"
|
||||
FontSize="12"/>
|
||||
</StackPanel>
|
||||
|
||||
<!-- Buttons -->
|
||||
<StackPanel Grid.Row="4" Orientation="Vertical">
|
||||
<StackPanel Orientation="Horizontal" HorizontalAlignment="Center" Margin="0,0,0,10">
|
||||
<Button Content="Replace" Width="120" Click="Replace_Click"/>
|
||||
<Button Content="Skip" Width="120" Click="Skip_Click"/>
|
||||
</StackPanel>
|
||||
<StackPanel Orientation="Horizontal" HorizontalAlignment="Center">
|
||||
<Button Content="Replace All" Width="120" Click="ReplaceAll_Click"/>
|
||||
<Button Content="Skip All" Width="120" Click="SkipAll_Click"/>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Window>
|
||||
@@ -1,67 +0,0 @@
|
||||
using System.Windows;
|
||||
|
||||
namespace PS5Upload
|
||||
{
|
||||
public partial class DuplicateFileDialog : Window
|
||||
{
|
||||
public enum FileAction
|
||||
{
|
||||
Replace,
|
||||
Skip,
|
||||
ReplaceAll,
|
||||
SkipAll
|
||||
}
|
||||
|
||||
public FileAction UserAction { get; private set; }
|
||||
|
||||
public DuplicateFileDialog(string fileName, long localSize, long remoteSize)
|
||||
{
|
||||
InitializeComponent();
|
||||
|
||||
FileNameTextBlock.Text = fileName;
|
||||
LocalSizeTextBlock.Text = $"Local file: {FormatFileSize(localSize)}";
|
||||
RemoteSizeTextBlock.Text = $"Remote file: {FormatFileSize(remoteSize)}";
|
||||
}
|
||||
|
||||
private void Replace_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
UserAction = FileAction.Replace;
|
||||
DialogResult = true;
|
||||
Close();
|
||||
}
|
||||
|
||||
private void Skip_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
UserAction = FileAction.Skip;
|
||||
DialogResult = true;
|
||||
Close();
|
||||
}
|
||||
|
||||
private void ReplaceAll_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
UserAction = FileAction.ReplaceAll;
|
||||
DialogResult = true;
|
||||
Close();
|
||||
}
|
||||
|
||||
private void SkipAll_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
UserAction = FileAction.SkipAll;
|
||||
DialogResult = true;
|
||||
Close();
|
||||
}
|
||||
|
||||
private string FormatFileSize(long bytes)
|
||||
{
|
||||
string[] sizes = { "B", "KB", "MB", "GB", "TB" };
|
||||
double len = bytes;
|
||||
int order = 0;
|
||||
while (len >= 1024 && order < sizes.Length - 1)
|
||||
{
|
||||
order++;
|
||||
len = len / 1024;
|
||||
}
|
||||
return $"{len:0.##} {sizes[order]}";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>WinExe</OutputType>
|
||||
<TargetFramework>net6.0-windows</TargetFramework>
|
||||
<UseWPF>true</UseWPF>
|
||||
<UseWindowsForms>true</UseWindowsForms>
|
||||
<AssemblyName>PS5Upload</AssemblyName>
|
||||
<RootNamespace>PS5Upload</RootNamespace>
|
||||
<Version>1.0.0</Version>
|
||||
<Authors>Manos</Authors>
|
||||
<Description>High-Speed File Upload Tool for PS5</Description>
|
||||
<Copyright>Copyright © 2026 Manos</Copyright>
|
||||
<Nullable>enable</Nullable>
|
||||
<PublishSingleFile>true</PublishSingleFile>
|
||||
<SelfContained>true</SelfContained>
|
||||
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
|
||||
<PublishReadyToRun>true</PublishReadyToRun>
|
||||
<ApplicationIcon>ps5_logo_fixed.ico</ApplicationIcon>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Resource Include="ps5_logo.png" />
|
||||
<None Include="ps5_logo.ico">
|
||||
<Link>ps5_logo.ico</Link>
|
||||
</None>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -1,29 +0,0 @@
|
||||
@echo off
|
||||
echo ================================================
|
||||
echo PS5 Upload Client - Building
|
||||
echo By Manos
|
||||
echo ================================================
|
||||
echo.
|
||||
|
||||
echo [+] Building Windows GUI Client...
|
||||
dotnet publish -c Release -r win-x64 --self-contained true /p:PublishSingleFile=true /p:IncludeNativeLibrariesForSelfExtract=true
|
||||
|
||||
if exist "bin\Release\net6.0-windows\win-x64\publish\PS5Upload.exe" (
|
||||
echo.
|
||||
echo ================================================
|
||||
echo [+] SUCCESS! Built PS5Upload.exe
|
||||
echo ================================================
|
||||
echo.
|
||||
echo Location: bin\Release\net6.0-windows\win-x64\publish\PS5Upload.exe
|
||||
echo.
|
||||
echo Copy PS5Upload.exe to any location and run it!
|
||||
) else (
|
||||
echo.
|
||||
echo ================================================
|
||||
echo [-] BUILD FAILED
|
||||
echo ================================================
|
||||
echo.
|
||||
echo Make sure .NET 6.0 SDK is installed:
|
||||
echo https://dotnet.microsoft.com/download/dotnet/6.0
|
||||
exit /b 1
|
||||
)
|
||||
|
Before Width: | Height: | Size: 6.7 KiB |
@@ -0,0 +1,68 @@
|
||||
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
|
||||
@@ -0,0 +1,767 @@
|
||||
/*
|
||||
* 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 */
|
||||
@@ -0,0 +1,124 @@
|
||||
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
|
||||
@@ -0,0 +1,114 @@
|
||||
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
|
||||
@@ -0,0 +1,5 @@
|
||||
bin/
|
||||
obj/
|
||||
.vs/
|
||||
*.user
|
||||
*.suo
|
||||
@@ -0,0 +1,674 @@
|
||||
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>.
|
||||
@@ -0,0 +1,48 @@
|
||||
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.
|
||||
@@ -0,0 +1,219 @@
|
||||
# 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).
|
||||
@@ -0,0 +1,43 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,382 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,224 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,359 @@
|
||||
# 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 |
|
||||
|
||||
|
||||
@@ -0,0 +1,460 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,96 @@
|
||||
<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>
|
||||
@@ -0,0 +1,27 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<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>
|
||||
@@ -0,0 +1,41 @@
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
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();
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace LibProsperoPkg.Gui.Services;
|
||||
|
||||
public interface IAppHost
|
||||
{
|
||||
IStorageService Storage { get; }
|
||||
|
||||
Task RunAsync(string label, Action<Action<string>> operation);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace LibProsperoPkg.Gui.ViewModels.Fields;
|
||||
|
||||
public sealed class CheckFieldViewModel : FieldViewModel
|
||||
{
|
||||
private bool _value;
|
||||
|
||||
public bool Value
|
||||
{
|
||||
get => _value;
|
||||
set => SetProperty(ref _value, value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace LibProsperoPkg.Gui.ViewModels.Fields;
|
||||
|
||||
public enum PickKind
|
||||
{
|
||||
None,
|
||||
OpenFile,
|
||||
OpenFolder,
|
||||
SaveFile,
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
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 = "";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
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),
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
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)");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
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}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
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());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
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)");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||