Files

3299 lines
127 KiB
C#

using System;
using System.Buffers;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net;
using System.Net.Sockets;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
namespace PS5Upload
{
// Protocol commands
public enum Command : byte
{
Ping = 0x01,
ListStorage = 0x02,
ListDir = 0x03,
CreateDir = 0x04,
DeleteFile = 0x05,
DeleteDir = 0x06,
Rename = 0x07,
CopyFile = 0x08,
MoveFile = 0x09,
StartUpload = 0x10,
UploadChunk = 0x11,
EndUpload = 0x12,
DownloadFile = 0x13,
ShellOpen = 0x20,
ShellExec = 0x21,
ShellInterrupt = 0x22,
ShellClose = 0x23,
IndexStart = 0x40,
IndexStatus = 0x41,
SearchIndex = 0x42,
MountGames = 0x30,
GetFileInfo = 0x31,
GetSystemInfo = 0x32,
VerifyFile = 0x33,
GetHwInfo = 0x34,
GetTemps = 0x35,
GetRunningApps = 0x36,
KillApp = 0x37,
LaunchBrowser = 0x38,
GetPowerInfo = 0x39,
GetGameList = 0x3A,
UnmountGame = 0x3B,
GetGameIcon = 0x3C,
GetGameDetails = 0x3D,
GetGamePic = 0x3E,
ListSaves = 0x3F,
IndexCancel = 0x43,
LaunchGame = 0x44,
ListScreenshots = 0x45,
DeleteScreenshot = 0x46,
GetExtendedInfo = 0x47,
GetCpuUsage = 0x48,
GetMemoryInfo = 0x49,
GetModuleList = 0x4A,
// New features (ps5upload-style)
PkgInstall = 0x50,
PkgInstallStatus = 0x51,
FanGetThreshold = 0x52,
FanSetThreshold = 0x53,
// Save manager (garlic-savemgr style)
SaveScan = 0x60,
SaveMount = 0x61,
SaveUnmount = 0x62,
SaveMountStatus = 0x63,
SaveDelete = 0x64,
ProcList = 0x66,
RestartUI = 0x67,
SelfUpdate = 0x68,
MemRead = 0x69,
MemWrite = 0x6A,
MemRegions = 0x6B,
MemSearch = 0x6C,
KlogRead = 0x6D,
MountGame = 0x6F,
Shutdown = 0xFF
}
// Protocol responses
public enum Response : byte
{
Ok = 0x01,
Error = 0x02,
Data = 0x03,
Ready = 0x04,
Progress = 0x05
}
public class PS5Protocol : IDisposable
{
private TcpClient? _client;
private NetworkStream? _stream;
private const int BufferSize = 16 * 1024 * 1024; // 16MB buffer - matches payload socket buffers for maximum throughput
private readonly SemaphoreSlim _commandLock = new SemaphoreSlim(1, 1); // Serialize all command-response cycles to prevent protocol desync
public bool IsConnected => _client != null && _stream != null && _client.Connected;
/// <summary>Local IP on the LAN interface used to reach the PS5 — the address the
/// console can reach this PC on (for the PKG stream-install HTTP server).
/// Dual-stack sockets report IPv4-mapped IPv6 (::ffff:x.x.x.x) which the
/// payload's URL parser can't handle — always map back to dotted IPv4.</summary>
public string? LocalIp
{
get
{
if (_client?.Client?.LocalEndPoint is not System.Net.IPEndPoint ep) return null;
var a = ep.Address;
if (a.IsIPv4MappedToIPv6) a = a.MapToIPv4();
return a.ToString();
}
}
public string LastError { get; private set; } = "";
/// <summary>The port this connection actually landed on.</summary>
public int Port { get; private set; } = 9113;
private enum ConnectResult { Ok, Refused, Timeout }
public async Task<bool> ConnectAsync(string ipAddress, int port = 9113, CancellationToken ct = default)
{
// A wedged payload keeps its listener alive in the kernel — TCP
// connect completes but nothing ever answers. Verify each
// candidate with a bounded PING, then scan the fallback ports the
// server binds when 9113 is held by a dead process.
// If the TCP connect itself times out the host is unreachable —
// every other port will fail identically, so bail immediately
// instead of hanging ~30s on a wrong IP.
var ports = new List<int> { port };
for (int p = 9113; p <= 9118; p++)
if (!ports.Contains(p)) ports.Add(p);
foreach (var p in ports)
{
ct.ThrowIfCancellationRequested();
var r = await ConnectSingleAsync(ipAddress, p, ct);
if (r == ConnectResult.Timeout) { Disconnect(); return false; }
if (r == ConnectResult.Ok && await VerifyAliveAsync())
{
Port = p;
return true;
}
Disconnect();
}
return false;
}
/// <summary>
/// Broadcasts a UDP discovery probe; the running payload answers
/// "PS5SUITE|&lt;port&gt;|&lt;ver&gt;" with its real IP + bound port.
/// Returns null when nothing answers within the timeout.
/// </summary>
public static async Task<(string ip, int port)?> DiscoverPS5Async(int timeoutMs = 2500, CancellationToken ct = default)
{
// The payload's discovery responder binds its UDP socket to the same
// port number as its TCP listener, which is the first free port in
// the takeover range 9113..9118 (a wedged instance keeps 9113, so the
// live server may sit on a fallback). Probe the whole range.
var discoveryPorts = Enumerable.Range(9113, 6).ToList();
try
{
using var udp = new UdpClient();
udp.EnableBroadcast = true;
var probe = Encoding.ASCII.GetBytes("PS5SUITE_DISCOVER");
var targets = new List<IPEndPoint>();
foreach (var dp in discoveryPorts)
targets.Add(new IPEndPoint(IPAddress.Broadcast, dp));
// Subnet-directed broadcasts per local interface — plain
// 255.255.255.255 is dropped by some routers/NICs.
try
{
foreach (var ni in System.Net.NetworkInformation.NetworkInterface.GetAllNetworkInterfaces())
{
var props = ni.GetIPProperties();
if (props == null || ni.OperationalStatus != System.Net.NetworkInformation.OperationalStatus.Up) continue;
foreach (var ua in props.UnicastAddresses)
{
if (ua.Address.AddressFamily != AddressFamily.InterNetwork || ua.IPv4Mask == null) continue;
var ip = ua.Address.GetAddressBytes();
var mask = ua.IPv4Mask.GetAddressBytes();
var bcast = new byte[4];
for (int i = 0; i < 4; i++) bcast[i] = (byte)(ip[i] | ~mask[i]);
var b = new IPAddress(bcast);
foreach (var dp in discoveryPorts)
{
var ep = new IPEndPoint(b, dp);
if (!targets.Any(t => t.Equals(ep))) targets.Add(ep);
}
}
}
}
catch { }
foreach (var ep in targets)
{
try { await udp.SendAsync(probe, probe.Length, ep); } catch { }
}
var deadline = DateTime.UtcNow + TimeSpan.FromMilliseconds(timeoutMs);
while (DateTime.UtcNow < deadline)
{
ct.ThrowIfCancellationRequested();
int remain = (int)(deadline - DateTime.UtcNow).TotalMilliseconds;
var recvTask = udp.ReceiveAsync();
var delayTask = Task.Delay(Math.Min(remain, 500), ct);
var done = await Task.WhenAny(recvTask, delayTask);
if (done == delayTask) continue; // re-send window elapsed
var res = await recvTask;
string msg = Encoding.ASCII.GetString(res.Buffer);
var p = msg.Split('|');
if (p.Length >= 2 && p[0] == "PS5SUITE" && int.TryParse(p[1], out int port))
return (res.RemoteEndPoint.Address.ToString(), port);
}
return null;
}
catch (OperationCanceledException) { throw; }
catch { return null; }
}
private async Task<bool> VerifyAliveAsync()
{
try
{
await SendCommandAsync(Command.Ping);
var (response, _) = await ReceiveResponseAsync(3000);
return response == Response.Ok;
}
catch { return false; }
}
private async Task<ConnectResult> ConnectSingleAsync(string ipAddress, int port, CancellationToken ct)
{
try
{
_client = new TcpClient();
_client.ReceiveBufferSize = 16 * 1024 * 1024; // 16MB - matches payload SO_RCVBUF setting
_client.SendBufferSize = 16 * 1024 * 1024; // 16MB - matches payload SO_RCVBUF setting
_client.NoDelay = true;
_client.LingerState = new System.Net.Sockets.LingerOption(false, 0);
// BUG FIX #1: Add 5 second timeout for connection to fail fast on wrong IP
var connectTask = _client.ConnectAsync(ipAddress, port);
var timeoutTask = Task.Delay(5000, ct); // 5 second timeout, cancellable
var completedTask = await Task.WhenAny(connectTask, timeoutTask);
if (completedTask == timeoutTask)
{
// Observe the abandoned connect task so a late failure
// (host unreachable etc.) does not surface as an unobserved
// task exception.
_ = connectTask.ContinueWith(t => _ = t.Exception,
TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously);
_client?.Close();
_client?.Dispose();
_client = null;
ct.ThrowIfCancellationRequested();
return ConnectResult.Timeout;
}
// Await the connect task to observe any exception it produced (prevents unobserved task crashes)
await connectTask.ConfigureAwait(false);
// Check if connection actually succeeded
if (!_client.Connected)
{
return ConnectResult.Refused;
}
_stream = _client.GetStream();
return ConnectResult.Ok;
}
catch (OperationCanceledException)
{
_client?.Close();
_client?.Dispose();
_client = null;
throw;
}
catch
{
_client?.Close();
_client?.Dispose();
_client = null;
return ConnectResult.Refused;
}
}
public void Disconnect()
{
_stream?.Dispose();
_client?.Dispose();
_stream = null;
_client = null;
}
private async Task SendCommandAsync(Command cmd, byte[]? data = null)
{
if (_stream == null) throw new InvalidOperationException("Not connected");
byte[] header = new byte[5];
header[0] = (byte)cmd;
uint dataLen = data != null ? (uint)data.Length : 0;
BitConverter.GetBytes(dataLen).CopyTo(header, 1);
await _stream.WriteAsync(header, 0, 5);
if (data != null && data.Length > 0)
{
await _stream.WriteAsync(data, 0, data.Length);
}
}
private async Task<(Response response, byte[] data)> ReceiveResponseAsync(int timeoutMs = 120000)
{
if (_stream == null) throw new InvalidOperationException("Not connected");
byte[] header = new byte[5];
await ReadExactAsync(header, 5, timeoutMs);
Response response = (Response)header[0];
uint dataLen = BitConverter.ToUInt32(header, 1);
byte[] data = new byte[dataLen];
if (dataLen > 0)
{
await ReadExactAsync(data, (int)dataLen, timeoutMs);
}
return (response, data);
}
private async Task ReadExactAsync(byte[] buffer, int count, int timeoutMs = 120000)
{
if (_stream == null) throw new InvalidOperationException("Not connected");
int offset = 0;
using var cts = new CancellationTokenSource(timeoutMs);
try
{
while (offset < count)
{
int read = await _stream.ReadAsync(buffer, offset, count - offset, cts.Token);
if (read == 0)
{
// Give PS5 a moment to recover before declaring connection dead
await Task.Delay(100);
read = await _stream.ReadAsync(buffer, offset, count - offset, cts.Token);
if (read == 0) throw new IOException("Connection closed");
}
offset += read;
}
}
catch (OperationCanceledException)
{
throw new TimeoutException("Read timeout");
}
}
public async Task<bool> PingAsync()
{
await _commandLock.WaitAsync();
try
{
await SendCommandAsync(Command.Ping);
var (response, _) = await ReceiveResponseAsync();
return response == Response.Ok;
}
catch (Exception ex)
{
LastError = $"PingAsync: {ex.GetType().Name}: {ex.Message}";
return false;
}
finally
{
_commandLock.Release();
}
}
public async Task<PS5StorageInfo?> ListStorageAsync()
{
await _commandLock.WaitAsync();
try
{
await SendCommandAsync(Command.ListStorage);
var (response, data) = await ReceiveResponseAsync();
if (response != Response.Data)
{
Console.WriteLine($"[DEBUG] ListStorage: Response is not Data, got {response}");
return null;
}
// Parse response: total|free|available|reserved|mounted_games|user_data|real_free
string responseStr = Encoding.UTF8.GetString(data);
Console.WriteLine($"[DEBUG] ListStorage raw response: '{responseStr}'");
string[] parts = responseStr.Split('|');
Console.WriteLine($"[DEBUG] ListStorage parts count: {parts.Length}");
if (parts.Length < 7)
{
Console.WriteLine($"[DEBUG] ListStorage: Expected at least 7 parts, got {parts.Length}");
for (int i = 0; i < parts.Length; i++)
{
Console.WriteLine($"[DEBUG] Part[{i}]: '{parts[i]}'");
}
return null;
}
// Get path if available (part 8)
string storagePath = (parts.Length >= 8) ? parts[7].Trim() : "unknown";
return new PS5StorageInfo
{
TotalBytes = ulong.Parse(parts[0].Trim()),
FreeBytes = ulong.Parse(parts[1].Trim()),
AvailableBytes = ulong.Parse(parts[2].Trim()),
ReservedBytes = ulong.Parse(parts[3].Trim()),
MountedGamesSize = ulong.Parse(parts[4].Trim()),
UserDataSize = ulong.Parse(parts[5].Trim()),
RealFreeSpace = ulong.Parse(parts[6].Trim()),
StoragePath = storagePath
};
}
catch (Exception ex)
{
Console.WriteLine($"[DEBUG] ListStorage exception: {ex.Message}");
Console.WriteLine($"[DEBUG] Stack trace: {ex.StackTrace}");
throw;
}
finally
{
_commandLock.Release();
}
}
// NEW: Get detailed file information
public async Task<PS5FileInfo?> GetFileInfoAsync(string path)
{
await _commandLock.WaitAsync();
try
{
byte[] pathBytes = Encoding.UTF8.GetBytes(path + "\0");
await SendCommandAsync(Command.GetFileInfo, pathBytes);
var (response, data) = await ReceiveResponseAsync();
if (response != Response.Data)
return null;
// Parse: size|mtime|atime|mode|is_dir|is_link
string responseStr = Encoding.UTF8.GetString(data);
string[] parts = responseStr.Split('|');
if (parts.Length < 6) return null;
return new PS5FileInfo
{
Size = long.Parse(parts[0]),
ModifiedTime = DateTimeOffset.FromUnixTimeSeconds(long.Parse(parts[1])).LocalDateTime,
AccessTime = DateTimeOffset.FromUnixTimeSeconds(long.Parse(parts[2])).LocalDateTime,
Permissions = Convert.ToInt32(parts[3], 8),
IsDirectory = parts[4] == "1",
IsSymlink = parts[5] == "1"
};
}
catch
{
return null;
}
finally
{
_commandLock.Release();
}
}
// NEW: Get PS5 system information
public async Task<PS5SystemInfo?> GetSystemInfoAsync()
{
await _commandLock.WaitAsync();
try
{
await SendCommandAsync(Command.GetSystemInfo);
var (response, data) = await ReceiveResponseAsync();
if (response != Response.Data)
return null;
string responseStr = Encoding.UTF8.GetString(data);
var info = new PS5SystemInfo();
foreach (var line in responseStr.Split('\n'))
{
var parts = line.Split('=', 2);
if (parts.Length != 2) continue;
var key = parts[0].Trim();
var value = parts[1].Trim();
switch (key)
{
case "hostname": info.Hostname = value; break;
case "server_version": info.ServerVersion = value; break;
case "protocol_version": int.TryParse(value, out int pv); info.ProtocolVersion = pv; break;
case "total_memory": ulong.TryParse(value, out ulong tm); info.TotalMemory = tm; break;
case "storage_total": ulong.TryParse(value, out ulong st); info.StorageTotal = st; break;
case "storage_free": ulong.TryParse(value, out ulong sf); info.StorageFree = sf; break;
case "mounted_games": int.TryParse(value, out int mg); info.MountedGames = mg; break;
case "index_ready": info.IndexReady = value == "1"; break;
case "index_files": int.TryParse(value, out int ifi); info.IndexFiles = ifi; break;
case "index_dirs": int.TryParse(value, out int idi); info.IndexDirs = idi; break;
case "server_uptime": long.TryParse(value, out long su); info.ServerUptime = su; break;
}
}
return info;
}
catch
{
return null;
}
finally
{
_commandLock.Release();
}
}
// NEW: Verify file integrity with CRC32
public async Task<FileVerificationResult> VerifyFileAsync(string path)
{
await _commandLock.WaitAsync();
try
{
byte[] pathBytes = Encoding.UTF8.GetBytes(path + "\0");
await SendCommandAsync(Command.VerifyFile, pathBytes);
var (response, data) = await ReceiveResponseAsync();
if (response == Response.Error)
{
return new FileVerificationResult
{
Success = false,
Error = Encoding.UTF8.GetString(data)
};
}
if (response != Response.Data)
{
return new FileVerificationResult
{
Success = false,
Error = "Unexpected response"
};
}
// Parse: CRC32|size
string responseStr = Encoding.UTF8.GetString(data);
string[] parts = responseStr.Split('|');
if (parts.Length < 2)
{
return new FileVerificationResult
{
Success = false,
Error = "Invalid response format"
};
}
return new FileVerificationResult
{
Success = true,
CRC32 = parts[0],
Size = ulong.Parse(parts[1])
};
}
catch (Exception ex)
{
return new FileVerificationResult
{
Success = false,
Error = ex.Message
};
}
finally
{
_commandLock.Release();
}
}
// NEW: Get hardware info (model, serial, features)
public async Task<PS5HardwareInfo?> GetHardwareInfoAsync()
{
await _commandLock.WaitAsync();
try
{
await SendCommandAsync(Command.GetHwInfo);
var (response, data) = await ReceiveResponseAsync();
if (response != Response.Data)
return null;
string responseStr = Encoding.UTF8.GetString(data);
var info = new PS5HardwareInfo();
foreach (var line in responseStr.Split('\n'))
{
var parts = line.Split('=', 2);
if (parts.Length != 2) continue;
var key = parts[0].Trim();
var value = parts[1].Trim();
switch (key)
{
case "model": info.Model = value; break;
case "serial": info.Serial = value; break;
case "has_wlan_bt": info.HasWlanBt = value == "1"; break;
case "has_optical_out": info.HasOpticalOut = value == "1"; break;
case "hw_machine": info.HwMachine = value; break;
case "os": info.OsVersion = value; break;
case "ncpu": int.TryParse(value, out int n); info.NumCpu = n; break;
case "physmem": ulong.TryParse(value, out ulong pm); info.PhysMem = pm; break;
}
}
return info;
}
catch
{
return null;
}
finally
{
_commandLock.Release();
}
}
// Store last raw diagnostic response for logging
public string LastTempRawResponse { get; private set; } = "";
// NEW: Get temperature and CPU info
public async Task<PS5TemperatureInfo?> GetTemperatureInfoAsync()
{
await _commandLock.WaitAsync();
try
{
await SendCommandAsync(Command.GetTemps);
var (response, data) = await ReceiveResponseAsync();
if (response != Response.Data)
return null;
string responseStr = Encoding.UTF8.GetString(data);
LastTempRawResponse = responseStr;
var info = new PS5TemperatureInfo();
foreach (var line in responseStr.Split('\n'))
{
var parts = line.Split('=', 2);
if (parts.Length != 2) continue;
var key = parts[0].Trim();
var value = parts[1].Trim();
switch (key)
{
case "cpu_temp": int.TryParse(value, out int ct); info.CpuTemp = ct; break;
case "soc_temp": int.TryParse(value, out int st); info.SocTemp = st; break;
case "cpu_freq_mhz": long.TryParse(value, out long cf); info.CpuFreqMhz = cf; break;
case "soc_power_mw": uint.TryParse(value, out uint pw); info.SocPowerMw = pw; break;
case "cpu_usage_0": int.TryParse(value, out int c0); info.CpuUsage[0] = c0; break;
case "cpu_usage_1": int.TryParse(value, out int c1); info.CpuUsage[1] = c1; break;
case "cpu_usage_2": int.TryParse(value, out int c2); info.CpuUsage[2] = c2; break;
case "cpu_usage_3": int.TryParse(value, out int c3); info.CpuUsage[3] = c3; break;
case "cpu_usage_4": int.TryParse(value, out int c4); info.CpuUsage[4] = c4; break;
case "cpu_usage_5": int.TryParse(value, out int c5); info.CpuUsage[5] = c5; break;
case "cpu_usage_6": int.TryParse(value, out int c6); info.CpuUsage[6] = c6; break;
case "cpu_usage_7": int.TryParse(value, out int c7); info.CpuUsage[7] = c7; break;
}
}
return info;
}
catch (Exception ex)
{
LastError = $"GetTemperatureInfoAsync: {ex.GetType().Name}: {ex.Message}";
return null;
}
finally
{
_commandLock.Release();
}
}
// NEW: Get list of running apps
public async Task<List<PS5RunningApp>> GetRunningAppsAsync()
{
var apps = new List<PS5RunningApp>();
await _commandLock.WaitAsync();
try
{
await SendCommandAsync(Command.GetRunningApps);
var (response, data) = await ReceiveResponseAsync();
if (response != Response.Data)
return apps;
string responseStr = Encoding.UTF8.GetString(data);
foreach (var line in responseStr.Split('\n'))
{
if (string.IsNullOrWhiteSpace(line) || line == "No apps running")
continue;
var app = new PS5RunningApp();
foreach (var part in line.Split('|'))
{
var kv = part.Split('=', 2);
if (kv.Length != 2) continue;
switch (kv[0])
{
case "pid": int.TryParse(kv[1], out int pid); app.Pid = pid; break;
case "name": app.Name = kv[1]; break;
case "title_id": app.TitleId = kv[1]; break;
case "app_id": uint.TryParse(kv[1], out uint aid); app.AppId = aid; break;
}
}
if (!string.IsNullOrEmpty(app.TitleId))
apps.Add(app);
}
return apps;
}
catch
{
return apps;
}
finally
{
_commandLock.Release();
}
}
// NEW: Kill an app by title ID
public async Task<(bool success, string message)> KillAppAsync(string titleId)
{
await _commandLock.WaitAsync();
try
{
byte[] data = Encoding.UTF8.GetBytes(titleId + "\0");
await SendCommandAsync(Command.KillApp, data);
var (response, respData) = await ReceiveResponseAsync();
string message = Encoding.UTF8.GetString(respData);
return (response == Response.Ok, message);
}
catch (Exception ex)
{
return (false, ex.Message);
}
finally
{
_commandLock.Release();
}
}
// NEW: Launch browser with URL
public async Task<(bool success, string message)> LaunchBrowserAsync(string url)
{
await _commandLock.WaitAsync();
try
{
byte[] data = Encoding.UTF8.GetBytes(url + "\0");
await SendCommandAsync(Command.LaunchBrowser, data);
var (response, respData) = await ReceiveResponseAsync();
string message = Encoding.UTF8.GetString(respData);
return (response == Response.Ok, message);
}
catch (Exception ex)
{
return (false, ex.Message);
}
finally
{
_commandLock.Release();
}
}
// NEW: Get power info (operating time, boot count, power consumption)
public async Task<PS5PowerInfo?> GetPowerInfoAsync()
{
await _commandLock.WaitAsync();
try
{
await SendCommandAsync(Command.GetPowerInfo);
var (response, data) = await ReceiveResponseAsync();
if (response != Response.Data)
return null;
string responseStr = Encoding.UTF8.GetString(data);
var info = new PS5PowerInfo();
foreach (var line in responseStr.Split('\n'))
{
var parts = line.Split('=', 2);
if (parts.Length != 2) continue;
var key = parts[0].Trim();
var value = parts[1].Trim();
switch (key)
{
case "operating_time_sec": ulong.TryParse(value, out ulong ots); info.OperatingTimeSec = ots; break;
case "operating_time_hours": ulong.TryParse(value, out ulong oth); info.OperatingTimeHours = oth; break;
case "operating_time_minutes": ulong.TryParse(value, out ulong otm); info.OperatingTimeMinutes = otm; break;
case "boot_count": uint.TryParse(value, out uint bc); info.BootCount = bc; break;
case "power_consumption_mw": uint.TryParse(value, out uint pc); info.PowerConsumptionMw = pc; break;
}
}
return info;
}
catch
{
return null;
}
finally
{
_commandLock.Release();
}
}
// NEW: Get extended system info (firmware, product, ICC data)
public async Task<PS5ExtendedInfo?> GetExtendedInfoAsync()
{
await _commandLock.WaitAsync();
try
{
await SendCommandAsync(Command.GetExtendedInfo);
var (response, data) = await ReceiveResponseAsync();
if (response != Response.Data)
return null;
string responseStr = Encoding.UTF8.GetString(data);
var info = new PS5ExtendedInfo();
foreach (var line in responseStr.Split('\n'))
{
var parts = line.Split('=', 2);
if (parts.Length != 2) continue;
var key = parts[0].Trim();
var value = parts[1].Trim();
switch (key)
{
case "firmware_version": info.FirmwareVersion = value; break;
case "prospero_version": info.ProsperoVersion = value; break;
case "product_code": info.ProductCode = value; break;
case "product_str": info.ProductStr = value; break;
case "total_operating_time_sec": ulong.TryParse(value, out ulong tots); info.TotalOperatingTimeSec = tots; break;
case "total_operating_time_hours": ulong.TryParse(value, out ulong toth); info.TotalOperatingTimeHours = toth; break;
case "boot_count": uint.TryParse(value, out uint bc); info.BootCount = bc; break;
case "shutdown_count": uint.TryParse(value, out uint sc); info.ShutdownCount = sc; break;
case "thermal_alert": int.TryParse(value, out int ta); info.ThermalAlert = ta; break;
case "bd_drive_power": int.TryParse(value, out int bd); info.BdDrivePower = bd; break;
}
}
return info;
}
catch
{
return null;
}
finally
{
_commandLock.Release();
}
}
// NEW: Get CPU usage per core
public async Task<PS5CpuUsage?> GetCpuUsageAsync()
{
await _commandLock.WaitAsync();
try
{
await SendCommandAsync(Command.GetCpuUsage);
var (response, data) = await ReceiveResponseAsync();
if (response != Response.Data)
return null;
string responseStr = Encoding.UTF8.GetString(data);
var info = new PS5CpuUsage();
foreach (var line in responseStr.Split('\n'))
{
var parts = line.Split('=', 2);
if (parts.Length != 2) continue;
var key = parts[0].Trim();
var value = parts[1].Trim();
if (key.StartsWith("core") && key.Length == 5)
{
int coreNum = key[4] - '0';
if (coreNum >= 0 && coreNum < 8 && int.TryParse(value, out int usage))
info.CoreUsage[coreNum] = usage;
}
else if (key == "average")
{
int.TryParse(value, out int avg);
info.Average = avg;
}
}
return info;
}
catch
{
return null;
}
finally
{
_commandLock.Release();
}
}
// NEW: Get memory info
public async Task<PS5MemoryInfo?> GetMemoryInfoAsync()
{
await _commandLock.WaitAsync();
try
{
await SendCommandAsync(Command.GetMemoryInfo);
var (response, data) = await ReceiveResponseAsync();
if (response != Response.Data)
return null;
string responseStr = Encoding.UTF8.GetString(data);
var info = new PS5MemoryInfo();
foreach (var line in responseStr.Split('\n'))
{
var parts = line.Split('=', 2);
if (parts.Length != 2) continue;
var key = parts[0].Trim();
var value = parts[1].Trim();
switch (key)
{
case "direct_total": ulong.TryParse(value, out ulong dt); info.DirectTotal = dt; break;
case "direct_available": ulong.TryParse(value, out ulong da); info.DirectAvailable = da; break;
case "direct_used": ulong.TryParse(value, out ulong du); info.DirectUsed = du; break;
case "direct_used_percent": int.TryParse(value, out int dup); info.DirectUsedPercent = dup; break;
case "flexible_available": ulong.TryParse(value, out ulong fa); info.FlexibleAvailable = fa; break;
case "physical_total": ulong.TryParse(value, out ulong pt); info.PhysicalTotal = pt; break;
case "user_memory": ulong.TryParse(value, out ulong um); info.UserMemory = um; break;
case "free_memory": ulong.TryParse(value, out ulong fm); info.FreeMemory = fm; break;
case "cpu_pool_total": ulong.TryParse(value, out ulong cpt); info.CpuPoolTotal = cpt; break;
case "cpu_pool_free": ulong.TryParse(value, out ulong cpf); info.CpuPoolFree = cpf; break;
case "gpu_pool_total": ulong.TryParse(value, out ulong gpt); info.GpuPoolTotal = gpt; break;
case "gpu_pool_free": ulong.TryParse(value, out ulong gpf); info.GpuPoolFree = gpf; break;
}
}
return info;
}
catch
{
return null;
}
finally
{
_commandLock.Release();
}
}
// NEW: Get loaded modules list
public async Task<List<PS5ModuleInfo>> GetModuleListAsync()
{
var modules = new List<PS5ModuleInfo>();
await _commandLock.WaitAsync();
try
{
await SendCommandAsync(Command.GetModuleList);
var (response, data) = await ReceiveResponseAsync();
if (response != Response.Data)
return modules;
string responseStr = Encoding.UTF8.GetString(data);
// Parse JSON-like format: {"id":123,"name":"module.sprx"}
foreach (var line in responseStr.Split('\n'))
{
if (!line.Contains("\"id\"")) continue;
var module = new PS5ModuleInfo();
// Extract id
var idMatch = System.Text.RegularExpressions.Regex.Match(line, "\"id\":(\\d+)");
if (idMatch.Success)
uint.TryParse(idMatch.Groups[1].Value, out uint id);
// Extract name
var nameMatch = System.Text.RegularExpressions.Regex.Match(line, "\"name\":\"([^\"]+)\"");
if (nameMatch.Success)
module.Name = nameMatch.Groups[1].Value;
// Extract path if present
var pathMatch = System.Text.RegularExpressions.Regex.Match(line, "\"path\":\"([^\"]+)\"");
if (pathMatch.Success)
module.Path = pathMatch.Groups[1].Value;
if (!string.IsNullOrEmpty(module.Name))
modules.Add(module);
}
return modules;
}
catch
{
return modules;
}
finally
{
_commandLock.Release();
}
}
// NEW: Get list of mounted games
public async Task<List<PS5MountedGame>> GetGameListAsync()
{
var games = new List<PS5MountedGame>();
await _commandLock.WaitAsync();
try
{
await SendCommandAsync(Command.GetGameList);
var (response, data) = await ReceiveResponseAsync();
if (response != Response.Data)
return games;
string responseStr = Encoding.UTF8.GetString(data);
if (responseStr.Trim() == "NO_GAMES")
return games;
foreach (var line in responseStr.Split('\n'))
{
if (string.IsNullOrWhiteSpace(line))
continue;
// Format: title_id|name|path|size|region|active
var parts = line.Split('|');
if (parts.Length < 6) continue;
var game = new PS5MountedGame
{
TitleId = parts[0],
Name = parts[1],
Path = parts[2],
Region = parts[4],
IsActive = parts[5] == "1"
};
if (ulong.TryParse(parts[3], out ulong size))
game.Size = size;
games.Add(game);
}
return games;
}
catch
{
return games;
}
finally
{
_commandLock.Release();
}
}
// NEW: List all save games on the PS5
public async Task<List<PS5SaveGame>> ListSavesAsync()
{
var saves = new List<PS5SaveGame>();
await _commandLock.WaitAsync();
try
{
await SendCommandAsync(Command.ListSaves);
var (response, data) = await ReceiveResponseAsync();
if (response != Response.Data) return saves;
string responseStr = Encoding.UTF8.GetString(data);
if (responseStr.Trim() == "NO_SAVES") return saves;
foreach (var line in responseStr.Split('\n'))
{
if (string.IsNullOrWhiteSpace(line)) continue;
// Format: title_id|user_id|save_path|size|mtime
var parts = line.Split('|');
if (parts.Length < 5) continue;
var save = new PS5SaveGame
{
TitleId = parts[0],
UserId = parts[1],
SavePath = parts[2],
};
if (ulong.TryParse(parts[3], out ulong sz)) save.Size = sz;
if (long.TryParse(parts[4], out long mt)) save.ModifiedUnixTime = mt;
saves.Add(save);
}
return saves;
}
catch { return saves; }
finally { _commandLock.Release(); }
}
// ============================================================
// SAVE MANAGER (garlic-savemgr style)
// Scans individual save image files, mounts them decrypted at
// /data/save_mnt for browsing/editing, unmounts (write-back).
// ============================================================
public const string SaveMountPoint = "/data/save_mnt";
public async Task<List<PS5SaveFile>> ListSaveFilesAsync()
{
var saves = new List<PS5SaveFile>();
await _commandLock.WaitAsync();
try
{
await SendCommandAsync(Command.SaveScan);
var (response, data) = await ReceiveResponseAsync();
if (response != Response.Data) return saves;
string responseStr = Encoding.UTF8.GetString(data);
if (responseStr.Trim() == "NO_SAVES") return saves;
foreach (var line in responseStr.Split('\n'))
{
if (string.IsNullOrWhiteSpace(line)) continue;
// Format: path|title_id|save_name|user|size|is_ps4|has_bin
var parts = line.Split('|');
if (parts.Length < 7) continue;
var save = new PS5SaveFile
{
Path = parts[0],
TitleId = parts[1],
SaveName = parts[2],
UserId = parts[3],
};
if (ulong.TryParse(parts[4], out ulong sz)) save.Size = sz;
save.IsPs4 = parts[5] == "1";
save.HasBinKey = parts[6] == "1";
saves.Add(save);
}
return saves;
}
catch { return saves; }
finally { _commandLock.Release(); }
}
// Mount a save image decrypted at /data/save_mnt. The payload emits
// progress heartbeats; the mount itself can take up to ~90s worst case
// (key ioctl + filesystem mount watchdogs).
public async Task<(bool success, string message)> SaveMountAsync(string path, Action<string>? onProgress = null)
{
await _commandLock.WaitAsync();
try
{
byte[] data = Encoding.UTF8.GetBytes(path + "\0");
await SendCommandAsync(Command.SaveMount, data);
var deadline = DateTime.UtcNow + TimeSpan.FromMinutes(3);
while (true)
{
int remainingMs = (int)(deadline - DateTime.UtcNow).TotalMilliseconds;
if (remainingMs <= 0) return (false, "Mount timed out");
var (response, respData) = await ReceiveResponseAsync(remainingMs);
string message = respData != null ? Encoding.UTF8.GetString(respData).TrimEnd('\0') : "";
if (response == Response.Progress) { if (!string.IsNullOrEmpty(message)) onProgress?.Invoke(message); continue; }
if (response == Response.Data) return (true, message);
if (response == Response.Error) return (false, message);
return (false, $"Unexpected response: {response}");
}
}
catch (Exception ex) { return (false, ex.Message); }
finally { _commandLock.Release(); }
}
public async Task<(bool success, string message)> SaveUnmountAsync(Action<string>? onProgress = null)
{
await _commandLock.WaitAsync();
try
{
await SendCommandAsync(Command.SaveUnmount);
var deadline = DateTime.UtcNow + TimeSpan.FromMinutes(3);
while (true)
{
int remainingMs = (int)(deadline - DateTime.UtcNow).TotalMilliseconds;
if (remainingMs <= 0) return (false, "Unmount timed out");
var (response, respData) = await ReceiveResponseAsync(remainingMs);
string message = respData != null ? Encoding.UTF8.GetString(respData).TrimEnd('\0') : "";
if (response == Response.Progress) { if (!string.IsNullOrEmpty(message)) onProgress?.Invoke(message); continue; }
if (response == Response.Data) return (true, message);
if (response == Response.Error) return (false, message);
return (false, $"Unexpected response: {response}");
}
}
catch (Exception ex) { return (false, ex.Message); }
finally { _commandLock.Release(); }
}
public async Task<(bool mounted, string src, string local, bool isPs4)> SaveMountStatusAsync()
{
await _commandLock.WaitAsync();
try
{
await SendCommandAsync(Command.SaveMountStatus);
var (response, data) = await ReceiveResponseAsync();
if (response != Response.Data || data == null) return (false, "", "", false);
string s = Encoding.UTF8.GetString(data).TrimEnd('\0').Trim();
if (!s.StartsWith("MOUNTED|")) return (false, "", "", false);
var p = s.Split('|');
return (true, p.Length > 1 ? p[1] : "", p.Length > 2 ? p[2] : "", p.Length > 3 && p[3] == "1");
}
catch { return (false, "", "", false); }
finally { _commandLock.Release(); }
}
public async Task<(bool success, string message)> SaveDeleteAsync(string path)
{
await _commandLock.WaitAsync();
try
{
byte[] data = Encoding.UTF8.GetBytes(path + "\0");
await SendCommandAsync(Command.SaveDelete, data);
var (response, respData) = await ReceiveResponseAsync();
string message = respData != null ? Encoding.UTF8.GetString(respData).TrimEnd('\0') : "";
if (response == Response.Data || response == Response.Ok) return (true, message);
return (false, message);
}
catch (Exception ex) { return (false, ex.Message); }
finally { _commandLock.Release(); }
}
// NEW: Unmount a game by title ID.
// v6.1.1: the server streams RESP_PROGRESS heartbeats while the unmount
// grinds (busy vnodes can stall kernel calls for a while) — we must
// consume them or the next frame would be misread as the final answer.
// A dedicated 10-minute budget replaces the default 120s read timeout.
public async Task<(bool success, string message)> UnmountGameAsync(string titleId, Action<string>? onProgress = null)
{
await _commandLock.WaitAsync();
try
{
byte[] data = Encoding.UTF8.GetBytes(titleId + "\0");
await SendCommandAsync(Command.UnmountGame, data);
var deadline = DateTime.UtcNow + TimeSpan.FromMinutes(10);
while (true)
{
int remainingMs = (int)(deadline - DateTime.UtcNow).TotalMilliseconds;
if (remainingMs <= 0) return (false, "Unmount timed out after 10 minutes");
var (response, respData) = await ReceiveResponseAsync(remainingMs);
string message = respData != null ? Encoding.UTF8.GetString(respData).TrimEnd('\0') : "";
if (response == Response.Progress)
{
if (!string.IsNullOrEmpty(message)) onProgress?.Invoke(message);
continue;
}
if (response == Response.Ok) return (true, message);
if (response == Response.Error) return (false, message);
return (false, $"Unexpected response: {response}");
}
}
catch (Exception ex)
{
return (false, ex.Message);
}
finally
{
_commandLock.Release();
}
}
// NEW: Launch a game by title ID
public async Task<(bool success, string message)> LaunchGameAsync(string titleId)
{
await _commandLock.WaitAsync();
try
{
byte[] data = Encoding.UTF8.GetBytes(titleId + "\0");
await SendCommandAsync(Command.LaunchGame, data);
var (response, respData) = await ReceiveResponseAsync();
string message = Encoding.UTF8.GetString(respData);
return (response == Response.Ok, message);
}
catch (Exception ex)
{
return (false, ex.Message);
}
finally
{
_commandLock.Release();
}
}
// NEW: Delete a screenshot and its matching thumbnail
public async Task<(bool success, string message)> DeleteScreenshotAsync(string fullPath)
{
await _commandLock.WaitAsync();
try
{
byte[] data = Encoding.UTF8.GetBytes(fullPath + "\0");
await SendCommandAsync(Command.DeleteScreenshot, data);
var (response, respData) = await ReceiveResponseAsync();
string message = Encoding.UTF8.GetString(respData);
return (response == Response.Ok, message);
}
catch (Exception ex)
{
return (false, ex.Message);
}
finally
{
_commandLock.Release();
}
}
// NEW: List all screenshots stored on the PS5
public async Task<List<PS5Screenshot>> ListScreenshotsAsync()
{
var shots = new List<PS5Screenshot>();
await _commandLock.WaitAsync();
try
{
await SendCommandAsync(Command.ListScreenshots);
var (response, data) = await ReceiveResponseAsync();
if (response != Response.Data) return shots;
string body = Encoding.UTF8.GetString(data);
if (body.StartsWith("NO_SCREENSHOTS")) return shots;
foreach (var line in body.Split('\n'))
{
if (string.IsNullOrWhiteSpace(line)) continue;
var parts = line.Split('|');
if (parts.Length < 4) continue;
long.TryParse(parts[2], out long size);
long.TryParse(parts[3], out long mtimeUnix);
shots.Add(new PS5Screenshot
{
FullPath = parts[0],
FileName = parts[1],
Size = size,
ModifiedTime = DateTimeOffset.FromUnixTimeSeconds(mtimeUnix).LocalDateTime
});
}
}
catch { }
finally
{
_commandLock.Release();
}
return shots.OrderByDescending(s => s.ModifiedTime).ToList();
}
// NEW: Get icon0.png binary data for a game
public async Task<byte[]?> GetGameIconAsync(string titleId)
{
await _commandLock.WaitAsync();
try
{
byte[] data = Encoding.UTF8.GetBytes(titleId + "\0");
await SendCommandAsync(Command.GetGameIcon, data);
var (response, respData) = await ReceiveResponseAsync();
if (response != Response.Data || respData == null || respData.Length == 0)
return null;
return respData;
}
catch
{
return null;
}
finally
{
_commandLock.Release();
}
}
// NEW: Get pic0.png (picType=0) or pic1.png (picType=1) binary data for a game
public async Task<byte[]?> GetGamePicAsync(string titleId, int picType)
{
if (picType != 0 && picType != 1) return null;
await _commandLock.WaitAsync();
try
{
string request = $"{titleId}:{picType}";
byte[] data = Encoding.UTF8.GetBytes(request + "\0");
await SendCommandAsync(Command.GetGamePic, data);
var (response, respData) = await ReceiveResponseAsync();
if (response != Response.Data || respData == null || respData.Length == 0)
return null;
return respData;
}
catch
{
return null;
}
finally
{
_commandLock.Release();
}
}
// NEW: Get detailed info about a game
public async Task<Dictionary<string, string>?> GetGameDetailsAsync(string titleId)
{
await _commandLock.WaitAsync();
try
{
byte[] data = Encoding.UTF8.GetBytes(titleId + "\0");
await SendCommandAsync(Command.GetGameDetails, data);
var (response, respData) = await ReceiveResponseAsync();
if (response != Response.Data) return null;
var dict = new Dictionary<string, string>();
string responseStr = Encoding.UTF8.GetString(respData);
foreach (var line in responseStr.Split('\n'))
{
if (string.IsNullOrWhiteSpace(line)) continue;
int eq = line.IndexOf('=');
if (eq <= 0) continue;
string key = line.Substring(0, eq).Trim();
string value = line.Substring(eq + 1).Trim();
dict[key] = value;
}
return dict;
}
catch
{
return null;
}
finally
{
_commandLock.Release();
}
}
public async Task<FileEntry[]> ListDirAsync(string path)
{
await _commandLock.WaitAsync();
try
{
byte[] pathBytes = Encoding.UTF8.GetBytes(path + "\0");
await SendCommandAsync(Command.ListDir, pathBytes);
// Payload sends ONLY RESP_DATA - no RESP_OK after!
var (response, data) = await ReceiveResponseAsync();
if (response != Response.Data)
{
return Array.Empty<FileEntry>();
}
using var ms = new MemoryStream(data);
using var br = new BinaryReader(ms);
int count = br.ReadInt32();
// Handle empty directory
if (count <= 0)
{
return Array.Empty<FileEntry>();
}
var result = new FileEntry[count];
for (int i = 0; i < count; i++)
{
byte type = br.ReadByte();
ushort nameLen = br.ReadUInt16();
string name = Encoding.UTF8.GetString(br.ReadBytes(nameLen));
long size = br.ReadInt64();
long timestamp = br.ReadInt64();
// Clamp timestamp to valid DateTimeOffset range
DateTime dt;
try
{
if (timestamp < -62135596800 || timestamp > 253402300799)
{
dt = DateTime.Now; // Use current time for invalid timestamps
}
else
{
dt = DateTimeOffset.FromUnixTimeSeconds(timestamp).DateTime;
}
}
catch
{
dt = DateTime.Now;
}
result[i] = new FileEntry
{
Name = name,
IsDirectory = type == 1,
Size = size,
Timestamp = dt
};
}
return result;
}
finally
{
_commandLock.Release();
}
}
public async Task<bool> CreateDirAsync(string path)
{
await _commandLock.WaitAsync();
try
{
byte[] pathBytes = Encoding.UTF8.GetBytes(path + "\0");
await SendCommandAsync(Command.CreateDir, pathBytes);
var (response, _) = await ReceiveResponseAsync();
return response == Response.Ok;
}
finally
{
_commandLock.Release();
}
}
public async Task<bool> DeleteFileAsync(string path)
{
await _commandLock.WaitAsync();
try
{
byte[] pathBytes = Encoding.UTF8.GetBytes(path + "\0");
await SendCommandAsync(Command.DeleteFile, pathBytes);
var (response, _) = await ReceiveResponseAsync();
return response == Response.Ok;
}
finally
{
_commandLock.Release();
}
}
public event Action<string>? OnProgressMessage;
public async Task<bool> DeleteDirAsync(string path)
{
await _commandLock.WaitAsync();
try
{
byte[] pathBytes = Encoding.UTF8.GetBytes(path + "\0");
await SendCommandAsync(Command.DeleteDir, pathBytes);
// Payload does NOT send initial OK - goes straight to progress messages
// Keep reading until we get the final OK/ERROR response
bool deletionComplete = false;
try
{
while (!deletionComplete)
{
var (progressResponse, progressData) = await ReceiveResponseAsync();
if (progressResponse == Response.Progress)
{
string message = Encoding.UTF8.GetString(progressData).TrimEnd('\0');
OnProgressMessage?.Invoke(message);
}
else if (progressResponse == Response.Ok)
{
// Final OK response received - deletion complete
OnProgressMessage?.Invoke("🔚 Received final OK - deletion complete");
deletionComplete = true;
}
else if (progressResponse == Response.Error)
{
// Error response received
OnProgressMessage?.Invoke("❌ Received error response");
deletionComplete = true;
}
else
{
// Unexpected response - log it
OnProgressMessage?.Invoke($"⚠️ Unexpected response: {progressResponse}");
deletionComplete = true;
}
}
}
catch (Exception ex)
{
// Connection closed - log it
OnProgressMessage?.Invoke($"⚠️ Connection closed: {ex.Message}");
}
// Give server a moment to fully close the deletion thread
// CRITICAL: Increased delay to prevent race condition with next delete
await Task.Delay(500);
return true;
}
finally
{
_commandLock.Release();
}
}
public async Task<bool> RenameAsync(string oldPath, string newPath)
{
await _commandLock.WaitAsync();
try
{
byte[] oldBytes = Encoding.UTF8.GetBytes(oldPath + "\0");
byte[] newBytes = Encoding.UTF8.GetBytes(newPath + "\0");
byte[] data = new byte[oldBytes.Length + newBytes.Length];
Array.Copy(oldBytes, 0, data, 0, oldBytes.Length);
Array.Copy(newBytes, 0, data, oldBytes.Length, newBytes.Length);
await SendCommandAsync(Command.Rename, data);
var (response, _) = await ReceiveResponseAsync();
return response == Response.Ok;
}
finally
{
_commandLock.Release();
}
}
public async Task<bool> CopyFileAsync(string srcPath, string dstPath)
{
await _commandLock.WaitAsync();
try
{
byte[] srcBytes = Encoding.UTF8.GetBytes(srcPath + "\0");
byte[] dstBytes = Encoding.UTF8.GetBytes(dstPath + "\0");
byte[] data = new byte[srcBytes.Length + dstBytes.Length];
Array.Copy(srcBytes, 0, data, 0, srcBytes.Length);
Array.Copy(dstBytes, 0, data, srcBytes.Length, dstBytes.Length);
await SendCommandAsync(Command.CopyFile, data);
var (response, _) = await ReceiveResponseAsync();
return response == Response.Ok;
}
finally
{
_commandLock.Release();
}
}
public async Task<bool> MoveFileAsync(string srcPath, string dstPath)
{
await _commandLock.WaitAsync();
try
{
byte[] srcBytes = Encoding.UTF8.GetBytes(srcPath + "\0");
byte[] dstBytes = Encoding.UTF8.GetBytes(dstPath + "\0");
byte[] data = new byte[srcBytes.Length + dstBytes.Length];
Array.Copy(srcBytes, 0, data, 0, srcBytes.Length);
Array.Copy(dstBytes, 0, data, srcBytes.Length, dstBytes.Length);
await SendCommandAsync(Command.MoveFile, data);
var (response, _) = await ReceiveResponseAsync();
return response == Response.Ok;
}
finally
{
_commandLock.Release();
}
}
public async Task<bool> UploadFileAsync(string localPath, string remotePath, IProgress<UploadProgress>? progress = null, CancellationToken cancellationToken = default, long chunkOffset = 0, long chunkSize = 0, Action? onReadyCallback = null)
{
// Hold the command lock for the ENTIRE transfer. Without this,
// concurrent commands (fan/poll/status) interleave their frames
// into the chunk byte stream, the payload reads a corrupted
// data_len and hard-closes the socket mid-upload.
await _commandLock.WaitAsync(cancellationToken);
try
{
return await UploadFileCoreAsync(localPath, remotePath, progress, cancellationToken, chunkOffset, chunkSize, onReadyCallback);
}
finally
{
_commandLock.Release();
}
}
private async Task<bool> UploadFileCoreAsync(string localPath, string remotePath, IProgress<UploadProgress>? progress, CancellationToken cancellationToken, long chunkOffset, long chunkSize, Action? onReadyCallback)
{
FileInfo fileInfo = new FileInfo(localPath);
if (!fileInfo.Exists) return false;
// Determine actual upload size
long uploadSize = chunkSize > 0 ? chunkSize : fileInfo.Length;
// Send START_UPLOAD with optional chunk offset
byte[] pathBytes = Encoding.UTF8.GetBytes(remotePath);
byte[] startData = new byte[pathBytes.Length + 1 + 8 + 8]; // path + null + size + offset
Array.Copy(pathBytes, 0, startData, 0, pathBytes.Length);
BitConverter.GetBytes(fileInfo.Length).CopyTo(startData, pathBytes.Length + 1);
BitConverter.GetBytes(chunkOffset).CopyTo(startData, pathBytes.Length + 9);
await SendCommandAsync(Command.StartUpload, startData);
var (response, responseData) = await ReceiveResponseAsync();
// Debug logging
OnProgressMessage?.Invoke($"[DEBUG] START_UPLOAD Response: {response} (Expected: {Response.Ready})");
if (response != Response.Ready)
{
string serverMsg = responseData.Length > 0 ? Encoding.UTF8.GetString(responseData).TrimEnd('\0') : "";
LastError = string.IsNullOrEmpty(serverMsg) ? $"START_UPLOAD rejected (response=0x{(byte)response:X2})" : serverMsg;
OnProgressMessage?.Invoke($"[DEBUG] Upload failed - response=0x{(byte)response:X2} msg={serverMsg}");
onReadyCallback?.Invoke(); // Signal even on failure so waiters don't deadlock
return false;
}
// Signal that file is created/pre-allocated on PS5 (for parallel chunk synchronization)
onReadyCallback?.Invoke();
// Upload chunks - simple async approach
long totalSent = 0;
var startTime = DateTime.Now;
double avgSpeed = 0;
byte[][] sendBuffers = new byte[2][];
sendBuffers[0] = ArrayPool<byte>.Shared.Rent(5 + BufferSize);
sendBuffers[1] = ArrayPool<byte>.Shared.Rent(5 + BufferSize);
try
{
// Use RandomAccess for chunked uploads to avoid disk I/O contention when multiple workers read same file
FileOptions fileOptions = chunkOffset > 0 ? FileOptions.RandomAccess : FileOptions.SequentialScan;
using (FileStream fs = new FileStream(localPath, FileMode.Open, FileAccess.Read, FileShare.Read, BufferSize, fileOptions))
{
if (chunkOffset > 0)
{
fs.Seek(chunkOffset, SeekOrigin.Begin);
}
int activeBufferIndex = 0;
long bytesRemaining = uploadSize;
int bytesRead = await fs.ReadAsync(sendBuffers[activeBufferIndex], 5, (int)Math.Min(BufferSize, bytesRemaining), cancellationToken);
int chunksSent = 0;
while (bytesRemaining > 0 && bytesRead > 0)
{
if (_stream == null) return false;
if (cancellationToken.IsCancellationRequested) return false;
byte[] writeBuffer = sendBuffers[activeBufferIndex];
writeBuffer[0] = (byte)Command.UploadChunk;
BitConverter.GetBytes((uint)bytesRead).CopyTo(writeBuffer, 1);
long remainingAfterCurrent = bytesRemaining - bytesRead;
int nextBufferIndex = 1 - activeBufferIndex;
Task<int>? pendingReadTask = null;
if (remainingAfterCurrent > 0)
{
int nextReadLength = (int)Math.Min(BufferSize, remainingAfterCurrent);
pendingReadTask = fs.ReadAsync(sendBuffers[nextBufferIndex], 5, nextReadLength, cancellationToken);
}
// FIX #6: Reduced timeout from 15 min to 3 min for faster failure detection
using var writeTimeout = new CancellationTokenSource(TimeSpan.FromMinutes(3));
using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, writeTimeout.Token);
try
{
await _stream.WriteAsync(writeBuffer, 0, 5 + bytesRead, linkedCts.Token);
}
catch (OperationCanceledException) when (writeTimeout.IsCancellationRequested)
{
if (pendingReadTask != null)
{
try { await pendingReadTask; } catch { }
}
return false;
}
totalSent += bytesRead;
bytesRemaining -= bytesRead;
chunksSent++;
if (chunksSent % 5 == 0 || bytesRemaining == 0)
{
var elapsed = DateTime.Now - startTime;
double currentSpeed = elapsed.TotalSeconds > 0 ? totalSent / elapsed.TotalSeconds : 0;
avgSpeed = currentSpeed;
TimeSpan eta = currentSpeed > 0
? TimeSpan.FromSeconds(bytesRemaining / currentSpeed)
: TimeSpan.Zero;
progress?.Report(new UploadProgress
{
BytesSent = chunkOffset + totalSent,
TotalBytes = fileInfo.Length,
SpeedBytesPerSecond = currentSpeed,
AverageSpeedBytesPerSecond = avgSpeed,
ElapsedTime = elapsed,
EstimatedTimeRemaining = eta,
CurrentFileName = fileInfo.Name
});
}
if (pendingReadTask == null)
{
break;
}
activeBufferIndex = 1 - activeBufferIndex;
bytesRead = await pendingReadTask;
}
}
}
finally
{
ArrayPool<byte>.Shared.Return(sendBuffers[0]);
ArrayPool<byte>.Shared.Return(sendBuffers[1]);
}
// Send END_UPLOAD and wait for response
// CRITICAL: Must wait for response to avoid protocol desync on chunked uploads
try
{
await SendCommandAsync(Command.EndUpload);
var (endResponse, endData) = await ReceiveResponseAsync();
if (endResponse != Response.Ok)
{
string endMsg = endData.Length > 0 ? Encoding.UTF8.GetString(endData).TrimEnd('\0') : "";
LastError = string.IsNullOrEmpty(endMsg) ? $"END_UPLOAD failed (response=0x{(byte)endResponse:X2})" : endMsg;
return false;
}
return true;
}
catch (Exception ex)
{
LastError = $"END_UPLOAD: {ex.Message}";
return false; // Connection issue means upload failed
}
}
public async Task<bool> DownloadFileAsync(string remotePath, string localPath, IProgress<UploadProgress>? progress = null, CancellationToken cancellationToken = default)
{
await _commandLock.WaitAsync();
try
{
// Send download command (must include null terminator for C strlen())
byte[] pathBytes = Encoding.UTF8.GetBytes(remotePath + "\0");
await SendCommandAsync(Command.DownloadFile, pathBytes);
// Receive response header (5 bytes: 1 response + 4 data_len)
byte[] header = new byte[5];
await ReadExactAsync(header, 5);
Response response = (Response)header[0];
uint dataLen = BitConverter.ToUInt32(header, 1);
// Check if error response
if (response == Response.Error)
{
if (dataLen > 0)
{
byte[] errorMsg = new byte[dataLen];
await ReadExactAsync(errorMsg, (int)dataLen);
}
return false;
}
// Expecting RESP_DATA with 8-byte file size
if (response != Response.Data || dataLen != 8)
{
return false;
}
// Read file size (8 bytes)
byte[] sizeBytes = new byte[8];
await ReadExactAsync(sizeBytes, 8);
long fileSize = BitConverter.ToInt64(sizeBytes, 0);
// Now read raw file data directly from socket
using var fs = new FileStream(localPath, FileMode.Create, FileAccess.Write, FileShare.None, BufferSize);
byte[] buffer = new byte[BufferSize];
long totalReceived = 0;
var startTime = DateTime.Now;
while (totalReceived < fileSize)
{
if (cancellationToken.IsCancellationRequested) return false;
int toRead = (int)Math.Min(BufferSize, fileSize - totalReceived);
int received = await _stream!.ReadAsync(buffer, 0, toRead, cancellationToken);
if (received == 0) break;
await fs.WriteAsync(buffer, 0, received, cancellationToken);
totalReceived += received;
// Report progress every 5MB or at completion
if (totalReceived % (5 * 1024 * 1024) < BufferSize || totalReceived == fileSize)
{
var elapsed = DateTime.Now - startTime;
double speed = elapsed.TotalSeconds > 0 ? totalReceived / elapsed.TotalSeconds : 0;
progress?.Report(new UploadProgress
{
BytesSent = totalReceived,
TotalBytes = fileSize,
SpeedBytesPerSecond = speed,
AverageSpeedBytesPerSecond = speed,
ElapsedTime = elapsed,
EstimatedTimeRemaining = speed > 0 ? TimeSpan.FromSeconds((fileSize - totalReceived) / speed) : TimeSpan.Zero,
CurrentFileName = Path.GetFileName(localPath)
});
}
}
return totalReceived == fileSize;
}
catch (Exception ex)
{
System.Diagnostics.Debug.WriteLine($"Download error: {ex.Message}");
return false;
}
finally
{
_commandLock.Release();
}
}
public async Task<bool> OpenShellAsync()
{
await _commandLock.WaitAsync();
try
{
await SendCommandAsync(Command.ShellOpen);
var (response, _) = await ReceiveResponseAsync();
return response == Response.Ok;
}
finally
{
_commandLock.Release();
}
}
public async Task<string> ExecuteShellCommandAsync(string command)
{
await _commandLock.WaitAsync();
try
{
byte[] cmdBytes = Encoding.UTF8.GetBytes(command + "\0");
await SendCommandAsync(Command.ShellExec, cmdBytes);
// Payload sends multiple RESP_DATA responses (e.g., ls sends one per file)
// Read all RESP_DATA until we get RESP_OK or RESP_ERROR
var outputBuilder = new System.Text.StringBuilder();
while (true)
{
var (response, data) = await ReceiveResponseAsync();
if (response == Response.Data)
{
// Accumulate output from multiple RESP_DATA responses
string chunk = Encoding.UTF8.GetString(data).TrimEnd('\0');
outputBuilder.Append(chunk);
}
else if (response == Response.Ok)
{
// End of output - return accumulated data
return outputBuilder.ToString();
}
else if (response == Response.Error)
{
string error = data.Length > 0 ? Encoding.UTF8.GetString(data).TrimEnd('\0') : "Command failed";
return $"Error: {error}";
}
else
{
// Unexpected response
break;
}
}
return outputBuilder.ToString();
}
finally
{
_commandLock.Release();
}
}
public async Task<bool> CloseShellAsync()
{
await _commandLock.WaitAsync();
try
{
await SendCommandAsync(Command.ShellClose);
var (response, _) = await ReceiveResponseAsync();
return response == Response.Ok;
}
finally
{
_commandLock.Release();
}
}
// Index methods
public async Task<bool> StartIndexAsync(string paths)
{
await _commandLock.WaitAsync();
try
{
byte[] pathBytes = Encoding.UTF8.GetBytes(paths + "\0");
await SendCommandAsync(Command.IndexStart, pathBytes);
var (response, data) = await ReceiveResponseAsync();
return response == Response.Ok;
}
finally
{
_commandLock.Release();
}
}
public async Task<string> GetIndexStatusAsync()
{
await _commandLock.WaitAsync();
try
{
await SendCommandAsync(Command.IndexStatus);
var (response, data) = await ReceiveResponseAsync();
if (response == Response.Ok && data.Length > 0)
{
return Encoding.UTF8.GetString(data).TrimEnd('\0');
}
return "Unknown";
}
finally
{
_commandLock.Release();
}
}
public async Task<SearchResult[]> SearchIndexAsync(string query)
{
await _commandLock.WaitAsync();
try
{
byte[] queryBytes = Encoding.UTF8.GetBytes(query + "\0");
await SendCommandAsync(Command.SearchIndex, queryBytes);
var results = new System.Collections.Generic.List<SearchResult>();
// Payload sends: RESP_DATA(1) + raw_data for each result, then RESP_OK(1) + data_len(4) + message
if (_stream == null) return Array.Empty<SearchResult>();
while (true)
{
// Read response byte
byte[] respBuf = new byte[1];
try { await ReadExactAsync(respBuf, 1); }
catch { break; }
Response response = (Response)respBuf[0];
if (response == Response.Data)
{
// Read raw data: path_len(4) + path + name_len(4) + name + size(8) + mtime(8) + is_dir(1)
byte[] pathLenBuf = new byte[4];
await ReadExactAsync(pathLenBuf, 4);
uint pathLen = BitConverter.ToUInt32(pathLenBuf, 0);
byte[] pathBuf = new byte[pathLen];
await ReadExactAsync(pathBuf, (int)pathLen);
string path = Encoding.UTF8.GetString(pathBuf);
byte[] nameLenBuf = new byte[4];
await ReadExactAsync(nameLenBuf, 4);
uint nameLen = BitConverter.ToUInt32(nameLenBuf, 0);
byte[] nameBuf = new byte[nameLen];
await ReadExactAsync(nameBuf, (int)nameLen);
string name = Encoding.UTF8.GetString(nameBuf);
byte[] sizeBuf = new byte[8];
await ReadExactAsync(sizeBuf, 8);
long size = BitConverter.ToInt64(sizeBuf, 0);
byte[] mtimeBuf = new byte[8];
await ReadExactAsync(mtimeBuf, 8);
long mtime = BitConverter.ToInt64(mtimeBuf, 0);
byte[] isDirBuf = new byte[1];
await ReadExactAsync(isDirBuf, 1);
bool isDir = isDirBuf[0] == 1;
results.Add(new SearchResult
{
Path = path,
Name = name,
Size = size,
Modified = DateTimeOffset.FromUnixTimeSeconds(mtime).DateTime,
IsDirectory = isDir
});
}
else if (response == Response.Ok)
{
// Read and discard the OK message
byte[] dataLenBuf = new byte[4];
await ReadExactAsync(dataLenBuf, 4);
uint dataLen = BitConverter.ToUInt32(dataLenBuf, 0);
if (dataLen > 0)
{
byte[] msgBuf = new byte[dataLen];
await ReadExactAsync(msgBuf, (int)dataLen);
}
break;
}
else if (response == Response.Error)
{
// Read error message
byte[] dataLenBuf = new byte[4];
await ReadExactAsync(dataLenBuf, 4);
uint dataLen = BitConverter.ToUInt32(dataLenBuf, 0);
if (dataLen > 0)
{
byte[] msgBuf = new byte[dataLen];
await ReadExactAsync(msgBuf, (int)dataLen);
}
break;
}
}
return results.ToArray();
}
finally
{
_commandLock.Release();
}
}
// Mount all games on PS5 (scans /data/etaHEN/games, USB drives, M.2 SSD)
// Response pattern: stream of RESP_PROGRESS lines (one per game) followed
// by a RESP_OK with the summary text.
public async Task<string?> MountGamesAsync(CancellationToken cancellationToken = default, Action<string>? onProgress = null)
{
await _commandLock.WaitAsync();
try
{
await SendCommandAsync(Command.MountGames);
// Read the streamed progress lines, then the final OK/ERROR.
while (true)
{
var (response, data) = await ReceiveResponseAsync();
if (response == Response.Progress)
{
string msg = Encoding.UTF8.GetString(data).TrimEnd('\0');
if (!string.IsNullOrEmpty(msg))
onProgress?.Invoke(msg);
}
else if (response == Response.Ok)
{
return data != null ? Encoding.UTF8.GetString(data) : "";
}
else if (response == Response.Error)
{
return "ERROR: " + (data != null ? Encoding.UTF8.GetString(data) : "unknown");
}
else
{
return null;
}
}
}
catch (Exception ex)
{
Console.WriteLine($"[ERROR] MountGamesAsync: {ex.Message}");
return null;
}
finally
{
_commandLock.Release();
}
}
/// <summary>Mount a single game by title id (same worker pipeline, filtered).</summary>
public async Task<string?> MountGameAsync(string titleId, Action<string>? onProgress = null)
{
await _commandLock.WaitAsync();
try
{
await SendCommandAsync(Command.MountGame, Encoding.UTF8.GetBytes(titleId));
while (true)
{
var (response, data) = await ReceiveResponseAsync();
if (response == Response.Progress)
{
string msg = Encoding.UTF8.GetString(data).TrimEnd('\0');
if (!string.IsNullOrEmpty(msg)) onProgress?.Invoke(msg);
}
else if (response == Response.Ok) return Encoding.UTF8.GetString(data);
else if (response == Response.Error) return "ERROR: " + Encoding.UTF8.GetString(data);
else return null;
}
}
catch (Exception ex) { LastError = $"MountGameAsync: {ex.Message}"; return null; }
finally { _commandLock.Release(); }
}
// Send payload ELF file to PS5 (port 9021)
public static async Task<bool> SendPayloadAsync(string ipAddress, string payloadPath, int port = 9021, IProgress<long>? progress = null)
{
TcpClient? client = null;
try
{
FileInfo fileInfo = new FileInfo(payloadPath);
if (!fileInfo.Exists) return false;
// Validate file extension
string ext = fileInfo.Extension.ToLower();
if (ext != ".elf" && ext != ".bin")
{
return false;
}
client = new TcpClient();
client.SendBufferSize = 8 * 1024 * 1024;
client.ReceiveBufferSize = 8 * 1024 * 1024;
client.NoDelay = true;
client.SendTimeout = 10000;
// Connect with 10 second timeout
var connectTask = client.ConnectAsync(ipAddress, port);
var timeoutTask = Task.Delay(10000);
var completedTask = await Task.WhenAny(connectTask, timeoutTask);
if (completedTask == timeoutTask)
{
client?.Close();
return false;
}
// Wait for connect task to complete
await connectTask;
if (!client.Connected)
{
return false;
}
using var stream = client.GetStream();
using var fs = new FileStream(payloadPath, FileMode.Open, FileAccess.Read, FileShare.Read);
// Use larger buffer for faster transfer
byte[] buffer = new byte[64 * 1024];
long totalSent = 0;
int bytesRead;
while ((bytesRead = await fs.ReadAsync(buffer, 0, buffer.Length)) > 0)
{
await stream.WriteAsync(buffer, 0, bytesRead);
totalSent += bytesRead;
progress?.Report(totalSent);
}
await stream.FlushAsync();
// Give PS5 time to process and execute payload
await Task.Delay(500);
return true;
}
catch (Exception)
{
return false;
}
finally
{
client?.Close();
}
}
// Ask the running payload to replace itself: it pushes the staged ELF
// (or an http:// download) to the local payload loader, which spawns
// the new instance — the old one dies via the normal stale-sweep
// takeover and this connection drops.
public async Task<(bool success, string message)> SelfUpdateAsync(string pathOrUrl, int loaderPort = 9021)
{
await _commandLock.WaitAsync();
try
{
string arg = $"{pathOrUrl}|{loaderPort}";
await SendCommandAsync(Command.SelfUpdate, Encoding.UTF8.GetBytes(arg));
var (response, data) = await ReceiveResponseAsync();
return (response == Response.Ok, Encoding.UTF8.GetString(data));
}
catch (Exception ex)
{
LastError = $"SelfUpdateAsync: {ex.GetType().Name}: {ex.Message}";
return (false, LastError);
}
finally
{
_commandLock.Release();
}
}
// ============================================================
// MEMORY EDITOR — ptrace/PT_IO on arbitrary PS5 processes
// ============================================================
public async Task<List<(int pid, string name)>> GetProcessListAsync()
{
await _commandLock.WaitAsync();
try
{
await SendCommandAsync(Command.ProcList);
var (response, data) = await ReceiveResponseAsync();
var list = new List<(int, string)>();
if (response == Response.Data)
{
foreach (var line in Encoding.UTF8.GetString(data).Split('\n'))
{
var p = line.Split('|', 2);
if (p.Length == 2 && int.TryParse(p[0], out int pid))
list.Add((pid, p[1].Trim()));
}
}
return list;
}
catch (Exception ex) { LastError = $"GetProcessListAsync: {ex.Message}"; return new(); }
finally { _commandLock.Release(); }
}
public async Task<byte[]?> MemReadAsync(int pid, ulong addr, int len)
{
await _commandLock.WaitAsync();
try
{
string arg = $"{pid}|0x{addr:X}|{len}";
await SendCommandAsync(Command.MemRead, Encoding.UTF8.GetBytes(arg));
var (response, data) = await ReceiveResponseAsync();
if (response != Response.Data) { LastError = Encoding.UTF8.GetString(data); return null; }
return data;
}
catch (Exception ex) { LastError = $"MemReadAsync: {ex.Message}"; return null; }
finally { _commandLock.Release(); }
}
public async Task<(bool ok, string message)> MemWriteAsync(int pid, ulong addr, byte[] bytes)
{
await _commandLock.WaitAsync();
try
{
string arg = $"{pid}|0x{addr:X}|{Convert.ToHexString(bytes)}";
await SendCommandAsync(Command.MemWrite, Encoding.UTF8.GetBytes(arg));
var (response, data) = await ReceiveResponseAsync();
return (response == Response.Ok, Encoding.UTF8.GetString(data));
}
catch (Exception ex) { LastError = $"MemWriteAsync: {ex.Message}"; return (false, LastError); }
finally { _commandLock.Release(); }
}
public async Task<string?> MemRegionsAsync(int pid)
{
await _commandLock.WaitAsync();
try
{
await SendCommandAsync(Command.MemRegions, Encoding.UTF8.GetBytes(pid.ToString()));
var (response, data) = await ReceiveResponseAsync();
if (response != Response.Data) { LastError = Encoding.UTF8.GetString(data); return null; }
return Encoding.UTF8.GetString(data);
}
catch (Exception ex) { LastError = $"MemRegionsAsync: {ex.Message}"; return null; }
finally { _commandLock.Release(); }
}
public async Task<string?> MemSearchAsync(int pid, ulong start, ulong end, byte[] pattern)
{
await _commandLock.WaitAsync();
try
{
string arg = $"{pid}|0x{start:X}|0x{end:X}|{Convert.ToHexString(pattern)}";
await SendCommandAsync(Command.MemSearch, Encoding.UTF8.GetBytes(arg));
var (response, data) = await ReceiveResponseAsync(60000); // scans can take up to ~30s
if (response != Response.Data) { LastError = Encoding.UTF8.GetString(data); return null; }
return Encoding.UTF8.GetString(data);
}
catch (Exception ex) { LastError = $"MemSearchAsync: {ex.Message}"; return null; }
finally { _commandLock.Release(); }
}
/// <summary>
/// Read the kernel message buffer (kern.msgbuf). tailBytes &gt; 0 limits to the tail.
/// </summary>
public async Task<string?> GetKernelLogAsync(int tailBytes = 0)
{
await _commandLock.WaitAsync();
try
{
await SendCommandAsync(Command.KlogRead, Encoding.UTF8.GetBytes(tailBytes.ToString()));
var (response, data) = await ReceiveResponseAsync();
if (response != Response.Data) { LastError = Encoding.UTF8.GetString(data); return null; }
return Encoding.UTF8.GetString(data);
}
catch (Exception ex) { LastError = $"GetKernelLogAsync: {ex.Message}"; return null; }
finally { _commandLock.Release(); }
}
/// <summary>
/// Recursively download an entire folder from PS5 to local PC.
/// Uses a separate connection for listing to avoid protocol desync.
/// </summary>
public async Task<(int filesDownloaded, int filesFailed, long totalBytes)> DownloadFolderAsync(
string remotePath,
string localBasePath,
string ps5IpAddress,
IProgress<DownloadFolderProgress>? progress = null,
CancellationToken cancellationToken = default)
{
// Phase 1: Scan the remote folder tree to build a file list
var filesToDownload = new System.Collections.Generic.List<(string remotePath, string localPath, long size)>();
var dirsToCreate = new System.Collections.Generic.List<string>();
await ScanRemoteFolderAsync(remotePath, localBasePath, remotePath, filesToDownload, dirsToCreate, ps5IpAddress, progress, cancellationToken);
if (cancellationToken.IsCancellationRequested) return (0, 0, 0);
// Phase 2: Create all local directories
foreach (var dir in dirsToCreate)
{
Directory.CreateDirectory(dir);
}
long totalBytes = 0;
foreach (var f in filesToDownload) totalBytes += f.size;
progress?.Report(new DownloadFolderProgress
{
Phase = "Downloading",
CurrentFile = "",
FilesCompleted = 0,
TotalFiles = filesToDownload.Count,
BytesDownloaded = 0,
TotalBytes = totalBytes
});
// Phase 3: Download each file using a dedicated connection per file
int filesDownloaded = 0;
int filesFailed = 0;
long bytesDownloaded = 0;
for (int i = 0; i < filesToDownload.Count; i++)
{
if (cancellationToken.IsCancellationRequested) break;
var (rPath, lPath, fSize) = filesToDownload[i];
string fileName = Path.GetFileName(lPath);
progress?.Report(new DownloadFolderProgress
{
Phase = "Downloading",
CurrentFile = fileName,
FilesCompleted = filesDownloaded,
TotalFiles = filesToDownload.Count,
BytesDownloaded = bytesDownloaded,
TotalBytes = totalBytes
});
// Use a fresh connection for each file to avoid protocol desync
using var dlProto = new PS5Protocol();
if (!await dlProto.ConnectAsync(ps5IpAddress))
{
filesFailed++;
continue;
}
try
{
bool ok = await dlProto.DownloadFileAsync(rPath, lPath, null, cancellationToken);
if (ok)
{
filesDownloaded++;
bytesDownloaded += fSize;
}
else
{
filesFailed++;
}
}
catch
{
filesFailed++;
}
}
progress?.Report(new DownloadFolderProgress
{
Phase = "Complete",
CurrentFile = "",
FilesCompleted = filesDownloaded,
TotalFiles = filesToDownload.Count,
BytesDownloaded = bytesDownloaded,
TotalBytes = totalBytes
});
return (filesDownloaded, filesFailed, bytesDownloaded);
}
private async Task ScanRemoteFolderAsync(
string currentRemotePath,
string localBasePath,
string remoteRootPath,
System.Collections.Generic.List<(string remotePath, string localPath, long size)> files,
System.Collections.Generic.List<string> dirs,
string ps5IpAddress,
IProgress<DownloadFolderProgress>? progress,
CancellationToken cancellationToken)
{
if (cancellationToken.IsCancellationRequested) return;
// Calculate relative path for local directory
string relativePath = currentRemotePath.Length > remoteRootPath.Length
? currentRemotePath.Substring(remoteRootPath.Length).TrimStart('/')
: "";
string localDir = string.IsNullOrEmpty(relativePath)
? localBasePath
: Path.Combine(localBasePath, relativePath.Replace('/', Path.DirectorySeparatorChar));
dirs.Add(localDir);
progress?.Report(new DownloadFolderProgress
{
Phase = "Scanning",
CurrentFile = currentRemotePath,
FilesCompleted = files.Count,
TotalFiles = 0,
BytesDownloaded = 0,
TotalBytes = 0
});
// Use a fresh connection for listing to avoid protocol desync
FileEntry[] entries;
using (var listProto = new PS5Protocol())
{
if (!await listProto.ConnectAsync(ps5IpAddress))
return;
entries = await listProto.ListDirAsync(currentRemotePath);
}
foreach (var entry in entries)
{
if (cancellationToken.IsCancellationRequested) return;
if (entry.Name == "." || entry.Name == "..") continue;
string entryRemotePath = currentRemotePath.TrimEnd('/') + "/" + entry.Name;
string entryLocalPath = Path.Combine(localDir, entry.Name);
if (entry.IsDirectory)
{
await ScanRemoteFolderAsync(entryRemotePath, localBasePath, remoteRootPath, files, dirs, ps5IpAddress, progress, cancellationToken);
}
else
{
files.Add((entryRemotePath, entryLocalPath, entry.Size));
}
}
}
// ============================================================================
// PKG Install Methods (ps5upload-style)
// ============================================================================
/// <summary>
/// Install a PKG from a local path on PS5 or HTTP URL
/// </summary>
/// <param name="pkgPath">Path to PKG (e.g., /user/data/pkg/game.pkg) or HTTP URL</param>
/// <returns>Tuple with success flag, content_id on success, or error message</returns>
public async Task<(bool success, string contentIdOrError)> InstallPkgAsync(string pkgPath)
{
await _commandLock.WaitAsync();
try
{
byte[] data = Encoding.UTF8.GetBytes(pkgPath + "\0");
await SendCommandAsync(Command.PkgInstall, data);
// The payload may emit RESP_PROGRESS diagnostics before the
// final answer — consume them so they don't desync the read.
var deadline = DateTime.UtcNow + TimeSpan.FromMinutes(5);
string result = "";
while (true)
{
int remainingMs = (int)(deadline - DateTime.UtcNow).TotalMilliseconds;
if (remainingMs <= 0) return (false, "Install timed out");
var (response, respData) = await ReceiveResponseAsync(remainingMs);
result = respData != null ? Encoding.UTF8.GetString(respData).TrimEnd('\0') : "";
if (response == Response.Progress) { Console.WriteLine($"[PKG] {result}"); continue; }
break;
}
if (result.StartsWith("OK:"))
{
return (true, result.Substring(3)); // Return content_id
}
else if (result == "STARTED")
{
// Worker model: install runs detached; poll 0x51 for state
return (true, "");
}
else if (result.StartsWith("ERROR:"))
{
return (false, result.Substring(6));
}
else
{
return (false, result);
}
}
catch (Exception ex)
{
return (false, ex.Message);
}
finally
{
_commandLock.Release();
}
}
/// <summary>
/// Get PKG install status
/// </summary>
/// <returns>Status: IDLE, PROGRESS:status:percent, DONE:content_id, ERROR:code, or UNKNOWN</returns>
public async Task<PkgInstallStatus> GetPkgInstallStatusAsync()
{
await _commandLock.WaitAsync();
try
{
await SendCommandAsync(Command.PkgInstallStatus);
var (response, respData) = await ReceiveResponseAsync();
string result = Encoding.UTF8.GetString(respData).TrimEnd('\0');
var status = new PkgInstallStatus();
if (result == "IDLE")
{
status.State = PkgInstallState.Idle;
}
else if (result == "UNKNOWN")
{
status.State = PkgInstallState.Unknown;
}
else if (result.StartsWith("PROGRESS:"))
{
status.State = PkgInstallState.InProgress;
var parts = result.Substring(9).Split(':');
if (parts.Length >= 2)
{
// First field may be an int (legacy) or the daemon's
// status string ("downloading", "promoting", ...)
if (int.TryParse(parts[0], out int state))
status.StatusCode = state;
else
status.StatusText2 = parts[0];
int.TryParse(parts[1], out int progress);
status.Progress = progress;
}
}
else if (result.StartsWith("DONE:"))
{
status.State = PkgInstallState.Done;
status.ContentId = result.Substring(5);
}
else if (result.StartsWith("ERROR:"))
{
status.State = PkgInstallState.Error;
status.Error = result.Substring(6);
}
return status;
}
catch (Exception ex)
{
return new PkgInstallStatus { State = PkgInstallState.Error, Error = ex.Message };
}
finally
{
_commandLock.Release();
}
}
// ============================================================================
// Fan Control Methods (etaHEN/Elf Arsenal compatible)
// ============================================================================
/// <summary>
/// Get current fan temperature threshold
/// </summary>
/// <returns>Tuple with success flag and threshold in Celsius (or error message)</returns>
public async Task<(bool success, int thresholdOrError)> GetFanThresholdAsync()
{
await _commandLock.WaitAsync();
try
{
await SendCommandAsync(Command.FanGetThreshold);
var (response, respData) = await ReceiveResponseAsync();
string result = Encoding.UTF8.GetString(respData).TrimEnd('\0');
if (result.StartsWith("OK:"))
{
// Format: "OK:<threshold|-1>:<duty0..5>" — threshold is the
// last value WE set (the driver has no read-back ioctl);
// -1 means unknown/system-managed.
string first = result.Substring(3).Split(':')[0];
if (int.TryParse(first, out int threshold))
{
return (true, threshold);
}
return (false, -1);
}
else if (result.StartsWith("ERROR:"))
{
return (false, -1);
}
return (false, -1);
}
catch
{
return (false, -1);
}
finally
{
_commandLock.Release();
}
}
/// <summary>
/// Set fan temperature threshold (30-90°C)
/// </summary>
/// <param name="temperatureCelsius">Temperature threshold in Celsius (30-90)</param>
/// <returns>Tuple with success flag and actual set temperature (or error)</returns>
public async Task<(bool success, string message)> SetFanThresholdAsync(int temperatureCelsius)
{
await _commandLock.WaitAsync();
try
{
// Clamp to valid range
temperatureCelsius = Math.Max(30, Math.Min(90, temperatureCelsius));
byte[] data = new byte[] { (byte)temperatureCelsius };
await SendCommandAsync(Command.FanSetThreshold, data);
var (response, respData) = await ReceiveResponseAsync();
string result = Encoding.UTF8.GetString(respData).TrimEnd('\0');
if (result.StartsWith("OK:"))
{
return (true, $"Fan threshold set to {result.Substring(3)}°C");
}
else if (result.StartsWith("ERROR:"))
{
return (false, result.Substring(6));
}
return (false, result);
}
catch (Exception ex)
{
return (false, ex.Message);
}
finally
{
_commandLock.Release();
}
}
public void Dispose()
{
Disconnect();
}
}
// ============================================================================
// PKG Install Status Classes
// ============================================================================
public enum PkgInstallState
{
Idle,
InProgress,
Done,
Error,
Unknown
}
public class PkgInstallStatus
{
public PkgInstallState State { get; set; } = PkgInstallState.Idle;
public int StatusCode { get; set; } // 0=idle, 1=transferring, 2=promoting, 3=playable
public string StatusText2 { get; set; } = ""; // daemon status string ("downloading", ...)
public int Progress { get; set; } // 0-100
public string ContentId { get; set; } = "";
public string Error { get; set; } = "";
public string StatusText => State switch
{
PkgInstallState.Idle => "Idle",
PkgInstallState.InProgress => !string.IsNullOrEmpty(StatusText2)
? $"{StatusText2}... {Progress}%"
: StatusCode switch
{
1 => $"Transferring... {Progress}%",
2 => $"Promoting... {Progress}%",
_ => $"Installing... {Progress}%"
},
PkgInstallState.Done => $"Installed: {ContentId}",
PkgInstallState.Error => $"Error: {Error}",
PkgInstallState.Unknown => "Unknown",
_ => "Unknown"
};
}
public class DownloadFolderProgress
{
public string Phase { get; set; } = "";
public string CurrentFile { get; set; } = "";
public int FilesCompleted { get; set; }
public int TotalFiles { get; set; }
public long BytesDownloaded { get; set; }
public long TotalBytes { get; set; }
}
public class SearchResult
{
public string Path { get; set; } = "";
public string Name { get; set; } = "";
public long Size { get; set; }
public DateTime Modified { get; set; }
public bool IsDirectory { get; set; }
public string SizeText => FormatFileSize(Size);
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 = len / 1024;
}
return $"{len:0.##} {sizes[order]}";
}
}
public class StorageInfo
{
public string Path { get; set; } = "";
public long TotalBytes { get; set; }
public long FreeBytes { get; set; }
}
public class PS5StorageInfo
{
public ulong TotalBytes { get; set; }
public ulong FreeBytes { get; set; }
public ulong AvailableBytes { get; set; }
public ulong ReservedBytes { get; set; }
public ulong MountedGamesSize { get; set; }
public ulong UserDataSize { get; set; }
public ulong RealFreeSpace { get; set; }
public string StoragePath { get; set; } = "unknown";
public string TotalGB => FormatBytes(TotalBytes);
public string FreeGB => FormatBytes(FreeBytes);
public string RealFreeGB => FormatBytes(RealFreeSpace);
public string MountedGamesGB => FormatBytes(MountedGamesSize);
public string UserDataGB => FormatBytes(UserDataSize);
public string ReservedGB => FormatBytes(ReservedBytes);
public static string FormatBytes(ulong bytes)
{
// Use 1000-based (decimal) like PS5, not 1024-based (binary)
if (bytes >= 1000UL * 1000 * 1000 * 1000)
return $"{bytes / (1000.0 * 1000 * 1000 * 1000):F1} TB";
if (bytes >= 1000UL * 1000 * 1000)
return $"{bytes / (1000.0 * 1000 * 1000):F1} GB";
if (bytes >= 1000UL * 1000)
return $"{bytes / (1000.0 * 1000):F1} MB";
if (bytes >= 1000UL)
return $"{bytes / 1000.0:F1} KB";
return $"{bytes} bytes";
}
}
public class FileEntry
{
public string Name { get; set; } = "";
public bool IsDirectory { get; set; }
public long Size { get; set; }
public DateTime Timestamp { get; set; }
}
public class UploadProgress
{
public long BytesSent { get; set; }
public long TotalBytes { get; set; }
public double SpeedBytesPerSecond { get; set; }
public double AverageSpeedBytesPerSecond { get; set; }
public TimeSpan ElapsedTime { get; set; }
public TimeSpan EstimatedTimeRemaining { get; set; }
public string CurrentFileName { get; set; } = "";
// For folder progress
public int CurrentFileIndex { get; set; }
public int TotalFiles { get; set; }
public long TotalFolderBytes { get; set; }
public long TotalFolderBytesSent { get; set; }
}
// High-speed parallel uploader using multiple connections
public class ParallelUploader : IDisposable
{
private readonly string _ipAddress;
private readonly int _port;
private readonly int _connectionCount;
private const int ChunkSize = 4 * 1024 * 1024; // 4MB chunks per connection
public ParallelUploader(string ipAddress, int port = 9113, int connectionCount = 4)
{
_ipAddress = ipAddress;
_port = port;
_connectionCount = connectionCount;
}
public async Task<bool> UploadFileAsync(string localPath, string remotePath, IProgress<UploadProgress>? progress = null, CancellationToken cancellationToken = default)
{
FileInfo fileInfo = new FileInfo(localPath);
if (!fileInfo.Exists) return false;
// For small files, use single connection
if (fileInfo.Length < ChunkSize * 2)
{
using var protocol = new PS5Protocol();
if (!await protocol.ConnectAsync(_ipAddress, _port)) return false;
return await protocol.UploadFileAsync(localPath, remotePath, progress, cancellationToken);
}
// For large files, use parallel connections
// This requires server support for chunked/parallel uploads
// For now, fall back to single connection
using var proto = new PS5Protocol();
if (!await proto.ConnectAsync(_ipAddress, _port)) return false;
return await proto.UploadFileAsync(localPath, remotePath, progress, cancellationToken);
}
public void Dispose() { }
}
// New data classes for enhanced features
public class PS5FileInfo
{
public long Size { get; set; }
public DateTime ModifiedTime { get; set; }
public DateTime AccessTime { get; set; }
public int Permissions { get; set; }
public bool IsDirectory { get; set; }
public bool IsSymlink { get; set; }
}
public class PS5SystemInfo
{
public string Hostname { get; set; } = "PS5";
public string ServerVersion { get; set; } = "";
public int ProtocolVersion { get; set; }
public ulong TotalMemory { get; set; }
public ulong StorageTotal { get; set; }
public ulong StorageFree { get; set; }
public int MountedGames { get; set; }
public bool IndexReady { get; set; }
public int IndexFiles { get; set; }
public int IndexDirs { get; set; }
public long ServerUptime { get; set; }
}
public class FileVerificationResult
{
public string CRC32 { get; set; } = "";
public ulong Size { get; set; }
public bool Success { get; set; }
public string? Error { get; set; }
}
// Hardware info
public class PS5HardwareInfo
{
public string Model { get; set; } = "";
public string Serial { get; set; } = "";
public bool HasWlanBt { get; set; }
public bool HasOpticalOut { get; set; }
public string HwMachine { get; set; } = "";
public string OsVersion { get; set; } = "";
public int NumCpu { get; set; }
public ulong PhysMem { get; set; }
}
// Temperature and CPU info
public class PS5TemperatureInfo
{
public int CpuTemp { get; set; }
public int SocTemp { get; set; }
public long CpuFreqMhz { get; set; }
public uint SocPowerMw { get; set; }
public int[] CpuUsage { get; set; } = new int[8];
}
// Running app info
public class PS5RunningApp
{
public int Pid { get; set; }
public string Name { get; set; } = "";
public string TitleId { get; set; } = "";
public uint AppId { get; set; }
}
// Screenshot info (notifies UI when thumbnail loads)
public class PS5Screenshot : System.ComponentModel.INotifyPropertyChanged
{
public string FullPath { get; set; } = "";
public string FileName { get; set; } = "";
public long Size { get; set; }
public DateTime ModifiedTime { get; set; }
private Avalonia.Media.IImage? _thumbnail;
public Avalonia.Media.IImage? Thumbnail
{
get => _thumbnail;
set { _thumbnail = value; PropertyChanged?.Invoke(this, new System.ComponentModel.PropertyChangedEventArgs(nameof(Thumbnail))); }
}
public event System.ComponentModel.PropertyChangedEventHandler? PropertyChanged;
public string SizeDisplay
{
get
{
if (Size < 1024) return $"{Size} B";
if (Size < 1024 * 1024) return $"{Size / 1024.0:0.0} KB";
return $"{Size / (1024.0 * 1024.0):0.0} MB";
}
}
public string DateDisplay => ModifiedTime.ToString("yyyy-MM-dd HH:mm");
}
// Power info
public class PS5PowerInfo
{
public ulong OperatingTimeSec { get; set; }
public ulong OperatingTimeHours { get; set; }
public ulong OperatingTimeMinutes { get; set; }
public uint BootCount { get; set; }
public uint PowerConsumptionMw { get; set; }
}
// Extended system info
public class PS5ExtendedInfo
{
public string FirmwareVersion { get; set; } = "";
public string ProsperoVersion { get; set; } = "";
public string ProductCode { get; set; } = "";
public string ProductStr { get; set; } = "";
public ulong TotalOperatingTimeSec { get; set; }
public ulong TotalOperatingTimeHours { get; set; }
public uint BootCount { get; set; }
public uint ShutdownCount { get; set; }
public int ThermalAlert { get; set; }
public int BdDrivePower { get; set; }
}
// CPU usage info
public class PS5CpuUsage
{
public int[] CoreUsage { get; set; } = new int[8];
public int Average { get; set; }
}
// Memory info
public class PS5MemoryInfo
{
public ulong DirectTotal { get; set; }
public ulong DirectAvailable { get; set; }
public ulong DirectUsed { get; set; }
public int DirectUsedPercent { get; set; }
public ulong FlexibleAvailable { get; set; }
public ulong PhysicalTotal { get; set; }
public ulong UserMemory { get; set; }
public ulong FreeMemory { get; set; }
public ulong CpuPoolTotal { get; set; }
public ulong CpuPoolFree { get; set; }
public ulong GpuPoolTotal { get; set; }
public ulong GpuPoolFree { get; set; }
}
// Module info
public class PS5ModuleInfo
{
public uint Id { get; set; }
public string Name { get; set; } = "";
public string Path { get; set; } = "";
}
// Save game info
public class PS5SaveGame : System.ComponentModel.INotifyPropertyChanged
{
public string TitleId { get; set; } = "";
public string UserId { get; set; } = "";
public string SavePath { get; set; } = "";
public ulong Size { get; set; }
public long ModifiedUnixTime { get; set; }
// Display helpers
public string SizeDisplay
{
get
{
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 string ModifiedDisplay
{
get
{
try
{
var dt = DateTimeOffset.FromUnixTimeSeconds(ModifiedUnixTime).LocalDateTime;
return dt.ToString("yyyy-MM-dd HH:mm");
}
catch { return "—"; }
}
}
public string ShortUserId => UserId.Length > 8 ? UserId.Substring(0, 8) + "…" : UserId;
private string _gameName = "";
public string GameName
{
get => _gameName;
set { _gameName = value; PropertyChanged?.Invoke(this, new System.ComponentModel.PropertyChangedEventArgs(nameof(GameName))); }
}
private Avalonia.Media.IImage? _icon;
public Avalonia.Media.IImage? Icon
{
get => _icon;
set { _icon = value; PropertyChanged?.Invoke(this, new System.ComponentModel.PropertyChangedEventArgs(nameof(Icon))); }
}
public event System.ComponentModel.PropertyChangedEventHandler? PropertyChanged;
}
// Individual save IMAGE file (garlic-savemgr style: one entry per save
// image under savedata_prospero/savedata, not per title folder)
public class PS5SaveFile : System.ComponentModel.INotifyPropertyChanged
{
public string Path { get; set; } = "";
public string TitleId { get; set; } = "";
public string SaveName { get; set; } = "";
public string UserId { get; set; } = "";
public ulong Size { get; set; }
public bool IsPs4 { get; set; }
public bool HasBinKey { get; set; }
public string TypeBadge => IsPs4 ? "PS4" : "PS5";
public string TypeBadgeColor => IsPs4 ? "#9C27B0" : "#007ACC";
public string SizeDisplay
{
get
{
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 string ShortUserId => UserId.Length > 8 ? UserId.Substring(0, 8) + "…" : UserId;
private string _gameName = "";
public string GameName
{
get => _gameName;
set { _gameName = value; PropertyChanged?.Invoke(this, new System.ComponentModel.PropertyChangedEventArgs(nameof(GameName))); }
}
private Avalonia.Media.IImage? _icon;
public Avalonia.Media.IImage? Icon
{
get => _icon;
set { _icon = value; PropertyChanged?.Invoke(this, new System.ComponentModel.PropertyChangedEventArgs(nameof(Icon))); }
}
public event System.ComponentModel.PropertyChangedEventHandler? PropertyChanged;
}
// Mounted game info
public class PS5MountedGame : System.ComponentModel.INotifyPropertyChanged
{
public string TitleId { get; set; } = "";
public string Name { get; set; } = "";
public string Path { get; set; } = "";
public ulong Size { get; set; }
public string Region { get; set; } = "";
public bool IsActive { get; set; }
private Avalonia.Media.IImage? _icon;
public Avalonia.Media.IImage? Icon
{
get => _icon;
set
{
_icon = value;
PropertyChanged?.Invoke(this, new System.ComponentModel.PropertyChangedEventArgs(nameof(Icon)));
}
}
public string SizeDisplay
{
get
{
double mb = Size / (1024.0 * 1024.0);
if (mb < 1024) return $"{mb:F1} MB";
return $"{mb / 1024.0:F2} GB";
}
}
public string StatusDisplay => IsActive ? "✓ Mounted" : "✗ Not Mounted";
public Avalonia.Media.IBrush StatusBrush => IsActive
? new Avalonia.Media.SolidColorBrush(Avalonia.Media.Color.Parse("#28A745"))
: new Avalonia.Media.SolidColorBrush(Avalonia.Media.Color.Parse("#6C757D"));
public event System.ComponentModel.PropertyChangedEventHandler? PropertyChanged;
}
}