psst
This commit is contained in:
8
Editor/Core.meta
Normal file
8
Editor/Core.meta
Normal file
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 8e74ebc4b575d27499f7abd4d82b8849
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
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
|
||||
@@ -1,21 +1,39 @@
|
||||
using UnityEditor;
|
||||
using UnityEditor.Callbacks;
|
||||
|
||||
[InitializeOnLoad]
|
||||
public static class DependencyCheckInitializer
|
||||
namespace SplashEdit.EditorCode
|
||||
{
|
||||
static DependencyCheckInitializer()
|
||||
/// <summary>
|
||||
/// Automatically opens the SplashEdit Control Panel on the first editor
|
||||
/// session if the MIPS toolchain has not been installed yet.
|
||||
/// </summary>
|
||||
[InitializeOnLoad]
|
||||
public static class DependencyCheckInitializer
|
||||
{
|
||||
EditorApplication.update += OpenInstallerOnStart;
|
||||
}
|
||||
private const string SessionKey = "SplashEditOpenedThisSession";
|
||||
|
||||
private static void OpenInstallerOnStart()
|
||||
{
|
||||
EditorApplication.update -= OpenInstallerOnStart;
|
||||
if (!SessionState.GetBool("InstallerWindowOpened", false))
|
||||
static DependencyCheckInitializer()
|
||||
{
|
||||
InstallerWindow.ShowWindow();
|
||||
SessionState.SetBool("InstallerWindowOpened", true); // only once per session
|
||||
EditorApplication.update += OpenControlPanelOnStart;
|
||||
}
|
||||
|
||||
private static void OpenControlPanelOnStart()
|
||||
{
|
||||
EditorApplication.update -= OpenControlPanelOnStart;
|
||||
|
||||
if (SessionState.GetBool(SessionKey, false))
|
||||
return;
|
||||
|
||||
SessionState.SetBool(SessionKey, true);
|
||||
|
||||
// Only auto-open the Control Panel when the toolchain is missing
|
||||
bool toolchainReady = ToolchainChecker.IsToolAvailable("mips") ||
|
||||
ToolchainChecker.IsToolAvailable("mipsel-none-elf-gcc") ||
|
||||
ToolchainChecker.IsToolAvailable("mipsel-linux-gnu-gcc");
|
||||
|
||||
if (!toolchainReady)
|
||||
{
|
||||
SplashControlPanel.ShowWindow();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 044a70d388c22fd2aa69bb757eb4c071
|
||||
guid: c7043b9e1acbfbe40b9bd9be80e764e5
|
||||
@@ -1,522 +0,0 @@
|
||||
using UnityEngine;
|
||||
using UnityEditor;
|
||||
using System.Collections.Generic;
|
||||
using SplashEdit.RuntimeCode;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
|
||||
namespace SplashEdit.EditorCode
|
||||
{
|
||||
public class InstallerWindow : EditorWindow
|
||||
{
|
||||
// Cached status for MIPS toolchain binaries.
|
||||
private Dictionary<string, bool> mipsToolStatus = new Dictionary<string, bool>();
|
||||
|
||||
// Cached status for optional tools.
|
||||
private bool makeInstalled;
|
||||
private bool gdbInstalled;
|
||||
private string pcsxReduxPath;
|
||||
|
||||
// PSXSplash related variables
|
||||
private bool psxsplashInstalled = false;
|
||||
private bool psxsplashInstalling = false;
|
||||
private bool psxsplashFetching = false;
|
||||
private string selectedVersion = "main";
|
||||
private Dictionary<string, string> availableBranches = new Dictionary<string, string>();
|
||||
private List<string> availableReleases = new List<string>();
|
||||
private bool showBranches = true;
|
||||
private bool showReleases = false;
|
||||
private Vector2 scrollPosition;
|
||||
private Vector2 versionScrollPosition;
|
||||
|
||||
private bool isInstalling = false;
|
||||
|
||||
[MenuItem("PSX/Toolchain & Build Tools Installer")]
|
||||
public static void ShowWindow()
|
||||
{
|
||||
InstallerWindow window = GetWindow<InstallerWindow>("Toolchain Installer");
|
||||
window.RefreshToolStatus();
|
||||
window.pcsxReduxPath = DataStorage.LoadData().PCSXReduxPath;
|
||||
window.CheckPSXSplashInstallation();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Refresh the cached statuses for all tools.
|
||||
/// </summary>
|
||||
private void RefreshToolStatus()
|
||||
{
|
||||
mipsToolStatus.Clear();
|
||||
foreach (var tool in ToolchainChecker.GetRequiredTools())
|
||||
{
|
||||
mipsToolStatus[tool] = ToolchainChecker.IsToolAvailable(tool);
|
||||
}
|
||||
|
||||
makeInstalled = ToolchainChecker.IsToolAvailable("make");
|
||||
gdbInstalled = ToolchainChecker.IsToolAvailable("gdb-multiarch");
|
||||
}
|
||||
|
||||
private void CheckPSXSplashInstallation()
|
||||
{
|
||||
psxsplashInstalled = PSXSplashInstaller.IsInstalled();
|
||||
|
||||
if (psxsplashInstalled)
|
||||
{
|
||||
FetchPSXSplashVersions();
|
||||
}
|
||||
else
|
||||
{
|
||||
availableBranches = new Dictionary<string, string>();
|
||||
availableReleases = new List<string>();
|
||||
}
|
||||
}
|
||||
|
||||
private async void FetchPSXSplashVersions()
|
||||
{
|
||||
if (psxsplashFetching) return;
|
||||
|
||||
psxsplashFetching = true;
|
||||
try
|
||||
{
|
||||
// Fetch latest from remote
|
||||
await PSXSplashInstaller.FetchLatestAsync();
|
||||
|
||||
// Get all available versions
|
||||
var branchesTask = PSXSplashInstaller.GetBranchesWithLatestCommitsAsync();
|
||||
var releasesTask = PSXSplashInstaller.GetReleasesAsync();
|
||||
|
||||
await Task.WhenAll(branchesTask, releasesTask);
|
||||
|
||||
availableBranches = branchesTask.Result;
|
||||
availableReleases = releasesTask.Result;
|
||||
|
||||
// If no branches were found, add main as default
|
||||
if (!availableBranches.Any())
|
||||
{
|
||||
availableBranches["main"] = "latest";
|
||||
}
|
||||
|
||||
// Select the first branch by default
|
||||
if (availableBranches.Any() && string.IsNullOrEmpty(selectedVersion))
|
||||
{
|
||||
selectedVersion = availableBranches.Keys.First();
|
||||
}
|
||||
|
||||
Repaint();
|
||||
}
|
||||
catch (System.Exception e)
|
||||
{
|
||||
UnityEngine.Debug.LogError($"Failed to fetch PSXSplash versions: {e.Message}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
psxsplashFetching = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnGUI()
|
||||
{
|
||||
scrollPosition = EditorGUILayout.BeginScrollView(scrollPosition);
|
||||
|
||||
GUILayout.Label("Toolchain & Build Tools Installer", EditorStyles.boldLabel);
|
||||
GUILayout.Space(5);
|
||||
|
||||
if (GUILayout.Button("Refresh Status"))
|
||||
{
|
||||
RefreshToolStatus();
|
||||
CheckPSXSplashInstallation();
|
||||
}
|
||||
GUILayout.Space(10);
|
||||
|
||||
EditorGUILayout.BeginHorizontal();
|
||||
DrawToolchainColumn();
|
||||
DrawAdditionalToolsColumn();
|
||||
DrawPSXSplashColumn();
|
||||
EditorGUILayout.EndHorizontal();
|
||||
|
||||
EditorGUILayout.EndScrollView();
|
||||
}
|
||||
|
||||
private void DrawToolchainColumn()
|
||||
{
|
||||
EditorGUILayout.BeginVertical("box", GUILayout.MaxWidth(position.width / 3 - 10));
|
||||
GUILayout.Label("MIPS Toolchain", EditorStyles.boldLabel);
|
||||
GUILayout.Space(5);
|
||||
|
||||
// Display cached status for each required MIPS tool.
|
||||
foreach (var kvp in mipsToolStatus)
|
||||
{
|
||||
GUI.color = kvp.Value ? Color.green : Color.red;
|
||||
GUILayout.Label($"{kvp.Key}: {(kvp.Value ? "Found" : "Missing")}");
|
||||
}
|
||||
GUI.color = Color.white;
|
||||
GUILayout.Space(5);
|
||||
|
||||
if (GUILayout.Button("Install MIPS Toolchain"))
|
||||
{
|
||||
if (!isInstalling)
|
||||
InstallMipsToolchainAsync();
|
||||
}
|
||||
EditorGUILayout.EndVertical();
|
||||
}
|
||||
|
||||
private void DrawAdditionalToolsColumn()
|
||||
{
|
||||
EditorGUILayout.BeginVertical("box", GUILayout.MaxWidth(position.width / 3 - 10));
|
||||
GUILayout.Label("Optional Tools", EditorStyles.boldLabel);
|
||||
GUILayout.Space(5);
|
||||
|
||||
// GNU Make status (required).
|
||||
GUI.color = makeInstalled ? Color.green : Color.red;
|
||||
GUILayout.Label($"GNU Make: {(makeInstalled ? "Found" : "Missing")} (Required)");
|
||||
GUI.color = Color.white;
|
||||
GUILayout.Space(5);
|
||||
if (GUILayout.Button("Install GNU Make"))
|
||||
{
|
||||
if (!isInstalling)
|
||||
InstallMakeAsync();
|
||||
}
|
||||
|
||||
GUILayout.Space(10);
|
||||
|
||||
// GDB status (optional).
|
||||
GUI.color = gdbInstalled ? Color.green : Color.red;
|
||||
GUILayout.Label($"GDB: {(gdbInstalled ? "Found" : "Missing")} (Optional)");
|
||||
GUI.color = Color.white;
|
||||
GUILayout.Space(5);
|
||||
if (GUILayout.Button("Install GDB"))
|
||||
{
|
||||
if (!isInstalling)
|
||||
InstallGDBAsync();
|
||||
}
|
||||
|
||||
GUILayout.Space(10);
|
||||
|
||||
// PCSX-Redux (manual install)
|
||||
GUI.color = string.IsNullOrEmpty(pcsxReduxPath) ? Color.red : Color.green;
|
||||
GUILayout.Label($"PCSX-Redux: {(string.IsNullOrEmpty(pcsxReduxPath) ? "Not Configured" : "Configured")} (Optional)");
|
||||
GUI.color = Color.white;
|
||||
|
||||
GUILayout.BeginHorizontal();
|
||||
if (GUILayout.Button("Browse for PCSX-Redux"))
|
||||
{
|
||||
string selectedPath = EditorUtility.OpenFilePanel("Select PCSX-Redux Executable", "", "");
|
||||
if (!string.IsNullOrEmpty(selectedPath))
|
||||
{
|
||||
pcsxReduxPath = selectedPath;
|
||||
PSXData data = DataStorage.LoadData();
|
||||
data.PCSXReduxPath = pcsxReduxPath;
|
||||
DataStorage.StoreData(data);
|
||||
}
|
||||
}
|
||||
if (!string.IsNullOrEmpty(pcsxReduxPath))
|
||||
{
|
||||
if (GUILayout.Button("Clear", GUILayout.Width(60)))
|
||||
{
|
||||
pcsxReduxPath = "";
|
||||
PSXData data = DataStorage.LoadData();
|
||||
data.PCSXReduxPath = pcsxReduxPath;
|
||||
DataStorage.StoreData(data);
|
||||
}
|
||||
}
|
||||
GUILayout.EndHorizontal();
|
||||
EditorGUILayout.EndVertical();
|
||||
}
|
||||
|
||||
private void DrawPSXSplashColumn()
|
||||
{
|
||||
EditorGUILayout.BeginVertical("box", GUILayout.MaxWidth(position.width / 3 - 10));
|
||||
GUILayout.Label("PSXSplash", EditorStyles.boldLabel);
|
||||
GUILayout.Space(5);
|
||||
|
||||
// PSXSplash status
|
||||
GUI.color = psxsplashInstalled ? Color.green : Color.red;
|
||||
GUILayout.Label($"PSXSplash: {(psxsplashInstalled ? "Installed" : "Not Installed")}");
|
||||
GUI.color = Color.white;
|
||||
|
||||
if (psxsplashFetching)
|
||||
{
|
||||
GUILayout.Label("Fetching versions...");
|
||||
}
|
||||
else if (!psxsplashInstalled)
|
||||
{
|
||||
GUILayout.Space(5);
|
||||
EditorGUILayout.HelpBox("Git is required to install PSXSplash. Make sure it's installed and available in your PATH.", MessageType.Info);
|
||||
|
||||
// Show version selection even before installation
|
||||
DrawVersionSelection();
|
||||
|
||||
if (GUILayout.Button("Install PSXSplash") && !psxsplashInstalling)
|
||||
{
|
||||
InstallPSXSplashAsync();
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
GUILayout.Space(10);
|
||||
|
||||
// Current version
|
||||
EditorGUILayout.LabelField($"Current Version: {selectedVersion}", EditorStyles.boldLabel);
|
||||
|
||||
// Version selection
|
||||
DrawVersionSelection();
|
||||
|
||||
GUILayout.Space(10);
|
||||
|
||||
// Refresh and update buttons
|
||||
EditorGUILayout.BeginHorizontal();
|
||||
if (GUILayout.Button("Refresh Versions"))
|
||||
{
|
||||
FetchPSXSplashVersions();
|
||||
}
|
||||
|
||||
if (GUILayout.Button("Update PSXSplash"))
|
||||
{
|
||||
UpdatePSXSplashAsync();
|
||||
}
|
||||
EditorGUILayout.EndHorizontal();
|
||||
}
|
||||
|
||||
EditorGUILayout.EndVertical();
|
||||
}
|
||||
|
||||
private void DrawVersionSelection()
|
||||
{
|
||||
EditorGUILayout.LabelField("Available Versions:", EditorStyles.boldLabel);
|
||||
|
||||
versionScrollPosition = EditorGUILayout.BeginScrollView(versionScrollPosition, GUILayout.Height(200));
|
||||
|
||||
// Branches (with latest commits)
|
||||
showBranches = EditorGUILayout.Foldout(showBranches, $"Branches ({availableBranches.Count})");
|
||||
if (showBranches && availableBranches.Any())
|
||||
{
|
||||
foreach (var branch in availableBranches)
|
||||
{
|
||||
EditorGUILayout.BeginHorizontal();
|
||||
bool isSelected = selectedVersion == branch.Key;
|
||||
if (GUILayout.Toggle(isSelected, "", GUILayout.Width(20)) && !isSelected)
|
||||
{
|
||||
selectedVersion = branch.Key;
|
||||
if (psxsplashInstalled)
|
||||
{
|
||||
CheckoutPSXSplashVersionAsync(branch.Key);
|
||||
}
|
||||
}
|
||||
GUILayout.Label($"{branch.Key} (Latest: {branch.Value})", EditorStyles.label);
|
||||
EditorGUILayout.EndHorizontal();
|
||||
}
|
||||
}
|
||||
else if (showBranches)
|
||||
{
|
||||
GUILayout.Label("No branches available");
|
||||
}
|
||||
|
||||
// Releases
|
||||
showReleases = EditorGUILayout.Foldout(showReleases, $"Releases ({availableReleases.Count})");
|
||||
if (showReleases && availableReleases.Any())
|
||||
{
|
||||
foreach (var release in availableReleases)
|
||||
{
|
||||
EditorGUILayout.BeginHorizontal();
|
||||
bool isSelected = selectedVersion == release;
|
||||
if (GUILayout.Toggle(isSelected, "", GUILayout.Width(20)) && !isSelected)
|
||||
{
|
||||
selectedVersion = release;
|
||||
if (psxsplashInstalled)
|
||||
{
|
||||
CheckoutPSXSplashVersionAsync(release);
|
||||
}
|
||||
}
|
||||
GUILayout.Label(release, EditorStyles.label);
|
||||
EditorGUILayout.EndHorizontal();
|
||||
}
|
||||
}
|
||||
else if (showReleases)
|
||||
{
|
||||
GUILayout.Label("No releases available");
|
||||
}
|
||||
|
||||
EditorGUILayout.EndScrollView();
|
||||
}
|
||||
|
||||
private async void InstallPSXSplashAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
psxsplashInstalling = true;
|
||||
EditorUtility.DisplayProgressBar("Installing PSXSplash", "Cloning repository...", 0.3f);
|
||||
|
||||
bool success = await PSXSplashInstaller.Install();
|
||||
|
||||
EditorUtility.ClearProgressBar();
|
||||
|
||||
if (success)
|
||||
{
|
||||
EditorUtility.DisplayDialog("Installation Complete", "PSXSplash installed successfully.", "OK");
|
||||
CheckPSXSplashInstallation();
|
||||
|
||||
// Checkout the selected version after installation
|
||||
if (!string.IsNullOrEmpty(selectedVersion))
|
||||
{
|
||||
await CheckoutPSXSplashVersionAsync(selectedVersion);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
EditorUtility.DisplayDialog("Installation Failed",
|
||||
"Failed to install PSXSplash. Make sure Git is installed and available in your PATH.", "OK");
|
||||
}
|
||||
}
|
||||
catch (System.Exception ex)
|
||||
{
|
||||
EditorUtility.ClearProgressBar();
|
||||
EditorUtility.DisplayDialog("Installation Failed", $"Error: {ex.Message}", "OK");
|
||||
}
|
||||
finally
|
||||
{
|
||||
psxsplashInstalling = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<bool> CheckoutPSXSplashVersionAsync(string version)
|
||||
{
|
||||
try
|
||||
{
|
||||
psxsplashInstalling = true;
|
||||
EditorUtility.DisplayProgressBar("Checking Out Version", $"Switching to {version}...", 0.3f);
|
||||
|
||||
bool success = await PSXSplashInstaller.CheckoutVersionAsync(version);
|
||||
|
||||
EditorUtility.ClearProgressBar();
|
||||
|
||||
if (success)
|
||||
{
|
||||
EditorUtility.DisplayDialog("Checkout Complete", $"Switched to {version} successfully.", "OK");
|
||||
return true;
|
||||
}
|
||||
else
|
||||
{
|
||||
EditorUtility.DisplayDialog("Checkout Failed",
|
||||
$"Failed to switch to {version}.", "OK");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
catch (System.Exception ex)
|
||||
{
|
||||
EditorUtility.ClearProgressBar();
|
||||
EditorUtility.DisplayDialog("Checkout Failed", $"Error: {ex.Message}", "OK");
|
||||
return false;
|
||||
}
|
||||
finally
|
||||
{
|
||||
psxsplashInstalling = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async void UpdatePSXSplashAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
psxsplashInstalling = true;
|
||||
EditorUtility.DisplayProgressBar("Updating PSXSplash", "Pulling latest changes...", 0.3f);
|
||||
|
||||
// Pull the latest changes
|
||||
bool success = await PSXSplashInstaller.CheckoutVersionAsync(selectedVersion);
|
||||
|
||||
EditorUtility.ClearProgressBar();
|
||||
|
||||
if (success)
|
||||
{
|
||||
EditorUtility.DisplayDialog("Update Complete", "PSXSplash updated successfully.", "OK");
|
||||
}
|
||||
else
|
||||
{
|
||||
EditorUtility.DisplayDialog("Update Failed",
|
||||
"Failed to update PSXSplash.", "OK");
|
||||
}
|
||||
}
|
||||
catch (System.Exception ex)
|
||||
{
|
||||
EditorUtility.ClearProgressBar();
|
||||
EditorUtility.DisplayDialog("Update Failed", $"Error: {ex.Message}", "OK");
|
||||
}
|
||||
finally
|
||||
{
|
||||
psxsplashInstalling = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async void InstallMipsToolchainAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
isInstalling = true;
|
||||
EditorUtility.DisplayProgressBar("Installing MIPS Toolchain",
|
||||
"Please wait while the MIPS toolchain is being installed...", 0f);
|
||||
bool success = await ToolchainInstaller.InstallToolchain();
|
||||
EditorUtility.ClearProgressBar();
|
||||
if (success)
|
||||
{
|
||||
EditorUtility.DisplayDialog("Installation Complete", "MIPS toolchain installed successfully.", "OK");
|
||||
}
|
||||
RefreshToolStatus(); // Update cached statuses after installation
|
||||
}
|
||||
catch (System.Exception ex)
|
||||
{
|
||||
EditorUtility.ClearProgressBar();
|
||||
EditorUtility.DisplayDialog("Installation Failed", $"Error: {ex.Message}", "OK");
|
||||
}
|
||||
finally
|
||||
{
|
||||
isInstalling = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async void InstallMakeAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
isInstalling = true;
|
||||
EditorUtility.DisplayProgressBar("Installing GNU Make",
|
||||
"Please wait while GNU Make is being installed...", 0f);
|
||||
await ToolchainInstaller.InstallMake();
|
||||
EditorUtility.ClearProgressBar();
|
||||
EditorUtility.DisplayDialog("Installation Complete", "GNU Make installed successfully.", "OK");
|
||||
RefreshToolStatus();
|
||||
}
|
||||
catch (System.Exception ex)
|
||||
{
|
||||
EditorUtility.ClearProgressBar();
|
||||
EditorUtility.DisplayDialog("Installation Failed", $"Error: {ex.Message}", "OK");
|
||||
}
|
||||
finally
|
||||
{
|
||||
isInstalling = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async void InstallGDBAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
isInstalling = true;
|
||||
EditorUtility.DisplayProgressBar("Installing GDB",
|
||||
"Please wait while GDB is being installed...", 0f);
|
||||
await ToolchainInstaller.InstallGDB();
|
||||
EditorUtility.ClearProgressBar();
|
||||
EditorUtility.DisplayDialog("Installation Complete", "GDB installed successfully.", "OK");
|
||||
RefreshToolStatus();
|
||||
}
|
||||
catch (System.Exception ex)
|
||||
{
|
||||
EditorUtility.ClearProgressBar();
|
||||
EditorUtility.DisplayDialog("Installation Failed", $"Error: {ex.Message}", "OK");
|
||||
}
|
||||
finally
|
||||
{
|
||||
isInstalling = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 72dec7ea237a8497abc6150ea907b3e2
|
||||
8
Editor/Inspectors.meta
Normal file
8
Editor/Inspectors.meta
Normal file
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 0279126b700b37d4485c1f4f1ae44e54
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
136
Editor/Inspectors/PSXComponentEditors.cs
Normal file
136
Editor/Inspectors/PSXComponentEditors.cs
Normal file
@@ -0,0 +1,136 @@
|
||||
using UnityEngine;
|
||||
using UnityEditor;
|
||||
using SplashEdit.RuntimeCode;
|
||||
|
||||
namespace SplashEdit.EditorCode
|
||||
{
|
||||
/// <summary>
|
||||
/// Custom inspector for PSXInteractable component.
|
||||
/// </summary>
|
||||
[CustomEditor(typeof(PSXInteractable))]
|
||||
public class PSXInteractableEditor : UnityEditor.Editor
|
||||
{
|
||||
private bool _interactionFoldout = true;
|
||||
private bool _advancedFoldout = false;
|
||||
|
||||
private SerializedProperty _interactionRadius;
|
||||
private SerializedProperty _interactButton;
|
||||
private SerializedProperty _isRepeatable;
|
||||
private SerializedProperty _cooldownFrames;
|
||||
private SerializedProperty _showPrompt;
|
||||
private SerializedProperty _requireLineOfSight;
|
||||
private SerializedProperty _interactionOffset;
|
||||
|
||||
private static readonly string[] ButtonNames =
|
||||
{
|
||||
"Select", "L3", "R3", "Start", "Up", "Right", "Down", "Left",
|
||||
"L2", "R2", "L1", "R1", "Triangle", "Circle", "Cross", "Square"
|
||||
};
|
||||
|
||||
private void OnEnable()
|
||||
{
|
||||
_interactionRadius = serializedObject.FindProperty("interactionRadius");
|
||||
_interactButton = serializedObject.FindProperty("interactButton");
|
||||
_isRepeatable = serializedObject.FindProperty("isRepeatable");
|
||||
_cooldownFrames = serializedObject.FindProperty("cooldownFrames");
|
||||
_showPrompt = serializedObject.FindProperty("showPrompt");
|
||||
_requireLineOfSight = serializedObject.FindProperty("requireLineOfSight");
|
||||
_interactionOffset = serializedObject.FindProperty("interactionOffset");
|
||||
}
|
||||
|
||||
public override void OnInspectorGUI()
|
||||
{
|
||||
serializedObject.Update();
|
||||
|
||||
DrawHeader();
|
||||
|
||||
EditorGUILayout.Space(5);
|
||||
|
||||
_interactionFoldout = DrawFoldoutSection("Interaction Settings", _interactionFoldout, () =>
|
||||
{
|
||||
EditorGUILayout.PropertyField(_interactionRadius);
|
||||
|
||||
// Button selector with visual
|
||||
EditorGUILayout.BeginHorizontal();
|
||||
EditorGUILayout.PrefixLabel("Interact Button");
|
||||
_interactButton.intValue = EditorGUILayout.Popup(_interactButton.intValue, ButtonNames);
|
||||
EditorGUILayout.EndHorizontal();
|
||||
|
||||
EditorGUILayout.PropertyField(_isRepeatable);
|
||||
|
||||
if (_isRepeatable.boolValue)
|
||||
{
|
||||
EditorGUI.indentLevel++;
|
||||
EditorGUILayout.PropertyField(_cooldownFrames, new GUIContent("Cooldown (frames)"));
|
||||
|
||||
// Show cooldown in seconds
|
||||
float seconds = _cooldownFrames.intValue / 60f;
|
||||
EditorGUILayout.LabelField($"≈ {seconds:F2} seconds at 60fps", EditorStyles.miniLabel);
|
||||
EditorGUI.indentLevel--;
|
||||
}
|
||||
|
||||
EditorGUILayout.PropertyField(_showPrompt);
|
||||
});
|
||||
|
||||
_advancedFoldout = DrawFoldoutSection("Advanced", _advancedFoldout, () =>
|
||||
{
|
||||
EditorGUILayout.PropertyField(_requireLineOfSight);
|
||||
EditorGUILayout.PropertyField(_interactionOffset);
|
||||
});
|
||||
|
||||
DrawLuaEventsInfo(new[] { "onInteract" });
|
||||
|
||||
serializedObject.ApplyModifiedProperties();
|
||||
}
|
||||
|
||||
private void DrawHeader()
|
||||
{
|
||||
EditorGUILayout.BeginHorizontal(EditorStyles.helpBox);
|
||||
|
||||
GUILayout.Label(EditorGUIUtility.IconContent("d_Selectable Icon"), GUILayout.Width(30), GUILayout.Height(30));
|
||||
|
||||
EditorGUILayout.BeginVertical();
|
||||
GUILayout.Label("PSX Interactable", EditorStyles.boldLabel);
|
||||
GUILayout.Label("Player interaction trigger for PS1", EditorStyles.miniLabel);
|
||||
EditorGUILayout.EndVertical();
|
||||
|
||||
EditorGUILayout.EndHorizontal();
|
||||
}
|
||||
|
||||
private bool DrawFoldoutSection(string title, bool isExpanded, System.Action drawContent)
|
||||
{
|
||||
EditorGUILayout.BeginVertical(EditorStyles.helpBox);
|
||||
|
||||
isExpanded = EditorGUILayout.Foldout(isExpanded, title, true, EditorStyles.foldoutHeader);
|
||||
|
||||
if (isExpanded)
|
||||
{
|
||||
EditorGUI.indentLevel++;
|
||||
drawContent?.Invoke();
|
||||
EditorGUI.indentLevel--;
|
||||
}
|
||||
|
||||
EditorGUILayout.EndVertical();
|
||||
EditorGUILayout.Space(3);
|
||||
|
||||
return isExpanded;
|
||||
}
|
||||
|
||||
private void DrawLuaEventsInfo(string[] events)
|
||||
{
|
||||
EditorGUILayout.Space(5);
|
||||
|
||||
EditorGUILayout.BeginVertical(EditorStyles.helpBox);
|
||||
GUILayout.Label("Lua Events", EditorStyles.boldLabel);
|
||||
|
||||
EditorGUILayout.BeginHorizontal();
|
||||
foreach (var evt in events)
|
||||
{
|
||||
GUILayout.Label($"• {evt}", EditorStyles.miniLabel);
|
||||
}
|
||||
EditorGUILayout.EndHorizontal();
|
||||
|
||||
EditorGUILayout.EndVertical();
|
||||
}
|
||||
}
|
||||
}
|
||||
2
Editor/Inspectors/PSXComponentEditors.cs.meta
Normal file
2
Editor/Inspectors/PSXComponentEditors.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 7bd9caaf5a0cb90409cf0acdf17d8d89
|
||||
@@ -1,15 +1,43 @@
|
||||
using Splashedit.RuntimeCode;
|
||||
using SplashEdit.RuntimeCode;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
|
||||
[CustomEditor(typeof(LuaFile))]
|
||||
public class LuaScriptAssetEditor : Editor
|
||||
namespace SplashEdit.EditorCode
|
||||
{
|
||||
private TextAsset asset;
|
||||
|
||||
public override void OnInspectorGUI()
|
||||
/// <summary>
|
||||
/// Custom inspector for <see cref="LuaFile"/> assets that displays the
|
||||
/// embedded Lua source code in a read-only text area with an option to
|
||||
/// open the source file in an external editor.
|
||||
/// </summary>
|
||||
[CustomEditor(typeof(LuaFile))]
|
||||
public class LuaScriptAssetEditor : Editor
|
||||
{
|
||||
LuaFile luaScriptAsset = (LuaFile)target;
|
||||
EditorGUILayout.TextArea(luaScriptAsset.LuaScript);
|
||||
private Vector2 _scrollPosition;
|
||||
|
||||
public override void OnInspectorGUI()
|
||||
{
|
||||
LuaFile luaScriptAsset = (LuaFile)target;
|
||||
|
||||
// Open in external editor button
|
||||
string assetPath = AssetDatabase.GetAssetPath(target);
|
||||
if (!string.IsNullOrEmpty(assetPath))
|
||||
{
|
||||
if (GUILayout.Button("Open in External Editor"))
|
||||
{
|
||||
// Opens the .lua source file in the OS-configured editor
|
||||
UnityEditorInternal.InternalEditorUtility.OpenFileAtLineExternal(assetPath, 1);
|
||||
}
|
||||
EditorGUILayout.Space(4);
|
||||
}
|
||||
|
||||
// Read-only source view
|
||||
EditorGUILayout.LabelField("Lua Source", EditorStyles.boldLabel);
|
||||
_scrollPosition = EditorGUILayout.BeginScrollView(_scrollPosition,
|
||||
GUILayout.MaxHeight(400));
|
||||
EditorGUI.BeginDisabledGroup(true);
|
||||
EditorGUILayout.TextArea(luaScriptAsset.LuaScript, GUILayout.ExpandHeight(true));
|
||||
EditorGUI.EndDisabledGroup();
|
||||
EditorGUILayout.EndScrollView();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 32c0501d523345500be12e6e4214ec9d
|
||||
guid: 66e212c64ebd0a34f9c23febe3e8545d
|
||||
@@ -2,11 +2,11 @@ using UnityEngine;
|
||||
using System.IO;
|
||||
using UnityEditor;
|
||||
using UnityEditor.AssetImporters;
|
||||
using Splashedit.RuntimeCode;
|
||||
using SplashEdit.RuntimeCode;
|
||||
|
||||
namespace SplashEdit.EditorCode
|
||||
{
|
||||
[ScriptedImporter(1, "lua")]
|
||||
[ScriptedImporter(2, "lua")]
|
||||
class LuaImporter : ScriptedImporter
|
||||
{
|
||||
public override void OnImportAsset(AssetImportContext ctx)
|
||||
@@ -19,7 +19,7 @@ namespace SplashEdit.EditorCode
|
||||
|
||||
ctx.AddObjectToAsset("Text", text);
|
||||
ctx.AddObjectToAsset("Script", asset);
|
||||
ctx.SetMainObject(text);
|
||||
ctx.SetMainObject(asset); // LuaFile is the main object, not TextAsset
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,2 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: d364a1392e3bccd77aca824ac471f89c
|
||||
guid: 74e983e6cf3376944af7b469023d6e4d
|
||||
80
Editor/PSXMenuItems.cs
Normal file
80
Editor/PSXMenuItems.cs
Normal file
@@ -0,0 +1,80 @@
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
using SplashEdit.RuntimeCode;
|
||||
using System.Linq;
|
||||
|
||||
namespace SplashEdit.EditorCode
|
||||
{
|
||||
/// <summary>
|
||||
/// Minimal menu items — everything goes through the unified Control Panel.
|
||||
/// Only keeps: Control Panel shortcut + GameObject creation helpers.
|
||||
/// </summary>
|
||||
public static class PSXMenuItems
|
||||
{
|
||||
private const string MENU_ROOT = "PlayStation 1/";
|
||||
|
||||
// ───── Main Entry Point ─────
|
||||
|
||||
[MenuItem(MENU_ROOT + "SplashEdit Control Panel %#p", false, 0)]
|
||||
public static void OpenControlPanel()
|
||||
{
|
||||
SplashControlPanel.ShowWindow();
|
||||
}
|
||||
|
||||
// ───── GameObject Menu ─────
|
||||
|
||||
[MenuItem("GameObject/PlayStation 1/Scene Exporter", false, 10)]
|
||||
public static void CreateSceneExporter(MenuCommand menuCommand)
|
||||
{
|
||||
var existing = Object.FindObjectOfType<PSXSceneExporter>();
|
||||
if (existing != null)
|
||||
{
|
||||
EditorUtility.DisplayDialog(
|
||||
"Scene Exporter Exists",
|
||||
"A PSXSceneExporter already exists in this scene.\n\n" +
|
||||
"Only one exporter is needed per scene.",
|
||||
"OK");
|
||||
Selection.activeGameObject = existing.gameObject;
|
||||
return;
|
||||
}
|
||||
|
||||
var go = new GameObject("PSXSceneExporter");
|
||||
go.AddComponent<PSXSceneExporter>();
|
||||
GameObjectUtility.SetParentAndAlign(go, menuCommand.context as GameObject);
|
||||
Undo.RegisterCreatedObjectUndo(go, "Create PSX Scene Exporter");
|
||||
Selection.activeGameObject = go;
|
||||
}
|
||||
|
||||
[MenuItem("GameObject/PlayStation 1/Exportable Object", false, 12)]
|
||||
public static void CreateExportableObject(MenuCommand menuCommand)
|
||||
{
|
||||
var go = new GameObject("PSXObject");
|
||||
go.AddComponent<PSXObjectExporter>();
|
||||
GameObjectUtility.SetParentAndAlign(go, menuCommand.context as GameObject);
|
||||
Undo.RegisterCreatedObjectUndo(go, "Create PSX Object");
|
||||
Selection.activeGameObject = go;
|
||||
}
|
||||
|
||||
// ───── Context Menu ─────
|
||||
|
||||
[MenuItem("CONTEXT/MeshFilter/Add PSX Object Exporter")]
|
||||
public static void AddPSXObjectExporterFromMesh(MenuCommand command)
|
||||
{
|
||||
var meshFilter = command.context as MeshFilter;
|
||||
if (meshFilter != null && meshFilter.GetComponent<PSXObjectExporter>() == null)
|
||||
{
|
||||
Undo.AddComponent<PSXObjectExporter>(meshFilter.gameObject);
|
||||
}
|
||||
}
|
||||
|
||||
[MenuItem("CONTEXT/MeshRenderer/Add PSX Object Exporter")]
|
||||
public static void AddPSXObjectExporterFromRenderer(MenuCommand command)
|
||||
{
|
||||
var renderer = command.context as MeshRenderer;
|
||||
if (renderer != null && renderer.GetComponent<PSXObjectExporter>() == null)
|
||||
{
|
||||
Undo.AddComponent<PSXObjectExporter>(renderer.gameObject);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
2
Editor/PSXMenuItems.cs.meta
Normal file
2
Editor/PSXMenuItems.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 174ee99c9e9aafd4ea9002fc3548f53d
|
||||
@@ -1,31 +0,0 @@
|
||||
using UnityEngine;
|
||||
using UnityEditor;
|
||||
using SplashEdit.RuntimeCode;
|
||||
using System.Linq;
|
||||
|
||||
namespace SplashEdit.EditorCode
|
||||
{
|
||||
[CustomEditor(typeof(PSXNavMesh))]
|
||||
public class PSXNavMeshEditor : Editor
|
||||
{
|
||||
public override void OnInspectorGUI()
|
||||
{
|
||||
DrawDefaultInspector();
|
||||
|
||||
PSXNavMesh comp = (PSXNavMesh)target;
|
||||
if (GUILayout.Button("Create preview"))
|
||||
{
|
||||
PSXSceneExporter exporter = FindObjectsByType<PSXSceneExporter>(FindObjectsSortMode.None).FirstOrDefault();
|
||||
if(exporter != null)
|
||||
{
|
||||
comp.CreateNavmesh(exporter.GTEScaling);
|
||||
}
|
||||
else
|
||||
{
|
||||
Debug.LogError("No PSXSceneExporter found in the scene. We can't pull the GTE scaling from the exporter.");
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 9d3bd83aac4c3ce9ab1698a6a2bc735d
|
||||
401
Editor/PSXNavRegionEditor.cs
Normal file
401
Editor/PSXNavRegionEditor.cs
Normal file
@@ -0,0 +1,401 @@
|
||||
using UnityEngine;
|
||||
using UnityEditor;
|
||||
using SplashEdit.RuntimeCode;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace SplashEdit.EditorCode
|
||||
{
|
||||
/// <summary>
|
||||
/// Editor window for PS1 navigation mesh generation.
|
||||
/// Uses DotRecast (C# Recast) to voxelize scene geometry and build
|
||||
/// convex navigation regions for the PS1 runtime.
|
||||
/// All nav settings live on the PSXPlayer component so the editor
|
||||
/// preview and the scene export always use the same values.
|
||||
/// </summary>
|
||||
public class PSXNavRegionEditor : EditorWindow
|
||||
{
|
||||
private PSXNavRegionBuilder _builder;
|
||||
private bool _previewRegions = true;
|
||||
private bool _previewPortals = true;
|
||||
private bool _previewLabels = true;
|
||||
private int _selectedRegion = -1;
|
||||
private bool _showAdvanced = false;
|
||||
|
||||
[MenuItem("PSX/Nav Region Builder")]
|
||||
public static void ShowWindow()
|
||||
{
|
||||
GetWindow<PSXNavRegionEditor>("Nav Region Builder");
|
||||
}
|
||||
|
||||
private void OnEnable()
|
||||
{
|
||||
SceneView.duringSceneGui += OnSceneGUI;
|
||||
}
|
||||
|
||||
private void OnDisable()
|
||||
{
|
||||
SceneView.duringSceneGui -= OnSceneGUI;
|
||||
}
|
||||
|
||||
private void OnGUI()
|
||||
{
|
||||
EditorGUILayout.Space(5);
|
||||
GUILayout.Label("PSX Nav Region Builder", EditorStyles.boldLabel);
|
||||
EditorGUILayout.Space(5);
|
||||
|
||||
var players = FindObjectsByType<PSXPlayer>(FindObjectsSortMode.None);
|
||||
|
||||
if (players.Length == 0)
|
||||
{
|
||||
EditorGUILayout.HelpBox(
|
||||
"No PSXPlayer in scene. Add a PSXPlayer component to configure navigation settings.",
|
||||
MessageType.Warning);
|
||||
return;
|
||||
}
|
||||
|
||||
var player = players[0];
|
||||
var so = new SerializedObject(player);
|
||||
so.Update();
|
||||
|
||||
// Info
|
||||
using (new EditorGUILayout.VerticalScope(EditorStyles.helpBox))
|
||||
{
|
||||
EditorGUILayout.HelpBox(
|
||||
"Uses DotRecast (Recast voxelization) to build PS1 nav regions.\n" +
|
||||
"Settings are on the PSXPlayer component so editor preview\n" +
|
||||
"and scene export always match.\n" +
|
||||
"1. Configure settings below (saved on PSXPlayer)\n" +
|
||||
"2. Click 'Build Nav Regions' to preview\n" +
|
||||
"3. Results export automatically with the scene",
|
||||
MessageType.Info);
|
||||
}
|
||||
|
||||
EditorGUILayout.Space(5);
|
||||
|
||||
// Agent settings (from PSXPlayer serialized fields)
|
||||
using (new EditorGUILayout.VerticalScope(EditorStyles.helpBox))
|
||||
{
|
||||
GUILayout.Label("Agent Settings (PSXPlayer)", EditorStyles.boldLabel);
|
||||
EditorGUILayout.PropertyField(so.FindProperty("playerHeight"),
|
||||
new GUIContent("Agent Height", "Camera eye height above feet"));
|
||||
EditorGUILayout.PropertyField(so.FindProperty("playerRadius"),
|
||||
new GUIContent("Agent Radius", "Collision radius for wall sliding"));
|
||||
EditorGUILayout.PropertyField(so.FindProperty("maxStepHeight"),
|
||||
new GUIContent("Max Step Height", "Maximum height the agent can step up"));
|
||||
EditorGUILayout.PropertyField(so.FindProperty("walkableSlopeAngle"),
|
||||
new GUIContent("Max Slope", "Maximum walkable slope angle in degrees"));
|
||||
}
|
||||
|
||||
EditorGUILayout.Space(5);
|
||||
|
||||
// Advanced settings
|
||||
_showAdvanced = EditorGUILayout.Foldout(_showAdvanced, "Advanced Settings");
|
||||
if (_showAdvanced)
|
||||
{
|
||||
using (new EditorGUILayout.VerticalScope(EditorStyles.helpBox))
|
||||
{
|
||||
EditorGUILayout.PropertyField(so.FindProperty("navCellSize"),
|
||||
new GUIContent("Cell Size", "Voxel size in XZ plane. Smaller = more accurate but slower."));
|
||||
EditorGUILayout.PropertyField(so.FindProperty("navCellHeight"),
|
||||
new GUIContent("Cell Height", "Voxel height. Smaller = more accurate vertical resolution."));
|
||||
|
||||
EditorGUILayout.Space(3);
|
||||
float cs = player.NavCellSize;
|
||||
float ch = player.NavCellHeight;
|
||||
int walkH = (int)System.Math.Ceiling(player.PlayerHeight / ch);
|
||||
int walkR = (int)System.Math.Ceiling(player.PlayerRadius / cs);
|
||||
int walkC = (int)System.Math.Floor(player.MaxStepHeight / ch);
|
||||
EditorGUILayout.LabelField("Voxel walkable height", $"{walkH} cells");
|
||||
EditorGUILayout.LabelField("Voxel walkable radius", $"{walkR} cells");
|
||||
EditorGUILayout.LabelField("Voxel walkable climb", $"{walkC} cells ({walkC * ch:F3} units)");
|
||||
}
|
||||
}
|
||||
|
||||
so.ApplyModifiedProperties();
|
||||
|
||||
EditorGUILayout.Space(5);
|
||||
|
||||
// Build button
|
||||
using (new EditorGUILayout.VerticalScope(EditorStyles.helpBox))
|
||||
{
|
||||
GUILayout.Label("Generation", EditorStyles.boldLabel);
|
||||
|
||||
GUI.backgroundColor = new Color(0.4f, 0.8f, 0.4f);
|
||||
if (GUILayout.Button("Build Nav Regions", GUILayout.Height(35)))
|
||||
{
|
||||
BuildNavRegions(player);
|
||||
}
|
||||
GUI.backgroundColor = Color.white;
|
||||
|
||||
if (_builder != null && _builder.RegionCount > 0)
|
||||
{
|
||||
EditorGUILayout.Space(3);
|
||||
if (GUILayout.Button("Clear Regions"))
|
||||
{
|
||||
_builder = null;
|
||||
_selectedRegion = -1;
|
||||
SceneView.RepaintAll();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
EditorGUILayout.Space(5);
|
||||
|
||||
// Visualization
|
||||
using (new EditorGUILayout.VerticalScope(EditorStyles.helpBox))
|
||||
{
|
||||
GUILayout.Label("Visualization", EditorStyles.boldLabel);
|
||||
_previewRegions = EditorGUILayout.Toggle("Show Regions", _previewRegions);
|
||||
_previewPortals = EditorGUILayout.Toggle("Show Portals", _previewPortals);
|
||||
_previewLabels = EditorGUILayout.Toggle("Show Labels", _previewLabels);
|
||||
}
|
||||
|
||||
EditorGUILayout.Space(5);
|
||||
|
||||
// Statistics
|
||||
if (_builder != null && _builder.RegionCount > 0)
|
||||
{
|
||||
using (new EditorGUILayout.VerticalScope(EditorStyles.helpBox))
|
||||
{
|
||||
GUILayout.Label("Statistics", EditorStyles.boldLabel);
|
||||
EditorGUILayout.LabelField("Regions", _builder.RegionCount.ToString());
|
||||
EditorGUILayout.LabelField("Portals", _builder.PortalCount.ToString());
|
||||
|
||||
var rooms = new HashSet<byte>();
|
||||
for (int i = 0; i < _builder.RegionCount; i++)
|
||||
rooms.Add(_builder.Regions[i].roomIndex);
|
||||
EditorGUILayout.LabelField("Rooms", rooms.Count.ToString());
|
||||
|
||||
int exportSize = _builder.GetBinarySize();
|
||||
EditorGUILayout.LabelField("Export Size",
|
||||
$"{exportSize:N0} bytes ({exportSize / 1024f:F1} KB)");
|
||||
|
||||
int flat = 0, ramp = 0, stairs = 0;
|
||||
for (int i = 0; i < _builder.RegionCount; i++)
|
||||
{
|
||||
switch (_builder.Regions[i].surfaceType)
|
||||
{
|
||||
case NavSurfaceType.Flat: flat++; break;
|
||||
case NavSurfaceType.Ramp: ramp++; break;
|
||||
case NavSurfaceType.Stairs: stairs++; break;
|
||||
}
|
||||
}
|
||||
EditorGUILayout.LabelField("Types",
|
||||
$"{flat} flat, {ramp} ramp, {stairs} stairs");
|
||||
|
||||
if (_selectedRegion >= 0 && _selectedRegion < _builder.RegionCount)
|
||||
{
|
||||
EditorGUILayout.Space(3);
|
||||
GUILayout.Label($"Selected Region #{_selectedRegion}",
|
||||
EditorStyles.miniLabel);
|
||||
var region = _builder.Regions[_selectedRegion];
|
||||
EditorGUILayout.LabelField(" Vertices",
|
||||
region.vertsXZ.Count.ToString());
|
||||
EditorGUILayout.LabelField(" Portals",
|
||||
region.portalCount.ToString());
|
||||
EditorGUILayout.LabelField(" Surface",
|
||||
region.surfaceType.ToString());
|
||||
EditorGUILayout.LabelField(" Room",
|
||||
region.roomIndex.ToString());
|
||||
EditorGUILayout.LabelField(" Floor Y",
|
||||
$"{region.planeD:F2} (A={region.planeA:F3}, B={region.planeB:F3})");
|
||||
}
|
||||
}
|
||||
|
||||
ValidateRegions();
|
||||
}
|
||||
else
|
||||
{
|
||||
EditorGUILayout.HelpBox(
|
||||
"No nav regions built. Click 'Build Nav Regions' to generate.",
|
||||
MessageType.Warning);
|
||||
}
|
||||
}
|
||||
|
||||
// ====================================================================
|
||||
// Build
|
||||
// ====================================================================
|
||||
|
||||
private void BuildNavRegions(PSXPlayer player)
|
||||
{
|
||||
EditorUtility.DisplayProgressBar("Nav Region Builder", "Building nav regions with DotRecast...", 0.3f);
|
||||
|
||||
_builder = new PSXNavRegionBuilder();
|
||||
_builder.AgentHeight = player.PlayerHeight;
|
||||
_builder.AgentRadius = player.PlayerRadius;
|
||||
_builder.MaxStepHeight = player.MaxStepHeight;
|
||||
_builder.WalkableSlopeAngle = player.WalkableSlopeAngle;
|
||||
_builder.CellSize = player.NavCellSize;
|
||||
_builder.CellHeight = player.NavCellHeight;
|
||||
|
||||
Vector3 playerSpawn = player.transform.position;
|
||||
player.FindNavmesh();
|
||||
playerSpawn = player.CamPoint;
|
||||
|
||||
PSXObjectExporter[] exporters =
|
||||
FindObjectsByType<PSXObjectExporter>(FindObjectsSortMode.None);
|
||||
|
||||
_builder.Build(exporters, playerSpawn);
|
||||
|
||||
_selectedRegion = -1;
|
||||
EditorUtility.ClearProgressBar();
|
||||
SceneView.RepaintAll();
|
||||
}
|
||||
|
||||
// ====================================================================
|
||||
// Validation
|
||||
// ====================================================================
|
||||
|
||||
private void ValidateRegions()
|
||||
{
|
||||
if (_builder == null) return;
|
||||
|
||||
List<string> warnings = new List<string>();
|
||||
|
||||
for (int i = 0; i < _builder.RegionCount; i++)
|
||||
{
|
||||
var region = _builder.Regions[i];
|
||||
if (region.vertsXZ.Count < 3)
|
||||
warnings.Add($"Region {i}: degenerate ({region.vertsXZ.Count} verts)");
|
||||
if (region.portalCount == 0 && _builder.RegionCount > 1)
|
||||
warnings.Add($"Region {i}: isolated (no portals)");
|
||||
if (region.vertsXZ.Count > 8)
|
||||
warnings.Add($"Region {i}: too many verts ({region.vertsXZ.Count} > 8)");
|
||||
}
|
||||
|
||||
int exportSize = _builder.GetBinarySize();
|
||||
if (exportSize > 8192)
|
||||
warnings.Add($"Export size {exportSize} bytes is large for PS1 (> 8KB)");
|
||||
|
||||
if (warnings.Count > 0)
|
||||
{
|
||||
EditorGUILayout.Space(5);
|
||||
using (new EditorGUILayout.VerticalScope(EditorStyles.helpBox))
|
||||
{
|
||||
GUILayout.Label("Warnings", EditorStyles.boldLabel);
|
||||
foreach (string w in warnings)
|
||||
EditorGUILayout.LabelField(w, EditorStyles.miniLabel);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ====================================================================
|
||||
// Scene view drawing
|
||||
// ====================================================================
|
||||
|
||||
private static readonly Color[] RoomColors = new Color[]
|
||||
{
|
||||
new Color(0.2f, 0.8f, 0.2f),
|
||||
new Color(0.2f, 0.6f, 0.9f),
|
||||
new Color(0.9f, 0.7f, 0.1f),
|
||||
new Color(0.8f, 0.2f, 0.8f),
|
||||
new Color(0.1f, 0.9f, 0.9f),
|
||||
new Color(0.9f, 0.5f, 0.2f),
|
||||
new Color(0.5f, 0.9f, 0.3f),
|
||||
new Color(0.9f, 0.3f, 0.5f),
|
||||
new Color(0.4f, 0.4f, 0.9f),
|
||||
new Color(0.7f, 0.9f, 0.7f),
|
||||
new Color(0.9f, 0.9f, 0.4f),
|
||||
new Color(0.6f, 0.3f, 0.6f),
|
||||
new Color(0.3f, 0.7f, 0.7f),
|
||||
new Color(0.8f, 0.6f, 0.4f),
|
||||
new Color(0.4f, 0.8f, 0.6f),
|
||||
new Color(0.7f, 0.4f, 0.4f),
|
||||
};
|
||||
|
||||
private void OnSceneGUI(SceneView sceneView)
|
||||
{
|
||||
if (_builder == null || _builder.RegionCount == 0) return;
|
||||
|
||||
var regions = _builder.Regions;
|
||||
|
||||
if (_previewRegions)
|
||||
{
|
||||
for (int i = 0; i < regions.Count; i++)
|
||||
{
|
||||
var region = regions[i];
|
||||
bool selected = (i == _selectedRegion);
|
||||
|
||||
Color baseColor = RoomColors[region.roomIndex % RoomColors.Length];
|
||||
float fillAlpha = selected ? 0.4f : 0.15f;
|
||||
|
||||
if (region.vertsXZ.Count >= 3)
|
||||
{
|
||||
Vector3[] worldVerts = new Vector3[region.vertsXZ.Count];
|
||||
for (int v = 0; v < region.vertsXZ.Count; v++)
|
||||
{
|
||||
float y = region.planeA * region.vertsXZ[v].x +
|
||||
region.planeB * region.vertsXZ[v].y +
|
||||
region.planeD;
|
||||
worldVerts[v] = new Vector3(
|
||||
region.vertsXZ[v].x, y + 0.05f, region.vertsXZ[v].y);
|
||||
}
|
||||
|
||||
Handles.color = selected
|
||||
? Color.white
|
||||
: new Color(baseColor.r, baseColor.g, baseColor.b, 0.8f);
|
||||
for (int v = 0; v < worldVerts.Length; v++)
|
||||
Handles.DrawLine(worldVerts[v],
|
||||
worldVerts[(v + 1) % worldVerts.Length]);
|
||||
|
||||
Handles.color = new Color(baseColor.r, baseColor.g, baseColor.b,
|
||||
fillAlpha);
|
||||
for (int v = 1; v < worldVerts.Length - 1; v++)
|
||||
Handles.DrawAAConvexPolygon(
|
||||
worldVerts[0], worldVerts[v], worldVerts[v + 1]);
|
||||
|
||||
if (_previewLabels)
|
||||
{
|
||||
Vector3 center = Vector3.zero;
|
||||
foreach (var wv in worldVerts) center += wv;
|
||||
center /= worldVerts.Length;
|
||||
|
||||
string label = $"R{i}";
|
||||
if (region.roomIndex != 0xFF)
|
||||
label += $"\nRm{region.roomIndex}";
|
||||
Handles.Label(center, label, EditorStyles.whiteBoldLabel);
|
||||
|
||||
if (Handles.Button(center, Quaternion.identity,
|
||||
0.2f, 0.3f, Handles.SphereHandleCap))
|
||||
{
|
||||
_selectedRegion = i;
|
||||
Repaint();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (_previewPortals && _builder.Portals != null)
|
||||
{
|
||||
for (int i = 0; i < regions.Count; i++)
|
||||
{
|
||||
var region = regions[i];
|
||||
int pStart = region.portalStart;
|
||||
int pCount = region.portalCount;
|
||||
|
||||
for (int p = pStart;
|
||||
p < pStart + pCount && p < _builder.Portals.Count; p++)
|
||||
{
|
||||
var portal = _builder.Portals[p];
|
||||
|
||||
float yA = region.planeA * portal.a.x +
|
||||
region.planeB * portal.a.y + region.planeD;
|
||||
float yB = region.planeA * portal.b.x +
|
||||
region.planeB * portal.b.y + region.planeD;
|
||||
|
||||
Vector3 worldA = new Vector3(portal.a.x, yA + 0.08f, portal.a.y);
|
||||
Vector3 worldB = new Vector3(portal.b.x, yB + 0.08f, portal.b.y);
|
||||
|
||||
if (Mathf.Abs(portal.heightDelta) <= 0.35f)
|
||||
Handles.color = new Color(1f, 1f, 1f, 0.9f);
|
||||
else
|
||||
Handles.color = new Color(1f, 0.9f, 0.2f, 0.9f);
|
||||
|
||||
Handles.DrawLine(worldA, worldB, 3f);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
2
Editor/PSXNavRegionEditor.cs.meta
Normal file
2
Editor/PSXNavRegionEditor.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: e6ea40b4c8e02314c9388c86b2920403
|
||||
514
Editor/PSXObjectExporterEditor.cs
Normal file
514
Editor/PSXObjectExporterEditor.cs
Normal file
@@ -0,0 +1,514 @@
|
||||
using UnityEngine;
|
||||
using UnityEditor;
|
||||
using SplashEdit.RuntimeCode;
|
||||
using System.Linq;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace SplashEdit.EditorCode
|
||||
{
|
||||
/// <summary>
|
||||
/// Custom inspector for PSXObjectExporter with enhanced UX.
|
||||
/// Shows mesh info, texture preview, collision visualization, and validation.
|
||||
/// </summary>
|
||||
[CustomEditor(typeof(PSXObjectExporter))]
|
||||
[CanEditMultipleObjects]
|
||||
public class PSXObjectExporterEditor : UnityEditor.Editor
|
||||
{
|
||||
// Serialized properties
|
||||
private SerializedProperty isActiveProp;
|
||||
private SerializedProperty bitDepthProp;
|
||||
private SerializedProperty luaFileProp;
|
||||
private SerializedProperty objectFlagsProp;
|
||||
private SerializedProperty collisionTypeProp;
|
||||
private SerializedProperty exportCollisionMeshProp;
|
||||
private SerializedProperty customCollisionMeshProp;
|
||||
private SerializedProperty collisionLayerProp;
|
||||
private SerializedProperty previewNormalsProp;
|
||||
private SerializedProperty normalPreviewLengthProp;
|
||||
private SerializedProperty showCollisionBoundsProp;
|
||||
private SerializedProperty textureProp;
|
||||
|
||||
// UI State
|
||||
private bool showMeshInfo = true;
|
||||
private bool showTextureInfo = true;
|
||||
private bool showExportSettings = true;
|
||||
private bool showCollisionSettings = true;
|
||||
private bool showGizmoSettings = false;
|
||||
private bool showValidation = true;
|
||||
|
||||
// Cached data
|
||||
private MeshFilter meshFilter;
|
||||
private MeshRenderer meshRenderer;
|
||||
private int triangleCount;
|
||||
private int vertexCount;
|
||||
private Bounds meshBounds;
|
||||
private List<string> validationErrors = new List<string>();
|
||||
private List<string> validationWarnings = new List<string>();
|
||||
|
||||
// Styles
|
||||
private GUIStyle headerStyle;
|
||||
private GUIStyle errorStyle;
|
||||
private GUIStyle warningStyle;
|
||||
|
||||
// Validation
|
||||
private bool _validationDirty = true;
|
||||
|
||||
private void OnEnable()
|
||||
{
|
||||
// Get serialized properties
|
||||
isActiveProp = serializedObject.FindProperty("isActive");
|
||||
bitDepthProp = serializedObject.FindProperty("bitDepth");
|
||||
luaFileProp = serializedObject.FindProperty("luaFile");
|
||||
objectFlagsProp = serializedObject.FindProperty("objectFlags");
|
||||
collisionTypeProp = serializedObject.FindProperty("collisionType");
|
||||
exportCollisionMeshProp = serializedObject.FindProperty("exportCollisionMesh");
|
||||
customCollisionMeshProp = serializedObject.FindProperty("customCollisionMesh");
|
||||
collisionLayerProp = serializedObject.FindProperty("collisionLayer");
|
||||
previewNormalsProp = serializedObject.FindProperty("previewNormals");
|
||||
normalPreviewLengthProp = serializedObject.FindProperty("normalPreviewLength");
|
||||
showCollisionBoundsProp = serializedObject.FindProperty("showCollisionBounds");
|
||||
textureProp = serializedObject.FindProperty("texture");
|
||||
|
||||
// Cache mesh info
|
||||
CacheMeshInfo();
|
||||
|
||||
// Defer validation to first inspector draw
|
||||
_validationDirty = true;
|
||||
}
|
||||
|
||||
private void CacheMeshInfo()
|
||||
{
|
||||
var exporter = target as PSXObjectExporter;
|
||||
if (exporter == null) return;
|
||||
|
||||
meshFilter = exporter.GetComponent<MeshFilter>();
|
||||
meshRenderer = exporter.GetComponent<MeshRenderer>();
|
||||
|
||||
if (meshFilter != null && meshFilter.sharedMesh != null)
|
||||
{
|
||||
var mesh = meshFilter.sharedMesh;
|
||||
triangleCount = mesh.triangles.Length / 3;
|
||||
vertexCount = mesh.vertexCount;
|
||||
meshBounds = mesh.bounds;
|
||||
}
|
||||
}
|
||||
|
||||
private void RunValidation()
|
||||
{
|
||||
validationErrors.Clear();
|
||||
validationWarnings.Clear();
|
||||
|
||||
var exporter = target as PSXObjectExporter;
|
||||
if (exporter == null) return;
|
||||
|
||||
// Check mesh
|
||||
if (meshFilter == null || meshFilter.sharedMesh == null)
|
||||
{
|
||||
validationErrors.Add("No mesh assigned to MeshFilter");
|
||||
}
|
||||
else
|
||||
{
|
||||
if (triangleCount > 100)
|
||||
{
|
||||
validationWarnings.Add($"High triangle count ({triangleCount}). PS1 recommended: <100 per object");
|
||||
}
|
||||
|
||||
// Check vertex bounds
|
||||
var mesh = meshFilter.sharedMesh;
|
||||
var verts = mesh.vertices;
|
||||
bool hasOutOfBounds = false;
|
||||
|
||||
foreach (var v in verts)
|
||||
{
|
||||
var world = exporter.transform.TransformPoint(v);
|
||||
float scaled = Mathf.Max(Mathf.Abs(world.x), Mathf.Abs(world.y), Mathf.Abs(world.z)) * 4096f;
|
||||
if (scaled > 32767f)
|
||||
{
|
||||
hasOutOfBounds = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (hasOutOfBounds)
|
||||
{
|
||||
validationErrors.Add("Vertices exceed PS1 coordinate limits (±8 units from origin)");
|
||||
}
|
||||
}
|
||||
|
||||
// Check renderer
|
||||
if (meshRenderer == null)
|
||||
{
|
||||
validationWarnings.Add("No MeshRenderer - object will not be visible");
|
||||
}
|
||||
else if (meshRenderer.sharedMaterial == null)
|
||||
{
|
||||
validationWarnings.Add("No material assigned - will use default colors");
|
||||
}
|
||||
}
|
||||
|
||||
public override void OnInspectorGUI()
|
||||
{
|
||||
serializedObject.Update();
|
||||
|
||||
// Run deferred validation
|
||||
if (_validationDirty)
|
||||
{
|
||||
RunValidation();
|
||||
_validationDirty = false;
|
||||
}
|
||||
|
||||
InitStyles();
|
||||
|
||||
// Active toggle at top
|
||||
EditorGUILayout.PropertyField(isActiveProp, new GUIContent("Export This Object"));
|
||||
|
||||
if (!isActiveProp.boolValue)
|
||||
{
|
||||
EditorGUILayout.HelpBox("This object will be skipped during export.", MessageType.Info);
|
||||
serializedObject.ApplyModifiedProperties();
|
||||
return;
|
||||
}
|
||||
|
||||
EditorGUILayout.Space(5);
|
||||
|
||||
// Mesh Info Section
|
||||
DrawMeshInfoSection();
|
||||
|
||||
// Texture Section
|
||||
DrawTextureSection();
|
||||
|
||||
// Export Settings Section
|
||||
DrawExportSettingsSection();
|
||||
|
||||
// Collision Settings Section
|
||||
DrawCollisionSettingsSection();
|
||||
|
||||
// Gizmo Settings Section
|
||||
DrawGizmoSettingsSection();
|
||||
|
||||
// Validation Section
|
||||
DrawValidationSection();
|
||||
|
||||
// Action Buttons
|
||||
DrawActionButtons();
|
||||
|
||||
if (serializedObject.ApplyModifiedProperties())
|
||||
{
|
||||
_validationDirty = true;
|
||||
}
|
||||
}
|
||||
|
||||
private void InitStyles()
|
||||
{
|
||||
if (headerStyle == null)
|
||||
{
|
||||
headerStyle = new GUIStyle(EditorStyles.foldoutHeader);
|
||||
}
|
||||
|
||||
if (errorStyle == null)
|
||||
{
|
||||
errorStyle = new GUIStyle(EditorStyles.label);
|
||||
errorStyle.normal.textColor = Color.red;
|
||||
}
|
||||
|
||||
if (warningStyle == null)
|
||||
{
|
||||
warningStyle = new GUIStyle(EditorStyles.label);
|
||||
warningStyle.normal.textColor = new Color(1f, 0.7f, 0f);
|
||||
}
|
||||
}
|
||||
|
||||
private void DrawMeshInfoSection()
|
||||
{
|
||||
showMeshInfo = EditorGUILayout.BeginFoldoutHeaderGroup(showMeshInfo, "Mesh Information");
|
||||
if (showMeshInfo)
|
||||
{
|
||||
EditorGUI.indentLevel++;
|
||||
|
||||
if (meshFilter != null && meshFilter.sharedMesh != null)
|
||||
{
|
||||
EditorGUILayout.LabelField("Mesh", meshFilter.sharedMesh.name);
|
||||
EditorGUILayout.LabelField("Triangles", triangleCount.ToString());
|
||||
EditorGUILayout.LabelField("Vertices", vertexCount.ToString());
|
||||
EditorGUILayout.LabelField("Bounds Size", meshBounds.size.ToString("F2"));
|
||||
|
||||
// Triangle budget bar
|
||||
float budgetPercent = triangleCount / 100f;
|
||||
Rect rect = EditorGUILayout.GetControlRect(false, 20);
|
||||
EditorGUI.ProgressBar(rect, Mathf.Clamp01(budgetPercent), $"Triangle Budget: {triangleCount}/100");
|
||||
}
|
||||
else
|
||||
{
|
||||
EditorGUILayout.HelpBox("No mesh assigned", MessageType.Warning);
|
||||
}
|
||||
|
||||
EditorGUI.indentLevel--;
|
||||
}
|
||||
EditorGUILayout.EndFoldoutHeaderGroup();
|
||||
}
|
||||
|
||||
private void DrawTextureSection()
|
||||
{
|
||||
showTextureInfo = EditorGUILayout.BeginFoldoutHeaderGroup(showTextureInfo, "Texture Settings");
|
||||
if (showTextureInfo)
|
||||
{
|
||||
EditorGUI.indentLevel++;
|
||||
|
||||
EditorGUILayout.PropertyField(textureProp, new GUIContent("Override Texture"));
|
||||
EditorGUILayout.PropertyField(bitDepthProp, new GUIContent("Bit Depth"));
|
||||
|
||||
// Show texture preview if assigned
|
||||
var tex = textureProp.objectReferenceValue as Texture2D;
|
||||
if (tex != null)
|
||||
{
|
||||
EditorGUILayout.Space(5);
|
||||
|
||||
using (new EditorGUILayout.HorizontalScope())
|
||||
{
|
||||
GUILayout.FlexibleSpace();
|
||||
Rect previewRect = GUILayoutUtility.GetRect(64, 64, GUILayout.Width(64));
|
||||
EditorGUI.DrawPreviewTexture(previewRect, tex);
|
||||
GUILayout.FlexibleSpace();
|
||||
}
|
||||
|
||||
EditorGUILayout.LabelField($"Size: {tex.width}x{tex.height}");
|
||||
|
||||
// VRAM estimate
|
||||
int bpp = bitDepthProp.enumValueIndex == 0 ? 4 : (bitDepthProp.enumValueIndex == 1 ? 8 : 16);
|
||||
int vramBytes = (tex.width * tex.height * bpp) / 8;
|
||||
EditorGUILayout.LabelField($"Est. VRAM: {vramBytes} bytes ({bpp}bpp)");
|
||||
}
|
||||
else if (meshRenderer != null && meshRenderer.sharedMaterial != null)
|
||||
{
|
||||
var matTex = meshRenderer.sharedMaterial.mainTexture;
|
||||
if (matTex != null)
|
||||
{
|
||||
EditorGUILayout.HelpBox($"Using material texture: {matTex.name}", MessageType.Info);
|
||||
}
|
||||
}
|
||||
|
||||
EditorGUI.indentLevel--;
|
||||
}
|
||||
EditorGUILayout.EndFoldoutHeaderGroup();
|
||||
}
|
||||
|
||||
private void DrawExportSettingsSection()
|
||||
{
|
||||
showExportSettings = EditorGUILayout.BeginFoldoutHeaderGroup(showExportSettings, "Export Settings");
|
||||
if (showExportSettings)
|
||||
{
|
||||
EditorGUI.indentLevel++;
|
||||
|
||||
EditorGUILayout.PropertyField(objectFlagsProp, new GUIContent("Object Flags"));
|
||||
EditorGUILayout.PropertyField(luaFileProp, new GUIContent("Lua Script"));
|
||||
|
||||
// Quick Lua file buttons
|
||||
if (luaFileProp.objectReferenceValue != null)
|
||||
{
|
||||
using (new EditorGUILayout.HorizontalScope())
|
||||
{
|
||||
if (GUILayout.Button("Edit Lua", GUILayout.Width(80)))
|
||||
{
|
||||
AssetDatabase.OpenAsset(luaFileProp.objectReferenceValue);
|
||||
}
|
||||
if (GUILayout.Button("Clear", GUILayout.Width(60)))
|
||||
{
|
||||
luaFileProp.objectReferenceValue = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
if (GUILayout.Button("Create New Lua Script"))
|
||||
{
|
||||
CreateNewLuaScript();
|
||||
}
|
||||
}
|
||||
|
||||
EditorGUI.indentLevel--;
|
||||
}
|
||||
EditorGUILayout.EndFoldoutHeaderGroup();
|
||||
}
|
||||
|
||||
private void DrawCollisionSettingsSection()
|
||||
{
|
||||
showCollisionSettings = EditorGUILayout.BeginFoldoutHeaderGroup(showCollisionSettings, "Collision Settings");
|
||||
if (showCollisionSettings)
|
||||
{
|
||||
EditorGUI.indentLevel++;
|
||||
|
||||
EditorGUILayout.PropertyField(collisionTypeProp, new GUIContent("Collision Type"));
|
||||
|
||||
var collType = (PSXCollisionType)collisionTypeProp.enumValueIndex;
|
||||
if (collType != PSXCollisionType.None)
|
||||
{
|
||||
EditorGUILayout.PropertyField(exportCollisionMeshProp, new GUIContent("Export Collision Mesh"));
|
||||
EditorGUILayout.PropertyField(customCollisionMeshProp, new GUIContent("Custom Collision Mesh"));
|
||||
EditorGUILayout.PropertyField(collisionLayerProp, new GUIContent("Collision Layer"));
|
||||
|
||||
// Collision info
|
||||
EditorGUILayout.Space(5);
|
||||
string collisionInfo = collType switch
|
||||
{
|
||||
PSXCollisionType.Solid => "Solid: Blocks movement, fires onCollision",
|
||||
PSXCollisionType.Trigger => "Trigger: Fires onTriggerEnter/Exit, doesn't block",
|
||||
PSXCollisionType.Platform => "Platform: Solid from above only",
|
||||
_ => ""
|
||||
};
|
||||
EditorGUILayout.HelpBox(collisionInfo, MessageType.Info);
|
||||
}
|
||||
|
||||
EditorGUI.indentLevel--;
|
||||
}
|
||||
EditorGUILayout.EndFoldoutHeaderGroup();
|
||||
}
|
||||
|
||||
private void DrawGizmoSettingsSection()
|
||||
{
|
||||
showGizmoSettings = EditorGUILayout.BeginFoldoutHeaderGroup(showGizmoSettings, "Gizmo Settings");
|
||||
if (showGizmoSettings)
|
||||
{
|
||||
EditorGUI.indentLevel++;
|
||||
|
||||
EditorGUILayout.PropertyField(previewNormalsProp, new GUIContent("Preview Normals"));
|
||||
if (previewNormalsProp.boolValue)
|
||||
{
|
||||
EditorGUILayout.PropertyField(normalPreviewLengthProp, new GUIContent("Normal Length"));
|
||||
}
|
||||
|
||||
EditorGUILayout.PropertyField(showCollisionBoundsProp, new GUIContent("Show Collision Bounds"));
|
||||
|
||||
EditorGUI.indentLevel--;
|
||||
}
|
||||
EditorGUILayout.EndFoldoutHeaderGroup();
|
||||
}
|
||||
|
||||
private void DrawValidationSection()
|
||||
{
|
||||
if (validationErrors.Count == 0 && validationWarnings.Count == 0)
|
||||
return;
|
||||
|
||||
showValidation = EditorGUILayout.BeginFoldoutHeaderGroup(showValidation, "Validation");
|
||||
if (showValidation)
|
||||
{
|
||||
foreach (var error in validationErrors)
|
||||
{
|
||||
EditorGUILayout.HelpBox(error, MessageType.Error);
|
||||
}
|
||||
|
||||
foreach (var warning in validationWarnings)
|
||||
{
|
||||
EditorGUILayout.HelpBox(warning, MessageType.Warning);
|
||||
}
|
||||
|
||||
if (GUILayout.Button("Refresh Validation"))
|
||||
{
|
||||
CacheMeshInfo();
|
||||
RunValidation();
|
||||
}
|
||||
}
|
||||
EditorGUILayout.EndFoldoutHeaderGroup();
|
||||
}
|
||||
|
||||
private void DrawActionButtons()
|
||||
{
|
||||
EditorGUILayout.Space(10);
|
||||
|
||||
using (new EditorGUILayout.HorizontalScope())
|
||||
{
|
||||
if (GUILayout.Button("Select Scene Exporter"))
|
||||
{
|
||||
var exporter = FindObjectOfType<PSXSceneExporter>();
|
||||
if (exporter != null)
|
||||
{
|
||||
Selection.activeGameObject = exporter.gameObject;
|
||||
}
|
||||
else
|
||||
{
|
||||
EditorUtility.DisplayDialog("Not Found", "No PSXSceneExporter in scene.", "OK");
|
||||
}
|
||||
}
|
||||
|
||||
if (GUILayout.Button("Open Scene Validator"))
|
||||
{
|
||||
PSXSceneValidatorWindow.ShowWindow();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void CreateNewLuaScript()
|
||||
{
|
||||
var exporter = target as PSXObjectExporter;
|
||||
string defaultName = exporter.gameObject.name.ToLower().Replace(" ", "_");
|
||||
string path = EditorUtility.SaveFilePanelInProject(
|
||||
"Create Lua Script",
|
||||
defaultName + ".lua",
|
||||
"lua",
|
||||
"Create a new Lua script for this object");
|
||||
|
||||
if (!string.IsNullOrEmpty(path))
|
||||
{
|
||||
string template = $@"-- Lua script for {exporter.gameObject.name}
|
||||
--
|
||||
-- Available globals: Entity, Vec3, Input, Timer, Camera, Audio,
|
||||
-- Debug, Math, Scene, Persist
|
||||
--
|
||||
-- Available events:
|
||||
-- onCreate(self) — called once when the object is registered
|
||||
-- onUpdate(self, dt) — called every frame (dt = delta frames, usually 1)
|
||||
-- onEnable(self) — called when the object becomes active
|
||||
-- onDisable(self) — called when the object becomes inactive
|
||||
-- onCollision(self, other) — called on collision with another object
|
||||
-- onTriggerEnter(self, other)
|
||||
-- onTriggerStay(self, other)
|
||||
-- onTriggerExit(self, other)
|
||||
-- onInteract(self) — called when the player interacts
|
||||
-- onButtonPress(self, btn) — called on button press (btn = Input.CROSS etc.)
|
||||
-- onButtonRelease(self, btn)
|
||||
-- onDestroy(self) — called before the object is destroyed
|
||||
--
|
||||
-- Properties: self.position (Vec3), self.rotationY (pi-units), self.active (bool)
|
||||
|
||||
function onCreate(self)
|
||||
-- Called once when this object is registered in the scene
|
||||
end
|
||||
|
||||
function onUpdate(self, dt)
|
||||
-- Called every frame. dt = number of elapsed frames (usually 1).
|
||||
end
|
||||
|
||||
function onInteract(self)
|
||||
-- Called when the player interacts with this object
|
||||
end
|
||||
";
|
||||
System.IO.File.WriteAllText(path, template);
|
||||
AssetDatabase.Refresh();
|
||||
|
||||
var luaFile = AssetDatabase.LoadAssetAtPath<LuaFile>(path);
|
||||
if (luaFile != null)
|
||||
{
|
||||
luaFileProp.objectReferenceValue = luaFile;
|
||||
serializedObject.ApplyModifiedProperties();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[MenuItem("CONTEXT/PSXObjectExporter/Copy Settings to Selected")]
|
||||
private static void CopySettingsToSelected(MenuCommand command)
|
||||
{
|
||||
var source = command.context as PSXObjectExporter;
|
||||
if (source == null) return;
|
||||
|
||||
foreach (var go in Selection.gameObjects)
|
||||
{
|
||||
var target = go.GetComponent<PSXObjectExporter>();
|
||||
if (target != null && target != source)
|
||||
{
|
||||
Undo.RecordObject(target, "Copy PSX Settings");
|
||||
// Copy via serialized object
|
||||
EditorUtility.CopySerialized(source, target);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
2
Editor/PSXObjectExporterEditor.cs.meta
Normal file
2
Editor/PSXObjectExporterEditor.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: d45032f12fc4b614783ad30927846e6c
|
||||
@@ -4,19 +4,140 @@ using SplashEdit.RuntimeCode;
|
||||
|
||||
namespace SplashEdit.EditorCode
|
||||
{
|
||||
/// <summary>
|
||||
/// Custom inspector for PSXSceneExporter.
|
||||
/// When the component is selected and fog is enabled, activates a Unity scene-view
|
||||
/// fog preview that approximates the PS1 linear fog distances.
|
||||
///
|
||||
/// Fog distance mapping:
|
||||
/// fogFarSZ = 8000 / FogDensity (GTE SZ units)
|
||||
/// fogNearSZ = fogFarSZ / 3
|
||||
/// SZ is 20.12 fixed-point: SZ = (unityCoord / GTEScaling) * 4096
|
||||
/// => unityDist = SZ * GTEScaling / 4096
|
||||
/// => Unity fog near = (8000 / (FogDensity * 3)) * GTEScaling / 4096
|
||||
/// => Unity fog far = (8000 / FogDensity) * GTEScaling / 4096
|
||||
/// </summary>
|
||||
[CustomEditor(typeof(PSXSceneExporter))]
|
||||
public class PSXSceneExporterEditor : Editor
|
||||
public class PSXSceneExporterEditor : UnityEditor.Editor
|
||||
{
|
||||
public override void OnInspectorGUI()
|
||||
{
|
||||
DrawDefaultInspector();
|
||||
// Saved RenderSettings state so we can restore it on deselect.
|
||||
private bool _savedFog;
|
||||
private Color _savedFogColor;
|
||||
private FogMode _savedFogMode;
|
||||
private float _savedFogStart;
|
||||
private float _savedFogEnd;
|
||||
|
||||
PSXSceneExporter comp = (PSXSceneExporter)target;
|
||||
if (GUILayout.Button("Export"))
|
||||
private bool _previewActive = false;
|
||||
|
||||
private void OnEnable()
|
||||
{
|
||||
SaveAndApplyFogPreview();
|
||||
// Re-apply whenever the scene is repainted (handles inspector value changes).
|
||||
EditorApplication.update += OnEditorUpdate;
|
||||
}
|
||||
|
||||
private void OnDisable()
|
||||
{
|
||||
EditorApplication.update -= OnEditorUpdate;
|
||||
RestoreFog();
|
||||
}
|
||||
|
||||
private void OnEditorUpdate()
|
||||
{
|
||||
// Keep the preview in sync when the user tweaks values in the inspector.
|
||||
if (_previewActive)
|
||||
ApplyFogPreview();
|
||||
}
|
||||
|
||||
private void SaveAndApplyFogPreview()
|
||||
{
|
||||
_savedFog = RenderSettings.fog;
|
||||
_savedFogColor = RenderSettings.fogColor;
|
||||
_savedFogMode = RenderSettings.fogMode;
|
||||
_savedFogStart = RenderSettings.fogStartDistance;
|
||||
_savedFogEnd = RenderSettings.fogEndDistance;
|
||||
|
||||
_previewActive = true;
|
||||
ApplyFogPreview();
|
||||
}
|
||||
|
||||
private void ApplyFogPreview()
|
||||
{
|
||||
var exporter = (PSXSceneExporter)target;
|
||||
if (exporter == null) return;
|
||||
|
||||
if (!exporter.FogEnabled)
|
||||
{
|
||||
comp.Export();
|
||||
// Fog disabled on the component - turn off the preview.
|
||||
RenderSettings.fog = false;
|
||||
return;
|
||||
}
|
||||
|
||||
float gteScale = exporter.GTEScaling;
|
||||
int density = Mathf.Clamp(exporter.FogDensity, 1, 10);
|
||||
|
||||
// fogFarSZ in GTE SZ units (20.12 fp); convert to Unity world-space.
|
||||
// SZ = (unityDist / GTEScaling) * 4096, so unityDist = SZ * GTEScaling / 4096
|
||||
float fogFarSZ = 8000f / density;
|
||||
float fogNearSZ = fogFarSZ / 3f;
|
||||
|
||||
float fogFarUnity = fogFarSZ * gteScale / 4096f;
|
||||
float fogNearUnity = fogNearSZ * gteScale / 4096f;
|
||||
|
||||
RenderSettings.fog = true;
|
||||
RenderSettings.fogColor = exporter.FogColor;
|
||||
RenderSettings.fogMode = FogMode.Linear;
|
||||
RenderSettings.fogStartDistance = fogNearUnity;
|
||||
RenderSettings.fogEndDistance = fogFarUnity;
|
||||
}
|
||||
|
||||
private void RestoreFog()
|
||||
{
|
||||
if (!_previewActive) return;
|
||||
_previewActive = false;
|
||||
|
||||
RenderSettings.fog = _savedFog;
|
||||
RenderSettings.fogColor = _savedFogColor;
|
||||
RenderSettings.fogMode = _savedFogMode;
|
||||
RenderSettings.fogStartDistance = _savedFogStart;
|
||||
RenderSettings.fogEndDistance = _savedFogEnd;
|
||||
}
|
||||
|
||||
public override void OnInspectorGUI()
|
||||
{
|
||||
serializedObject.Update();
|
||||
|
||||
DrawDefaultInspector();
|
||||
|
||||
// Show computed fog distances when fog is enabled, so the user
|
||||
// can see exactly what range the preview represents.
|
||||
var exporter = (PSXSceneExporter)target;
|
||||
if (exporter.FogEnabled)
|
||||
{
|
||||
EditorGUILayout.Space(4);
|
||||
EditorGUILayout.BeginVertical(EditorStyles.helpBox);
|
||||
GUILayout.Label("Fog Preview (active in Scene view)", EditorStyles.boldLabel);
|
||||
|
||||
float gteScale = exporter.GTEScaling;
|
||||
int density = Mathf.Clamp(exporter.FogDensity, 1, 10);
|
||||
float fogFarUnity = (8000f / density) * gteScale / 4096f;
|
||||
float fogNearUnity = fogFarUnity / 3f;
|
||||
|
||||
EditorGUILayout.LabelField("Near distance", $"{fogNearUnity:F1} Unity units");
|
||||
EditorGUILayout.LabelField("Far distance", $"{fogFarUnity:F1} Unity units");
|
||||
EditorGUILayout.LabelField("(PS1 SZ range)", $"{8000f / (density * 3f):F0} - {8000f / density:F0} GTE units");
|
||||
EditorGUILayout.EndVertical();
|
||||
|
||||
// Keep preview applied as values may have changed.
|
||||
ApplyFogPreview();
|
||||
}
|
||||
else
|
||||
{
|
||||
// Make sure preview is off when fog is disabled.
|
||||
RenderSettings.fog = false;
|
||||
}
|
||||
|
||||
serializedObject.ApplyModifiedProperties();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: becf2eb607e7a60baaf3bebe4683d66f
|
||||
guid: 738efb5c0ed755b45991d2067957b997
|
||||
496
Editor/PSXSceneValidatorWindow.cs
Normal file
496
Editor/PSXSceneValidatorWindow.cs
Normal file
@@ -0,0 +1,496 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
using SplashEdit.RuntimeCode;
|
||||
|
||||
namespace SplashEdit.EditorCode
|
||||
{
|
||||
/// <summary>
|
||||
/// Scene Validator Window - Validates the current scene for PS1 compatibility.
|
||||
/// Checks for common issues that would cause problems on real hardware.
|
||||
/// </summary>
|
||||
public class PSXSceneValidatorWindow : EditorWindow
|
||||
{
|
||||
private Vector2 scrollPosition;
|
||||
private List<ValidationResult> validationResults = new List<ValidationResult>();
|
||||
private bool hasValidated = false;
|
||||
private int errorCount = 0;
|
||||
private int warningCount = 0;
|
||||
private int infoCount = 0;
|
||||
|
||||
// Filter toggles
|
||||
private bool showErrors = true;
|
||||
private bool showWarnings = true;
|
||||
private bool showInfo = true;
|
||||
|
||||
// PS1 Limits
|
||||
private const int MAX_RECOMMENDED_TRIS_PER_OBJECT = 100;
|
||||
private const int MAX_RECOMMENDED_TOTAL_TRIS = 400;
|
||||
private const int MAX_VERTEX_COORD = 32767; // signed 16-bit
|
||||
private const int MIN_VERTEX_COORD = -32768;
|
||||
private const int VRAM_WIDTH = 1024;
|
||||
private const int VRAM_HEIGHT = 512;
|
||||
|
||||
private static readonly Vector2 MinSize = new Vector2(500, 400);
|
||||
|
||||
public static void ShowWindow()
|
||||
{
|
||||
var window = GetWindow<PSXSceneValidatorWindow>("Scene Validator");
|
||||
window.minSize = MinSize;
|
||||
}
|
||||
|
||||
private void OnEnable()
|
||||
{
|
||||
validationResults.Clear();
|
||||
hasValidated = false;
|
||||
}
|
||||
|
||||
private void OnGUI()
|
||||
{
|
||||
DrawHeader();
|
||||
DrawFilters();
|
||||
DrawResults();
|
||||
DrawFooter();
|
||||
}
|
||||
|
||||
private void DrawHeader()
|
||||
{
|
||||
EditorGUILayout.Space(5);
|
||||
|
||||
using (new EditorGUILayout.HorizontalScope())
|
||||
{
|
||||
GUILayout.Label("PS1 Scene Validator", EditorStyles.boldLabel);
|
||||
GUILayout.FlexibleSpace();
|
||||
|
||||
if (GUILayout.Button("Validate Scene", GUILayout.Width(120)))
|
||||
{
|
||||
ValidateScene();
|
||||
}
|
||||
}
|
||||
|
||||
EditorGUILayout.Space(5);
|
||||
|
||||
// Summary bar
|
||||
if (hasValidated)
|
||||
{
|
||||
using (new EditorGUILayout.HorizontalScope(EditorStyles.helpBox))
|
||||
{
|
||||
var errorStyle = new GUIStyle(EditorStyles.label);
|
||||
errorStyle.normal.textColor = errorCount > 0 ? Color.red : Color.green;
|
||||
GUILayout.Label($"✗ {errorCount} Errors", errorStyle);
|
||||
|
||||
var warnStyle = new GUIStyle(EditorStyles.label);
|
||||
warnStyle.normal.textColor = warningCount > 0 ? new Color(1f, 0.7f, 0f) : Color.green;
|
||||
GUILayout.Label($"⚠ {warningCount} Warnings", warnStyle);
|
||||
|
||||
var infoStyle = new GUIStyle(EditorStyles.label);
|
||||
infoStyle.normal.textColor = Color.cyan;
|
||||
GUILayout.Label($"ℹ {infoCount} Info", infoStyle);
|
||||
|
||||
GUILayout.FlexibleSpace();
|
||||
}
|
||||
}
|
||||
|
||||
EditorGUILayout.Space(5);
|
||||
}
|
||||
|
||||
private void DrawFilters()
|
||||
{
|
||||
using (new EditorGUILayout.HorizontalScope())
|
||||
{
|
||||
GUILayout.Label("Show:", GUILayout.Width(40));
|
||||
showErrors = GUILayout.Toggle(showErrors, "Errors", EditorStyles.miniButtonLeft);
|
||||
showWarnings = GUILayout.Toggle(showWarnings, "Warnings", EditorStyles.miniButtonMid);
|
||||
showInfo = GUILayout.Toggle(showInfo, "Info", EditorStyles.miniButtonRight);
|
||||
GUILayout.FlexibleSpace();
|
||||
}
|
||||
|
||||
EditorGUILayout.Space(5);
|
||||
}
|
||||
|
||||
private void DrawResults()
|
||||
{
|
||||
using (var scrollView = new EditorGUILayout.ScrollViewScope(scrollPosition))
|
||||
{
|
||||
scrollPosition = scrollView.scrollPosition;
|
||||
|
||||
if (!hasValidated)
|
||||
{
|
||||
EditorGUILayout.HelpBox("Click 'Validate Scene' to check for PS1 compatibility issues.", MessageType.Info);
|
||||
return;
|
||||
}
|
||||
|
||||
if (validationResults.Count == 0)
|
||||
{
|
||||
EditorGUILayout.HelpBox("No issues found! Your scene looks ready for PS1 export.", MessageType.Info);
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var result in validationResults)
|
||||
{
|
||||
if (result.Type == ValidationType.Error && !showErrors) continue;
|
||||
if (result.Type == ValidationType.Warning && !showWarnings) continue;
|
||||
if (result.Type == ValidationType.Info && !showInfo) continue;
|
||||
|
||||
DrawValidationResult(result);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void DrawValidationResult(ValidationResult result)
|
||||
{
|
||||
MessageType msgType = result.Type switch
|
||||
{
|
||||
ValidationType.Error => MessageType.Error,
|
||||
ValidationType.Warning => MessageType.Warning,
|
||||
_ => MessageType.Info
|
||||
};
|
||||
|
||||
using (new EditorGUILayout.VerticalScope(EditorStyles.helpBox))
|
||||
{
|
||||
EditorGUILayout.HelpBox(result.Message, msgType);
|
||||
|
||||
if (result.RelatedObject != null)
|
||||
{
|
||||
using (new EditorGUILayout.HorizontalScope())
|
||||
{
|
||||
GUILayout.Label("Object:", GUILayout.Width(50));
|
||||
|
||||
if (GUILayout.Button(result.RelatedObject.name, EditorStyles.linkLabel))
|
||||
{
|
||||
Selection.activeObject = result.RelatedObject;
|
||||
EditorGUIUtility.PingObject(result.RelatedObject);
|
||||
}
|
||||
|
||||
GUILayout.FlexibleSpace();
|
||||
|
||||
if (!string.IsNullOrEmpty(result.FixAction))
|
||||
{
|
||||
if (GUILayout.Button("Fix", GUILayout.Width(50)))
|
||||
{
|
||||
ApplyFix(result);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
EditorGUILayout.Space(2);
|
||||
}
|
||||
|
||||
private void DrawFooter()
|
||||
{
|
||||
EditorGUILayout.Space(10);
|
||||
|
||||
using (new EditorGUILayout.HorizontalScope())
|
||||
{
|
||||
if (GUILayout.Button("Select All With Errors"))
|
||||
{
|
||||
var errorObjects = validationResults
|
||||
.Where(r => r.Type == ValidationType.Error && r.RelatedObject != null)
|
||||
.Select(r => r.RelatedObject)
|
||||
.Distinct()
|
||||
.ToArray();
|
||||
|
||||
Selection.objects = errorObjects;
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
private void ValidateScene()
|
||||
{
|
||||
validationResults.Clear();
|
||||
errorCount = 0;
|
||||
warningCount = 0;
|
||||
infoCount = 0;
|
||||
|
||||
// Check for scene exporter
|
||||
ValidateSceneExporter();
|
||||
|
||||
// Check all PSX objects
|
||||
ValidatePSXObjects();
|
||||
|
||||
// Check textures and VRAM
|
||||
ValidateTextures();
|
||||
|
||||
|
||||
// Check Lua files
|
||||
ValidateLuaFiles();
|
||||
|
||||
// Overall scene stats
|
||||
ValidateSceneStats();
|
||||
|
||||
hasValidated = true;
|
||||
Repaint();
|
||||
}
|
||||
|
||||
private void ValidateSceneExporter()
|
||||
{
|
||||
var exporters = Object.FindObjectsOfType<PSXSceneExporter>();
|
||||
|
||||
if (exporters.Length == 0)
|
||||
{
|
||||
AddResult(ValidationType.Error,
|
||||
"No PSXSceneExporter found in scene. Add one via GameObject > PlayStation 1 > Scene Exporter",
|
||||
null, "AddExporter");
|
||||
}
|
||||
else if (exporters.Length > 1)
|
||||
{
|
||||
AddResult(ValidationType.Warning,
|
||||
$"Multiple PSXSceneExporters found ({exporters.Length}). Only one is needed per scene.",
|
||||
exporters[0].gameObject);
|
||||
}
|
||||
}
|
||||
|
||||
private void ValidatePSXObjects()
|
||||
{
|
||||
var exporters = Object.FindObjectsOfType<PSXObjectExporter>();
|
||||
|
||||
if (exporters.Length == 0)
|
||||
{
|
||||
AddResult(ValidationType.Info,
|
||||
"No objects marked for PSX export. Add PSXObjectExporter components to GameObjects you want to export.",
|
||||
null);
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var exporter in exporters)
|
||||
{
|
||||
ValidateSingleObject(exporter);
|
||||
}
|
||||
}
|
||||
|
||||
private void ValidateSingleObject(PSXObjectExporter exporter)
|
||||
{
|
||||
var go = exporter.gameObject;
|
||||
|
||||
// Check for mesh
|
||||
var meshFilter = go.GetComponent<MeshFilter>();
|
||||
if (meshFilter == null || meshFilter.sharedMesh == null)
|
||||
{
|
||||
AddResult(ValidationType.Warning,
|
||||
$"'{go.name}' has no mesh. It will be exported as an empty object.",
|
||||
go);
|
||||
return;
|
||||
}
|
||||
|
||||
var mesh = meshFilter.sharedMesh;
|
||||
int triCount = mesh.triangles.Length / 3;
|
||||
|
||||
// Check triangle count
|
||||
if (triCount > MAX_RECOMMENDED_TRIS_PER_OBJECT)
|
||||
{
|
||||
AddResult(ValidationType.Warning,
|
||||
$"'{go.name}' has {triCount} triangles (recommended max: {MAX_RECOMMENDED_TRIS_PER_OBJECT}). Consider simplifying.",
|
||||
go);
|
||||
}
|
||||
|
||||
// Check vertex coordinates for GTE limits
|
||||
var vertices = mesh.vertices;
|
||||
var transform = go.transform;
|
||||
bool hasOutOfBounds = false;
|
||||
|
||||
foreach (var vert in vertices)
|
||||
{
|
||||
var worldPos = transform.TransformPoint(vert);
|
||||
// Check if fixed-point conversion would overflow (assuming scale factor)
|
||||
float scaledX = worldPos.x * 4096f; // FixedPoint<12> scale
|
||||
float scaledY = worldPos.y * 4096f;
|
||||
float scaledZ = worldPos.z * 4096f;
|
||||
|
||||
if (scaledX > MAX_VERTEX_COORD || scaledX < MIN_VERTEX_COORD ||
|
||||
scaledY > MAX_VERTEX_COORD || scaledY < MIN_VERTEX_COORD ||
|
||||
scaledZ > MAX_VERTEX_COORD || scaledZ < MIN_VERTEX_COORD)
|
||||
{
|
||||
hasOutOfBounds = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (hasOutOfBounds)
|
||||
{
|
||||
AddResult(ValidationType.Error,
|
||||
$"'{go.name}' has vertices that exceed PS1 coordinate limits. Move closer to origin or scale down.",
|
||||
go);
|
||||
}
|
||||
|
||||
// Check for renderer and material
|
||||
var renderer = go.GetComponent<MeshRenderer>();
|
||||
if (renderer == null)
|
||||
{
|
||||
AddResult(ValidationType.Info,
|
||||
$"'{go.name}' has no MeshRenderer. Will be exported without visual rendering.",
|
||||
go);
|
||||
}
|
||||
else if (renderer.sharedMaterial == null)
|
||||
{
|
||||
AddResult(ValidationType.Warning,
|
||||
$"'{go.name}' has no material assigned. Will use default colors.",
|
||||
go);
|
||||
}
|
||||
|
||||
// Check texture settings on exporter
|
||||
if (exporter.texture != null)
|
||||
{
|
||||
ValidateTexture(exporter.texture, go);
|
||||
}
|
||||
}
|
||||
|
||||
private void ValidateTextures()
|
||||
{
|
||||
var exporters = Object.FindObjectsOfType<PSXObjectExporter>();
|
||||
var textures = exporters
|
||||
.Where(e => e.texture != null)
|
||||
.Select(e => e.texture)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
if (textures.Count == 0)
|
||||
{
|
||||
AddResult(ValidationType.Info,
|
||||
"No textures assigned to any PSX objects. Scene will be vertex-colored only.",
|
||||
null);
|
||||
return;
|
||||
}
|
||||
|
||||
// Rough VRAM estimation
|
||||
int estimatedVramUsage = 0;
|
||||
foreach (var tex in textures)
|
||||
{
|
||||
// Rough estimate: width * height * bits/8
|
||||
// This is simplified - actual packing is more complex
|
||||
int bitsPerPixel = 16; // Assume 16bpp worst case
|
||||
estimatedVramUsage += (tex.width * tex.height * bitsPerPixel) / 8;
|
||||
}
|
||||
|
||||
int vramTotal = VRAM_WIDTH * VRAM_HEIGHT * 2; // 16bpp
|
||||
int vramAvailable = vramTotal / 2; // Assume half for framebuffers
|
||||
|
||||
if (estimatedVramUsage > vramAvailable)
|
||||
{
|
||||
AddResult(ValidationType.Warning,
|
||||
$"Estimated texture VRAM usage ({estimatedVramUsage / 1024}KB) may exceed available space (~{vramAvailable / 1024}KB). " +
|
||||
"Consider using lower bit depths or smaller textures.",
|
||||
null);
|
||||
}
|
||||
}
|
||||
|
||||
private void ValidateTexture(Texture2D texture, GameObject relatedObject)
|
||||
{
|
||||
// Check power of 2
|
||||
if (!Mathf.IsPowerOfTwo(texture.width) || !Mathf.IsPowerOfTwo(texture.height))
|
||||
{
|
||||
AddResult(ValidationType.Warning,
|
||||
$"Texture '{texture.name}' dimensions ({texture.width}x{texture.height}) are not power of 2. May cause issues.",
|
||||
relatedObject);
|
||||
}
|
||||
|
||||
// Check max size
|
||||
if (texture.width > 256 || texture.height > 256)
|
||||
{
|
||||
AddResult(ValidationType.Warning,
|
||||
$"Texture '{texture.name}' is large ({texture.width}x{texture.height}). Consider using 256x256 or smaller.",
|
||||
relatedObject);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
private void ValidateLuaFiles()
|
||||
{
|
||||
var exporters = Object.FindObjectsOfType<PSXObjectExporter>();
|
||||
|
||||
foreach (var exporter in exporters)
|
||||
{
|
||||
if (exporter.LuaFile != null)
|
||||
{
|
||||
// Check if Lua file exists and is valid
|
||||
string path = AssetDatabase.GetAssetPath(exporter.LuaFile);
|
||||
if (string.IsNullOrEmpty(path))
|
||||
{
|
||||
AddResult(ValidationType.Error,
|
||||
$"'{exporter.name}' references an invalid Lua file.",
|
||||
exporter.gameObject);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void ValidateSceneStats()
|
||||
{
|
||||
var exporters = Object.FindObjectsOfType<PSXObjectExporter>();
|
||||
int totalTris = 0;
|
||||
|
||||
foreach (var exporter in exporters)
|
||||
{
|
||||
var mf = exporter.GetComponent<MeshFilter>();
|
||||
if (mf != null && mf.sharedMesh != null)
|
||||
{
|
||||
totalTris += mf.sharedMesh.triangles.Length / 3;
|
||||
}
|
||||
}
|
||||
|
||||
AddResult(ValidationType.Info,
|
||||
$"Scene statistics: {exporters.Length} objects, {totalTris} total triangles.",
|
||||
null);
|
||||
|
||||
if (totalTris > MAX_RECOMMENDED_TOTAL_TRIS)
|
||||
{
|
||||
AddResult(ValidationType.Warning,
|
||||
$"Total triangle count ({totalTris}) exceeds recommended maximum ({MAX_RECOMMENDED_TOTAL_TRIS}). " +
|
||||
"Performance may be poor on real hardware.",
|
||||
null);
|
||||
}
|
||||
}
|
||||
|
||||
private void AddResult(ValidationType type, string message, GameObject relatedObject, string fixAction = null)
|
||||
{
|
||||
validationResults.Add(new ValidationResult
|
||||
{
|
||||
Type = type,
|
||||
Message = message,
|
||||
RelatedObject = relatedObject,
|
||||
FixAction = fixAction
|
||||
});
|
||||
|
||||
switch (type)
|
||||
{
|
||||
case ValidationType.Error: errorCount++; break;
|
||||
case ValidationType.Warning: warningCount++; break;
|
||||
case ValidationType.Info: infoCount++; break;
|
||||
}
|
||||
}
|
||||
|
||||
private void ApplyFix(ValidationResult result)
|
||||
{
|
||||
switch (result.FixAction)
|
||||
{
|
||||
case "AddExporter":
|
||||
var go = new GameObject("PSXSceneExporter");
|
||||
go.AddComponent<PSXSceneExporter>();
|
||||
Undo.RegisterCreatedObjectUndo(go, "Create PSX Scene Exporter");
|
||||
Selection.activeGameObject = go;
|
||||
ValidateScene(); // Re-validate
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private enum ValidationType
|
||||
{
|
||||
Error,
|
||||
Warning,
|
||||
Info
|
||||
}
|
||||
|
||||
private class ValidationResult
|
||||
{
|
||||
public ValidationType Type;
|
||||
public string Message;
|
||||
public GameObject RelatedObject;
|
||||
public string FixAction;
|
||||
}
|
||||
}
|
||||
}
|
||||
2
Editor/PSXSceneValidatorWindow.cs.meta
Normal file
2
Editor/PSXSceneValidatorWindow.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 0a26bf89301a2554ca287b9e28e44906
|
||||
@@ -17,7 +17,7 @@ namespace SplashEdit.EditorCode
|
||||
private PSXBPP bpp = PSXBPP.TEX_4BIT;
|
||||
private readonly int previewSize = 256;
|
||||
|
||||
[MenuItem("PSX/Quantized Preview")]
|
||||
[MenuItem("PlayStation 1/Quantized Preview")]
|
||||
public static void ShowWindow()
|
||||
{
|
||||
// Creates and displays the window
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
using System.IO.Ports;
|
||||
|
||||
|
||||
namespace SplashEdit.EditorCode
|
||||
{
|
||||
public class SerialConnection
|
||||
{
|
||||
private static SerialPort serialPort;
|
||||
|
||||
public SerialConnection(string portName, int baudRate)
|
||||
{
|
||||
serialPort = new SerialPort(portName, baudRate);
|
||||
serialPort.ReadTimeout = 50;
|
||||
serialPort.WriteTimeout = 50;
|
||||
}
|
||||
|
||||
public void Open()
|
||||
{ serialPort.Open(); }
|
||||
|
||||
public void Close()
|
||||
{ serialPort.Close(); }
|
||||
|
||||
public int ReadByte()
|
||||
{ return serialPort.ReadByte(); }
|
||||
|
||||
public int ReadChar()
|
||||
{ return serialPort.ReadChar(); }
|
||||
|
||||
public void Write(string text)
|
||||
{ serialPort.Write(text); }
|
||||
|
||||
public void Write(char[] buffer, int offset, int count)
|
||||
{ serialPort.Write(buffer, offset, count); }
|
||||
|
||||
public void Write(byte[] buffer, int offset, int count)
|
||||
{ serialPort.Write(buffer, offset, count); }
|
||||
|
||||
}
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 714bd2374b7a9a14686078e5eb431795
|
||||
@@ -4,9 +4,16 @@ using System.Linq;
|
||||
using System.IO;
|
||||
using System;
|
||||
|
||||
/// <summary>
|
||||
/// Utility that detects whether required build tools (MIPS cross-compiler,
|
||||
/// GNU Make, GDB, etc.) are available on the host system by probing the
|
||||
/// PATH via <c>where</c> (Windows) or <c>which</c> (Unix).
|
||||
/// </summary>
|
||||
namespace SplashEdit.EditorCode
|
||||
{
|
||||
public static class ToolchainChecker
|
||||
{
|
||||
public static readonly string[] mipsToolSuffixes = new[]
|
||||
private static readonly string[] mipsToolSuffixes = new[]
|
||||
{
|
||||
"addr2line", "ar", "as", "cpp", "elfedit", "g++", "gcc", "gcc-ar", "gcc-nm",
|
||||
"gcc-ranlib", "gcov", "ld", "nm", "objcopy", "objdump", "ranlib", "readelf",
|
||||
@@ -74,3 +81,4 @@ public static class ToolchainChecker
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 326c6443947d4e5e783e90882b641ce8
|
||||
guid: 142296fdef504c64bb08110e6f28e581
|
||||
@@ -5,360 +5,133 @@ using UnityEngine;
|
||||
using UnityEditor;
|
||||
using System.IO;
|
||||
|
||||
public static class ToolchainInstaller
|
||||
namespace SplashEdit.EditorCode
|
||||
{
|
||||
// Flags to prevent duplicate installations.
|
||||
private static bool mipsInstalling = false;
|
||||
private static bool win32MipsToolsInstalling = false;
|
||||
|
||||
// The version string used by the installer command.
|
||||
public static string mipsVersion = "14.2.0";
|
||||
|
||||
/// <summary>
|
||||
/// Executes an external process asynchronously.
|
||||
/// Throws an exception if the process returns a nonzero exit code.
|
||||
/// Installs the MIPS cross-compiler toolchain and GNU Make.
|
||||
/// Supports Windows and Linux only.
|
||||
/// </summary>
|
||||
public static async Task RunCommandAsync(string fileName, string arguments, string workingDirectory = "")
|
||||
public static class ToolchainInstaller
|
||||
{
|
||||
var tcs = new TaskCompletionSource<int>();
|
||||
private static bool _installing;
|
||||
|
||||
if (fileName.Equals("mips", StringComparison.OrdinalIgnoreCase))
|
||||
public static string MipsVersion = "14.2.0";
|
||||
|
||||
/// <summary>
|
||||
/// Runs an external process and waits for it to exit.
|
||||
/// </summary>
|
||||
public static async Task RunCommandAsync(string fileName, string arguments, string workingDirectory = "")
|
||||
{
|
||||
fileName = "powershell";
|
||||
if (fileName.Equals("mips", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
fileName = "powershell";
|
||||
string roamingPath = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData);
|
||||
string scriptPath = Path.Combine(roamingPath, "mips", "mips.ps1");
|
||||
arguments = $"-ExecutionPolicy Bypass -File \"{scriptPath}\" {arguments}";
|
||||
}
|
||||
|
||||
// Get the AppData\Roaming path for the user
|
||||
string roamingPath = System.Environment.GetFolderPath(System.Environment.SpecialFolder.ApplicationData);
|
||||
string scriptPath = Path.Combine(roamingPath, "mips\\mips.ps1");
|
||||
var tcs = new TaskCompletionSource<int>();
|
||||
|
||||
// Pass the arguments to the PowerShell script
|
||||
arguments = $"-ExecutionPolicy Bypass -File \"{scriptPath}\" {arguments}";
|
||||
}
|
||||
Process process = new Process();
|
||||
process.StartInfo.FileName = fileName;
|
||||
process.StartInfo.Arguments = arguments;
|
||||
process.StartInfo.CreateNoWindow = false;
|
||||
process.StartInfo.UseShellExecute = true;
|
||||
|
||||
if (!string.IsNullOrEmpty(workingDirectory))
|
||||
process.StartInfo.WorkingDirectory = workingDirectory;
|
||||
|
||||
Process process = new Process();
|
||||
process.StartInfo.FileName = fileName;
|
||||
process.StartInfo.Arguments = arguments;
|
||||
process.StartInfo.CreateNoWindow = false;
|
||||
process.StartInfo.UseShellExecute = true;
|
||||
process.EnableRaisingEvents = true;
|
||||
process.Exited += (sender, args) =>
|
||||
{
|
||||
tcs.SetResult(process.ExitCode);
|
||||
process.Dispose();
|
||||
};
|
||||
|
||||
if (!string.IsNullOrEmpty(workingDirectory))
|
||||
process.StartInfo.WorkingDirectory = workingDirectory;
|
||||
|
||||
process.EnableRaisingEvents = true;
|
||||
process.Exited += (sender, args) =>
|
||||
{
|
||||
tcs.SetResult(process.ExitCode);
|
||||
process.Dispose();
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
process.Start();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
throw new Exception($"Failed to start process {fileName}: {ex.Message}");
|
||||
|
||||
int exitCode = await tcs.Task;
|
||||
if (exitCode != 0)
|
||||
throw new Exception($"Process '{fileName}' exited with code {exitCode}");
|
||||
}
|
||||
|
||||
int exitCode = await tcs.Task;
|
||||
if (exitCode != 0)
|
||||
throw new Exception($"Process '{fileName} {arguments}' exited with code {exitCode}");
|
||||
}
|
||||
|
||||
|
||||
#region MIPS Toolchain Installation
|
||||
|
||||
/// <summary>
|
||||
/// Installs the MIPS toolchain on Windows using a PowerShell script.
|
||||
/// (On Windows this installer bundles GNU Make as part of the toolchain.)
|
||||
/// </summary>
|
||||
public static async Task InstallMips()
|
||||
{
|
||||
if (mipsInstalling) return;
|
||||
mipsInstalling = true;
|
||||
try
|
||||
/// <summary>
|
||||
/// Installs the MIPS GCC cross-compiler for the current platform.
|
||||
/// </summary>
|
||||
public static async Task<bool> InstallToolchain()
|
||||
{
|
||||
// Download and run the installer script via PowerShell.
|
||||
await RunCommandAsync("powershell",
|
||||
"-c \"& { iwr -UseBasicParsing https://raw.githubusercontent.com/grumpycoders/pcsx-redux/main/mips.ps1 | iex }\"");
|
||||
EditorUtility.DisplayDialog("Reboot Required",
|
||||
"Installing the MIPS toolchain requires a reboot. Please reboot your computer and click the button again.",
|
||||
"OK");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
EditorUtility.DisplayDialog("Error",
|
||||
"An error occurred while installing the MIPS toolchain. Please install it manually.", "OK");
|
||||
throw ex;
|
||||
}
|
||||
}
|
||||
if (_installing) return false;
|
||||
_installing = true;
|
||||
|
||||
/// <summary>
|
||||
/// Installs the MIPS toolchain based on the current platform.
|
||||
/// Uses pkexec on Linux to request graphical elevation.
|
||||
/// </summary>
|
||||
public static async Task<bool> InstallToolchain()
|
||||
{
|
||||
switch (Application.platform)
|
||||
{
|
||||
case RuntimePlatform.WindowsEditor:
|
||||
try
|
||||
try
|
||||
{
|
||||
if (Application.platform == RuntimePlatform.WindowsEditor)
|
||||
{
|
||||
if (!ToolchainChecker.IsToolAvailable("mips"))
|
||||
{
|
||||
await InstallMips();
|
||||
await RunCommandAsync("powershell",
|
||||
"-c \"& { iwr -UseBasicParsing https://raw.githubusercontent.com/grumpycoders/pcsx-redux/main/mips.ps1 | iex }\"");
|
||||
EditorUtility.DisplayDialog("Reboot Required",
|
||||
"Installing the MIPS toolchain requires a reboot. Please reboot and try again.",
|
||||
"OK");
|
||||
return false;
|
||||
}
|
||||
else
|
||||
{
|
||||
if (win32MipsToolsInstalling) return false;
|
||||
win32MipsToolsInstalling = true;
|
||||
await RunCommandAsync("mips", $"install {mipsVersion}");
|
||||
await RunCommandAsync("mips", $"install {MipsVersion}");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
EditorUtility.DisplayDialog("Error",
|
||||
"An error occurred while installing the MIPS toolchain. Please install it manually.", "OK");
|
||||
throw ex;
|
||||
}
|
||||
break;
|
||||
|
||||
case RuntimePlatform.LinuxEditor:
|
||||
try
|
||||
else if (Application.platform == RuntimePlatform.LinuxEditor)
|
||||
{
|
||||
if (ToolchainChecker.IsToolAvailable("apt"))
|
||||
{
|
||||
await RunCommandAsync("pkexec", "apt install g++-mipsel-linux-gnu -y");
|
||||
}
|
||||
else if (ToolchainChecker.IsToolAvailable("trizen"))
|
||||
{
|
||||
await RunCommandAsync("trizen", "-S cross-mipsel-linux-gnu-binutils cross-mipsel-linux-gnu-gcc");
|
||||
}
|
||||
else if (ToolchainChecker.IsToolAvailable("brew"))
|
||||
{
|
||||
string binutilsScriptPath = Application.dataPath + "/Scripts/mipsel-none-elf-binutils.rb";
|
||||
string gccScriptPath = Application.dataPath + "/Scripts/mipsel-none-elf-gcc.rb";
|
||||
await RunCommandAsync("brew", $"install --formula \"{binutilsScriptPath}\" \"{gccScriptPath}\"");
|
||||
}
|
||||
else
|
||||
{
|
||||
EditorUtility.DisplayDialog("Error",
|
||||
"Your Linux distribution is not supported. Please install the MIPS toolchain manually.", "OK");
|
||||
throw new Exception("Unsupported Linux distribution");
|
||||
}
|
||||
throw new Exception("Unsupported Linux distribution. Install mipsel-linux-gnu-gcc manually.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
else
|
||||
{
|
||||
EditorUtility.DisplayDialog("Error",
|
||||
"An error occurred while installing the MIPS toolchain. Please install it manually.", "OK");
|
||||
throw ex;
|
||||
throw new Exception("Only Windows and Linux are supported.");
|
||||
}
|
||||
break;
|
||||
|
||||
case RuntimePlatform.OSXEditor:
|
||||
try
|
||||
{
|
||||
if (ToolchainChecker.IsToolAvailable("brew"))
|
||||
{
|
||||
string binutilsScriptPath = Application.dataPath + "/Scripts/mipsel-none-elf-binutils.rb";
|
||||
string gccScriptPath = Application.dataPath + "/Scripts/mipsel-none-elf-gcc.rb";
|
||||
await RunCommandAsync("brew", $"install --formula \"{binutilsScriptPath}\" \"{gccScriptPath}\"");
|
||||
}
|
||||
else
|
||||
{
|
||||
await RunCommandAsync("/bin/bash", "-c \"$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)\"");
|
||||
EditorUtility.DisplayDialog("Reboot Required",
|
||||
"Installing Homebrew requires a reboot. Please reboot your computer before proceeding further.", "OK");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
EditorUtility.DisplayDialog("Error",
|
||||
"An error occurred while installing the MIPS toolchain. Please install it manually.", "OK");
|
||||
throw ex;
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
EditorUtility.DisplayDialog("Error",
|
||||
"Your platform is not supported by this extension. Please install the MIPS toolchain manually.", "OK");
|
||||
throw new Exception("Unsupported platform");
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
EditorUtility.DisplayDialog("Error",
|
||||
$"Toolchain installation failed: {ex.Message}", "OK");
|
||||
return false;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_installing = false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region GNU Make Installation
|
||||
|
||||
/// <summary>
|
||||
/// Installs GNU Make.
|
||||
/// On Linux/macOS it installs GNU Make normally.
|
||||
/// On Windows, GNU Make is bundled with the MIPS toolchain—so the user is warned before proceeding.
|
||||
/// </summary>
|
||||
public static async Task InstallMake()
|
||||
{
|
||||
switch (Application.platform)
|
||||
/// <summary>
|
||||
/// Installs GNU Make. On Windows it is bundled with the MIPS toolchain.
|
||||
/// </summary>
|
||||
public static async Task InstallMake()
|
||||
{
|
||||
case RuntimePlatform.WindowsEditor:
|
||||
// Inform the user that GNU Make is bundled with the MIPS toolchain.
|
||||
if (Application.platform == RuntimePlatform.WindowsEditor)
|
||||
{
|
||||
bool proceed = EditorUtility.DisplayDialog(
|
||||
"Install GNU Make",
|
||||
"On Windows, GNU Make is installed as part of the MIPS toolchain installer. Would you like to install the full toolchain?",
|
||||
"Yes",
|
||||
"No"
|
||||
);
|
||||
if (proceed)
|
||||
{
|
||||
await InstallToolchain();
|
||||
}
|
||||
break;
|
||||
|
||||
case RuntimePlatform.LinuxEditor:
|
||||
try
|
||||
{
|
||||
if (ToolchainChecker.IsToolAvailable("apt"))
|
||||
{
|
||||
await RunCommandAsync("pkexec", "apt install build-essential -y");
|
||||
}
|
||||
else
|
||||
{
|
||||
EditorUtility.DisplayDialog("Error",
|
||||
"Your Linux distribution is not supported. Please install GNU Make manually.", "OK");
|
||||
throw new Exception("Unsupported Linux distribution");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
EditorUtility.DisplayDialog("Error",
|
||||
"An error occurred while installing GNU Make. Please install it manually.", "OK");
|
||||
throw ex;
|
||||
}
|
||||
break;
|
||||
|
||||
case RuntimePlatform.OSXEditor:
|
||||
try
|
||||
{
|
||||
if (ToolchainChecker.IsToolAvailable("brew"))
|
||||
{
|
||||
await RunCommandAsync("brew", "install make");
|
||||
}
|
||||
else
|
||||
{
|
||||
EditorUtility.DisplayDialog("Error",
|
||||
"Homebrew is not installed. Please install GNU Make manually.", "OK");
|
||||
throw new Exception("Brew not installed");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
EditorUtility.DisplayDialog("Error",
|
||||
"An error occurred while installing GNU Make. Please install it manually.", "OK");
|
||||
throw ex;
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
EditorUtility.DisplayDialog("Error",
|
||||
"Your platform is not supported. Please install GNU Make manually.", "OK");
|
||||
throw new Exception("Unsupported platform");
|
||||
"On Windows, GNU Make is included with the MIPS toolchain installer. Install the full toolchain?",
|
||||
"Yes", "No");
|
||||
if (proceed) await InstallToolchain();
|
||||
}
|
||||
else if (Application.platform == RuntimePlatform.LinuxEditor)
|
||||
{
|
||||
if (ToolchainChecker.IsToolAvailable("apt"))
|
||||
await RunCommandAsync("pkexec", "apt install build-essential -y");
|
||||
else
|
||||
throw new Exception("Unsupported Linux distribution. Install 'make' manually.");
|
||||
}
|
||||
else
|
||||
{
|
||||
throw new Exception("Only Windows and Linux are supported.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region GDB Installation (Optional)
|
||||
|
||||
/// <summary>
|
||||
/// Installs GDB Multiarch (or GDB on macOS)
|
||||
/// </summary>
|
||||
public static async Task InstallGDB()
|
||||
{
|
||||
switch (Application.platform)
|
||||
{
|
||||
case RuntimePlatform.WindowsEditor:
|
||||
try
|
||||
{
|
||||
if (!ToolchainChecker.IsToolAvailable("mips"))
|
||||
{
|
||||
await InstallMips();
|
||||
}
|
||||
else
|
||||
{
|
||||
if (win32MipsToolsInstalling) return;
|
||||
win32MipsToolsInstalling = true;
|
||||
await RunCommandAsync("mips", $"install {mipsVersion}");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
EditorUtility.DisplayDialog("Error",
|
||||
"An error occurred while installing GDB Multiarch. Please install it manually.", "OK");
|
||||
throw ex;
|
||||
}
|
||||
break;
|
||||
|
||||
case RuntimePlatform.LinuxEditor:
|
||||
try
|
||||
{
|
||||
if (ToolchainChecker.IsToolAvailable("apt"))
|
||||
{
|
||||
await RunCommandAsync("pkexec", "apt install gdb-multiarch -y");
|
||||
}
|
||||
else if (ToolchainChecker.IsToolAvailable("trizen"))
|
||||
{
|
||||
await RunCommandAsync("trizen", "-S gdb-multiarch");
|
||||
}
|
||||
else if (ToolchainChecker.IsToolAvailable("brew"))
|
||||
{
|
||||
await RunCommandAsync("brew", "install gdb-multiarch");
|
||||
}
|
||||
else
|
||||
{
|
||||
EditorUtility.DisplayDialog("Error",
|
||||
"Your Linux distribution is not supported. Please install GDB Multiarch manually.", "OK");
|
||||
throw new Exception("Unsupported Linux distribution");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
EditorUtility.DisplayDialog("Error",
|
||||
"An error occurred while installing GDB Multiarch. Please install it manually.", "OK");
|
||||
throw ex;
|
||||
}
|
||||
break;
|
||||
|
||||
case RuntimePlatform.OSXEditor:
|
||||
try
|
||||
{
|
||||
if (ToolchainChecker.IsToolAvailable("brew"))
|
||||
{
|
||||
await RunCommandAsync("brew", "install gdb");
|
||||
}
|
||||
else
|
||||
{
|
||||
await RunCommandAsync("/bin/bash", "-c \"$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)\"");
|
||||
EditorUtility.DisplayDialog("Reboot Required",
|
||||
"Installing Homebrew requires a reboot. Please reboot your computer before proceeding further.", "OK");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
EditorUtility.DisplayDialog("Error",
|
||||
"An error occurred while installing GDB Multiarch. Please install it manually.", "OK");
|
||||
throw ex;
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
EditorUtility.DisplayDialog("Error",
|
||||
"Your platform is not supported. Please install GDB Multiarch manually.", "OK");
|
||||
throw new Exception("Unsupported platform");
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
|
||||
}
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 93df68ab9518c5bf6a962c185fe742fe
|
||||
guid: c5aa88b01a3eef145806c8e9e59f4e9d
|
||||
@@ -1,22 +0,0 @@
|
||||
namespace SplashEdit.EditorCode
|
||||
{
|
||||
public class UniromConnection
|
||||
{
|
||||
|
||||
private SerialConnection serialConnection;
|
||||
|
||||
public UniromConnection(int baudRate, string portName)
|
||||
{
|
||||
serialConnection = new SerialConnection(portName, baudRate);
|
||||
}
|
||||
|
||||
public void Reset()
|
||||
{
|
||||
serialConnection.Open();
|
||||
serialConnection.Write("REST");
|
||||
serialConnection.Close();
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: d8fbc734f42ab9d42a843b6718127da7
|
||||
@@ -1,145 +0,0 @@
|
||||
using UnityEngine;
|
||||
using UnityEditor;
|
||||
using System.IO.Ports;
|
||||
using System.Collections;
|
||||
using SplashEdit.RuntimeCode;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace SplashEdit.EditorCode
|
||||
{
|
||||
public class PSXConnectionConfigWindow : EditorWindow
|
||||
{
|
||||
|
||||
public PSXConnectionType connectionType = PSXConnectionType.REAL_HARDWARE;
|
||||
|
||||
// REAL HARDWARE (Unirom) SETTINGS
|
||||
private string[] portNames;
|
||||
private int selectedPortIndex = 1;
|
||||
private int[] baudRates = { 9600, 115200 };
|
||||
private int selectedBaudIndex = 0;
|
||||
|
||||
|
||||
|
||||
|
||||
private string statusMessage = "";
|
||||
private MessageType statusType;
|
||||
private Vector2 scrollPosition;
|
||||
|
||||
[MenuItem("PSX/Console or Emulator Connection")]
|
||||
public static void ShowWindow()
|
||||
{
|
||||
GetWindow<PSXConnectionConfigWindow>("Serial Config");
|
||||
}
|
||||
|
||||
private void OnEnable()
|
||||
{
|
||||
RefreshPorts();
|
||||
LoadSettings();
|
||||
}
|
||||
|
||||
private void RefreshPorts()
|
||||
{
|
||||
portNames = SerialPort.GetPortNames();
|
||||
if (portNames.Length == 0)
|
||||
{
|
||||
portNames = new[] { "No ports available" };
|
||||
}
|
||||
}
|
||||
|
||||
private void OnGUI()
|
||||
{
|
||||
using (var scrollView = new EditorGUILayout.ScrollViewScope(scrollPosition))
|
||||
{
|
||||
scrollPosition = scrollView.scrollPosition;
|
||||
|
||||
EditorGUILayout.LabelField("Pick connection type", EditorStyles.boldLabel);
|
||||
connectionType = (PSXConnectionType)EditorGUILayout.EnumPopup("Connection Type", connectionType);
|
||||
|
||||
if (connectionType == PSXConnectionType.REAL_HARDWARE)
|
||||
{
|
||||
// Port selection
|
||||
EditorGUILayout.LabelField("Select COM Port", EditorStyles.boldLabel);
|
||||
selectedPortIndex = EditorGUILayout.Popup("Available Ports", selectedPortIndex, portNames);
|
||||
|
||||
// Baud rate selection
|
||||
EditorGUILayout.Space();
|
||||
EditorGUILayout.LabelField("Select Baud Rate", EditorStyles.boldLabel);
|
||||
selectedBaudIndex = EditorGUILayout.Popup("Baud Rate", selectedBaudIndex, new[] { "9600", "115200" });
|
||||
|
||||
// Buttons
|
||||
EditorGUILayout.Space();
|
||||
using (new EditorGUILayout.HorizontalScope())
|
||||
{
|
||||
if (GUILayout.Button("Refresh Ports"))
|
||||
{
|
||||
RefreshPorts();
|
||||
}
|
||||
|
||||
if (GUILayout.Button("Test Connection"))
|
||||
{
|
||||
TestConnection();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (GUILayout.Button("Save settings"))
|
||||
{
|
||||
SaveSettings();
|
||||
|
||||
}
|
||||
|
||||
// Status message
|
||||
EditorGUILayout.Space();
|
||||
if (!string.IsNullOrEmpty(statusMessage))
|
||||
{
|
||||
EditorGUILayout.HelpBox(statusMessage, statusType);
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
private void LoadSettings()
|
||||
{
|
||||
PSXData _psxData = DataStorage.LoadData();
|
||||
if (_psxData != null)
|
||||
{
|
||||
connectionType = _psxData.ConnectionType;
|
||||
selectedBaudIndex = System.Array.IndexOf(baudRates, _psxData.BaudRate);
|
||||
if (selectedBaudIndex == -1) selectedBaudIndex = 0;
|
||||
|
||||
RefreshPorts();
|
||||
selectedPortIndex = System.Array.IndexOf(portNames, _psxData.PortName);
|
||||
if (selectedPortIndex == -1) selectedPortIndex = 0;
|
||||
}
|
||||
}
|
||||
|
||||
private void TestConnection()
|
||||
{
|
||||
if (portNames.Length == 0 || portNames[0] == "No ports available")
|
||||
{
|
||||
statusMessage = "No serial ports available";
|
||||
statusType = MessageType.Error;
|
||||
return;
|
||||
}
|
||||
|
||||
UniromConnection connection = new UniromConnection(baudRates[selectedBaudIndex], portNames[selectedPortIndex]);
|
||||
connection.Reset();
|
||||
|
||||
statusMessage = "Connection tested. If your PlayStation reset, it worked!";
|
||||
statusType = MessageType.Info;
|
||||
Repaint();
|
||||
}
|
||||
|
||||
private void SaveSettings()
|
||||
{
|
||||
PSXData _psxData = DataStorage.LoadData();
|
||||
_psxData.ConnectionType = connectionType;
|
||||
_psxData.BaudRate = baudRates[selectedBaudIndex];
|
||||
_psxData.PortName = portNames[selectedPortIndex];
|
||||
DataStorage.StoreData(_psxData);
|
||||
statusMessage = "Settings saved";
|
||||
statusType = MessageType.Info;
|
||||
Repaint();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: ade0bf0fd69f449458c5b43e0f48ddff
|
||||
@@ -37,7 +37,7 @@ namespace SplashEdit.EditorCode
|
||||
};
|
||||
private static string[] resolutionsStrings => resolutions.Select(c => $"{c.x}x{c.y}").ToArray();
|
||||
|
||||
[MenuItem("PSX/VRAM Editor")]
|
||||
[MenuItem("PlayStation 1/VRAM Editor")]
|
||||
public static void ShowWindow()
|
||||
{
|
||||
VRAMEditorWindow window = GetWindow<VRAMEditorWindow>("VRAM Editor");
|
||||
@@ -61,7 +61,7 @@ namespace SplashEdit.EditorCode
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pastes an overlay texture onto a base texture at the specified position.
|
||||
/// Pastes an overlay texture onto a base texture at the specified position.
|
||||
/// </summary>
|
||||
public static void PasteTexture(Texture2D baseTexture, Texture2D overlayTexture, int posX, int posY)
|
||||
{
|
||||
@@ -299,11 +299,5 @@ namespace SplashEdit.EditorCode
|
||||
|
||||
GUILayout.EndHorizontal();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stores current configuration to the PSX data asset.
|
||||
/// This is now triggered manually via the "Save Settings" button.
|
||||
/// </summary>
|
||||
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,13 @@
|
||||
{
|
||||
"name": "net.psxplash.splashedit.Editor",
|
||||
"name": "net.psxsplash.splashedit.Editor",
|
||||
"rootNamespace": "",
|
||||
"references": [
|
||||
"net.psxsplash.splashedit.Runtime"
|
||||
"net.psxsplash.splashedit.Runtime",
|
||||
"Unity.AI.Navigation"
|
||||
],
|
||||
"includePlatforms": [
|
||||
"Editor"
|
||||
],
|
||||
"includePlatforms": [],
|
||||
"excludePlatforms": [],
|
||||
"allowUnsafeCode": false,
|
||||
"overrideReferences": false,
|
||||
@@ -13,4 +16,4 @@
|
||||
"defineConstraints": [],
|
||||
"versionDefines": [],
|
||||
"noEngineReferences": false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 7e3500b5974da9723bdd0d457348ea2d
|
||||
guid: 8bf64a45e6e447140a68258cd60d0ec1
|
||||
AssemblyDefinitionImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
|
||||
Reference in New Issue
Block a user