This commit is contained in:
Jan Racek
2026-03-24 13:00:54 +01:00
parent 53e993f58e
commit 4aa4e49424
145 changed files with 10853 additions and 2965 deletions

View 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);
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: b3eaffbb1caed9648b5b57d211ead4d6

View 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;
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: d27c6a94b1c1f07418799b65d13f7097

View 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);
}
}
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 372b2ef07e125584ba43312b0662d7ac

View 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();
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: c4e13fc5b859ac14099eb9f259ba11f0

View 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;
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 8aefa79a412d32c4f8bc8249bb4cd118

View 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);
}
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 3988772ca929eb14ea3bee6b643de4d0

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 5540e6cbefeb70d48a0c1e3843719784

View 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);
}
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 4765dbe728569d84699a22347e7c14ff

View 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;
}
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: e39963a5097ad6a48952a0a9d04d1563