psst
This commit is contained in:
263
Editor/Core/PCSXReduxDownloader.cs
Normal file
263
Editor/Core/PCSXReduxDownloader.cs
Normal file
@@ -0,0 +1,263 @@
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Net.Http;
|
||||
using System.Threading.Tasks;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
|
||||
namespace SplashEdit.EditorCode
|
||||
{
|
||||
/// <summary>
|
||||
/// Downloads and installs PCSX-Redux from the official distrib.app CDN.
|
||||
/// Mirrors the logic from pcsx-redux.js (the official download script).
|
||||
///
|
||||
/// Flow: fetch platform manifest → find latest build ID → fetch build manifest →
|
||||
/// get download URL → download zip → extract to .tools/pcsx-redux/
|
||||
/// </summary>
|
||||
public static class PCSXReduxDownloader
|
||||
{
|
||||
private const string MANIFEST_BASE = "https://distrib.app/storage/manifests/pcsx-redux/";
|
||||
|
||||
private static readonly HttpClient _http;
|
||||
|
||||
static PCSXReduxDownloader()
|
||||
{
|
||||
var handler = new HttpClientHandler
|
||||
{
|
||||
AutomaticDecompression = System.Net.DecompressionMethods.GZip
|
||||
| System.Net.DecompressionMethods.Deflate
|
||||
};
|
||||
_http = new HttpClient(handler);
|
||||
_http.Timeout = TimeSpan.FromSeconds(60);
|
||||
_http.DefaultRequestHeaders.UserAgent.ParseAdd("SplashEdit/1.0");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the platform variant string for the current platform.
|
||||
/// </summary>
|
||||
private static string GetPlatformVariant()
|
||||
{
|
||||
switch (Application.platform)
|
||||
{
|
||||
case RuntimePlatform.WindowsEditor:
|
||||
return "dev-win-cli-x64";
|
||||
case RuntimePlatform.LinuxEditor:
|
||||
return "dev-linux-x64";
|
||||
default:
|
||||
return "dev-win-cli-x64";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Downloads and installs PCSX-Redux to .tools/pcsx-redux/.
|
||||
/// Shows progress bar during download.
|
||||
/// </summary>
|
||||
public static async Task<bool> DownloadAndInstall(Action<string> log = null)
|
||||
{
|
||||
string variant = GetPlatformVariant();
|
||||
log?.Invoke($"Platform variant: {variant}");
|
||||
|
||||
try
|
||||
{
|
||||
// Step 1: Fetch the master manifest to get the latest build ID
|
||||
string manifestUrl = $"{MANIFEST_BASE}{variant}/manifest.json";
|
||||
log?.Invoke($"Fetching manifest: {manifestUrl}");
|
||||
string manifestJson = await _http.GetStringAsync(manifestUrl);
|
||||
|
||||
// Parse the latest build ID from the manifest.
|
||||
// The manifest is JSON with a "builds" array. We want the highest ID.
|
||||
// Simple JSON parsing without dependencies:
|
||||
int latestBuildId = ParseLatestBuildId(manifestJson);
|
||||
if (latestBuildId < 0)
|
||||
{
|
||||
log?.Invoke("Failed to parse build ID from manifest.");
|
||||
return false;
|
||||
}
|
||||
log?.Invoke($"Latest build ID: {latestBuildId}");
|
||||
|
||||
// Step 2: Fetch the specific build manifest
|
||||
string buildManifestUrl = $"{MANIFEST_BASE}{variant}/manifest-{latestBuildId}.json";
|
||||
log?.Invoke($"Fetching build manifest...");
|
||||
string buildManifestJson = await _http.GetStringAsync(buildManifestUrl);
|
||||
|
||||
// Parse the download path
|
||||
string downloadPath = ParseDownloadPath(buildManifestJson);
|
||||
if (string.IsNullOrEmpty(downloadPath))
|
||||
{
|
||||
log?.Invoke("Failed to parse download path from build manifest.");
|
||||
return false;
|
||||
}
|
||||
|
||||
string downloadUrl = $"https://distrib.app{downloadPath}";
|
||||
log?.Invoke($"Downloading: {downloadUrl}");
|
||||
|
||||
// Step 3: Download the file
|
||||
string tempFile = Path.Combine(Path.GetTempPath(), $"pcsx-redux-{latestBuildId}.zip");
|
||||
|
||||
EditorUtility.DisplayProgressBar("Downloading PCSX-Redux", "Downloading...", 0.1f);
|
||||
|
||||
using (var response = await _http.GetAsync(downloadUrl, HttpCompletionOption.ResponseHeadersRead))
|
||||
{
|
||||
response.EnsureSuccessStatusCode();
|
||||
long? totalBytes = response.Content.Headers.ContentLength;
|
||||
long downloadedBytes = 0;
|
||||
|
||||
using (var fileStream = File.Create(tempFile))
|
||||
using (var downloadStream = await response.Content.ReadAsStreamAsync())
|
||||
{
|
||||
byte[] buffer = new byte[81920];
|
||||
int bytesRead;
|
||||
while ((bytesRead = await downloadStream.ReadAsync(buffer, 0, buffer.Length)) > 0)
|
||||
{
|
||||
await fileStream.WriteAsync(buffer, 0, bytesRead);
|
||||
downloadedBytes += bytesRead;
|
||||
|
||||
if (totalBytes.HasValue)
|
||||
{
|
||||
float progress = (float)downloadedBytes / totalBytes.Value;
|
||||
string sizeMB = $"{downloadedBytes / (1024 * 1024)}/{totalBytes.Value / (1024 * 1024)} MB";
|
||||
EditorUtility.DisplayProgressBar("Downloading PCSX-Redux",
|
||||
$"Downloading... {sizeMB}", progress);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log?.Invoke($"Downloaded to {tempFile}");
|
||||
EditorUtility.DisplayProgressBar("Installing PCSX-Redux", "Extracting...", 0.9f);
|
||||
|
||||
// Step 4: Extract
|
||||
string installDir = SplashBuildPaths.PCSXReduxDir;
|
||||
if (Directory.Exists(installDir))
|
||||
Directory.Delete(installDir, true);
|
||||
Directory.CreateDirectory(installDir);
|
||||
|
||||
if (Application.platform == RuntimePlatform.LinuxEditor && tempFile.EndsWith(".tar.gz"))
|
||||
{
|
||||
var psi = new ProcessStartInfo
|
||||
{
|
||||
FileName = "tar",
|
||||
Arguments = $"xzf \"{tempFile}\" -C \"{installDir}\" --strip-components=1",
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true
|
||||
};
|
||||
var proc = Process.Start(psi);
|
||||
proc?.WaitForExit();
|
||||
}
|
||||
else
|
||||
{
|
||||
System.IO.Compression.ZipFile.ExtractToDirectory(tempFile, installDir);
|
||||
log?.Invoke($"Extracted to {installDir}");
|
||||
}
|
||||
|
||||
// Clean up temp file
|
||||
try { File.Delete(tempFile); } catch { }
|
||||
|
||||
// Step 5: Verify
|
||||
if (SplashBuildPaths.IsPCSXReduxInstalled())
|
||||
{
|
||||
log?.Invoke("PCSX-Redux installed successfully!");
|
||||
EditorUtility.ClearProgressBar();
|
||||
return true;
|
||||
}
|
||||
else
|
||||
{
|
||||
// The zip might have a nested directory — try to find the exe
|
||||
FixNestedDirectory(installDir);
|
||||
if (SplashBuildPaths.IsPCSXReduxInstalled())
|
||||
{
|
||||
log?.Invoke("PCSX-Redux installed successfully!");
|
||||
EditorUtility.ClearProgressBar();
|
||||
return true;
|
||||
}
|
||||
|
||||
log?.Invoke("Installation completed but PCSX-Redux binary not found at expected path.");
|
||||
log?.Invoke($"Expected: {SplashBuildPaths.PCSXReduxBinary}");
|
||||
log?.Invoke($"Check: {installDir}");
|
||||
EditorUtility.ClearProgressBar();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
log?.Invoke($"Download failed: {ex.Message}");
|
||||
EditorUtility.ClearProgressBar();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// If the zip extracts into a nested directory, move files up.
|
||||
/// </summary>
|
||||
private static void FixNestedDirectory(string installDir)
|
||||
{
|
||||
var subdirs = Directory.GetDirectories(installDir);
|
||||
if (subdirs.Length == 1)
|
||||
{
|
||||
string nested = subdirs[0];
|
||||
foreach (string file in Directory.GetFiles(nested))
|
||||
{
|
||||
string dest = Path.Combine(installDir, Path.GetFileName(file));
|
||||
File.Move(file, dest);
|
||||
}
|
||||
foreach (string dir in Directory.GetDirectories(nested))
|
||||
{
|
||||
string dest = Path.Combine(installDir, Path.GetFileName(dir));
|
||||
Directory.Move(dir, dest);
|
||||
}
|
||||
try { Directory.Delete(nested); } catch { }
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parse the latest build ID from the master manifest JSON.
|
||||
/// Expected format: {"builds":[{"id":1234,...},...],...}
|
||||
/// distrib.app returns builds sorted newest-first, so we take the first.
|
||||
/// Falls back to scanning all IDs if the "builds" section isn't found.
|
||||
/// </summary>
|
||||
private static int ParseLatestBuildId(string json)
|
||||
{
|
||||
// Fast path: find the first "id" inside "builds" array
|
||||
int buildsIdx = json.IndexOf("\"builds\"", StringComparison.Ordinal);
|
||||
int startPos = buildsIdx >= 0 ? buildsIdx : 0;
|
||||
|
||||
string searchToken = "\"id\":";
|
||||
int idx = json.IndexOf(searchToken, startPos, StringComparison.Ordinal);
|
||||
if (idx < 0) return -1;
|
||||
|
||||
int pos = idx + searchToken.Length;
|
||||
while (pos < json.Length && char.IsWhiteSpace(json[pos])) pos++;
|
||||
|
||||
int numStart = pos;
|
||||
while (pos < json.Length && char.IsDigit(json[pos])) pos++;
|
||||
|
||||
if (pos > numStart && int.TryParse(json.Substring(numStart, pos - numStart), out int id))
|
||||
return id;
|
||||
|
||||
return -1;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parse the download path from a build-specific manifest.
|
||||
/// Expected format: {...,"path":"/storage/builds/..."}
|
||||
/// </summary>
|
||||
private static string ParseDownloadPath(string json)
|
||||
{
|
||||
string searchToken = "\"path\":";
|
||||
int idx = json.IndexOf(searchToken, StringComparison.Ordinal);
|
||||
if (idx < 0) return null;
|
||||
|
||||
int pos = idx + searchToken.Length;
|
||||
while (pos < json.Length && char.IsWhiteSpace(json[pos])) pos++;
|
||||
|
||||
if (pos >= json.Length || json[pos] != '"') return null;
|
||||
pos++; // skip opening quote
|
||||
|
||||
int pathStart = pos;
|
||||
while (pos < json.Length && json[pos] != '"') pos++;
|
||||
|
||||
return json.Substring(pathStart, pos - pathStart);
|
||||
}
|
||||
}
|
||||
}
|
||||
2
Editor/Core/PCSXReduxDownloader.cs.meta
Normal file
2
Editor/Core/PCSXReduxDownloader.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: b3eaffbb1caed9648b5b57d211ead4d6
|
||||
769
Editor/Core/PCdrvSerialHost.cs
Normal file
769
Editor/Core/PCdrvSerialHost.cs
Normal file
@@ -0,0 +1,769 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.IO.Ports;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using UnityEngine;
|
||||
|
||||
namespace SplashEdit.EditorCode
|
||||
{
|
||||
/// <summary>
|
||||
/// PCdrv Host — serves files to a PS1 over serial (Unirom/NOTPSXSerial protocol).
|
||||
///
|
||||
/// PCdrv uses MIPS `break` instructions to request file I/O from the host.
|
||||
/// For real hardware running Unirom, we must:
|
||||
/// 1. Enter debug mode (DEBG/OKAY) — installs Unirom's kernel-resident SIO handler
|
||||
/// 2. Continue execution (CONT/OKAY)
|
||||
/// 3. Monitor serial for escape sequences: 0x00 followed by 'p' = PCDrv command
|
||||
/// 4. Handle file operations (init, open, read, close, seek, etc.)
|
||||
///
|
||||
/// Without entering debug mode first, `break` instructions cause an unhandled
|
||||
/// "BP break (0x9)" crash because no handler is registered.
|
||||
///
|
||||
/// Protocol based on NOTPSXSerial: https://github.com/JonathanDotCel/NOTPSXSerial
|
||||
/// </summary>
|
||||
public class PCdrvSerialHost : IDisposable
|
||||
{
|
||||
private SerialPort _port;
|
||||
private CancellationTokenSource _cts;
|
||||
private Task _listenTask;
|
||||
private readonly string _portName;
|
||||
private readonly int _baudRate;
|
||||
private readonly string _baseDir;
|
||||
private readonly Action<string> _log;
|
||||
private readonly Action<string> _psxLog;
|
||||
|
||||
// File handle table (1-indexed, handles are not recycled)
|
||||
private readonly List<PCFile> _files = new List<PCFile>();
|
||||
|
||||
private class PCFile
|
||||
{
|
||||
public string Name;
|
||||
public FileStream Stream;
|
||||
public int Handle;
|
||||
public bool Closed;
|
||||
public FileAccess Mode;
|
||||
}
|
||||
|
||||
// Protocol escape char — PCDrv commands are prefixed with 0x00 + 'p'
|
||||
private const byte ESCAPE_CHAR = 0x00;
|
||||
|
||||
// PCDrv function codes (from Unirom kernel)
|
||||
private const int FUNC_INIT = 0x101;
|
||||
private const int FUNC_CREAT = 0x102;
|
||||
private const int FUNC_OPEN = 0x103;
|
||||
private const int FUNC_CLOSE = 0x104;
|
||||
private const int FUNC_READ = 0x105;
|
||||
private const int FUNC_WRITE = 0x106;
|
||||
private const int FUNC_SEEK = 0x107;
|
||||
|
||||
public bool IsRunning => _listenTask != null && !_listenTask.IsCompleted;
|
||||
|
||||
public PCdrvSerialHost(string portName, int baudRate, string baseDir, Action<string> log, Action<string> psxLog = null)
|
||||
{
|
||||
_portName = portName;
|
||||
_baudRate = baudRate;
|
||||
_baseDir = baseDir;
|
||||
_log = log;
|
||||
_psxLog = psxLog;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Opens a new serial port and begins the monitor/PCdrv loop.
|
||||
/// Note: DEBG must have been sent BEFORE the exe was uploaded (via UniromUploader.UploadExeForPCdrv).
|
||||
/// Use the Start(SerialPort) overload to pass an already-open port from the uploader.
|
||||
/// </summary>
|
||||
public void Start()
|
||||
{
|
||||
if (IsRunning) return;
|
||||
|
||||
_port = new SerialPort(_portName, _baudRate)
|
||||
{
|
||||
ReadTimeout = 5000,
|
||||
WriteTimeout = 5000,
|
||||
StopBits = StopBits.Two,
|
||||
Parity = Parity.None,
|
||||
DataBits = 8,
|
||||
Handshake = Handshake.None,
|
||||
DtrEnable = true,
|
||||
RtsEnable = true
|
||||
};
|
||||
|
||||
_port.Open();
|
||||
_log?.Invoke($"PCdrv host: opened {_portName} @ {_baudRate}");
|
||||
_log?.Invoke($"PCdrv host: serving files from {_baseDir}");
|
||||
|
||||
StartMonitorLoop();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Starts the PCDrv monitor loop on an already-open serial port.
|
||||
/// Use this after UniromUploader.UploadExeForPCdrv() which sends DEBG → SEXE
|
||||
/// and returns the open port. The debug hooks are already installed and the
|
||||
/// exe is already running — we just need to listen for escape sequences.
|
||||
/// </summary>
|
||||
public void Start(SerialPort openPort)
|
||||
{
|
||||
if (IsRunning) return;
|
||||
|
||||
_port = openPort;
|
||||
_log?.Invoke($"PCdrv host: serving files from {_baseDir}");
|
||||
_log?.Invoke("PCdrv host: monitoring for PCDrv requests...");
|
||||
|
||||
StartMonitorLoop();
|
||||
}
|
||||
|
||||
private void StartMonitorLoop()
|
||||
{
|
||||
_cts = new CancellationTokenSource();
|
||||
_listenTask = Task.Run(() => MonitorLoop(_cts.Token));
|
||||
}
|
||||
|
||||
public void Stop()
|
||||
{
|
||||
_cts?.Cancel();
|
||||
try { _listenTask?.Wait(2000); } catch { }
|
||||
|
||||
foreach (var f in _files)
|
||||
{
|
||||
if (!f.Closed && f.Stream != null)
|
||||
{
|
||||
try { f.Stream.Close(); f.Stream.Dispose(); } catch { }
|
||||
}
|
||||
}
|
||||
_files.Clear();
|
||||
|
||||
if (_port != null && _port.IsOpen)
|
||||
{
|
||||
try { _port.Close(); } catch { }
|
||||
}
|
||||
|
||||
_port?.Dispose();
|
||||
_port = null;
|
||||
_log?.Invoke("PCdrv host stopped.");
|
||||
}
|
||||
|
||||
public void Dispose() => Stop();
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Monitor loop — reads serial byte-by-byte looking for escape sequences
|
||||
// Matches NOTPSXSerial's Bridge.MonitorSerial()
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
private void MonitorLoop(CancellationToken ct)
|
||||
{
|
||||
bool lastByteWasEscape = false;
|
||||
var textBuffer = new StringBuilder();
|
||||
int totalBytesReceived = 0;
|
||||
DateTime lastLogTime = DateTime.Now;
|
||||
|
||||
_log?.Invoke("PCdrv monitor: waiting for data from PS1...");
|
||||
|
||||
while (!ct.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (_port.BytesToRead == 0)
|
||||
{
|
||||
// Flush any accumulated text output periodically
|
||||
if (textBuffer.Length > 0 && (DateTime.Now - lastLogTime).TotalMilliseconds > 100)
|
||||
{
|
||||
EmitPsxLine(textBuffer.ToString());
|
||||
textBuffer.Clear();
|
||||
lastLogTime = DateTime.Now;
|
||||
}
|
||||
Thread.Sleep(1);
|
||||
continue;
|
||||
}
|
||||
|
||||
int b = _port.ReadByte();
|
||||
totalBytesReceived++;
|
||||
|
||||
// Log first bytes received to help diagnose protocol issues
|
||||
if (totalBytesReceived <= 32)
|
||||
{
|
||||
_log?.Invoke($"PCdrv monitor: byte #{totalBytesReceived} = 0x{b:X2} ('{(b >= 0x20 && b < 0x7F ? (char)b : '.')}')");
|
||||
}
|
||||
else if (totalBytesReceived == 33)
|
||||
{
|
||||
_log?.Invoke("PCdrv monitor: (suppressing per-byte logging, check PS1> lines for output)");
|
||||
}
|
||||
|
||||
if (lastByteWasEscape)
|
||||
{
|
||||
lastByteWasEscape = false;
|
||||
|
||||
// Flush any text before handling escape
|
||||
if (textBuffer.Length > 0)
|
||||
{
|
||||
EmitPsxLine(textBuffer.ToString());
|
||||
textBuffer.Clear();
|
||||
}
|
||||
|
||||
if (b == ESCAPE_CHAR)
|
||||
{
|
||||
// Double escape = literal 0x00 in output, ignore
|
||||
continue;
|
||||
}
|
||||
|
||||
if (b == 'p')
|
||||
{
|
||||
// PCDrv command incoming
|
||||
_log?.Invoke("PCdrv monitor: got escape+p → PCDrv command!");
|
||||
HandlePCDrvCommand(ct);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Unknown escape sequence — log it
|
||||
_log?.Invoke($"PCdrv monitor: unknown escape seq: 0x00 + 0x{b:X2} ('{(b >= 0x20 && b < 0x7F ? (char)b : '.')}')");
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (b == ESCAPE_CHAR)
|
||||
{
|
||||
lastByteWasEscape = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Regular byte — this is printf output from the PS1
|
||||
if (b == '\n' || b == '\r')
|
||||
{
|
||||
if (textBuffer.Length > 0)
|
||||
{
|
||||
EmitPsxLine(textBuffer.ToString());
|
||||
textBuffer.Clear();
|
||||
lastLogTime = DateTime.Now;
|
||||
}
|
||||
}
|
||||
else if (b >= 0x20 && b < 0x7F)
|
||||
{
|
||||
textBuffer.Append((char)b);
|
||||
// Flush long lines immediately
|
||||
if (textBuffer.Length >= 200)
|
||||
{
|
||||
EmitPsxLine(textBuffer.ToString());
|
||||
textBuffer.Clear();
|
||||
lastLogTime = DateTime.Now;
|
||||
}
|
||||
}
|
||||
// else: non-printable byte that's not escape, skip
|
||||
}
|
||||
catch (TimeoutException) { }
|
||||
catch (OperationCanceledException) { break; }
|
||||
catch (Exception ex)
|
||||
{
|
||||
if (!ct.IsCancellationRequested)
|
||||
_log?.Invoke($"PCdrv monitor error: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// PCDrv command dispatcher
|
||||
// Matches NOTPSXSerial's PCDrv.ReadCommand()
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
private void HandlePCDrvCommand(CancellationToken ct)
|
||||
{
|
||||
int funcCode = ReadInt32(ct);
|
||||
|
||||
switch (funcCode)
|
||||
{
|
||||
case FUNC_INIT: HandleInit(); break;
|
||||
case FUNC_CREAT: HandleCreate(ct); break;
|
||||
case FUNC_OPEN: HandleOpen(ct); break;
|
||||
case FUNC_CLOSE: HandleClose(ct); break;
|
||||
case FUNC_READ: HandleRead(ct); break;
|
||||
case FUNC_WRITE: HandleWrite(ct); break;
|
||||
case FUNC_SEEK: HandleSeek(ct); break;
|
||||
default:
|
||||
_log?.Invoke($"PCdrv: unknown function 0x{funcCode:X}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Individual PCDrv handlers — match NOTPSXSerial's PCDrv.cs
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
private void HandleInit()
|
||||
{
|
||||
_log?.Invoke("PCdrv: INIT");
|
||||
SendString("OKAY");
|
||||
_port.Write(new byte[] { 0 }, 0, 1); // null terminator expected by Unirom
|
||||
}
|
||||
|
||||
private void HandleOpen(CancellationToken ct)
|
||||
{
|
||||
// Unirom sends: we respond OKAY first, then read filename + mode
|
||||
SendString("OKAY");
|
||||
|
||||
string filename = ReadNullTermString(ct);
|
||||
int modeParam = ReadInt32(ct);
|
||||
|
||||
// Log raw bytes for debugging garbled filenames
|
||||
_log?.Invoke($"PCdrv: OPEN \"{filename}\" mode={modeParam} (len={filename.Length}, hex={BitConverter.ToString(System.Text.Encoding.ASCII.GetBytes(filename))})");
|
||||
|
||||
// Check if already open
|
||||
var existing = FindOpenFile(filename);
|
||||
if (existing != null)
|
||||
{
|
||||
_log?.Invoke($"PCdrv: already open, handle={existing.Handle}");
|
||||
SendString("OKAY");
|
||||
WriteInt32(existing.Handle);
|
||||
return;
|
||||
}
|
||||
|
||||
string fullPath;
|
||||
try
|
||||
{
|
||||
fullPath = ResolvePath(filename);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log?.Invoke($"PCdrv: invalid filename \"{filename}\": {ex.Message}");
|
||||
SendString("NOPE");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!File.Exists(fullPath))
|
||||
{
|
||||
_log?.Invoke($"PCdrv: file not found: {fullPath}");
|
||||
SendString("NOPE");
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var fs = new FileStream(fullPath, FileMode.Open, FileAccess.ReadWrite, FileShare.ReadWrite);
|
||||
int handle = NextHandle();
|
||||
_files.Add(new PCFile { Name = filename, Stream = fs, Handle = handle, Closed = false, Mode = FileAccess.ReadWrite });
|
||||
|
||||
SendString("OKAY");
|
||||
WriteInt32(handle);
|
||||
_log?.Invoke($"PCdrv: opened handle={handle}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log?.Invoke($"PCdrv: open failed: {ex.Message}");
|
||||
SendString("NOPE");
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleCreate(CancellationToken ct)
|
||||
{
|
||||
SendString("OKAY");
|
||||
|
||||
string filename = ReadNullTermString(ct);
|
||||
int parameters = ReadInt32(ct);
|
||||
|
||||
_log?.Invoke($"PCdrv: CREAT \"{filename}\" params={parameters}");
|
||||
|
||||
var existing = FindOpenFile(filename);
|
||||
if (existing != null)
|
||||
{
|
||||
SendString("OKAY");
|
||||
WriteInt32(existing.Handle);
|
||||
return;
|
||||
}
|
||||
|
||||
string fullPath;
|
||||
try { fullPath = ResolvePath(filename); }
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log?.Invoke($"PCdrv: invalid filename \"{filename}\": {ex.Message}");
|
||||
SendString("NOPE");
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Create or truncate the file
|
||||
if (!File.Exists(fullPath))
|
||||
{
|
||||
var temp = File.Create(fullPath);
|
||||
temp.Flush(); temp.Close(); temp.Dispose();
|
||||
}
|
||||
|
||||
var fs = new FileStream(fullPath, FileMode.Open, FileAccess.ReadWrite, FileShare.ReadWrite);
|
||||
int handle = NextHandle();
|
||||
_files.Add(new PCFile { Name = filename, Stream = fs, Handle = handle, Closed = false, Mode = FileAccess.ReadWrite });
|
||||
|
||||
SendString("OKAY");
|
||||
WriteInt32(handle);
|
||||
_log?.Invoke($"PCdrv: created handle={handle}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log?.Invoke($"PCdrv: create failed: {ex.Message}");
|
||||
SendString("NOPE");
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleClose(CancellationToken ct)
|
||||
{
|
||||
// Unirom sends: we respond OKAY first, then read handle + 2 unused params
|
||||
SendString("OKAY");
|
||||
|
||||
int handle = ReadInt32(ct);
|
||||
int _unused1 = ReadInt32(ct);
|
||||
int _unused2 = ReadInt32(ct);
|
||||
|
||||
_log?.Invoke($"PCdrv: CLOSE handle={handle}");
|
||||
|
||||
var f = FindOpenFile(handle);
|
||||
if (f == null)
|
||||
{
|
||||
// No such file — "great success" per NOTPSXSerial
|
||||
SendString("OKAY");
|
||||
WriteInt32(0);
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
f.Stream.Close();
|
||||
f.Stream.Dispose();
|
||||
f.Closed = true;
|
||||
SendString("OKAY");
|
||||
WriteInt32(handle);
|
||||
_log?.Invoke($"PCdrv: closed handle={handle}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log?.Invoke($"PCdrv: close error: {ex.Message}");
|
||||
SendString("NOPE");
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleRead(CancellationToken ct)
|
||||
{
|
||||
// Unirom sends: we respond OKAY first, then read handle + len + memaddr
|
||||
SendString("OKAY");
|
||||
|
||||
int handle = ReadInt32(ct);
|
||||
int length = ReadInt32(ct);
|
||||
int memAddr = ReadInt32(ct); // for debugging only
|
||||
|
||||
_log?.Invoke($"PCdrv: READ handle={handle} len=0x{length:X} memAddr=0x{memAddr:X}");
|
||||
|
||||
var f = FindOpenFile(handle);
|
||||
if (f == null)
|
||||
{
|
||||
_log?.Invoke($"PCdrv: no file with handle {handle}");
|
||||
SendString("NOPE");
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
byte[] data = new byte[length];
|
||||
int bytesRead = f.Stream.Read(data, 0, length);
|
||||
|
||||
SendString("OKAY");
|
||||
WriteInt32(data.Length);
|
||||
|
||||
// Checksum (simple byte sum, forced V3 = true per NOTPSXSerial)
|
||||
uint checksum = CalculateChecksum(data);
|
||||
WriteUInt32(checksum);
|
||||
|
||||
// Send data using chunked writer (with per-chunk ack for V2+)
|
||||
WriteDataChunked(data);
|
||||
|
||||
_log?.Invoke($"PCdrv: sent {bytesRead} bytes for handle={handle}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log?.Invoke($"PCdrv: read error: {ex.Message}");
|
||||
SendString("NOPE");
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleWrite(CancellationToken ct)
|
||||
{
|
||||
SendString("OKAY");
|
||||
|
||||
int handle = ReadInt32(ct);
|
||||
int length = ReadInt32(ct);
|
||||
int memAddr = ReadInt32(ct);
|
||||
|
||||
_log?.Invoke($"PCdrv: WRITE handle={handle} len={length}");
|
||||
|
||||
var f = FindOpenFile(handle);
|
||||
if (f == null)
|
||||
{
|
||||
SendString("NOPE");
|
||||
return;
|
||||
}
|
||||
|
||||
SendString("OKAY");
|
||||
|
||||
// Read data from PSX
|
||||
byte[] data = ReadBytes(length, ct);
|
||||
|
||||
f.Stream.Write(data, 0, length);
|
||||
f.Stream.Flush();
|
||||
|
||||
SendString("OKAY");
|
||||
WriteInt32(length);
|
||||
|
||||
_log?.Invoke($"PCdrv: wrote {length} bytes to handle={handle}");
|
||||
}
|
||||
|
||||
private void HandleSeek(CancellationToken ct)
|
||||
{
|
||||
SendString("OKAY");
|
||||
|
||||
int handle = ReadInt32(ct);
|
||||
int offset = ReadInt32(ct);
|
||||
int whence = ReadInt32(ct);
|
||||
|
||||
_log?.Invoke($"PCdrv: SEEK handle={handle} offset={offset} whence={whence}");
|
||||
|
||||
var f = FindOpenFile(handle);
|
||||
if (f == null)
|
||||
{
|
||||
SendString("NOPE");
|
||||
return;
|
||||
}
|
||||
|
||||
SeekOrigin origin = whence switch
|
||||
{
|
||||
0 => SeekOrigin.Begin,
|
||||
1 => SeekOrigin.Current,
|
||||
2 => SeekOrigin.End,
|
||||
_ => SeekOrigin.Begin
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
long newPos = f.Stream.Seek(offset, origin);
|
||||
SendString("OKAY");
|
||||
WriteInt32((int)newPos);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log?.Invoke($"PCdrv: seek error: {ex.Message}");
|
||||
SendString("NOPE");
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// PS1 output routing
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
/// <summary>
|
||||
/// Routes PS1 printf output to PSXConsoleWindow (via _psxLog) if available,
|
||||
/// otherwise falls back to the control panel log.
|
||||
/// </summary>
|
||||
private void EmitPsxLine(string text)
|
||||
{
|
||||
if (_psxLog != null)
|
||||
_psxLog.Invoke(text);
|
||||
else
|
||||
_log?.Invoke($"PS1> {text}");
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Chunked data write — matches NOTPSXSerial's WriteBytes()
|
||||
// Sends data in 2048-byte chunks; for protocol V2+ Unirom
|
||||
// responds with CHEK/MORE/ERR! per chunk.
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
private void WriteDataChunked(byte[] data)
|
||||
{
|
||||
int chunkSize = 2048;
|
||||
for (int i = 0; i < data.Length; i += chunkSize)
|
||||
{
|
||||
int thisChunk = Math.Min(chunkSize, data.Length - i);
|
||||
_port.Write(data, i, thisChunk);
|
||||
|
||||
// Wait for bytes to drain
|
||||
while (_port.BytesToWrite > 0)
|
||||
Thread.Sleep(0);
|
||||
|
||||
// V2 protocol: wait for CHEK, send chunk checksum, wait for MORE
|
||||
// For now, handle this if present
|
||||
if (_port.BytesToRead >= 4)
|
||||
{
|
||||
string resp = ReadFixedString(4);
|
||||
if (resp == "CHEK")
|
||||
{
|
||||
ulong chunkSum = 0;
|
||||
for (int j = 0; j < thisChunk; j++)
|
||||
chunkSum += data[i + j];
|
||||
_port.Write(BitConverter.GetBytes((uint)chunkSum), 0, 4);
|
||||
Thread.Sleep(1);
|
||||
|
||||
// Wait for MORE or ERR!
|
||||
string ack = WaitFor4CharResponse(5000);
|
||||
if (ack == "ERR!")
|
||||
{
|
||||
_log?.Invoke("PCdrv: chunk checksum error, retrying...");
|
||||
i -= chunkSize; // retry
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// File handle helpers
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
private int NextHandle() => _files.Count + 1;
|
||||
|
||||
private PCFile FindOpenFile(string name)
|
||||
{
|
||||
for (int i = 0; i < _files.Count; i++)
|
||||
{
|
||||
if (!_files[i].Closed && _files[i].Name.Equals(name, StringComparison.OrdinalIgnoreCase))
|
||||
return _files[i];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private PCFile FindOpenFile(int handle)
|
||||
{
|
||||
for (int i = 0; i < _files.Count; i++)
|
||||
{
|
||||
if (!_files[i].Closed && _files[i].Handle == handle)
|
||||
return _files[i];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private string ResolvePath(string filename)
|
||||
{
|
||||
// Strip leading slashes and backslashes
|
||||
filename = filename.TrimStart('/', '\\');
|
||||
return Path.Combine(_baseDir, filename);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Low-level serial I/O
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
private int ReadInt32(CancellationToken ct)
|
||||
{
|
||||
byte[] buf = new byte[4];
|
||||
for (int i = 0; i < 4; i++)
|
||||
buf[i] = (byte)ReadByteBlocking(ct);
|
||||
return BitConverter.ToInt32(buf, 0);
|
||||
}
|
||||
|
||||
private uint ReadUInt32(CancellationToken ct)
|
||||
{
|
||||
byte[] buf = new byte[4];
|
||||
for (int i = 0; i < 4; i++)
|
||||
buf[i] = (byte)ReadByteBlocking(ct);
|
||||
return BitConverter.ToUInt32(buf, 0);
|
||||
}
|
||||
|
||||
private byte[] ReadBytes(int count, CancellationToken ct)
|
||||
{
|
||||
byte[] data = new byte[count];
|
||||
int pos = 0;
|
||||
while (pos < count)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
if (_port.BytesToRead > 0)
|
||||
{
|
||||
int read = _port.Read(data, pos, Math.Min(count - pos, _port.BytesToRead));
|
||||
pos += read;
|
||||
}
|
||||
else
|
||||
{
|
||||
Thread.Sleep(1);
|
||||
}
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
private int ReadByteBlocking(CancellationToken ct)
|
||||
{
|
||||
while (!ct.IsCancellationRequested)
|
||||
{
|
||||
if (_port.BytesToRead > 0)
|
||||
return _port.ReadByte();
|
||||
Thread.Sleep(1);
|
||||
}
|
||||
throw new OperationCanceledException();
|
||||
}
|
||||
|
||||
private string ReadNullTermString(CancellationToken ct)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
while (true)
|
||||
{
|
||||
int b = ReadByteBlocking(ct);
|
||||
if (b == 0) break;
|
||||
sb.Append((char)b);
|
||||
if (sb.Length > 255) break;
|
||||
}
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private void SendString(string s)
|
||||
{
|
||||
byte[] data = Encoding.ASCII.GetBytes(s);
|
||||
_port.Write(data, 0, data.Length);
|
||||
}
|
||||
|
||||
private void WriteInt32(int value)
|
||||
{
|
||||
_port.Write(BitConverter.GetBytes(value), 0, 4);
|
||||
}
|
||||
|
||||
private void WriteUInt32(uint value)
|
||||
{
|
||||
_port.Write(BitConverter.GetBytes(value), 0, 4);
|
||||
}
|
||||
|
||||
private string ReadFixedString(int count)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
if (_port.BytesToRead > 0)
|
||||
sb.Append((char)_port.ReadByte());
|
||||
}
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private string WaitFor4CharResponse(int timeoutMs)
|
||||
{
|
||||
string buffer = "";
|
||||
DateTime deadline = DateTime.Now.AddMilliseconds(timeoutMs);
|
||||
while (DateTime.Now < deadline)
|
||||
{
|
||||
if (_port.BytesToRead > 0)
|
||||
{
|
||||
buffer += (char)_port.ReadByte();
|
||||
if (buffer.Length > 4)
|
||||
buffer = buffer.Substring(buffer.Length - 4);
|
||||
if (buffer == "MORE" || buffer == "ERR!")
|
||||
return buffer;
|
||||
}
|
||||
else
|
||||
{
|
||||
Thread.Sleep(1);
|
||||
}
|
||||
}
|
||||
return buffer;
|
||||
}
|
||||
|
||||
private static uint CalculateChecksum(byte[] data)
|
||||
{
|
||||
// Force V3-style for PCDrv reads (per NOTPSXSerial: forceProtocolV3=true)
|
||||
uint sum = 0;
|
||||
for (int i = 0; i < data.Length; i++)
|
||||
sum += data[i];
|
||||
return sum;
|
||||
}
|
||||
}
|
||||
}
|
||||
2
Editor/Core/PCdrvSerialHost.cs.meta
Normal file
2
Editor/Core/PCdrvSerialHost.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: d27c6a94b1c1f07418799b65d13f7097
|
||||
318
Editor/Core/PSXAudioConverter.cs
Normal file
318
Editor/Core/PSXAudioConverter.cs
Normal file
@@ -0,0 +1,318 @@
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Net.Http;
|
||||
using System.Threading.Tasks;
|
||||
using SplashEdit.RuntimeCode;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
using Debug = UnityEngine.Debug;
|
||||
|
||||
namespace SplashEdit.EditorCode
|
||||
{
|
||||
/// <summary>
|
||||
/// Downloads psxavenc and converts WAV audio to PS1 SPU ADPCM format.
|
||||
/// psxavenc is the standard tool for PS1 audio encoding from the
|
||||
/// WonderfulToolchain project.
|
||||
/// </summary>
|
||||
[InitializeOnLoad]
|
||||
public static class PSXAudioConverter
|
||||
{
|
||||
static PSXAudioConverter()
|
||||
{
|
||||
// Register the converter delegate so Runtime code can call it
|
||||
// without directly referencing this Editor assembly.
|
||||
PSXSceneExporter.AudioConvertDelegate = ConvertToADPCM;
|
||||
}
|
||||
|
||||
private const string PSXAVENC_VERSION = "v0.3.1";
|
||||
private const string PSXAVENC_RELEASE_BASE =
|
||||
"https://github.com/WonderfulToolchain/psxavenc/releases/download/";
|
||||
|
||||
private static readonly HttpClient _http = new HttpClient();
|
||||
|
||||
/// <summary>
|
||||
/// Path to the psxavenc binary inside .tools/
|
||||
/// </summary>
|
||||
public static string PsxavencBinary
|
||||
{
|
||||
get
|
||||
{
|
||||
string dir = Path.Combine(SplashBuildPaths.ToolsDir, "psxavenc");
|
||||
if (Application.platform == RuntimePlatform.WindowsEditor)
|
||||
return Path.Combine(dir, "psxavenc.exe");
|
||||
return Path.Combine(dir, "psxavenc");
|
||||
}
|
||||
}
|
||||
|
||||
public static bool IsInstalled() => File.Exists(PsxavencBinary);
|
||||
|
||||
/// <summary>
|
||||
/// Downloads and installs psxavenc from the official GitHub releases.
|
||||
/// </summary>
|
||||
public static async Task<bool> DownloadAndInstall(Action<string> log = null)
|
||||
{
|
||||
string platformSuffix;
|
||||
string archiveName;
|
||||
switch (Application.platform)
|
||||
{
|
||||
case RuntimePlatform.WindowsEditor:
|
||||
platformSuffix = "x86_64-pc-windows-msvc";
|
||||
archiveName = $"psxavenc-{PSXAVENC_VERSION}-{platformSuffix}.zip";
|
||||
break;
|
||||
case RuntimePlatform.LinuxEditor:
|
||||
platformSuffix = "x86_64-unknown-linux-gnu";
|
||||
archiveName = $"psxavenc-{PSXAVENC_VERSION}-{platformSuffix}.tar.gz";
|
||||
break;
|
||||
default:
|
||||
log?.Invoke("Only Windows and Linux are supported.");
|
||||
return false;
|
||||
}
|
||||
|
||||
string downloadUrl = $"{PSXAVENC_RELEASE_BASE}{PSXAVENC_VERSION}/{archiveName}";
|
||||
log?.Invoke($"Downloading psxavenc: {downloadUrl}");
|
||||
|
||||
try
|
||||
{
|
||||
string tempFile = Path.Combine(Path.GetTempPath(), archiveName);
|
||||
EditorUtility.DisplayProgressBar("Downloading psxavenc", "Downloading...", 0.1f);
|
||||
|
||||
using (var response = await _http.GetAsync(downloadUrl, HttpCompletionOption.ResponseHeadersRead))
|
||||
{
|
||||
response.EnsureSuccessStatusCode();
|
||||
long? totalBytes = response.Content.Headers.ContentLength;
|
||||
long downloaded = 0;
|
||||
|
||||
using (var fs = File.Create(tempFile))
|
||||
using (var stream = await response.Content.ReadAsStreamAsync())
|
||||
{
|
||||
byte[] buffer = new byte[81920];
|
||||
int bytesRead;
|
||||
while ((bytesRead = await stream.ReadAsync(buffer, 0, buffer.Length)) > 0)
|
||||
{
|
||||
await fs.WriteAsync(buffer, 0, bytesRead);
|
||||
downloaded += bytesRead;
|
||||
if (totalBytes.HasValue)
|
||||
{
|
||||
float progress = (float)downloaded / totalBytes.Value;
|
||||
EditorUtility.DisplayProgressBar("Downloading psxavenc",
|
||||
$"{downloaded / 1024}/{totalBytes.Value / 1024} KB", progress);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log?.Invoke("Extracting...");
|
||||
EditorUtility.DisplayProgressBar("Installing psxavenc", "Extracting...", 0.9f);
|
||||
|
||||
string installDir = Path.Combine(SplashBuildPaths.ToolsDir, "psxavenc");
|
||||
if (Directory.Exists(installDir))
|
||||
Directory.Delete(installDir, true);
|
||||
Directory.CreateDirectory(installDir);
|
||||
|
||||
if (tempFile.EndsWith(".zip"))
|
||||
{
|
||||
System.IO.Compression.ZipFile.ExtractToDirectory(tempFile, installDir);
|
||||
}
|
||||
else
|
||||
{
|
||||
// tar.gz extraction — use system tar
|
||||
var psi = new ProcessStartInfo
|
||||
{
|
||||
FileName = "tar",
|
||||
Arguments = $"xzf \"{tempFile}\" -C \"{installDir}\" --strip-components=1",
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true
|
||||
};
|
||||
var proc = Process.Start(psi);
|
||||
proc.WaitForExit();
|
||||
}
|
||||
|
||||
// Fix nested directory (sometimes archives have one extra level)
|
||||
FixNestedDirectory(installDir);
|
||||
|
||||
try { File.Delete(tempFile); } catch { }
|
||||
|
||||
EditorUtility.ClearProgressBar();
|
||||
|
||||
if (IsInstalled())
|
||||
{
|
||||
// Make executable on Linux
|
||||
if (Application.platform == RuntimePlatform.LinuxEditor)
|
||||
{
|
||||
var chmod = Process.Start("chmod", $"+x \"{PsxavencBinary}\"");
|
||||
chmod?.WaitForExit();
|
||||
}
|
||||
log?.Invoke("psxavenc installed successfully!");
|
||||
return true;
|
||||
}
|
||||
|
||||
log?.Invoke($"psxavenc binary not found at: {PsxavencBinary}");
|
||||
return false;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
log?.Invoke($"psxavenc download failed: {ex.Message}");
|
||||
EditorUtility.ClearProgressBar();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static void FixNestedDirectory(string dir)
|
||||
{
|
||||
// If extraction created exactly one subdirectory, flatten it
|
||||
var subdirs = Directory.GetDirectories(dir);
|
||||
if (subdirs.Length == 1)
|
||||
{
|
||||
string nested = subdirs[0];
|
||||
foreach (string file in Directory.GetFiles(nested))
|
||||
{
|
||||
string dest = Path.Combine(dir, Path.GetFileName(file));
|
||||
if (!File.Exists(dest)) File.Move(file, dest);
|
||||
}
|
||||
foreach (string sub in Directory.GetDirectories(nested))
|
||||
{
|
||||
string dest = Path.Combine(dir, Path.GetFileName(sub));
|
||||
if (!Directory.Exists(dest)) Directory.Move(sub, dest);
|
||||
}
|
||||
try { Directory.Delete(nested, true); } catch { }
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts a Unity AudioClip to PS1 SPU ADPCM format using psxavenc.
|
||||
/// Returns the ADPCM byte array, or null on failure.
|
||||
/// </summary>
|
||||
public static byte[] ConvertToADPCM(AudioClip clip, int targetSampleRate, bool loop)
|
||||
{
|
||||
if (!IsInstalled())
|
||||
{
|
||||
Debug.LogError("[SplashEdit] psxavenc not installed. Install it from the Setup tab.");
|
||||
return null;
|
||||
}
|
||||
|
||||
if (clip == null)
|
||||
{
|
||||
Debug.LogError("[SplashEdit] AudioClip is null.");
|
||||
return null;
|
||||
}
|
||||
|
||||
// Export Unity AudioClip to a temporary WAV file
|
||||
string tempWav = Path.Combine(Path.GetTempPath(), $"psx_audio_{clip.name}.wav");
|
||||
string tempVag = Path.Combine(Path.GetTempPath(), $"psx_audio_{clip.name}.vag");
|
||||
|
||||
try
|
||||
{
|
||||
ExportWav(clip, tempWav);
|
||||
|
||||
// Run psxavenc: convert WAV to SPU ADPCM
|
||||
// -t spu: raw SPU ADPCM output (no header, ready for DMA upload)
|
||||
// -f <rate>: target sample rate
|
||||
// -L: enable looping flag in the last ADPCM block
|
||||
string loopFlag = loop ? "-L" : "";
|
||||
string args = $"-t spu -f {targetSampleRate} {loopFlag} \"{tempWav}\" \"{tempVag}\"";
|
||||
|
||||
var psi = new ProcessStartInfo
|
||||
{
|
||||
FileName = PsxavencBinary,
|
||||
Arguments = args,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true
|
||||
};
|
||||
|
||||
var process = Process.Start(psi);
|
||||
string stderr = process.StandardError.ReadToEnd();
|
||||
process.WaitForExit();
|
||||
|
||||
if (process.ExitCode != 0)
|
||||
{
|
||||
Debug.LogError($"[SplashEdit] psxavenc failed: {stderr}");
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!File.Exists(tempVag))
|
||||
{
|
||||
Debug.LogError("[SplashEdit] psxavenc produced no output file.");
|
||||
return null;
|
||||
}
|
||||
|
||||
// -t spu outputs raw SPU ADPCM blocks (no header) — use directly.
|
||||
byte[] adpcm = File.ReadAllBytes(tempVag);
|
||||
if (adpcm.Length == 0)
|
||||
{
|
||||
Debug.LogError("[SplashEdit] psxavenc produced empty output.");
|
||||
return null;
|
||||
}
|
||||
return adpcm;
|
||||
}
|
||||
finally
|
||||
{
|
||||
try { if (File.Exists(tempWav)) File.Delete(tempWav); } catch { }
|
||||
try { if (File.Exists(tempVag)) File.Delete(tempVag); } catch { }
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Exports a Unity AudioClip to a 16-bit mono WAV file.
|
||||
/// </summary>
|
||||
private static void ExportWav(AudioClip clip, string path)
|
||||
{
|
||||
float[] samples = new float[clip.samples * clip.channels];
|
||||
clip.GetData(samples, 0);
|
||||
|
||||
// Downmix to mono if stereo
|
||||
float[] mono;
|
||||
if (clip.channels > 1)
|
||||
{
|
||||
mono = new float[clip.samples];
|
||||
for (int i = 0; i < clip.samples; i++)
|
||||
{
|
||||
float sum = 0;
|
||||
for (int ch = 0; ch < clip.channels; ch++)
|
||||
sum += samples[i * clip.channels + ch];
|
||||
mono[i] = sum / clip.channels;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
mono = samples;
|
||||
}
|
||||
|
||||
// Write WAV
|
||||
using (var fs = new FileStream(path, FileMode.Create))
|
||||
using (var writer = new BinaryWriter(fs))
|
||||
{
|
||||
int sampleCount = mono.Length;
|
||||
int dataSize = sampleCount * 2; // 16-bit
|
||||
int fileSize = 44 + dataSize;
|
||||
|
||||
// RIFF header
|
||||
writer.Write(new char[] { 'R', 'I', 'F', 'F' });
|
||||
writer.Write(fileSize - 8);
|
||||
writer.Write(new char[] { 'W', 'A', 'V', 'E' });
|
||||
|
||||
// fmt chunk
|
||||
writer.Write(new char[] { 'f', 'm', 't', ' ' });
|
||||
writer.Write(16); // chunk size
|
||||
writer.Write((short)1); // PCM
|
||||
writer.Write((short)1); // mono
|
||||
writer.Write(clip.frequency);
|
||||
writer.Write(clip.frequency * 2); // byte rate
|
||||
writer.Write((short)2); // block align
|
||||
writer.Write((short)16); // bits per sample
|
||||
|
||||
// data chunk
|
||||
writer.Write(new char[] { 'd', 'a', 't', 'a' });
|
||||
writer.Write(dataSize);
|
||||
|
||||
for (int i = 0; i < sampleCount; i++)
|
||||
{
|
||||
short sample = (short)(Mathf.Clamp(mono[i], -1f, 1f) * 32767f);
|
||||
writer.Write(sample);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
2
Editor/Core/PSXAudioConverter.cs.meta
Normal file
2
Editor/Core/PSXAudioConverter.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 372b2ef07e125584ba43312b0662d7ac
|
||||
418
Editor/Core/PSXConsoleWindow.cs
Normal file
418
Editor/Core/PSXConsoleWindow.cs
Normal file
@@ -0,0 +1,418 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
|
||||
namespace SplashEdit.EditorCode
|
||||
{
|
||||
/// <summary>
|
||||
/// A live console window that displays stdout/stderr from PCSX-Redux and PSX build output.
|
||||
/// Opens automatically when a build starts or the emulator launches.
|
||||
/// </summary>
|
||||
public class PSXConsoleWindow : EditorWindow
|
||||
{
|
||||
private const string WINDOW_TITLE = "PSX Console";
|
||||
private const string MENU_PATH = "PlayStation 1/PSX Console";
|
||||
private const int MAX_LINES = 2000;
|
||||
private const int TRIM_AMOUNT = 500;
|
||||
|
||||
// ── Shared state (set by SplashControlPanel) ──
|
||||
private static Process _process;
|
||||
private static readonly List<LogLine> _lines = new List<LogLine>();
|
||||
private static readonly object _lock = new object();
|
||||
private static volatile bool _autoScroll = true;
|
||||
private static volatile bool _reading;
|
||||
|
||||
// ── Instance state ──
|
||||
private Vector2 _scrollPos;
|
||||
private string _filterText = "";
|
||||
private bool _showStdout = true;
|
||||
private bool _showStderr = true;
|
||||
private bool _wrapLines = true;
|
||||
private GUIStyle _monoStyle;
|
||||
private GUIStyle _monoStyleErr;
|
||||
private GUIStyle _monoStyleSelected;
|
||||
private int _lastLineCount;
|
||||
|
||||
// ── Selection state (for shift-click range and right-click copy) ──
|
||||
private int _selectionAnchor = -1; // first clicked line index (into _lines)
|
||||
private int _selectionEnd = -1; // last shift-clicked line index (into _lines)
|
||||
|
||||
private struct LogLine
|
||||
{
|
||||
public string text;
|
||||
public bool isError;
|
||||
public string timestamp;
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Menu
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
[MenuItem(MENU_PATH, false, 10)]
|
||||
public static void ShowWindow()
|
||||
{
|
||||
var window = GetWindow<PSXConsoleWindow>();
|
||||
window.titleContent = new GUIContent(WINDOW_TITLE, EditorGUIUtility.IconContent("d_UnityEditor.ConsoleWindow").image);
|
||||
window.minSize = new Vector2(400, 200);
|
||||
window.Show();
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Public API — called by SplashControlPanel
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
/// <summary>
|
||||
/// Adds a line to the console from any source (serial host, emulator fallback, etc.).
|
||||
/// Thread-safe. Works whether the window is open or not.
|
||||
/// </summary>
|
||||
public static void AddLine(string text, bool isError = false)
|
||||
{
|
||||
if (string.IsNullOrEmpty(text)) return;
|
||||
|
||||
lock (_lock)
|
||||
{
|
||||
_lines.Add(new LogLine
|
||||
{
|
||||
text = text,
|
||||
isError = isError,
|
||||
timestamp = DateTime.Now.ToString("HH:mm:ss.fff")
|
||||
});
|
||||
|
||||
if (_lines.Count > MAX_LINES)
|
||||
{
|
||||
_lines.RemoveRange(0, TRIM_AMOUNT);
|
||||
}
|
||||
}
|
||||
|
||||
// Repaint is handled by OnEditorUpdate polling _lines.Count changes.
|
||||
// Do NOT call EditorApplication.delayCall here - AddLine is called
|
||||
// from background threads (serial host, process readers) and
|
||||
// delayCall is not thread-safe. It kills the calling thread.
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Opens the console window and begins capturing output from the given process.
|
||||
/// The process must have RedirectStandardOutput and RedirectStandardError enabled.
|
||||
/// </summary>
|
||||
public static PSXConsoleWindow Attach(Process process)
|
||||
{
|
||||
// Stop reading from any previous process (but keep existing lines)
|
||||
_reading = false;
|
||||
|
||||
_process = process;
|
||||
|
||||
var window = GetWindow<PSXConsoleWindow>();
|
||||
window.titleContent = new GUIContent(WINDOW_TITLE, EditorGUIUtility.IconContent("d_UnityEditor.ConsoleWindow").image);
|
||||
window.minSize = new Vector2(400, 200);
|
||||
window.Show();
|
||||
|
||||
// Start async readers
|
||||
_reading = true;
|
||||
StartReader(process.StandardOutput, false);
|
||||
StartReader(process.StandardError, true);
|
||||
|
||||
return window;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stops reading and detaches from the current process.
|
||||
/// </summary>
|
||||
public static void Detach()
|
||||
{
|
||||
_reading = false;
|
||||
_process = null;
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Async readers
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
private static void StartReader(System.IO.StreamReader reader, bool isError)
|
||||
{
|
||||
var thread = new Thread(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
while (_reading && !reader.EndOfStream)
|
||||
{
|
||||
string line = reader.ReadLine();
|
||||
if (line == null) break;
|
||||
|
||||
lock (_lock)
|
||||
{
|
||||
_lines.Add(new LogLine
|
||||
{
|
||||
text = line,
|
||||
isError = isError,
|
||||
timestamp = DateTime.Now.ToString("HH:mm:ss.fff")
|
||||
});
|
||||
|
||||
// Trim if too many lines
|
||||
if (_lines.Count > MAX_LINES)
|
||||
{
|
||||
_lines.RemoveRange(0, TRIM_AMOUNT);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// Stream closed — normal when process exits
|
||||
}
|
||||
})
|
||||
{
|
||||
IsBackground = true,
|
||||
Name = isError ? "PSXConsole-stderr" : "PSXConsole-stdout"
|
||||
};
|
||||
thread.Start();
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Window lifecycle
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
private void OnEnable()
|
||||
{
|
||||
EditorApplication.update += OnEditorUpdate;
|
||||
}
|
||||
|
||||
private void OnDisable()
|
||||
{
|
||||
EditorApplication.update -= OnEditorUpdate;
|
||||
}
|
||||
|
||||
private void OnEditorUpdate()
|
||||
{
|
||||
// Repaint when new lines arrive
|
||||
int count;
|
||||
lock (_lock) { count = _lines.Count; }
|
||||
if (count != _lastLineCount)
|
||||
{
|
||||
_lastLineCount = count;
|
||||
Repaint();
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// GUI
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
private void EnsureStyles()
|
||||
{
|
||||
if (_monoStyle == null)
|
||||
{
|
||||
_monoStyle = new GUIStyle(EditorStyles.label)
|
||||
{
|
||||
font = Font.CreateDynamicFontFromOSFont("Consolas", 12),
|
||||
fontSize = 11,
|
||||
richText = false,
|
||||
wordWrap = _wrapLines,
|
||||
normal = { textColor = new Color(0.85f, 0.85f, 0.85f) },
|
||||
padding = new RectOffset(4, 4, 1, 1),
|
||||
margin = new RectOffset(0, 0, 0, 0)
|
||||
};
|
||||
}
|
||||
if (_monoStyleErr == null)
|
||||
{
|
||||
_monoStyleErr = new GUIStyle(_monoStyle)
|
||||
{
|
||||
normal = { textColor = new Color(1f, 0.45f, 0.4f) }
|
||||
};
|
||||
}
|
||||
if (_monoStyleSelected == null)
|
||||
{
|
||||
_monoStyleSelected = new GUIStyle(_monoStyle)
|
||||
{
|
||||
normal =
|
||||
{
|
||||
textColor = new Color(0.95f, 0.95f, 0.95f),
|
||||
background = MakeSolidTexture(new Color(0.25f, 0.40f, 0.65f, 0.6f))
|
||||
}
|
||||
};
|
||||
}
|
||||
_monoStyle.wordWrap = _wrapLines;
|
||||
_monoStyleErr.wordWrap = _wrapLines;
|
||||
_monoStyleSelected.wordWrap = _wrapLines;
|
||||
}
|
||||
|
||||
private static Texture2D MakeSolidTexture(Color color)
|
||||
{
|
||||
var tex = new Texture2D(1, 1);
|
||||
tex.SetPixel(0, 0, color);
|
||||
tex.Apply();
|
||||
return tex;
|
||||
}
|
||||
|
||||
private void OnGUI()
|
||||
{
|
||||
EnsureStyles();
|
||||
DrawToolbar();
|
||||
DrawConsoleOutput();
|
||||
}
|
||||
|
||||
private void DrawToolbar()
|
||||
{
|
||||
EditorGUILayout.BeginHorizontal(EditorStyles.toolbar);
|
||||
|
||||
// Process status
|
||||
bool alive = _process != null && !_process.HasExited;
|
||||
var statusColor = GUI.contentColor;
|
||||
GUI.contentColor = alive ? Color.green : Color.gray;
|
||||
GUILayout.Label(alive ? "● Live" : "● Stopped", EditorStyles.toolbarButton, GUILayout.Width(60));
|
||||
GUI.contentColor = statusColor;
|
||||
|
||||
// Filter
|
||||
GUILayout.Label("Filter:", GUILayout.Width(40));
|
||||
_filterText = EditorGUILayout.TextField(_filterText, EditorStyles.toolbarSearchField, GUILayout.Width(150));
|
||||
|
||||
GUILayout.FlexibleSpace();
|
||||
|
||||
// Toggles
|
||||
_showStdout = GUILayout.Toggle(_showStdout, "stdout", EditorStyles.toolbarButton, GUILayout.Width(50));
|
||||
_showStderr = GUILayout.Toggle(_showStderr, "stderr", EditorStyles.toolbarButton, GUILayout.Width(50));
|
||||
_wrapLines = GUILayout.Toggle(_wrapLines, "Wrap", EditorStyles.toolbarButton, GUILayout.Width(40));
|
||||
|
||||
// Auto-scroll
|
||||
_autoScroll = GUILayout.Toggle(_autoScroll, "Auto↓", EditorStyles.toolbarButton, GUILayout.Width(50));
|
||||
|
||||
// Clear
|
||||
if (GUILayout.Button("Clear", EditorStyles.toolbarButton, GUILayout.Width(45)))
|
||||
{
|
||||
lock (_lock) { _lines.Clear(); }
|
||||
}
|
||||
|
||||
// Copy all
|
||||
if (GUILayout.Button("Copy", EditorStyles.toolbarButton, GUILayout.Width(40)))
|
||||
{
|
||||
CopyToClipboard();
|
||||
}
|
||||
|
||||
EditorGUILayout.EndHorizontal();
|
||||
}
|
||||
|
||||
private void DrawConsoleOutput()
|
||||
{
|
||||
// Simple scroll view - no BeginArea/EndArea mixing that causes layout errors.
|
||||
_scrollPos = EditorGUILayout.BeginScrollView(_scrollPos, GUILayout.ExpandHeight(true));
|
||||
|
||||
// Dark background behind the scroll content
|
||||
Rect scrollBg = EditorGUILayout.BeginVertical();
|
||||
EditorGUI.DrawRect(scrollBg, new Color(0.13f, 0.13f, 0.15f));
|
||||
|
||||
bool hasFilter = !string.IsNullOrEmpty(_filterText);
|
||||
string filterLower = hasFilter ? _filterText.ToLowerInvariant() : null;
|
||||
|
||||
int selMin = Mathf.Min(_selectionAnchor, _selectionEnd);
|
||||
int selMax = Mathf.Max(_selectionAnchor, _selectionEnd);
|
||||
bool hasSelection = _selectionAnchor >= 0 && _selectionEnd >= 0;
|
||||
|
||||
lock (_lock)
|
||||
{
|
||||
if (_lines.Count == 0)
|
||||
{
|
||||
GUILayout.Label("Waiting for output...", EditorStyles.centeredGreyMiniLabel);
|
||||
}
|
||||
|
||||
for (int i = 0; i < _lines.Count; i++)
|
||||
{
|
||||
var line = _lines[i];
|
||||
|
||||
if (line.isError && !_showStderr) continue;
|
||||
if (!line.isError && !_showStdout) continue;
|
||||
if (hasFilter && line.text.ToLowerInvariant().IndexOf(filterLower, StringComparison.Ordinal) < 0)
|
||||
continue;
|
||||
|
||||
bool selected = hasSelection && i >= selMin && i <= selMax;
|
||||
GUIStyle style = selected ? _monoStyleSelected : (line.isError ? _monoStyleErr : _monoStyle);
|
||||
|
||||
string label = $"[{line.timestamp}] {line.text}";
|
||||
GUILayout.Label(label, style);
|
||||
|
||||
// Handle click/right-click on last drawn rect
|
||||
Rect lineRect = GUILayoutUtility.GetLastRect();
|
||||
Event evt = Event.current;
|
||||
if (evt.type == EventType.MouseDown && lineRect.Contains(evt.mousePosition))
|
||||
{
|
||||
if (evt.button == 0)
|
||||
{
|
||||
if (evt.shift && _selectionAnchor >= 0)
|
||||
_selectionEnd = i;
|
||||
else
|
||||
{
|
||||
_selectionAnchor = i;
|
||||
_selectionEnd = i;
|
||||
}
|
||||
evt.Use();
|
||||
Repaint();
|
||||
}
|
||||
else if (evt.button == 1)
|
||||
{
|
||||
int clickedLine = i;
|
||||
bool lineInSelection = hasSelection && clickedLine >= selMin && clickedLine <= selMax;
|
||||
var menu = new GenericMenu();
|
||||
if (lineInSelection && selMin != selMax)
|
||||
{
|
||||
menu.AddItem(new GUIContent("Copy selected lines"), false, () => CopyRange(selMin, selMax));
|
||||
menu.AddSeparator("");
|
||||
}
|
||||
menu.AddItem(new GUIContent("Copy this line"), false, () =>
|
||||
{
|
||||
string text;
|
||||
lock (_lock)
|
||||
{
|
||||
text = clickedLine < _lines.Count
|
||||
? $"[{_lines[clickedLine].timestamp}] {_lines[clickedLine].text}"
|
||||
: "";
|
||||
}
|
||||
EditorGUIUtility.systemCopyBuffer = text;
|
||||
});
|
||||
menu.ShowAsContext();
|
||||
evt.Use();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
EditorGUILayout.EndVertical();
|
||||
|
||||
if (_autoScroll)
|
||||
_scrollPos.y = float.MaxValue;
|
||||
|
||||
EditorGUILayout.EndScrollView();
|
||||
}
|
||||
|
||||
private void CopyRange(int fromIndex, int toIndex)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
lock (_lock)
|
||||
{
|
||||
int lo = Mathf.Min(fromIndex, toIndex);
|
||||
int hi = Mathf.Max(fromIndex, toIndex);
|
||||
for (int i = lo; i <= hi && i < _lines.Count; i++)
|
||||
{
|
||||
string prefix = _lines[i].isError ? "[ERR]" : "[OUT]";
|
||||
sb.AppendLine($"[{_lines[i].timestamp}] {prefix} {_lines[i].text}");
|
||||
}
|
||||
}
|
||||
EditorGUIUtility.systemCopyBuffer = sb.ToString();
|
||||
}
|
||||
|
||||
private void CopyToClipboard()
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
lock (_lock)
|
||||
{
|
||||
foreach (var line in _lines)
|
||||
{
|
||||
string prefix = line.isError ? "[ERR]" : "[OUT]";
|
||||
sb.AppendLine($"[{line.timestamp}] {prefix} {line.text}");
|
||||
}
|
||||
}
|
||||
EditorGUIUtility.systemCopyBuffer = sb.ToString();
|
||||
}
|
||||
}
|
||||
}
|
||||
2
Editor/Core/PSXConsoleWindow.cs.meta
Normal file
2
Editor/Core/PSXConsoleWindow.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: c4e13fc5b859ac14099eb9f259ba11f0
|
||||
777
Editor/Core/PSXEditorStyles.cs
Normal file
777
Editor/Core/PSXEditorStyles.cs
Normal file
@@ -0,0 +1,777 @@
|
||||
using UnityEngine;
|
||||
using UnityEditor;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace SplashEdit.EditorCode
|
||||
{
|
||||
/// <summary>
|
||||
/// Unified styling system for PSX Splash editor windows.
|
||||
/// Provides consistent colors, fonts, icons, and GUIStyles across the entire plugin.
|
||||
/// </summary>
|
||||
[InitializeOnLoad]
|
||||
public static class PSXEditorStyles
|
||||
{
|
||||
static PSXEditorStyles()
|
||||
{
|
||||
AssemblyReloadEvents.beforeAssemblyReload += OnBeforeAssemblyReload;
|
||||
}
|
||||
|
||||
private static void OnBeforeAssemblyReload()
|
||||
{
|
||||
foreach (var tex in _textureCache.Values)
|
||||
{
|
||||
if (tex != null)
|
||||
Object.DestroyImmediate(tex);
|
||||
}
|
||||
_textureCache.Clear();
|
||||
_styleCache.Clear();
|
||||
}
|
||||
|
||||
#region Colors - PS1 Inspired Palette
|
||||
|
||||
// Primary colors
|
||||
public static readonly Color PrimaryBlue = new Color(0.15f, 0.35f, 0.65f);
|
||||
public static readonly Color PrimaryDark = new Color(0.12f, 0.12f, 0.14f);
|
||||
public static readonly Color PrimaryLight = new Color(0.22f, 0.22f, 0.25f);
|
||||
|
||||
// Accent colors
|
||||
public static readonly Color AccentGold = new Color(0.95f, 0.75f, 0.2f);
|
||||
public static readonly Color AccentCyan = new Color(0.3f, 0.85f, 0.95f);
|
||||
public static readonly Color AccentMagenta = new Color(0.85f, 0.3f, 0.65f);
|
||||
public static readonly Color AccentGreen = new Color(0.35f, 0.85f, 0.45f);
|
||||
|
||||
// Semantic colors
|
||||
public static readonly Color Success = new Color(0.35f, 0.8f, 0.4f);
|
||||
public static readonly Color Warning = new Color(0.95f, 0.75f, 0.2f);
|
||||
public static readonly Color Error = new Color(0.9f, 0.3f, 0.35f);
|
||||
public static readonly Color Info = new Color(0.4f, 0.7f, 0.95f);
|
||||
|
||||
// Background colors
|
||||
public static readonly Color BackgroundDark = new Color(0.15f, 0.15f, 0.17f);
|
||||
public static readonly Color BackgroundMedium = new Color(0.2f, 0.2f, 0.22f);
|
||||
public static readonly Color BackgroundLight = new Color(0.28f, 0.28f, 0.3f);
|
||||
public static readonly Color BackgroundHighlight = new Color(0.25f, 0.35f, 0.5f);
|
||||
|
||||
// Text colors
|
||||
public static readonly Color TextPrimary = new Color(0.9f, 0.9f, 0.92f);
|
||||
public static readonly Color TextSecondary = new Color(0.65f, 0.65f, 0.7f);
|
||||
public static readonly Color TextMuted = new Color(0.45f, 0.45f, 0.5f);
|
||||
|
||||
// VRAM specific colors
|
||||
public static readonly Color VRAMFrameBuffer1 = new Color(1f, 0.3f, 0.3f, 0.4f);
|
||||
public static readonly Color VRAMFrameBuffer2 = new Color(0.3f, 1f, 0.3f, 0.4f);
|
||||
public static readonly Color VRAMProhibited = new Color(1f, 0f, 0f, 0.25f);
|
||||
public static readonly Color VRAMTexture = new Color(0.3f, 0.6f, 1f, 0.5f);
|
||||
public static readonly Color VRAMCLUT = new Color(1f, 0.6f, 0.3f, 0.5f);
|
||||
|
||||
#endregion
|
||||
|
||||
#region Cached Styles
|
||||
|
||||
private static Dictionary<string, GUIStyle> _styleCache = new Dictionary<string, GUIStyle>();
|
||||
private static Dictionary<string, Texture2D> _textureCache = new Dictionary<string, Texture2D>();
|
||||
|
||||
#endregion
|
||||
|
||||
#region Textures
|
||||
|
||||
public static Texture2D GetSolidTexture(Color color)
|
||||
{
|
||||
string key = $"solid_{color.r}_{color.g}_{color.b}_{color.a}";
|
||||
if (!_textureCache.TryGetValue(key, out var tex) || tex == null)
|
||||
{
|
||||
tex = new Texture2D(1, 1);
|
||||
tex.SetPixel(0, 0, color);
|
||||
tex.Apply();
|
||||
tex.hideFlags = HideFlags.HideAndDontSave;
|
||||
_textureCache[key] = tex;
|
||||
}
|
||||
return tex;
|
||||
}
|
||||
|
||||
public static Texture2D CreateGradientTexture(int width, int height, Color top, Color bottom)
|
||||
{
|
||||
Texture2D tex = new Texture2D(width, height);
|
||||
for (int y = 0; y < height; y++)
|
||||
{
|
||||
Color c = Color.Lerp(bottom, top, (float)y / height);
|
||||
for (int x = 0; x < width; x++)
|
||||
{
|
||||
tex.SetPixel(x, y, c);
|
||||
}
|
||||
}
|
||||
tex.Apply();
|
||||
tex.hideFlags = HideFlags.HideAndDontSave;
|
||||
return tex;
|
||||
}
|
||||
|
||||
public static Texture2D CreateRoundedRect(int width, int height, int radius, Color fillColor, Color borderColor, int borderWidth = 1)
|
||||
{
|
||||
Texture2D tex = new Texture2D(width, height);
|
||||
Color transparent = new Color(0, 0, 0, 0);
|
||||
|
||||
for (int y = 0; y < height; y++)
|
||||
{
|
||||
for (int x = 0; x < width; x++)
|
||||
{
|
||||
// Check if pixel is within rounded corners
|
||||
bool inCorner = false;
|
||||
float dist = 0;
|
||||
|
||||
// Top-left
|
||||
if (x < radius && y > height - radius - 1)
|
||||
{
|
||||
dist = Vector2.Distance(new Vector2(x, y), new Vector2(radius, height - radius - 1));
|
||||
inCorner = true;
|
||||
}
|
||||
// Top-right
|
||||
else if (x > width - radius - 1 && y > height - radius - 1)
|
||||
{
|
||||
dist = Vector2.Distance(new Vector2(x, y), new Vector2(width - radius - 1, height - radius - 1));
|
||||
inCorner = true;
|
||||
}
|
||||
// Bottom-left
|
||||
else if (x < radius && y < radius)
|
||||
{
|
||||
dist = Vector2.Distance(new Vector2(x, y), new Vector2(radius, radius));
|
||||
inCorner = true;
|
||||
}
|
||||
// Bottom-right
|
||||
else if (x > width - radius - 1 && y < radius)
|
||||
{
|
||||
dist = Vector2.Distance(new Vector2(x, y), new Vector2(width - radius - 1, radius));
|
||||
inCorner = true;
|
||||
}
|
||||
|
||||
if (inCorner)
|
||||
{
|
||||
if (dist > radius)
|
||||
tex.SetPixel(x, y, transparent);
|
||||
else if (dist > radius - borderWidth)
|
||||
tex.SetPixel(x, y, borderColor);
|
||||
else
|
||||
tex.SetPixel(x, y, fillColor);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Check border
|
||||
if (x < borderWidth || x >= width - borderWidth || y < borderWidth || y >= height - borderWidth)
|
||||
tex.SetPixel(x, y, borderColor);
|
||||
else
|
||||
tex.SetPixel(x, y, fillColor);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tex.Apply();
|
||||
tex.hideFlags = HideFlags.HideAndDontSave;
|
||||
return tex;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region GUIStyles
|
||||
|
||||
private static GUIStyle _windowHeader;
|
||||
public static GUIStyle WindowHeader
|
||||
{
|
||||
get
|
||||
{
|
||||
if (_windowHeader == null)
|
||||
{
|
||||
_windowHeader = new GUIStyle(EditorStyles.boldLabel)
|
||||
{
|
||||
fontSize = 18,
|
||||
alignment = TextAnchor.MiddleLeft,
|
||||
padding = new RectOffset(10, 10, 8, 8),
|
||||
margin = new RectOffset(0, 0, 0, 5)
|
||||
};
|
||||
_windowHeader.normal.textColor = TextPrimary;
|
||||
}
|
||||
return _windowHeader;
|
||||
}
|
||||
}
|
||||
|
||||
private static GUIStyle _sectionHeader;
|
||||
public static GUIStyle SectionHeader
|
||||
{
|
||||
get
|
||||
{
|
||||
if (_sectionHeader == null)
|
||||
{
|
||||
_sectionHeader = new GUIStyle(EditorStyles.boldLabel)
|
||||
{
|
||||
fontSize = 14,
|
||||
alignment = TextAnchor.MiddleLeft,
|
||||
padding = new RectOffset(5, 5, 8, 8),
|
||||
margin = new RectOffset(0, 0, 10, 5)
|
||||
};
|
||||
_sectionHeader.normal.textColor = TextPrimary;
|
||||
}
|
||||
return _sectionHeader;
|
||||
}
|
||||
}
|
||||
|
||||
private static GUIStyle _cardStyle;
|
||||
public static GUIStyle CardStyle
|
||||
{
|
||||
get
|
||||
{
|
||||
if (_cardStyle == null)
|
||||
{
|
||||
_cardStyle = new GUIStyle()
|
||||
{
|
||||
padding = new RectOffset(12, 12, 10, 10),
|
||||
margin = new RectOffset(5, 5, 5, 5),
|
||||
normal = { background = GetSolidTexture(BackgroundMedium) }
|
||||
};
|
||||
}
|
||||
return _cardStyle;
|
||||
}
|
||||
}
|
||||
|
||||
private static GUIStyle _cardHeaderStyle;
|
||||
public static GUIStyle CardHeaderStyle
|
||||
{
|
||||
get
|
||||
{
|
||||
if (_cardHeaderStyle == null)
|
||||
{
|
||||
_cardHeaderStyle = new GUIStyle(EditorStyles.boldLabel)
|
||||
{
|
||||
fontSize = 13,
|
||||
padding = new RectOffset(0, 0, 0, 5),
|
||||
margin = new RectOffset(0, 0, 0, 5)
|
||||
};
|
||||
_cardHeaderStyle.normal.textColor = TextPrimary;
|
||||
}
|
||||
return _cardHeaderStyle;
|
||||
}
|
||||
}
|
||||
|
||||
private static GUIStyle _primaryButton;
|
||||
public static GUIStyle PrimaryButton
|
||||
{
|
||||
get
|
||||
{
|
||||
if (_primaryButton == null)
|
||||
{
|
||||
_primaryButton = new GUIStyle(GUI.skin.button)
|
||||
{
|
||||
fontSize = 12,
|
||||
fontStyle = FontStyle.Bold,
|
||||
padding = new RectOffset(15, 15, 8, 8),
|
||||
margin = new RectOffset(5, 5, 5, 5),
|
||||
alignment = TextAnchor.MiddleCenter
|
||||
};
|
||||
_primaryButton.normal.textColor = Color.white;
|
||||
_primaryButton.normal.background = GetSolidTexture(PrimaryBlue);
|
||||
_primaryButton.hover.background = GetSolidTexture(PrimaryBlue * 1.2f);
|
||||
_primaryButton.active.background = GetSolidTexture(PrimaryBlue * 0.8f);
|
||||
}
|
||||
return _primaryButton;
|
||||
}
|
||||
}
|
||||
|
||||
private static GUIStyle _secondaryButton;
|
||||
public static GUIStyle SecondaryButton
|
||||
{
|
||||
get
|
||||
{
|
||||
if (_secondaryButton == null)
|
||||
{
|
||||
_secondaryButton = new GUIStyle(GUI.skin.button)
|
||||
{
|
||||
fontSize = 11,
|
||||
padding = new RectOffset(12, 12, 6, 6),
|
||||
margin = new RectOffset(3, 3, 3, 3),
|
||||
alignment = TextAnchor.MiddleCenter
|
||||
};
|
||||
_secondaryButton.normal.textColor = TextPrimary;
|
||||
_secondaryButton.normal.background = GetSolidTexture(BackgroundLight);
|
||||
_secondaryButton.hover.background = GetSolidTexture(BackgroundLight * 1.3f);
|
||||
_secondaryButton.active.background = GetSolidTexture(BackgroundLight * 0.7f);
|
||||
}
|
||||
return _secondaryButton;
|
||||
}
|
||||
}
|
||||
|
||||
private static GUIStyle _successButton;
|
||||
public static GUIStyle SuccessButton
|
||||
{
|
||||
get
|
||||
{
|
||||
if (_successButton == null)
|
||||
{
|
||||
_successButton = new GUIStyle(PrimaryButton);
|
||||
_successButton.normal.background = GetSolidTexture(Success * 0.8f);
|
||||
_successButton.hover.background = GetSolidTexture(Success);
|
||||
_successButton.active.background = GetSolidTexture(Success * 0.6f);
|
||||
}
|
||||
return _successButton;
|
||||
}
|
||||
}
|
||||
|
||||
private static GUIStyle _dangerButton;
|
||||
public static GUIStyle DangerButton
|
||||
{
|
||||
get
|
||||
{
|
||||
if (_dangerButton == null)
|
||||
{
|
||||
_dangerButton = new GUIStyle(PrimaryButton);
|
||||
_dangerButton.normal.background = GetSolidTexture(Error * 0.8f);
|
||||
_dangerButton.hover.background = GetSolidTexture(Error);
|
||||
_dangerButton.active.background = GetSolidTexture(Error * 0.6f);
|
||||
}
|
||||
return _dangerButton;
|
||||
}
|
||||
}
|
||||
|
||||
private static GUIStyle _statusBadge;
|
||||
public static GUIStyle StatusBadge
|
||||
{
|
||||
get
|
||||
{
|
||||
if (_statusBadge == null)
|
||||
{
|
||||
_statusBadge = new GUIStyle(EditorStyles.label)
|
||||
{
|
||||
fontSize = 10,
|
||||
fontStyle = FontStyle.Bold,
|
||||
alignment = TextAnchor.MiddleCenter,
|
||||
padding = new RectOffset(8, 8, 3, 3),
|
||||
margin = new RectOffset(3, 3, 3, 3)
|
||||
};
|
||||
}
|
||||
return _statusBadge;
|
||||
}
|
||||
}
|
||||
|
||||
private static GUIStyle _toolbarStyle;
|
||||
public static GUIStyle ToolbarStyle
|
||||
{
|
||||
get
|
||||
{
|
||||
if (_toolbarStyle == null)
|
||||
{
|
||||
_toolbarStyle = new GUIStyle()
|
||||
{
|
||||
padding = new RectOffset(8, 8, 6, 6),
|
||||
margin = new RectOffset(0, 0, 0, 0),
|
||||
normal = { background = GetSolidTexture(BackgroundDark) }
|
||||
};
|
||||
}
|
||||
return _toolbarStyle;
|
||||
}
|
||||
}
|
||||
|
||||
private static GUIStyle _infoBox;
|
||||
public static GUIStyle InfoBox
|
||||
{
|
||||
get
|
||||
{
|
||||
if (_infoBox == null)
|
||||
{
|
||||
_infoBox = new GUIStyle(EditorStyles.helpBox)
|
||||
{
|
||||
fontSize = 11,
|
||||
padding = new RectOffset(10, 10, 8, 8),
|
||||
margin = new RectOffset(5, 5, 5, 5),
|
||||
richText = true
|
||||
};
|
||||
}
|
||||
return _infoBox;
|
||||
}
|
||||
}
|
||||
|
||||
private static GUIStyle _centeredLabel;
|
||||
public static GUIStyle CenteredLabel
|
||||
{
|
||||
get
|
||||
{
|
||||
if (_centeredLabel == null)
|
||||
{
|
||||
_centeredLabel = new GUIStyle(EditorStyles.label)
|
||||
{
|
||||
alignment = TextAnchor.MiddleCenter,
|
||||
wordWrap = true
|
||||
};
|
||||
}
|
||||
return _centeredLabel;
|
||||
}
|
||||
}
|
||||
|
||||
private static GUIStyle _richLabel;
|
||||
public static GUIStyle RichLabel
|
||||
{
|
||||
get
|
||||
{
|
||||
if (_richLabel == null)
|
||||
{
|
||||
_richLabel = new GUIStyle(EditorStyles.label)
|
||||
{
|
||||
richText = true,
|
||||
wordWrap = true
|
||||
};
|
||||
}
|
||||
return _richLabel;
|
||||
}
|
||||
}
|
||||
|
||||
private static GUIStyle _foldoutHeader;
|
||||
public static GUIStyle FoldoutHeader
|
||||
{
|
||||
get
|
||||
{
|
||||
if (_foldoutHeader == null)
|
||||
{
|
||||
_foldoutHeader = new GUIStyle(EditorStyles.foldout)
|
||||
{
|
||||
fontSize = 12,
|
||||
fontStyle = FontStyle.Bold,
|
||||
padding = new RectOffset(15, 0, 3, 3)
|
||||
};
|
||||
_foldoutHeader.normal.textColor = TextPrimary;
|
||||
}
|
||||
return _foldoutHeader;
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Drawing Helpers
|
||||
|
||||
/// <summary>
|
||||
/// Draw a horizontal separator line
|
||||
/// </summary>
|
||||
public static void DrawSeparator(float topMargin = 5, float bottomMargin = 5)
|
||||
{
|
||||
GUILayout.Space(topMargin);
|
||||
var rect = GUILayoutUtility.GetRect(1, 1, GUILayout.ExpandWidth(true));
|
||||
EditorGUI.DrawRect(rect, TextMuted * 0.5f);
|
||||
GUILayout.Space(bottomMargin);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Draw a status badge with color
|
||||
/// </summary>
|
||||
public static void DrawStatusBadge(string text, Color color, float width = 80)
|
||||
{
|
||||
var style = new GUIStyle(StatusBadge);
|
||||
style.normal.background = GetSolidTexture(color);
|
||||
style.normal.textColor = GetContrastColor(color);
|
||||
GUILayout.Label(text, style, GUILayout.Width(width));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Draw a progress bar
|
||||
/// </summary>
|
||||
public static void DrawProgressBar(float progress, string label, Color fillColor, float height = 20)
|
||||
{
|
||||
var rect = GUILayoutUtility.GetRect(100, height, GUILayout.ExpandWidth(true));
|
||||
|
||||
// Background
|
||||
EditorGUI.DrawRect(rect, BackgroundDark);
|
||||
|
||||
// Fill
|
||||
var fillRect = new Rect(rect.x, rect.y, rect.width * Mathf.Clamp01(progress), rect.height);
|
||||
EditorGUI.DrawRect(fillRect, fillColor);
|
||||
|
||||
// Border
|
||||
DrawBorder(rect, TextMuted * 0.5f, 1);
|
||||
|
||||
// Label
|
||||
var labelStyle = new GUIStyle(EditorStyles.label)
|
||||
{
|
||||
alignment = TextAnchor.MiddleCenter,
|
||||
normal = { textColor = TextPrimary }
|
||||
};
|
||||
GUI.Label(rect, $"{label} ({progress * 100:F0}%)", labelStyle);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Draw a border around a rect
|
||||
/// </summary>
|
||||
public static void DrawBorder(Rect rect, Color color, int thickness = 1)
|
||||
{
|
||||
// Top
|
||||
EditorGUI.DrawRect(new Rect(rect.x, rect.y, rect.width, thickness), color);
|
||||
// Bottom
|
||||
EditorGUI.DrawRect(new Rect(rect.x, rect.yMax - thickness, rect.width, thickness), color);
|
||||
// Left
|
||||
EditorGUI.DrawRect(new Rect(rect.x, rect.y, thickness, rect.height), color);
|
||||
// Right
|
||||
EditorGUI.DrawRect(new Rect(rect.xMax - thickness, rect.y, thickness, rect.height), color);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get a contrasting text color for a background
|
||||
/// </summary>
|
||||
public static Color GetContrastColor(Color background)
|
||||
{
|
||||
float luminance = 0.299f * background.r + 0.587f * background.g + 0.114f * background.b;
|
||||
return luminance > 0.5f ? Color.black : Color.white;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Begin a styled card section
|
||||
/// </summary>
|
||||
public static void BeginCard()
|
||||
{
|
||||
EditorGUILayout.BeginVertical(CardStyle);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// End a styled card section
|
||||
/// </summary>
|
||||
public static void EndCard()
|
||||
{
|
||||
EditorGUILayout.EndVertical();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Draw a card with header and content
|
||||
/// </summary>
|
||||
public static bool DrawFoldoutCard(string title, bool isExpanded, System.Action drawContent)
|
||||
{
|
||||
EditorGUILayout.BeginVertical(CardStyle);
|
||||
|
||||
EditorGUILayout.BeginHorizontal();
|
||||
isExpanded = EditorGUILayout.Foldout(isExpanded, title, true, FoldoutHeader);
|
||||
EditorGUILayout.EndHorizontal();
|
||||
|
||||
if (isExpanded)
|
||||
{
|
||||
EditorGUILayout.Space(5);
|
||||
drawContent?.Invoke();
|
||||
}
|
||||
|
||||
EditorGUILayout.EndVertical();
|
||||
|
||||
return isExpanded;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Draw a large icon button (for dashboard)
|
||||
/// </summary>
|
||||
public static bool DrawIconButton(string label, string icon, string description, float width = 150, float height = 100)
|
||||
{
|
||||
var rect = GUILayoutUtility.GetRect(width, height);
|
||||
|
||||
bool isHover = rect.Contains(Event.current.mousePosition);
|
||||
var bgColor = isHover ? BackgroundHighlight : BackgroundMedium;
|
||||
|
||||
EditorGUI.DrawRect(rect, bgColor);
|
||||
DrawBorder(rect, isHover ? AccentCyan : TextMuted * 0.3f, 1);
|
||||
|
||||
// Icon (using Unity's built-in icons or a placeholder)
|
||||
var iconRect = new Rect(rect.x + rect.width / 2 - 16, rect.y + 15, 32, 32);
|
||||
var iconContent = EditorGUIUtility.IconContent(icon);
|
||||
if (iconContent != null && iconContent.image != null)
|
||||
{
|
||||
GUI.DrawTexture(iconRect, iconContent.image);
|
||||
}
|
||||
|
||||
// Label
|
||||
var labelRect = new Rect(rect.x, rect.y + 52, rect.width, 20);
|
||||
var labelStyle = new GUIStyle(EditorStyles.boldLabel)
|
||||
{
|
||||
alignment = TextAnchor.MiddleCenter,
|
||||
normal = { textColor = TextPrimary }
|
||||
};
|
||||
GUI.Label(labelRect, label, labelStyle);
|
||||
|
||||
// Description
|
||||
var descRect = new Rect(rect.x + 5, rect.y + 70, rect.width - 10, 25);
|
||||
var descStyle = new GUIStyle(EditorStyles.miniLabel)
|
||||
{
|
||||
alignment = TextAnchor.UpperCenter,
|
||||
wordWrap = true,
|
||||
normal = { textColor = TextSecondary }
|
||||
};
|
||||
GUI.Label(descRect, description, descStyle);
|
||||
|
||||
return GUI.Button(rect, GUIContent.none, GUIStyle.none);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Draw a horizontal button group
|
||||
/// </summary>
|
||||
public static int DrawButtonGroup(string[] labels, int selected, float height = 25)
|
||||
{
|
||||
EditorGUILayout.BeginHorizontal();
|
||||
|
||||
for (int i = 0; i < labels.Length; i++)
|
||||
{
|
||||
bool isSelected = i == selected;
|
||||
var style = new GUIStyle(GUI.skin.button)
|
||||
{
|
||||
fontStyle = isSelected ? FontStyle.Bold : FontStyle.Normal
|
||||
};
|
||||
|
||||
if (isSelected)
|
||||
{
|
||||
style.normal.background = GetSolidTexture(PrimaryBlue);
|
||||
style.normal.textColor = Color.white;
|
||||
}
|
||||
else
|
||||
{
|
||||
style.normal.background = GetSolidTexture(BackgroundLight);
|
||||
style.normal.textColor = TextSecondary;
|
||||
}
|
||||
|
||||
if (GUILayout.Button(labels[i], style, GUILayout.Height(height)))
|
||||
{
|
||||
selected = i;
|
||||
}
|
||||
}
|
||||
|
||||
EditorGUILayout.EndHorizontal();
|
||||
|
||||
return selected;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Layout Helpers
|
||||
|
||||
/// <summary>
|
||||
/// Begin a toolbar row
|
||||
/// </summary>
|
||||
public static void BeginToolbar()
|
||||
{
|
||||
EditorGUILayout.BeginHorizontal(ToolbarStyle);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// End a toolbar row
|
||||
/// </summary>
|
||||
public static void EndToolbar()
|
||||
{
|
||||
EditorGUILayout.EndHorizontal();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Add flexible space
|
||||
/// </summary>
|
||||
public static void FlexibleSpace()
|
||||
{
|
||||
GUILayout.FlexibleSpace();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Begin a centered layout
|
||||
/// </summary>
|
||||
public static void BeginCentered()
|
||||
{
|
||||
EditorGUILayout.BeginHorizontal();
|
||||
GUILayout.FlexibleSpace();
|
||||
EditorGUILayout.BeginVertical();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// End a centered layout
|
||||
/// </summary>
|
||||
public static void EndCentered()
|
||||
{
|
||||
EditorGUILayout.EndVertical();
|
||||
GUILayout.FlexibleSpace();
|
||||
EditorGUILayout.EndHorizontal();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Cleanup
|
||||
|
||||
/// <summary>
|
||||
/// Clear cached styles and textures. Call when recompiling.
|
||||
/// </summary>
|
||||
public static void ClearCache()
|
||||
{
|
||||
foreach (var tex in _textureCache.Values)
|
||||
{
|
||||
if (tex != null)
|
||||
Object.DestroyImmediate(tex);
|
||||
}
|
||||
_textureCache.Clear();
|
||||
|
||||
_windowHeader = null;
|
||||
_sectionHeader = null;
|
||||
_cardStyle = null;
|
||||
_cardHeaderStyle = null;
|
||||
_primaryButton = null;
|
||||
_secondaryButton = null;
|
||||
_successButton = null;
|
||||
_dangerButton = null;
|
||||
_statusBadge = null;
|
||||
_toolbarStyle = null;
|
||||
_infoBox = null;
|
||||
_centeredLabel = null;
|
||||
_richLabel = null;
|
||||
_foldoutHeader = null;
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Icons used throughout the PSX Splash editor
|
||||
/// </summary>
|
||||
public static class PSXIcons
|
||||
{
|
||||
// Unity built-in icons that work well for our purposes
|
||||
public const string Scene = "d_SceneAsset Icon";
|
||||
public const string Build = "d_BuildSettings.SelectedIcon";
|
||||
public const string Settings = "d_Settings";
|
||||
public const string Play = "d_PlayButton";
|
||||
public const string Refresh = "d_Refresh";
|
||||
public const string Warning = "d_console.warnicon";
|
||||
public const string Error = "d_console.erroricon";
|
||||
public const string Info = "d_console.infoicon";
|
||||
public const string Success = "d_Progress";
|
||||
public const string Texture = "d_Texture Icon";
|
||||
public const string Mesh = "d_Mesh Icon";
|
||||
public const string Script = "d_cs Script Icon";
|
||||
public const string Folder = "d_Folder Icon";
|
||||
public const string Download = "d_Download-Available";
|
||||
public const string Upload = "d_UpArrow";
|
||||
public const string Link = "d_Linked";
|
||||
public const string Unlink = "d_Unlinked";
|
||||
public const string Eye = "d_scenevis_visible_hover";
|
||||
public const string EyeOff = "d_scenevis_hidden_hover";
|
||||
public const string Add = "d_Toolbar Plus";
|
||||
public const string Remove = "d_Toolbar Minus";
|
||||
public const string Edit = "d_editicon.sml";
|
||||
public const string Search = "d_Search Icon";
|
||||
public const string Console = "d_UnityEditor.ConsoleWindow";
|
||||
public const string Help = "d__Help";
|
||||
public const string GameObject = "d_GameObject Icon";
|
||||
public const string Camera = "d_Camera Icon";
|
||||
public const string Light = "d_Light Icon";
|
||||
public const string Prefab = "d_Prefab Icon";
|
||||
|
||||
/// <summary>
|
||||
/// Get a GUIContent with icon and tooltip
|
||||
/// </summary>
|
||||
public static GUIContent GetContent(string icon, string tooltip = "")
|
||||
{
|
||||
var content = EditorGUIUtility.IconContent(icon);
|
||||
if (content == null) content = new GUIContent();
|
||||
content.tooltip = tooltip;
|
||||
return content;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get a GUIContent with icon, text and tooltip
|
||||
/// </summary>
|
||||
public static GUIContent GetContent(string icon, string text, string tooltip)
|
||||
{
|
||||
var content = EditorGUIUtility.IconContent(icon);
|
||||
if (content == null) content = new GUIContent();
|
||||
content.text = text;
|
||||
content.tooltip = tooltip;
|
||||
return content;
|
||||
}
|
||||
}
|
||||
}
|
||||
2
Editor/Core/PSXEditorStyles.cs.meta
Normal file
2
Editor/Core/PSXEditorStyles.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 8aefa79a412d32c4f8bc8249bb4cd118
|
||||
178
Editor/Core/SplashBuildPaths.cs
Normal file
178
Editor/Core/SplashBuildPaths.cs
Normal file
@@ -0,0 +1,178 @@
|
||||
using System.IO;
|
||||
using UnityEngine;
|
||||
|
||||
namespace SplashEdit.EditorCode
|
||||
{
|
||||
/// <summary>
|
||||
/// Manages all build-related paths for the SplashEdit pipeline.
|
||||
/// All output goes outside Assets/ to avoid Unity import overhead.
|
||||
/// </summary>
|
||||
public static class SplashBuildPaths
|
||||
{
|
||||
/// <summary>
|
||||
/// The build output directory at the Unity project root.
|
||||
/// Contains exported splashpacks, manifest, compiled .ps-exe, ISO, build log.
|
||||
/// </summary>
|
||||
public static string BuildOutputDir =>
|
||||
Path.Combine(ProjectRoot, "PSXBuild");
|
||||
|
||||
/// <summary>
|
||||
/// The tools directory at the Unity project root.
|
||||
/// Contains auto-downloaded tools like PCSX-Redux.
|
||||
/// </summary>
|
||||
public static string ToolsDir =>
|
||||
Path.Combine(ProjectRoot, ".tools");
|
||||
|
||||
/// <summary>
|
||||
/// PCSX-Redux install directory inside .tools/.
|
||||
/// </summary>
|
||||
public static string PCSXReduxDir =>
|
||||
Path.Combine(ToolsDir, "pcsx-redux");
|
||||
|
||||
/// <summary>
|
||||
/// Platform-specific PCSX-Redux binary path.
|
||||
/// </summary>
|
||||
public static string PCSXReduxBinary
|
||||
{
|
||||
get
|
||||
{
|
||||
switch (Application.platform)
|
||||
{
|
||||
case RuntimePlatform.WindowsEditor:
|
||||
return Path.Combine(PCSXReduxDir, "pcsx-redux.exe");
|
||||
case RuntimePlatform.LinuxEditor:
|
||||
return Path.Combine(ToolsDir, "PCSX-Redux-HEAD-x86_64.AppImage");
|
||||
default:
|
||||
return Path.Combine(PCSXReduxDir, "pcsx-redux");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The Unity project root (parent of Assets/).
|
||||
/// </summary>
|
||||
public static string ProjectRoot =>
|
||||
Directory.GetParent(Application.dataPath).FullName;
|
||||
|
||||
/// <summary>
|
||||
/// Path to the native psxsplash source.
|
||||
/// First checks SplashSettings override, then looks for common locations.
|
||||
/// </summary>
|
||||
public static string NativeSourceDir
|
||||
{
|
||||
get
|
||||
{
|
||||
// 1. Check the user-configured path from SplashSettings
|
||||
string custom = SplashSettings.NativeProjectPath;
|
||||
if (!string.IsNullOrEmpty(custom) && Directory.Exists(custom))
|
||||
return custom;
|
||||
|
||||
// 2. Look inside the Unity project's Assets/ folder (git clone location)
|
||||
string assetsClone = Path.Combine(UnityEngine.Application.dataPath, "psxsplash");
|
||||
if (Directory.Exists(assetsClone) && File.Exists(Path.Combine(assetsClone, "Makefile")))
|
||||
return assetsClone;
|
||||
|
||||
// 3. Look for Native/ inside the package
|
||||
string packageNative = Path.GetFullPath(
|
||||
Path.Combine("Packages", "net.psxsplash.splashedit", "Native"));
|
||||
if (Directory.Exists(packageNative))
|
||||
return packageNative;
|
||||
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The compiled .ps-exe output from the native build.
|
||||
/// </summary>
|
||||
public static string CompiledExePath =>
|
||||
Path.Combine(BuildOutputDir, "psxsplash.ps-exe");
|
||||
|
||||
/// <summary>
|
||||
/// The scene manifest file path.
|
||||
/// </summary>
|
||||
public static string ManifestPath =>
|
||||
Path.Combine(BuildOutputDir, "manifest.bin");
|
||||
|
||||
/// <summary>
|
||||
/// Build log file path.
|
||||
/// </summary>
|
||||
public static string BuildLogPath =>
|
||||
Path.Combine(BuildOutputDir, "build.log");
|
||||
|
||||
/// <summary>
|
||||
/// Gets the splashpack output path for a scene by index.
|
||||
/// Uses a deterministic naming scheme: scene_0.splashpack, scene_1.splashpack, etc.
|
||||
/// </summary>
|
||||
public static string GetSceneSplashpackPath(int sceneIndex, string sceneName)
|
||||
{
|
||||
return Path.Combine(BuildOutputDir, $"scene_{sceneIndex}.splashpack");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// ISO output path for release builds.
|
||||
/// </summary>
|
||||
public static string ISOOutputPath =>
|
||||
Path.Combine(BuildOutputDir, "psxsplash.bin");
|
||||
|
||||
/// <summary>
|
||||
/// CUE sheet path for release builds.
|
||||
/// </summary>
|
||||
public static string CUEOutputPath =>
|
||||
Path.Combine(BuildOutputDir, "psxsplash.cue");
|
||||
|
||||
/// <summary>
|
||||
/// Ensures the build output and tools directories exist.
|
||||
/// Also appends entries to the project .gitignore if not present.
|
||||
/// </summary>
|
||||
public static void EnsureDirectories()
|
||||
{
|
||||
Directory.CreateDirectory(BuildOutputDir);
|
||||
Directory.CreateDirectory(ToolsDir);
|
||||
EnsureGitIgnore();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if PCSX-Redux is installed in the tools directory.
|
||||
/// </summary>
|
||||
public static bool IsPCSXReduxInstalled()
|
||||
{
|
||||
return File.Exists(PCSXReduxBinary);
|
||||
}
|
||||
|
||||
private static void EnsureGitIgnore()
|
||||
{
|
||||
string gitignorePath = Path.Combine(ProjectRoot, ".gitignore");
|
||||
|
||||
string[] entriesToAdd = new[] { "/PSXBuild/", "/.tools/" };
|
||||
|
||||
string existingContent = "";
|
||||
if (File.Exists(gitignorePath))
|
||||
{
|
||||
existingContent = File.ReadAllText(gitignorePath);
|
||||
}
|
||||
|
||||
bool modified = false;
|
||||
string toAppend = "";
|
||||
|
||||
foreach (string entry in entriesToAdd)
|
||||
{
|
||||
// Check if entry already exists (exact line match)
|
||||
if (!existingContent.Contains(entry))
|
||||
{
|
||||
if (!modified)
|
||||
{
|
||||
toAppend += "\n# SplashEdit build output\n";
|
||||
modified = true;
|
||||
}
|
||||
toAppend += entry + "\n";
|
||||
}
|
||||
}
|
||||
|
||||
if (modified)
|
||||
{
|
||||
File.AppendAllText(gitignorePath, toAppend);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
2
Editor/Core/SplashBuildPaths.cs.meta
Normal file
2
Editor/Core/SplashBuildPaths.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 3988772ca929eb14ea3bee6b643de4d0
|
||||
1564
Editor/Core/SplashControlPanel.cs
Normal file
1564
Editor/Core/SplashControlPanel.cs
Normal file
File diff suppressed because it is too large
Load Diff
2
Editor/Core/SplashControlPanel.cs.meta
Normal file
2
Editor/Core/SplashControlPanel.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 5540e6cbefeb70d48a0c1e3843719784
|
||||
159
Editor/Core/SplashSettings.cs
Normal file
159
Editor/Core/SplashSettings.cs
Normal file
@@ -0,0 +1,159 @@
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
|
||||
namespace SplashEdit.EditorCode
|
||||
{
|
||||
/// <summary>
|
||||
/// Enumerates the pipeline target for builds.
|
||||
/// </summary>
|
||||
public enum BuildTarget
|
||||
{
|
||||
Emulator, // PCSX-Redux with PCdrv
|
||||
RealHardware, // Send .ps-exe over serial via Unirom
|
||||
ISO // Build a CD image
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Enumerates the build configuration.
|
||||
/// </summary>
|
||||
public enum BuildMode
|
||||
{
|
||||
Debug,
|
||||
Release
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Centralized EditorPrefs-backed settings for the SplashEdit pipeline.
|
||||
/// All settings are project-scoped using a prefix derived from the project path.
|
||||
/// </summary>
|
||||
public static class SplashSettings
|
||||
{
|
||||
// Prefix all keys with project path hash to support multiple projects
|
||||
internal static string Prefix => "SplashEdit_" + Application.dataPath.GetHashCode().ToString("X8") + "_";
|
||||
|
||||
// --- Build settings ---
|
||||
public static BuildTarget Target
|
||||
{
|
||||
get => (BuildTarget)EditorPrefs.GetInt(Prefix + "Target", (int)BuildTarget.Emulator);
|
||||
set => EditorPrefs.SetInt(Prefix + "Target", (int)value);
|
||||
}
|
||||
|
||||
public static BuildMode Mode
|
||||
{
|
||||
get => (BuildMode)EditorPrefs.GetInt(Prefix + "Mode", (int)BuildMode.Release);
|
||||
set => EditorPrefs.SetInt(Prefix + "Mode", (int)value);
|
||||
}
|
||||
|
||||
// --- Toolchain paths ---
|
||||
public static string NativeProjectPath
|
||||
{
|
||||
get => EditorPrefs.GetString(Prefix + "NativeProjectPath", "");
|
||||
set => EditorPrefs.SetString(Prefix + "NativeProjectPath", value);
|
||||
}
|
||||
|
||||
public static string MIPSToolchainPath
|
||||
{
|
||||
get => EditorPrefs.GetString(Prefix + "MIPSToolchainPath", "");
|
||||
set => EditorPrefs.SetString(Prefix + "MIPSToolchainPath", value);
|
||||
}
|
||||
|
||||
// --- PCSX-Redux ---
|
||||
public static string PCSXReduxPath
|
||||
{
|
||||
get
|
||||
{
|
||||
string custom = EditorPrefs.GetString(Prefix + "PCSXReduxPath", "");
|
||||
if (!string.IsNullOrEmpty(custom))
|
||||
return custom;
|
||||
// Fall back to auto-downloaded location
|
||||
if (SplashBuildPaths.IsPCSXReduxInstalled())
|
||||
return SplashBuildPaths.PCSXReduxBinary;
|
||||
return "";
|
||||
}
|
||||
set => EditorPrefs.SetString(Prefix + "PCSXReduxPath", value);
|
||||
}
|
||||
|
||||
public static string PCSXReduxPCdrvBase
|
||||
{
|
||||
get => EditorPrefs.GetString(Prefix + "PCSXReduxPCdrvBase", SplashBuildPaths.BuildOutputDir);
|
||||
set => EditorPrefs.SetString(Prefix + "PCSXReduxPCdrvBase", value);
|
||||
}
|
||||
|
||||
// --- Serial / Real Hardware ---
|
||||
public static string SerialPort
|
||||
{
|
||||
get => EditorPrefs.GetString(Prefix + "SerialPort", "COM3");
|
||||
set => EditorPrefs.SetString(Prefix + "SerialPort", value);
|
||||
}
|
||||
|
||||
public static int SerialBaudRate
|
||||
{
|
||||
get => EditorPrefs.GetInt(Prefix + "SerialBaudRate", 115200);
|
||||
set => EditorPrefs.SetInt(Prefix + "SerialBaudRate", value);
|
||||
}
|
||||
|
||||
// --- VRAM Layout ---
|
||||
public static int ResolutionWidth
|
||||
{
|
||||
get => EditorPrefs.GetInt(Prefix + "ResWidth", 320);
|
||||
set => EditorPrefs.SetInt(Prefix + "ResWidth", value);
|
||||
}
|
||||
|
||||
public static int ResolutionHeight
|
||||
{
|
||||
get => EditorPrefs.GetInt(Prefix + "ResHeight", 240);
|
||||
set => EditorPrefs.SetInt(Prefix + "ResHeight", value);
|
||||
}
|
||||
|
||||
public static bool DualBuffering
|
||||
{
|
||||
get => EditorPrefs.GetBool(Prefix + "DualBuffering", true);
|
||||
set => EditorPrefs.SetBool(Prefix + "DualBuffering", value);
|
||||
}
|
||||
|
||||
public static bool VerticalLayout
|
||||
{
|
||||
get => EditorPrefs.GetBool(Prefix + "VerticalLayout", true);
|
||||
set => EditorPrefs.SetBool(Prefix + "VerticalLayout", value);
|
||||
}
|
||||
|
||||
// --- Export settings ---
|
||||
public static float DefaultGTEScaling
|
||||
{
|
||||
get => EditorPrefs.GetFloat(Prefix + "GTEScaling", 100f);
|
||||
set => EditorPrefs.SetFloat(Prefix + "GTEScaling", value);
|
||||
}
|
||||
|
||||
public static bool AutoValidateOnExport
|
||||
{
|
||||
get => EditorPrefs.GetBool(Prefix + "AutoValidate", true);
|
||||
set => EditorPrefs.SetBool(Prefix + "AutoValidate", value);
|
||||
}
|
||||
|
||||
// --- Play Mode Intercept ---
|
||||
public static bool InterceptPlayMode
|
||||
{
|
||||
get => EditorPrefs.GetBool(Prefix + "InterceptPlayMode", false);
|
||||
set => EditorPrefs.SetBool(Prefix + "InterceptPlayMode", value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resets all settings to defaults by deleting all prefixed keys.
|
||||
/// </summary>
|
||||
public static void ResetAll()
|
||||
{
|
||||
string[] keys = new[]
|
||||
{
|
||||
"Target", "Mode", "NativeProjectPath", "MIPSToolchainPath",
|
||||
"PCSXReduxPath", "PCSXReduxPCdrvBase", "SerialPort", "SerialBaudRate",
|
||||
"ResWidth", "ResHeight", "DualBuffering", "VerticalLayout",
|
||||
"GTEScaling", "AutoValidate", "InterceptPlayMode"
|
||||
};
|
||||
|
||||
foreach (string key in keys)
|
||||
{
|
||||
EditorPrefs.DeleteKey(Prefix + key);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
2
Editor/Core/SplashSettings.cs.meta
Normal file
2
Editor/Core/SplashSettings.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 4765dbe728569d84699a22347e7c14ff
|
||||
549
Editor/Core/UniromUploader.cs
Normal file
549
Editor/Core/UniromUploader.cs
Normal file
@@ -0,0 +1,549 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.IO.Ports;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using UnityEngine;
|
||||
|
||||
namespace SplashEdit.EditorCode
|
||||
{
|
||||
/// <summary>
|
||||
/// Uploads a .ps-exe to a PS1 running Unirom 8 via serial.
|
||||
/// Implements the NOTPSXSerial / Unirom protocol:
|
||||
/// Challenge/Response handshake → header → metadata → chunked data with checksums.
|
||||
/// Reference: https://github.com/JonathanDotCel/NOTPSXSerial
|
||||
/// </summary>
|
||||
public static class UniromUploader
|
||||
{
|
||||
// Protocol constants
|
||||
private const string CHALLENGE_SEND_EXE = "SEXE";
|
||||
private const string RESPONSE_OK = "OKAY";
|
||||
private const int CHUNK_SIZE = 2048;
|
||||
private const int HEADER_SIZE = 0x800; // 2048
|
||||
private const int SERIAL_TIMEOUT_MS = 5000;
|
||||
|
||||
// Protocol version — negotiated during handshake
|
||||
private static int _protocolVersion = 1;
|
||||
|
||||
/// <summary>
|
||||
/// Uploads a .ps-exe file to the PS1 via serial.
|
||||
/// The PS1 must be at the Unirom shell prompt.
|
||||
/// </summary>
|
||||
public static bool UploadExe(string portName, int baudRate, string exePath, Action<string> log)
|
||||
{
|
||||
var port = DoUpload(portName, baudRate, exePath, log, installDebugHooks: false);
|
||||
if (port == null) return false;
|
||||
try { port.Close(); } catch { }
|
||||
port.Dispose();
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Uploads a .ps-exe with Unirom debug hooks installed, using SBIN+JUMP
|
||||
/// instead of SEXE to avoid BIOS Exec() clobbering the debug handler.
|
||||
///
|
||||
/// Flow: DEBG (install kernel-resident debug hooks) → SBIN (raw binary to address)
|
||||
/// → JUMP (start execution at entry point). This bypasses BIOS Exec() entirely,
|
||||
/// so the exception vector table patched by DEBG survives into the running program.
|
||||
///
|
||||
/// Returns the open SerialPort for the caller to use for PCDrv monitoring.
|
||||
/// The caller takes ownership of the returned port.
|
||||
/// </summary>
|
||||
public static SerialPort UploadExeForPCdrv(string portName, int baudRate, string exePath, Action<string> log)
|
||||
{
|
||||
return DoUploadSBIN(portName, baudRate, exePath, log);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Core SEXE upload implementation. Opens port, optionally sends DEBG, does SEXE upload.
|
||||
/// Used by UploadExe() for simple uploads without PCDrv.
|
||||
/// Returns the open SerialPort (caller must close/dispose when done).
|
||||
/// Returns null on failure.
|
||||
/// </summary>
|
||||
private static SerialPort DoUpload(string portName, int baudRate, string exePath, Action<string> log, bool installDebugHooks)
|
||||
{
|
||||
if (!File.Exists(exePath))
|
||||
{
|
||||
log?.Invoke($"File not found: {exePath}");
|
||||
return null;
|
||||
}
|
||||
|
||||
byte[] exeData = File.ReadAllBytes(exePath);
|
||||
log?.Invoke($"Uploading {Path.GetFileName(exePath)} ({exeData.Length} bytes)");
|
||||
|
||||
// Pad to 2048-byte sector boundary (required by Unirom)
|
||||
int mod = exeData.Length % CHUNK_SIZE;
|
||||
if (mod != 0)
|
||||
{
|
||||
int paddingRequired = CHUNK_SIZE - mod;
|
||||
byte[] padded = new byte[exeData.Length + paddingRequired];
|
||||
Buffer.BlockCopy(exeData, 0, padded, 0, exeData.Length);
|
||||
exeData = padded;
|
||||
log?.Invoke($"Padded to {exeData.Length} bytes (2048-byte boundary)");
|
||||
}
|
||||
|
||||
_protocolVersion = 1;
|
||||
SerialPort port = null;
|
||||
|
||||
try
|
||||
{
|
||||
port = new SerialPort(portName, baudRate)
|
||||
{
|
||||
ReadTimeout = SERIAL_TIMEOUT_MS,
|
||||
WriteTimeout = SERIAL_TIMEOUT_MS,
|
||||
StopBits = StopBits.Two,
|
||||
Parity = Parity.None,
|
||||
DataBits = 8,
|
||||
Handshake = Handshake.None,
|
||||
DtrEnable = true,
|
||||
RtsEnable = true
|
||||
};
|
||||
port.Open();
|
||||
|
||||
// Drain any leftover bytes in the buffer
|
||||
while (port.BytesToRead > 0)
|
||||
port.ReadByte();
|
||||
|
||||
// ── Step 0 (PCDrv only): Install debug hooks while Unirom is still in command mode ──
|
||||
if (installDebugHooks)
|
||||
{
|
||||
log?.Invoke("Installing debug hooks (DEBG)...");
|
||||
if (!ChallengeResponse(port, "DEBG", "OKAY", log))
|
||||
{
|
||||
log?.Invoke("WARNING: DEBG failed. Is Unirom at the shell? PCDrv may not work.");
|
||||
}
|
||||
else
|
||||
{
|
||||
log?.Invoke("Debug hooks installed.");
|
||||
}
|
||||
|
||||
Thread.Sleep(100);
|
||||
while (port.BytesToRead > 0)
|
||||
port.ReadByte();
|
||||
}
|
||||
|
||||
// ── Step 1: Challenge/Response handshake ──
|
||||
log?.Invoke("Sending SEXE challenge...");
|
||||
if (!ChallengeResponse(port, CHALLENGE_SEND_EXE, RESPONSE_OK, log))
|
||||
{
|
||||
log?.Invoke("No response from Unirom. Is the PS1 at the Unirom shell?");
|
||||
port.Close(); port.Dispose();
|
||||
return null;
|
||||
}
|
||||
log?.Invoke($"Unirom responded (protocol V{_protocolVersion}). Starting transfer...");
|
||||
|
||||
// ── Step 2: Calculate checksum (skip first 0x800 header sector) ──
|
||||
uint checksum = CalculateChecksum(exeData, skipFirstSector: true);
|
||||
|
||||
// ── Step 3: Send the 2048-byte header sector ──
|
||||
port.Write(exeData, 0, HEADER_SIZE);
|
||||
|
||||
// ── Step 4: Send metadata ──
|
||||
port.Write(exeData, 0x10, 4); // Jump/PC address
|
||||
port.Write(exeData, 0x18, 4); // Base/write address
|
||||
port.Write(BitConverter.GetBytes(exeData.Length - HEADER_SIZE), 0, 4); // Data length
|
||||
port.Write(BitConverter.GetBytes(checksum), 0, 4); // Checksum
|
||||
|
||||
// ── Step 5: Send data chunks (skip first sector) ──
|
||||
if (!WriteChunked(port, exeData, skipFirstSector: true, log))
|
||||
{
|
||||
log?.Invoke("Data transfer failed.");
|
||||
port.Close(); port.Dispose();
|
||||
return null;
|
||||
}
|
||||
|
||||
log?.Invoke("Upload complete. Exe executing on PS1.");
|
||||
return port;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
log?.Invoke($"Upload failed: {ex.Message}");
|
||||
if (port != null && port.IsOpen)
|
||||
{
|
||||
try { port.Close(); } catch { }
|
||||
}
|
||||
port?.Dispose();
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Uploads a .ps-exe using DEBG + SBIN + JUMP to preserve debug hooks.
|
||||
///
|
||||
/// Unlike SEXE which calls BIOS Exec() (reinitializing the exception vector table
|
||||
/// and destroying DEBG's kernel-resident debug handler), SBIN writes raw bytes
|
||||
/// directly to the target address and JUMP starts execution without touching
|
||||
/// the BIOS. This preserves the break-instruction handler that PCDrv depends on.
|
||||
///
|
||||
/// Protocol:
|
||||
/// 1. DEBG → OKAY: Install kernel-resident SIO debug stub
|
||||
/// 2. SBIN → OKAY: addr(4 LE) + len(4 LE) + checksum(4 LE) + raw program data
|
||||
/// 3. JUMP → OKAY: addr(4 LE) — jump to entry point
|
||||
/// </summary>
|
||||
private static SerialPort DoUploadSBIN(string portName, int baudRate, string exePath, Action<string> log)
|
||||
{
|
||||
if (!File.Exists(exePath))
|
||||
{
|
||||
log?.Invoke($"File not found: {exePath}");
|
||||
return null;
|
||||
}
|
||||
|
||||
byte[] exeData = File.ReadAllBytes(exePath);
|
||||
log?.Invoke($"Uploading {Path.GetFileName(exePath)} ({exeData.Length} bytes) via SBIN+JUMP");
|
||||
|
||||
// Validate this is a PS-X EXE
|
||||
if (exeData.Length < HEADER_SIZE + 4)
|
||||
{
|
||||
log?.Invoke("File too small to be a valid PS-X EXE.");
|
||||
return null;
|
||||
}
|
||||
string magic = Encoding.ASCII.GetString(exeData, 0, 8);
|
||||
if (!magic.StartsWith("PS-X EXE"))
|
||||
{
|
||||
log?.Invoke($"Not a PS-X EXE (magic: '{magic}')");
|
||||
return null;
|
||||
}
|
||||
|
||||
// Parse header
|
||||
uint entryPoint = BitConverter.ToUInt32(exeData, 0x10); // PC / jump address
|
||||
uint destAddr = BitConverter.ToUInt32(exeData, 0x18); // Copy destination
|
||||
uint textSize = BitConverter.ToUInt32(exeData, 0x1C); // Text section size
|
||||
|
||||
log?.Invoke($"PS-X EXE: entry=0x{entryPoint:X8}, dest=0x{destAddr:X8}, textSz=0x{textSize:X}");
|
||||
|
||||
// Extract program data (everything after the 2048-byte header)
|
||||
int progDataLen = exeData.Length - HEADER_SIZE;
|
||||
byte[] progData = new byte[progDataLen];
|
||||
Buffer.BlockCopy(exeData, HEADER_SIZE, progData, 0, progDataLen);
|
||||
|
||||
// Pad program data to 2048-byte boundary (required by Unirom chunked transfer)
|
||||
int mod = progData.Length % CHUNK_SIZE;
|
||||
if (mod != 0)
|
||||
{
|
||||
int paddingRequired = CHUNK_SIZE - mod;
|
||||
byte[] padded = new byte[progData.Length + paddingRequired];
|
||||
Buffer.BlockCopy(progData, 0, padded, 0, progData.Length);
|
||||
progData = padded;
|
||||
log?.Invoke($"Program data padded to {progData.Length} bytes");
|
||||
}
|
||||
|
||||
_protocolVersion = 1;
|
||||
SerialPort port = null;
|
||||
|
||||
try
|
||||
{
|
||||
port = new SerialPort(portName, baudRate)
|
||||
{
|
||||
ReadTimeout = SERIAL_TIMEOUT_MS,
|
||||
WriteTimeout = SERIAL_TIMEOUT_MS,
|
||||
StopBits = StopBits.Two,
|
||||
Parity = Parity.None,
|
||||
DataBits = 8,
|
||||
Handshake = Handshake.None,
|
||||
DtrEnable = true,
|
||||
RtsEnable = true
|
||||
};
|
||||
port.Open();
|
||||
|
||||
// Drain any leftover bytes
|
||||
while (port.BytesToRead > 0)
|
||||
port.ReadByte();
|
||||
|
||||
// ── Step 1: DEBG — Install kernel-resident debug hooks ──
|
||||
log?.Invoke("Installing debug hooks (DEBG)...");
|
||||
if (!ChallengeResponse(port, "DEBG", "OKAY", log))
|
||||
{
|
||||
log?.Invoke("DEBG failed. Is Unirom at the shell?");
|
||||
port.Close(); port.Dispose();
|
||||
return null;
|
||||
}
|
||||
log?.Invoke("Debug hooks installed.");
|
||||
|
||||
// Drain + settle — Unirom may send extra bytes after DEBG
|
||||
Thread.Sleep(100);
|
||||
while (port.BytesToRead > 0)
|
||||
port.ReadByte();
|
||||
|
||||
// ── Step 2: SBIN — Upload raw program data to target address ──
|
||||
log?.Invoke($"Sending SBIN to 0x{destAddr:X8} ({progData.Length} bytes)...");
|
||||
if (!ChallengeResponse(port, "SBIN", "OKAY", log))
|
||||
{
|
||||
log?.Invoke("SBIN failed. Unirom may not support this command.");
|
||||
port.Close(); port.Dispose();
|
||||
return null;
|
||||
}
|
||||
|
||||
// SBIN metadata: address(4) + length(4) + checksum(4)
|
||||
uint checksum = CalculateChecksum(progData, skipFirstSector: false);
|
||||
port.Write(BitConverter.GetBytes(destAddr), 0, 4);
|
||||
port.Write(BitConverter.GetBytes(progData.Length), 0, 4);
|
||||
port.Write(BitConverter.GetBytes(checksum), 0, 4);
|
||||
|
||||
log?.Invoke($"SBIN metadata sent (checksum=0x{checksum:X8}). Sending data...");
|
||||
|
||||
// Send program data chunks
|
||||
if (!WriteChunked(port, progData, skipFirstSector: false, log))
|
||||
{
|
||||
log?.Invoke("SBIN data transfer failed.");
|
||||
port.Close(); port.Dispose();
|
||||
return null;
|
||||
}
|
||||
log?.Invoke("SBIN upload complete.");
|
||||
|
||||
// Drain any residual
|
||||
Thread.Sleep(100);
|
||||
while (port.BytesToRead > 0)
|
||||
port.ReadByte();
|
||||
|
||||
// ── Step 3: JUMP — Start execution at entry point ──
|
||||
log?.Invoke($"Sending JUMP to 0x{entryPoint:X8}...");
|
||||
if (!ChallengeResponse(port, "JUMP", "OKAY", log))
|
||||
{
|
||||
log?.Invoke("JUMP failed.");
|
||||
port.Close(); port.Dispose();
|
||||
return null;
|
||||
}
|
||||
// JUMP payload: just the address (4 bytes LE)
|
||||
port.Write(BitConverter.GetBytes(entryPoint), 0, 4);
|
||||
|
||||
log?.Invoke("JUMP sent. Exe now running (debug hooks preserved).");
|
||||
return port;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
log?.Invoke($"Upload failed: {ex.Message}");
|
||||
if (port != null && port.IsOpen)
|
||||
{
|
||||
try { port.Close(); } catch { }
|
||||
}
|
||||
port?.Dispose();
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Challenge / Response with protocol negotiation
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
private static bool ChallengeResponse(SerialPort port, string challenge, string expectedResponse, Action<string> log)
|
||||
{
|
||||
// Send the challenge
|
||||
byte[] challengeBytes = Encoding.ASCII.GetBytes(challenge);
|
||||
port.Write(challengeBytes, 0, challengeBytes.Length);
|
||||
Thread.Sleep(50);
|
||||
|
||||
// Wait for the response with protocol negotiation
|
||||
return WaitResponse(port, expectedResponse, log);
|
||||
}
|
||||
|
||||
private static bool WaitResponse(SerialPort port, string expected, Action<string> log, int timeoutMs = 10000)
|
||||
{
|
||||
string buffer = "";
|
||||
DateTime deadline = DateTime.Now.AddMilliseconds(timeoutMs);
|
||||
|
||||
while (DateTime.Now < deadline)
|
||||
{
|
||||
if (port.BytesToRead > 0)
|
||||
{
|
||||
buffer += (char)port.ReadByte();
|
||||
|
||||
// Keep buffer at 4 chars max (rolling window)
|
||||
if (buffer.Length > 4)
|
||||
buffer = buffer.Substring(buffer.Length - 4);
|
||||
|
||||
// Protocol V3 upgrade (DJB2 checksums)
|
||||
// Always respond — Unirom re-offers V2/V3 for each command,
|
||||
// and our protocolVersion may already be >1 from a prior DEBG exchange.
|
||||
if (buffer == "OKV3")
|
||||
{
|
||||
log?.Invoke("Upgraded to protocol V3");
|
||||
byte[] upv3 = Encoding.ASCII.GetBytes("UPV3");
|
||||
port.Write(upv3, 0, upv3.Length);
|
||||
_protocolVersion = 3;
|
||||
buffer = "";
|
||||
continue;
|
||||
}
|
||||
|
||||
// Protocol V2 upgrade (per-chunk checksums)
|
||||
if (buffer == "OKV2")
|
||||
{
|
||||
log?.Invoke("Upgraded to protocol V2");
|
||||
byte[] upv2 = Encoding.ASCII.GetBytes("UPV2");
|
||||
port.Write(upv2, 0, upv2.Length);
|
||||
if (_protocolVersion < 2) _protocolVersion = 2;
|
||||
buffer = "";
|
||||
continue;
|
||||
}
|
||||
|
||||
// Unsupported in debug mode
|
||||
if (buffer == "UNSP")
|
||||
{
|
||||
log?.Invoke("Command not supported while Unirom is in debug mode.");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Got the expected response
|
||||
if (buffer == expected)
|
||||
return true;
|
||||
}
|
||||
else
|
||||
{
|
||||
Thread.Sleep(1);
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Chunked data transfer with per-chunk checksum verification
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
private static bool WriteChunked(SerialPort port, byte[] data, bool skipFirstSector, Action<string> log)
|
||||
{
|
||||
int start = skipFirstSector ? CHUNK_SIZE : 0;
|
||||
int totalDataBytes = data.Length - start;
|
||||
int numChunks = (totalDataBytes + CHUNK_SIZE - 1) / CHUNK_SIZE;
|
||||
int chunkIndex = 0;
|
||||
|
||||
for (int offset = start; offset < data.Length; )
|
||||
{
|
||||
// Determine chunk size (last chunk may be smaller)
|
||||
int thisChunk = Math.Min(CHUNK_SIZE, data.Length - offset);
|
||||
|
||||
// Calculate per-chunk checksum (simple byte sum for V2, also works for V1)
|
||||
ulong chunkChecksum = 0;
|
||||
for (int j = 0; j < thisChunk; j++)
|
||||
chunkChecksum += data[offset + j];
|
||||
|
||||
// Send the chunk
|
||||
port.Write(data, offset, thisChunk);
|
||||
|
||||
// Wait for bytes to drain
|
||||
while (port.BytesToWrite > 0)
|
||||
Thread.Sleep(0);
|
||||
|
||||
chunkIndex++;
|
||||
|
||||
// Progress report every 10 chunks or on last chunk
|
||||
if (chunkIndex % 10 == 0 || offset + thisChunk >= data.Length)
|
||||
{
|
||||
int sent = offset + thisChunk - start;
|
||||
int pct = totalDataBytes > 0 ? sent * 100 / totalDataBytes : 100;
|
||||
log?.Invoke($"Upload: {pct}% ({sent}/{totalDataBytes})");
|
||||
}
|
||||
|
||||
// Protocol V2/V3: per-chunk checksum verification
|
||||
if (_protocolVersion >= 2)
|
||||
{
|
||||
if (!HandleChunkAck(port, chunkChecksum, data, offset, thisChunk, log, out bool retry))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if (retry)
|
||||
continue; // Don't advance offset — resend this chunk
|
||||
}
|
||||
|
||||
offset += thisChunk;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles the per-chunk CHEK/MORE/ERR! exchange for protocol V2+.
|
||||
/// </summary>
|
||||
private static bool HandleChunkAck(SerialPort port, ulong chunkChecksum, byte[] data, int offset, int chunkSize, Action<string> log, out bool retry)
|
||||
{
|
||||
retry = false;
|
||||
|
||||
// Wait for "CHEK" request from Unirom
|
||||
string cmdBuffer = "";
|
||||
DateTime deadline = DateTime.Now.AddMilliseconds(SERIAL_TIMEOUT_MS);
|
||||
|
||||
while (DateTime.Now < deadline)
|
||||
{
|
||||
if (port.BytesToRead > 0)
|
||||
{
|
||||
cmdBuffer += (char)port.ReadByte();
|
||||
if (cmdBuffer.Length > 4)
|
||||
cmdBuffer = cmdBuffer.Substring(cmdBuffer.Length - 4);
|
||||
|
||||
if (cmdBuffer == "CHEK")
|
||||
break;
|
||||
}
|
||||
else
|
||||
{
|
||||
Thread.Sleep(1);
|
||||
}
|
||||
}
|
||||
|
||||
if (cmdBuffer != "CHEK")
|
||||
{
|
||||
log?.Invoke("Timeout waiting for CHEK from Unirom");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Send the chunk checksum (4 bytes, little-endian)
|
||||
port.Write(BitConverter.GetBytes((uint)chunkChecksum), 0, 4);
|
||||
Thread.Sleep(1);
|
||||
|
||||
// Wait for MORE (ok) or ERR! (resend)
|
||||
cmdBuffer = "";
|
||||
deadline = DateTime.Now.AddMilliseconds(SERIAL_TIMEOUT_MS);
|
||||
|
||||
while (DateTime.Now < deadline)
|
||||
{
|
||||
if (port.BytesToRead > 0)
|
||||
{
|
||||
cmdBuffer += (char)port.ReadByte();
|
||||
if (cmdBuffer.Length > 4)
|
||||
cmdBuffer = cmdBuffer.Substring(cmdBuffer.Length - 4);
|
||||
|
||||
if (cmdBuffer == "MORE")
|
||||
return true;
|
||||
|
||||
if (cmdBuffer == "ERR!")
|
||||
{
|
||||
log?.Invoke("Checksum error — retrying chunk...");
|
||||
retry = true;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
Thread.Sleep(1);
|
||||
}
|
||||
}
|
||||
|
||||
log?.Invoke("Timeout waiting for MORE/ERR! from Unirom");
|
||||
return false;
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Checksum calculation
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
private static uint CalculateChecksum(byte[] data, bool skipFirstSector)
|
||||
{
|
||||
int start = skipFirstSector ? HEADER_SIZE : 0;
|
||||
|
||||
if (_protocolVersion == 3)
|
||||
{
|
||||
// DJB2 hash
|
||||
uint hash = 5381;
|
||||
for (int i = start; i < data.Length; i++)
|
||||
hash = ((hash << 5) + hash) ^ data[i];
|
||||
return hash;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Simple byte sum
|
||||
uint sum = 0;
|
||||
for (int i = start; i < data.Length; i++)
|
||||
sum += data[i];
|
||||
return sum;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
2
Editor/Core/UniromUploader.cs.meta
Normal file
2
Editor/Core/UniromUploader.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: e39963a5097ad6a48952a0a9d04d1563
|
||||
Reference in New Issue
Block a user