Files
secretsplash/Editor/Core/SplashControlPanel.cs
2026-03-27 19:29:41 +01:00

2046 lines
81 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.IO.Ports;
using System.Linq;
using System.Threading.Tasks;
using UnityEditor;
using UnityEditor.SceneManagement;
using UnityEngine;
using UnityEngine.SceneManagement;
using SplashEdit.RuntimeCode;
using Debug = UnityEngine.Debug;
namespace SplashEdit.EditorCode
{
/// <summary>
/// SplashEdit Control Panel — the single unified window for the entire pipeline.
/// One window. One button. Everything works.
/// </summary>
public class SplashControlPanel : EditorWindow
{
// ───── Constants ─────
private const string WINDOW_TITLE = "SplashEdit Control Panel";
private const string MENU_PATH = "PlayStation 1/SplashEdit Control Panel %#l";
// ───── UI State ─────
private Vector2 _scrollPos;
private int _selectedTab = 0;
private static readonly string[] _tabNames = { "Dependencies", "Scenes", "Build" };
private bool _showNativeProject = true;
private bool _showToolchainSection = true;
private bool _showScenesSection = true;
private bool _showVRAMSection = true;
private bool _showBuildSection = true;
// ───── Build State ─────
private static bool _isBuilding;
private static bool _isRunning;
private static Process _emulatorProcess;
// ───── Scene List ─────
private List<SceneEntry> _sceneList = new List<SceneEntry>();
// ───── Memory Reports ─────
private List<SceneMemoryReport> _memoryReports = new List<SceneMemoryReport>();
private bool _showMemoryReport = true;
// ───── Toolchain Cache ─────
private bool _hasMIPS;
private bool _hasMake;
private bool _hasRedux;
private bool _hasNativeProject;
private bool _hasPsxavenc;
private bool _hasMkpsxiso;
private string _reduxVersion = "";
// ───── Native project installer ─────
private bool _isInstallingNative;
private string _nativeInstallStatus = "";
private string _manualNativePath = "";
// PCdrv serial host instance (for real hardware file serving)
private static PCdrvSerialHost _pcdrvHost;
private struct SceneEntry
{
public SceneAsset asset;
public string path;
public string name;
}
// ═══════════════════════════════════════════════════════════════
// Menu & Window Lifecycle
// ═══════════════════════════════════════════════════════════════
[MenuItem(MENU_PATH, false, 0)]
public static void ShowWindow()
{
var window = GetWindow<SplashControlPanel>();
window.titleContent = new GUIContent(WINDOW_TITLE, EditorGUIUtility.IconContent("d_BuildSettings.PSP2.Small").image);
window.minSize = new Vector2(420, 600);
window.Show();
}
private void OnEnable()
{
SplashBuildPaths.EnsureDirectories();
RefreshToolchainStatus();
LoadSceneList();
_manualNativePath = SplashSettings.NativeProjectPath;
}
private void OnDisable()
{
}
private void OnFocus()
{
RefreshToolchainStatus();
}
// ═══════════════════════════════════════════════════════════════
// Main GUI
// ═══════════════════════════════════════════════════════════════
private void OnGUI()
{
if (_isRunning && _pcdrvHost != null && !_pcdrvHost.IsRunning)
{
StopAll();
Log("PCdrv host connection lost.", LogType.Warning);
}
DrawHeader();
EditorGUILayout.Space(4);
_selectedTab = PSXEditorStyles.DrawButtonGroup(_tabNames, _selectedTab, 28);
EditorGUILayout.Space(4);
_scrollPos = EditorGUILayout.BeginScrollView(_scrollPos);
switch (_selectedTab)
{
case 0: // Dependencies
DrawToolchainSection();
EditorGUILayout.Space(2);
DrawNativeProjectSection();
break;
case 1: // Scenes
DrawScenesSection();
break;
case 2: // Build
DrawBuildSection();
break;
}
EditorGUILayout.EndScrollView();
}
// ═══════════════════════════════════════════════════════════════
// Header
// ═══════════════════════════════════════════════════════════════
private void DrawHeader()
{
EditorGUILayout.BeginHorizontal(PSXEditorStyles.ToolbarStyle);
GUILayout.Label("SplashEdit", PSXEditorStyles.WindowHeader);
GUILayout.FlexibleSpace();
EditorGUILayout.EndHorizontal();
// Status bar
{
string statusText;
Color statusColor;
if (!_hasMIPS)
{
statusText = "Setup required — install the MIPS toolchain to get started";
statusColor = PSXEditorStyles.Warning;
}
else if (!_hasNativeProject)
{
statusText = "Native project not found — clone or set path below";
statusColor = PSXEditorStyles.Warning;
}
else if (_isBuilding)
{
statusText = "Building...";
statusColor = PSXEditorStyles.Info;
}
else if (_isRunning)
{
statusText = "Running on " + (SplashSettings.Target == BuildTarget.Emulator ? "emulator" : "hardware");
statusColor = PSXEditorStyles.AccentGreen;
}
else
{
statusText = "Ready";
statusColor = PSXEditorStyles.Success;
}
EditorGUILayout.BeginHorizontal(PSXEditorStyles.InfoBox);
PSXEditorStyles.DrawStatusBadge(statusColor == PSXEditorStyles.Success ? "OK" :
statusColor == PSXEditorStyles.Warning ? "WARN" :
statusColor == PSXEditorStyles.Info ? "INFO" : "RUN", statusColor, 50);
GUILayout.Label(statusText, PSXEditorStyles.RichLabel);
EditorGUILayout.EndHorizontal();
}
}
// ═══════════════════════════════════════════════════════════════
// Native Project Section
// ═══════════════════════════════════════════════════════════════
private void DrawNativeProjectSection()
{
_showNativeProject = DrawSectionFoldout("Native Project (psxsplash)", _showNativeProject);
if (!_showNativeProject) return;
EditorGUILayout.BeginVertical(PSXEditorStyles.CardStyle);
string currentPath = SplashBuildPaths.NativeSourceDir;
bool hasProject = !string.IsNullOrEmpty(currentPath) && Directory.Exists(currentPath);
// Status
EditorGUILayout.BeginHorizontal();
DrawStatusIcon(hasProject);
if (hasProject)
{
GUILayout.Label("Found at:", GUILayout.Width(60));
GUILayout.Label(TruncatePath(currentPath, 50), EditorStyles.miniLabel);
}
else
{
GUILayout.Label("Not found — clone from GitHub or set path manually", EditorStyles.miniLabel);
}
EditorGUILayout.EndHorizontal();
EditorGUILayout.Space(6);
// ── Option 1: Auto-clone from GitHub ──
PSXEditorStyles.DrawSeparator(4, 4);
GUILayout.Label("Clone from GitHub", PSXEditorStyles.SectionHeader);
EditorGUILayout.BeginHorizontal();
GUILayout.Label(PSXSplashInstaller.RepoUrl, EditorStyles.miniLabel);
GUILayout.FlexibleSpace();
EditorGUI.BeginDisabledGroup(_isInstallingNative || PSXSplashInstaller.IsInstalled());
if (GUILayout.Button(PSXSplashInstaller.IsInstalled() ? "Installed" : "Clone", GUILayout.Width(80)))
{
CloneNativeProject();
}
EditorGUI.EndDisabledGroup();
EditorGUILayout.EndHorizontal();
if (_isInstallingNative)
{
GUILayout.Label(_nativeInstallStatus, PSXEditorStyles.InfoBox);
}
// If already cloned, show version management
if (PSXSplashInstaller.IsInstalled())
{
EditorGUILayout.BeginHorizontal();
if (GUILayout.Button("Fetch Latest", EditorStyles.miniButton, GUILayout.Width(90)))
{
FetchNativeLatest();
}
if (GUILayout.Button("Open Folder", EditorStyles.miniButton, GUILayout.Width(90)))
{
EditorUtility.RevealInFinder(PSXSplashInstaller.FullInstallPath);
}
EditorGUILayout.EndHorizontal();
}
EditorGUILayout.Space(6);
// ── Option 2: Manual path ──
PSXEditorStyles.DrawSeparator(4, 4);
GUILayout.Label("Or set path manually", PSXEditorStyles.SectionHeader);
EditorGUILayout.BeginHorizontal();
string newPath = EditorGUILayout.TextField(_manualNativePath);
if (newPath != _manualNativePath)
{
_manualNativePath = newPath;
}
if (GUILayout.Button("...", GUILayout.Width(30)))
{
string selected = EditorUtility.OpenFolderPanel("Select psxsplash Source Directory", _manualNativePath, "");
if (!string.IsNullOrEmpty(selected))
{
_manualNativePath = selected;
}
}
EditorGUILayout.EndHorizontal();
// Validate & apply the path
bool manualPathValid = !string.IsNullOrEmpty(_manualNativePath) &&
Directory.Exists(_manualNativePath) &&
File.Exists(Path.Combine(_manualNativePath, "Makefile"));
EditorGUILayout.BeginHorizontal();
if (!string.IsNullOrEmpty(_manualNativePath) && !manualPathValid)
{
GUILayout.Label("Invalid path. The directory must contain a Makefile.", PSXEditorStyles.InfoBox);
}
else if (manualPathValid && _manualNativePath != SplashSettings.NativeProjectPath)
{
if (GUILayout.Button("Apply", GUILayout.Width(60)))
{
SplashSettings.NativeProjectPath = _manualNativePath;
RefreshToolchainStatus();
Log($"Native project path set: {_manualNativePath}", LogType.Log);
}
}
EditorGUILayout.EndHorizontal();
if (manualPathValid && _manualNativePath == SplashSettings.NativeProjectPath)
{
var prevColor = GUI.contentColor;
GUI.contentColor = PSXEditorStyles.Success;
GUILayout.Label("✓ Path is set and valid", EditorStyles.miniLabel);
GUI.contentColor = prevColor;
}
EditorGUILayout.EndVertical();
}
// ═══════════════════════════════════════════════════════════════
// Toolchain Section
// ═══════════════════════════════════════════════════════════════
private void DrawToolchainSection()
{
_showToolchainSection = DrawSectionFoldout("Toolchain", _showToolchainSection);
if (!_showToolchainSection) return;
EditorGUILayout.BeginVertical(PSXEditorStyles.CardStyle);
// MIPS Compiler
EditorGUILayout.BeginHorizontal();
DrawStatusIcon(_hasMIPS);
GUILayout.Label("MIPS Cross-Compiler", GUILayout.Width(160));
GUILayout.FlexibleSpace();
if (!_hasMIPS)
{
if (GUILayout.Button("Install", PSXEditorStyles.SecondaryButton, GUILayout.Width(70)))
InstallMIPS();
}
else
{
PSXEditorStyles.DrawStatusBadge("Ready", PSXEditorStyles.Success);
}
EditorGUILayout.EndHorizontal();
PSXEditorStyles.DrawSeparator(2, 2);
// GNU Make
EditorGUILayout.BeginHorizontal();
DrawStatusIcon(_hasMake);
GUILayout.Label("GNU Make", GUILayout.Width(160));
GUILayout.FlexibleSpace();
if (!_hasMake)
{
if (GUILayout.Button("Install", PSXEditorStyles.SecondaryButton, GUILayout.Width(70)))
InstallMake();
}
else
{
PSXEditorStyles.DrawStatusBadge("Ready", PSXEditorStyles.Success);
}
EditorGUILayout.EndHorizontal();
PSXEditorStyles.DrawSeparator(2, 2);
// PCSX-Redux
EditorGUILayout.BeginHorizontal();
DrawStatusIcon(_hasRedux);
GUILayout.Label("PCSX-Redux", GUILayout.Width(160));
GUILayout.FlexibleSpace();
if (!_hasRedux)
{
if (GUILayout.Button("Download", PSXEditorStyles.SecondaryButton, GUILayout.Width(80)))
DownloadRedux();
}
else
{
PSXEditorStyles.DrawStatusBadge(_reduxVersion, PSXEditorStyles.Success);
}
EditorGUILayout.EndHorizontal();
PSXEditorStyles.DrawSeparator(2, 2);
// psxavenc (audio encoder)
EditorGUILayout.BeginHorizontal();
DrawStatusIcon(_hasPsxavenc);
GUILayout.Label("psxavenc (Audio)", GUILayout.Width(160));
GUILayout.FlexibleSpace();
if (!_hasPsxavenc)
{
if (GUILayout.Button("Download", PSXEditorStyles.SecondaryButton, GUILayout.Width(80)))
DownloadPsxavenc();
}
else
{
PSXEditorStyles.DrawStatusBadge("Installed", PSXEditorStyles.Success);
}
EditorGUILayout.EndHorizontal();
PSXEditorStyles.DrawSeparator(2, 2);
// mkpsxiso (ISO builder)
EditorGUILayout.BeginHorizontal();
DrawStatusIcon(_hasMkpsxiso);
GUILayout.Label("mkpsxiso (ISO)", GUILayout.Width(160));
GUILayout.FlexibleSpace();
if (!_hasMkpsxiso)
{
if (GUILayout.Button("Download", PSXEditorStyles.SecondaryButton, GUILayout.Width(80)))
DownloadMkpsxiso();
}
else
{
PSXEditorStyles.DrawStatusBadge("Installed", PSXEditorStyles.Success);
}
EditorGUILayout.EndHorizontal();
// Refresh button
EditorGUILayout.Space(2);
EditorGUILayout.BeginHorizontal();
GUILayout.FlexibleSpace();
if (GUILayout.Button("Refresh", EditorStyles.miniButton, GUILayout.Width(60)))
RefreshToolchainStatus();
EditorGUILayout.EndHorizontal();
EditorGUILayout.EndVertical();
}
// ═══════════════════════════════════════════════════════════════
// Scenes Section
// ═══════════════════════════════════════════════════════════════
private void DrawScenesSection()
{
_showScenesSection = DrawSectionFoldout("Scenes", _showScenesSection);
if (!_showScenesSection) return;
EditorGUILayout.BeginVertical(PSXEditorStyles.CardStyle);
if (_sceneList.Count == 0)
{
GUILayout.Label(
"No scenes added yet.\n" +
"Each scene needs a GameObject with a PSXSceneExporter component.\n" +
"Drag scene assets here, or use the buttons below to add them.",
PSXEditorStyles.InfoBox);
}
// Draw scene list
int removeIndex = -1;
int moveUp = -1;
int moveDown = -1;
for (int i = 0; i < _sceneList.Count; i++)
{
EditorGUILayout.BeginHorizontal();
// Index badge
GUILayout.Label($"[{i}]", EditorStyles.miniLabel, GUILayout.Width(24));
// Scene asset field
var newAsset = (SceneAsset)EditorGUILayout.ObjectField(
_sceneList[i].asset, typeof(SceneAsset), false);
if (newAsset != _sceneList[i].asset)
{
var entry = _sceneList[i];
entry.asset = newAsset;
if (newAsset != null)
{
entry.path = AssetDatabase.GetAssetPath(newAsset);
entry.name = newAsset.name;
}
_sceneList[i] = entry;
SaveSceneList();
}
// Move buttons
EditorGUI.BeginDisabledGroup(i == 0);
if (GUILayout.Button("▲", EditorStyles.miniButtonLeft, GUILayout.Width(22)))
moveUp = i;
EditorGUI.EndDisabledGroup();
EditorGUI.BeginDisabledGroup(i == _sceneList.Count - 1);
if (GUILayout.Button("▼", EditorStyles.miniButtonRight, GUILayout.Width(22)))
moveDown = i;
EditorGUI.EndDisabledGroup();
// Remove
if (GUILayout.Button("×", EditorStyles.miniButton, GUILayout.Width(20)))
removeIndex = i;
EditorGUILayout.EndHorizontal();
}
// Apply deferred operations
if (removeIndex >= 0)
{
_sceneList.RemoveAt(removeIndex);
SaveSceneList();
}
if (moveUp >= 1)
{
var temp = _sceneList[moveUp];
_sceneList[moveUp] = _sceneList[moveUp - 1];
_sceneList[moveUp - 1] = temp;
SaveSceneList();
}
if (moveDown >= 0 && moveDown < _sceneList.Count - 1)
{
var temp = _sceneList[moveDown];
_sceneList[moveDown] = _sceneList[moveDown + 1];
_sceneList[moveDown + 1] = temp;
SaveSceneList();
}
// Add scene buttons
EditorGUILayout.Space(4);
EditorGUILayout.BeginHorizontal();
if (GUILayout.Button("+ Add Current Scene", EditorStyles.miniButton))
{
AddCurrentScene();
}
if (GUILayout.Button("+ Add Scene...", EditorStyles.miniButton))
{
string path = EditorUtility.OpenFilePanel("Select Scene", "Assets", "unity");
if (!string.IsNullOrEmpty(path))
{
// Convert to project-relative path
string projectPath = Application.dataPath;
if (path.StartsWith(projectPath))
path = "Assets" + path.Substring(projectPath.Length);
AddSceneByPath(path);
}
}
EditorGUILayout.EndHorizontal();
// Handle drag & drop
HandleSceneDragDrop();
EditorGUILayout.EndVertical();
}
// ═══════════════════════════════════════════════════════════════
// VRAM & Textures Section
// ═══════════════════════════════════════════════════════════════
private void DrawVRAMSection()
{
_showVRAMSection = DrawSectionFoldout("VRAM & Textures", _showVRAMSection);
if (!_showVRAMSection) return;
EditorGUILayout.BeginVertical(PSXEditorStyles.CardStyle);
// Framebuffer: hardcoded 320x240, vertical, dual-buffered
GUILayout.Label("Framebuffer", PSXEditorStyles.SectionHeader);
GUILayout.Label("Resolution: 320x240 (dual-buffered, vertical layout)", PSXEditorStyles.InfoBox);
PSXEditorStyles.DrawSeparator(4, 4);
GUILayout.Label("Advanced Tools", PSXEditorStyles.SectionHeader);
EditorGUILayout.BeginHorizontal();
if (GUILayout.Button("Open VRAM Editor", PSXEditorStyles.PrimaryButton, GUILayout.Height(26)))
{
VRAMEditorWindow.ShowWindow();
}
if (GUILayout.Button("Quantized Preview", PSXEditorStyles.PrimaryButton, GUILayout.Height(26)))
{
QuantizedPreviewWindow.ShowWindow();
}
EditorGUILayout.EndHorizontal();
EditorGUILayout.EndVertical();
}
// ═══════════════════════════════════════════════════════════════
// Build & Run Section
// ═══════════════════════════════════════════════════════════════
private void DrawBuildSection()
{
_showBuildSection = DrawSectionFoldout("Build && Run", _showBuildSection);
if (!_showBuildSection) return;
EditorGUILayout.BeginVertical(PSXEditorStyles.CardStyle);
// Target & Mode
GUILayout.Label("Configuration", PSXEditorStyles.SectionHeader);
EditorGUILayout.BeginHorizontal();
GUILayout.Label("Target:", GUILayout.Width(50));
SplashSettings.Target = (BuildTarget)EditorGUILayout.EnumPopup(SplashSettings.Target);
GUILayout.Label("Mode:", GUILayout.Width(40));
SplashSettings.Mode = (BuildMode)EditorGUILayout.EnumPopup(SplashSettings.Mode);
EditorGUILayout.EndHorizontal();
// Clean Build toggle
SplashSettings.CleanBuild = EditorGUILayout.Toggle("Clean Build", SplashSettings.CleanBuild);
// Memory Overlay toggle
SplashSettings.MemoryOverlay = EditorGUILayout.Toggle(
new GUIContent("Memory Overlay", "Show heap/RAM usage bar at top-right during gameplay"),
SplashSettings.MemoryOverlay);
// Serial port (only for Real Hardware)
if (SplashSettings.Target == BuildTarget.RealHardware)
{
EditorGUILayout.BeginHorizontal();
GUILayout.Label("Serial Port:", GUILayout.Width(80));
SplashSettings.SerialPort = EditorGUILayout.TextField(SplashSettings.SerialPort);
if (GUILayout.Button("Scan", EditorStyles.miniButton, GUILayout.Width(40)))
ScanSerialPorts();
EditorGUILayout.EndHorizontal();
}
// ISO settings (only for ISO build target)
if (SplashSettings.Target == BuildTarget.ISO)
{
EditorGUILayout.BeginHorizontal();
GUILayout.Label("Volume Label:", GUILayout.Width(80));
SplashSettings.ISOVolumeLabel = EditorGUILayout.TextField(SplashSettings.ISOVolumeLabel);
EditorGUILayout.EndHorizontal();
EditorGUILayout.BeginHorizontal();
GUILayout.Label("License File:", GUILayout.Width(80));
string licensePath = SplashSettings.LicenseFilePath;
string displayPath = string.IsNullOrEmpty(licensePath) ? "(none — homebrew)" : Path.GetFileName(licensePath);
GUILayout.Label(displayPath, EditorStyles.miniLabel, GUILayout.ExpandWidth(true));
if (GUILayout.Button("Browse", EditorStyles.miniButton, GUILayout.Width(60)))
{
string path = EditorUtility.OpenFilePanel(
"Select Sony License File", "", "dat");
if (!string.IsNullOrEmpty(path))
SplashSettings.LicenseFilePath = path;
}
if (!string.IsNullOrEmpty(licensePath) &&
GUILayout.Button("Clear", EditorStyles.miniButton, GUILayout.Width(40)))
{
SplashSettings.LicenseFilePath = "";
}
EditorGUILayout.EndHorizontal();
}
PSXEditorStyles.DrawSeparator(6, 6);
// Big Build & Run button
EditorGUI.BeginDisabledGroup(_isBuilding);
EditorGUILayout.BeginHorizontal();
GUILayout.FlexibleSpace();
var largeBuildButton = new GUIStyle(PSXEditorStyles.SuccessButton)
{
fontSize = 14,
fontStyle = FontStyle.Bold,
padding = new RectOffset(20, 20, 10, 10)
};
string buttonLabel = _isBuilding ? "Building..." :
(SplashSettings.Target == BuildTarget.ISO ? "BUILD" : "BUILD & RUN");
if (GUILayout.Button(buttonLabel, largeBuildButton, GUILayout.Width(200), GUILayout.Height(38)))
{
BuildAndRun();
}
GUILayout.FlexibleSpace();
EditorGUILayout.EndHorizontal();
EditorGUI.EndDisabledGroup();
// Stop button (if running - emulator or hardware PCdrv host)
if (_isRunning)
{
EditorGUILayout.Space(4);
EditorGUILayout.BeginHorizontal();
GUILayout.FlexibleSpace();
string stopLabel = _emulatorProcess != null ? "STOP EMULATOR" : "STOP PCdrv HOST";
if (GUILayout.Button(stopLabel, PSXEditorStyles.DangerButton, GUILayout.Width(200), GUILayout.Height(26)))
{
StopAll();
}
GUILayout.FlexibleSpace();
EditorGUILayout.EndHorizontal();
}
// Export-only / Compile-only
PSXEditorStyles.DrawSeparator(4, 4);
EditorGUILayout.BeginHorizontal();
GUILayout.FlexibleSpace();
if (GUILayout.Button("Export Only", PSXEditorStyles.SecondaryButton, GUILayout.Width(100)))
{
ExportAllScenes();
}
if (GUILayout.Button("Compile Only", PSXEditorStyles.SecondaryButton, GUILayout.Width(100)))
{
CompileOnly();
}
GUILayout.FlexibleSpace();
EditorGUILayout.EndHorizontal();
EditorGUILayout.EndVertical();
// Memory report (shown after export)
if (_memoryReports.Count > 0)
{
EditorGUILayout.Space(8);
DrawMemoryReports();
}
}
// ═══════════════════════════════════════════════════════════════
// Memory Reports
// ═══════════════════════════════════════════════════════════════
private void DrawMemoryReports()
{
_showMemoryReport = DrawSectionFoldout("Memory Report", _showMemoryReport);
if (!_showMemoryReport) return;
foreach (var report in _memoryReports)
{
EditorGUILayout.BeginVertical(PSXEditorStyles.CardStyle);
GUILayout.Label($"Scene: {report.sceneName}", PSXEditorStyles.SectionHeader);
EditorGUILayout.Space(4);
// Main RAM bar
DrawMemoryBar("Main RAM",
report.TotalRamUsage, SceneMemoryReport.USABLE_RAM,
report.RamPercent,
new Color(0.3f, 0.6f, 1f),
$"Scene: {FormatBytes(report.SceneRamUsage)} | " +
$"Fixed: {FormatBytes(report.FixedOverhead)} | " +
$"Free: {FormatBytes(report.RamFree)}");
EditorGUILayout.Space(4);
// VRAM bar
DrawMemoryBar("VRAM",
report.TotalVramUsed, SceneMemoryReport.TOTAL_VRAM,
report.VramPercent,
new Color(0.9f, 0.5f, 0.2f),
$"FB: {FormatBytes(report.framebufferSize)} | " +
$"Tex: {FormatBytes(report.textureAtlasSize)} | " +
$"CLUT: {FormatBytes(report.clutSize)} | " +
$"Free: {FormatBytes(report.VramFree)}");
EditorGUILayout.Space(4);
// SPU RAM bar
DrawMemoryBar("SPU RAM",
report.TotalSpuUsed, SceneMemoryReport.USABLE_SPU,
report.SpuPercent,
new Color(0.6f, 0.3f, 0.9f),
report.audioClipCount > 0
? $"{report.audioClipCount} clips | {FormatBytes(report.audioDataSize)} | Free: {FormatBytes(report.SpuFree)}"
: "No audio clips");
EditorGUILayout.Space(4);
// CD Storage (no bar, just info)
EditorGUILayout.BeginHorizontal();
GUILayout.Label("CD Storage:", EditorStyles.miniLabel, GUILayout.Width(70));
GUILayout.Label(
$"Scene: {FormatBytes(report.splashpackFileSize)}" +
(report.loaderPackSize > 0 ? $" | Loader: {FormatBytes(report.loaderPackSize)}" : "") +
$" | Total: {FormatBytes(report.TotalDiscSize)}",
EditorStyles.miniLabel);
EditorGUILayout.EndHorizontal();
// Summary stats
PSXEditorStyles.DrawSeparator(4, 4);
EditorGUILayout.LabelField(
$"<b>{report.gameObjectCount}</b> objects | " +
$"<b>{report.triangleCount}</b> tris | " +
$"<b>{report.atlasCount}</b> atlases | " +
$"<b>{report.clutCount}</b> CLUTs",
PSXEditorStyles.RichLabel);
EditorGUILayout.EndVertical();
EditorGUILayout.Space(4);
}
}
private void DrawMemoryBar(string label, long used, long total, float percent, Color barColor, string details)
{
// Label row
EditorGUILayout.BeginHorizontal();
GUILayout.Label(label, EditorStyles.boldLabel, GUILayout.Width(70));
GUILayout.Label($"{FormatBytes(used)} / {FormatBytes(total)} ({percent:F1}%)", EditorStyles.miniLabel);
GUILayout.FlexibleSpace();
EditorGUILayout.EndHorizontal();
// Progress bar
Rect barRect = GUILayoutUtility.GetRect(0, 16, GUILayout.ExpandWidth(true));
// Background
EditorGUI.DrawRect(barRect, new Color(0.15f, 0.15f, 0.17f));
// Fill
float fillFraction = Mathf.Clamp01((float)used / total);
Rect fillRect = new Rect(barRect.x, barRect.y, barRect.width * fillFraction, barRect.height);
// Color shifts toward red when over 80%
Color fillColor = barColor;
if (percent > 90f)
fillColor = Color.Lerp(PSXEditorStyles.Warning, PSXEditorStyles.Error, (percent - 90f) / 10f);
else if (percent > 80f)
fillColor = Color.Lerp(barColor, PSXEditorStyles.Warning, (percent - 80f) / 10f);
EditorGUI.DrawRect(fillRect, fillColor);
// Border
DrawRectOutline(barRect, new Color(0.3f, 0.3f, 0.35f));
// Percent text overlay
var style = new GUIStyle(EditorStyles.miniLabel)
{
alignment = TextAnchor.MiddleCenter,
normal = { textColor = Color.white }
};
GUI.Label(barRect, $"{percent:F1}%", style);
// Details row
GUILayout.Label(details, EditorStyles.miniLabel);
}
private static void DrawRectOutline(Rect rect, Color color)
{
EditorGUI.DrawRect(new Rect(rect.x, rect.y, rect.width, 1), color);
EditorGUI.DrawRect(new Rect(rect.x, rect.yMax - 1, rect.width, 1), color);
EditorGUI.DrawRect(new Rect(rect.x, rect.y, 1, rect.height), color);
EditorGUI.DrawRect(new Rect(rect.xMax - 1, rect.y, 1, rect.height), color);
}
private static string FormatBytes(long bytes)
{
if (bytes < 0) return "N/A";
if (bytes < 1024) return $"{bytes} B";
if (bytes < 1024 * 1024) return $"{bytes / 1024f:F1} KB";
return $"{bytes / (1024f * 1024f):F2} MB";
}
// ═══════════════════════════════════════════════════════════════
// Pipeline Actions
// ═══════════════════════════════════════════════════════════════
/// <summary>
/// The main pipeline: Validate → Export all scenes → Compile → Launch.
/// </summary>
public async void BuildAndRun()
{
if (_isBuilding) return;
if (UnityEditor.SceneManagement.EditorSceneManager.GetActiveScene().isDirty)
{
int choice = EditorUtility.DisplayDialogComplex(
"Unsaved Changes",
"The current scene has unsaved changes. Save before building?",
"Save and Build", // 0
"Cancel", // 1
"Build Without Saving" // 2
);
if (choice == 1) return; // Cancel
if (choice == 0) EditorSceneManager.SaveOpenScenes();
}
_isBuilding = true;
var console = EditorWindow.GetWindow<PSXConsoleWindow>();
console.titleContent = new GUIContent("PSX Console", EditorGUIUtility.IconContent("d_UnityEditor.ConsoleWindow").image);
console.minSize = new Vector2(400, 200);
console.Show();
try
{
Log("Validating toolchain...", LogType.Log);
if (!ValidateToolchain())
{
Log("Toolchain validation failed. Fix issues above.", LogType.Error);
return;
}
Log("Toolchain OK.", LogType.Log);
Log("Exporting scenes...", LogType.Log);
if (!ExportAllScenes())
{
Log("Export failed.", LogType.Error);
return;
}
Log($"Exported {_sceneList.Count} scene(s).", LogType.Log);
Log("Compiling native code...", LogType.Log);
EditorUtility.DisplayProgressBar("SplashEdit", "Compiling native code...", 0.6f);
if (!await CompileNativeAsync())
{
Log("Compilation failed. Check build log.", LogType.Error);
return;
}
Log("Compile succeeded.", LogType.Log);
Log("Launching...", LogType.Log);
Launch();
}
catch (Exception ex)
{
Log($"Pipeline error: {ex.Message}", LogType.Error);
Debug.LogException(ex);
}
finally
{
_isBuilding = false;
EditorUtility.ClearProgressBar();
Repaint();
}
}
// ───── Step 1: Validate ─────
private bool ValidateToolchain()
{
RefreshToolchainStatus();
if (!_hasMIPS)
{
Log("MIPS cross-compiler not found. Click Install in the Toolchain section.", LogType.Error);
return false;
}
if (!_hasMake)
{
Log("GNU Make not found. Click Install in the Toolchain section.", LogType.Error);
return false;
}
if (SplashSettings.Target == BuildTarget.Emulator && !_hasRedux)
{
Log("PCSX-Redux not found. Click Download in the Toolchain section.", LogType.Error);
return false;
}
if (SplashSettings.Target == BuildTarget.ISO && !_hasMkpsxiso)
{
Log("mkpsxiso not found. Click Download in the Toolchain section.", LogType.Error);
return false;
}
string nativeDir = SplashBuildPaths.NativeSourceDir;
if (string.IsNullOrEmpty(nativeDir) || !Directory.Exists(nativeDir))
{
Log("Native project directory not found. Set it in the Toolchain section.", LogType.Error);
return false;
}
if (_sceneList.Count == 0)
{
Log("No scenes in the scene list. Add at least one scene.", LogType.Error);
return false;
}
return true;
}
// ───── Step 2: Export ─────
/// <summary>
/// Exports all scenes in the scene list to splashpack files in PSXBuild/.
/// </summary>
public bool ExportAllScenes()
{
SplashBuildPaths.EnsureDirectories();
_loaderPackCache = new Dictionary<string, string>();
_memoryReports.Clear();
// Save current scene
string currentScenePath = SceneManager.GetActiveScene().path;
bool success = true;
for (int i = 0; i < _sceneList.Count; i++)
{
var scene = _sceneList[i];
if (scene.asset == null)
{
Log($"Scene [{i}] is null, skipping.", LogType.Warning);
continue;
}
EditorUtility.DisplayProgressBar("SplashEdit Export",
$"Exporting scene {i + 1}/{_sceneList.Count}: {scene.name}",
(float)i / _sceneList.Count);
try
{
// Open the scene
EditorSceneManager.OpenScene(scene.path, OpenSceneMode.Single);
// Find the exporter
var exporter = UnityEngine.Object.FindFirstObjectByType<PSXSceneExporter>();
if (exporter == null)
{
Log($"Scene '{scene.name}' has no PSXSceneExporter. Skipping.", LogType.Warning);
continue;
}
// Export to the build directory
string outputPath = SplashBuildPaths.GetSceneSplashpackPath(i, scene.name);
string loaderPath = null;
exporter.ExportToPath(outputPath);
Log($"Exported '{scene.name}' → {Path.GetFileName(outputPath)}", LogType.Log);
// Export loading screen if assigned
if (exporter.LoadingScreenPrefab != null)
{
loaderPath = SplashBuildPaths.GetSceneLoaderPackPath(i, scene.name);
ExportLoaderPack(exporter.LoadingScreenPrefab, loaderPath, i, scene.name);
}
// Generate memory report for this scene
try
{
var report = SceneMemoryAnalyzer.Analyze(
scene.name,
outputPath,
loaderPath,
exporter.LastExportAtlases,
exporter.LastExportAudioSizes,
exporter.LastExportFonts,
exporter.LastExportTriangleCount);
_memoryReports.Add(report);
}
catch (Exception reportEx)
{
Log($"Memory report for '{scene.name}' failed: {reportEx.Message}", LogType.Warning);
}
}
catch (Exception ex)
{
Log($"Error exporting '{scene.name}': {ex.Message}", LogType.Error);
success = false;
}
}
// Write manifest (simple binary: scene count + list of filenames)
WriteManifest();
EditorUtility.ClearProgressBar();
// Reopen orignal scene
if (!string.IsNullOrEmpty(currentScenePath))
{
EditorSceneManager.OpenScene(currentScenePath, OpenSceneMode.Single);
}
return success;
}
/// <summary>
/// Cache of already-exported loader packs for deduplication.
/// Key = prefab asset GUID, Value = path of the written file.
/// If two scenes reference the same loading screen prefab, we copy the file
/// instead of regenerating it.
/// </summary>
private Dictionary<string, string> _loaderPackCache = new Dictionary<string, string>();
private void ExportLoaderPack(GameObject prefab, string outputPath, int sceneIndex, string sceneName)
{
string prefabPath = AssetDatabase.GetAssetPath(prefab);
string guid = AssetDatabase.AssetPathToGUID(prefabPath);
// Dedup: if we already exported this exact prefab, just copy the file
if (!string.IsNullOrEmpty(guid) && _loaderPackCache.TryGetValue(guid, out string cachedPath))
{
if (File.Exists(cachedPath))
{
File.Copy(cachedPath, outputPath, true);
Log($"Loading screen for '{sceneName}' → {Path.GetFileName(outputPath)} (deduped from {Path.GetFileName(cachedPath)})", LogType.Log);
return;
}
}
// Need the PSXData resolution to pass to the writer
Vector2 resolution;
bool db, vl;
List<ProhibitedArea> pa;
DataStorage.LoadData(out resolution, out db, out vl, out pa);
// Instantiate the prefab temporarily so the components are live
// (GetComponentsInChildren needs active hierarchy)
GameObject instance = (GameObject)PrefabUtility.InstantiatePrefab(prefab);
try
{
// Pack UI image textures into VRAM (same flow as PSXSceneExporter)
TextureAtlas[] atlases = null;
PSXUIImage[] uiImages = instance.GetComponentsInChildren<PSXUIImage>(true);
if (uiImages != null && uiImages.Length > 0)
{
List<PSXTexture2D> uiTextures = new List<PSXTexture2D>();
foreach (PSXUIImage img in uiImages)
{
if (img.SourceTexture != null)
{
Utils.SetTextureImporterFormat(img.SourceTexture, true);
PSXTexture2D tex = PSXTexture2D.CreateFromTexture2D(img.SourceTexture, img.BitDepth);
tex.OriginalTexture = img.SourceTexture;
img.PackedTexture = tex;
uiTextures.Add(tex);
}
}
if (uiTextures.Count > 0)
{
(Rect buffer1, Rect buffer2) = Utils.BufferForResolution(resolution, vl);
List<Rect> framebuffers = new List<Rect> { buffer1 };
if (db) framebuffers.Add(buffer2);
VRAMPacker packer = new VRAMPacker(framebuffers, pa);
var packed = packer.PackTexturesIntoVRAM(new PSXObjectExporter[0], uiTextures);
atlases = packed.atlases;
}
}
// CollectCanvasFromPrefab reads PackedTexture VRAM coords (set by packer above)
bool ok = PSXLoaderPackWriter.Write(outputPath, instance, resolution, atlases,
(msg, type) => Log(msg, type));
if (ok)
{
Log($"Loading screen for '{sceneName}' → {Path.GetFileName(outputPath)}", LogType.Log);
if (!string.IsNullOrEmpty(guid))
_loaderPackCache[guid] = outputPath;
}
}
finally
{
UnityEngine.Object.DestroyImmediate(instance);
}
}
private void WriteManifest()
{
string manifestPath = SplashBuildPaths.ManifestPath;
using (var writer = new BinaryWriter(File.Open(manifestPath, FileMode.Create)))
{
// Magic "SM" for Scene Manifest
writer.Write((byte)'S');
writer.Write((byte)'M');
// Version
writer.Write((ushort)1);
// Scene count
writer.Write((uint)_sceneList.Count);
for (int i = 0; i < _sceneList.Count; i++)
{
string filename = Path.GetFileName(
SplashBuildPaths.GetSceneSplashpackPath(i, _sceneList[i].name));
byte[] nameBytes = System.Text.Encoding.UTF8.GetBytes(filename);
// Length-prefixed string
writer.Write((byte)nameBytes.Length);
writer.Write(nameBytes);
}
}
Log("Wrote scene manifest.", LogType.Log);
}
// ───── Step 3: Compile ─────
private async void CompileOnly()
{
if (_isBuilding) return;
_isBuilding = true;
Repaint();
try
{
Log("Compiling native code...", LogType.Log);
EditorUtility.DisplayProgressBar("SplashEdit", "Compiling native code...", 0.5f);
if (await CompileNativeAsync())
Log("Compile succeeded.", LogType.Log);
else
Log("Compilation failed. Check build log.", LogType.Error);
}
finally
{
_isBuilding = false;
EditorUtility.ClearProgressBar();
Repaint();
}
}
private async Task<bool> CompileNativeAsync()
{
string nativeDir = SplashBuildPaths.NativeSourceDir;
if (string.IsNullOrEmpty(nativeDir))
{
Log("Native project directory not set.", LogType.Error);
return false;
}
string buildArg = SplashSettings.Mode == BuildMode.Debug ? "BUILD=Debug" : "";
if (SplashSettings.Target == BuildTarget.ISO)
buildArg += " LOADER=cdrom";
if (SplashSettings.MemoryOverlay)
buildArg += " MEMOVERLAY=1";
int jobCount = Math.Max(1, SystemInfo.processorCount - 1);
string cleanPrefix = SplashSettings.CleanBuild ? "make clean && " : "";
string makeCmd = $"{cleanPrefix}make all -j{jobCount} {buildArg}".Trim();
Log($"Running: {makeCmd}", LogType.Log);
var psi = new ProcessStartInfo
{
FileName = Application.platform == RuntimePlatform.WindowsEditor ? "cmd.exe" : "/bin/bash",
Arguments = Application.platform == RuntimePlatform.WindowsEditor
? $"/c \"cd /d \"{nativeDir}\" && {makeCmd}\""
: $"-c \"cd \\\"{nativeDir}\\\" && {makeCmd}\"",
WorkingDirectory = nativeDir,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true
};
try
{
var process = new Process { StartInfo = psi, EnableRaisingEvents = true };
var stdoutBuf = new System.Text.StringBuilder();
var stderrBuf = new System.Text.StringBuilder();
process.OutputDataReceived += (s, e) =>
{
if (e.Data != null) stdoutBuf.AppendLine(e.Data);
};
process.ErrorDataReceived += (s, e) =>
{
if (e.Data != null) stderrBuf.AppendLine(e.Data);
};
var tcs = new TaskCompletionSource<int>();
process.Exited += (s, e) => tcs.TrySetResult(process.ExitCode);
process.Start();
process.BeginOutputReadLine();
process.BeginErrorReadLine();
int exitCode = await tcs.Task;
process.Dispose();
string stdout = stdoutBuf.ToString();
string stderr = stderrBuf.ToString();
foreach (string line in stdout.Split('\n'))
{
if (!string.IsNullOrWhiteSpace(line))
LogToPanel(line.Trim(), LogType.Log);
}
if (exitCode != 0)
{
foreach (string line in stderr.Split('\n'))
{
if (!string.IsNullOrWhiteSpace(line))
LogToPanel(line.Trim(), LogType.Error);
}
Log($"Make exited with code {exitCode}", LogType.Error);
File.WriteAllText(SplashBuildPaths.BuildLogPath,
$"=== STDOUT ===\n{stdout}\n=== STDERR ===\n{stderr}");
return false;
}
string exeSource = FindCompiledExe(nativeDir);
if (!string.IsNullOrEmpty(exeSource))
{
File.Copy(exeSource, SplashBuildPaths.CompiledExePath, true);
Log("Copied .ps-exe to PSXBuild/", LogType.Log);
}
else
{
Log("Warning: Could not find compiled .ps-exe", LogType.Warning);
}
return true;
}
catch (Exception ex)
{
Log($"Compile error: {ex.Message}", LogType.Error);
return false;
}
}
private string FindCompiledExe(string nativeDir)
{
// Look for .ps-exe files in the native dir
var files = Directory.GetFiles(nativeDir, "*.ps-exe", SearchOption.TopDirectoryOnly);
if (files.Length > 0)
return files[0];
// Also check common build output locations
foreach (string subdir in new[] { "build", "bin", "." })
{
string dir = Path.Combine(nativeDir, subdir);
if (Directory.Exists(dir))
{
files = Directory.GetFiles(dir, "*.ps-exe", SearchOption.TopDirectoryOnly);
if (files.Length > 0)
return files[0];
}
}
return null;
}
// ───── Step 4: Launch ─────
private void Launch()
{
switch (SplashSettings.Target)
{
case BuildTarget.Emulator:
LaunchEmulator();
break;
case BuildTarget.RealHardware:
LaunchToHardware();
break;
case BuildTarget.ISO:
BuildAndLaunchISO();
break;
}
}
private void LaunchEmulator()
{
string reduxPath = SplashSettings.PCSXReduxPath;
if (string.IsNullOrEmpty(reduxPath) || !File.Exists(reduxPath))
{
Log("PCSX-Redux binary not found.", LogType.Error);
return;
}
string exePath = SplashBuildPaths.CompiledExePath;
if (!File.Exists(exePath))
{
Log("Compiled .ps-exe not found in PSXBuild/.", LogType.Error);
return;
}
// Kill previous instance without clearing the console
StopAllQuiet();
string pcdrvBase = SplashBuildPaths.BuildOutputDir;
string args = $"-exe \"{exePath}\" -run -fastboot -pcdrv -pcdrvbase \"{pcdrvBase}\" -pad1type dualshock -stdout";
Log($"Launching: {Path.GetFileName(reduxPath)} {args}", LogType.Log);
var psi = new ProcessStartInfo
{
FileName = reduxPath,
Arguments = args,
UseShellExecute = false,
// CreateNoWindow = true prevents pcsx-redux's -stdout AllocConsole() from
// stealing stdout away from our pipe. pcsx-redux is a GUI app and doesn't
// need a console window - it creates its own OpenGL/SDL window.
CreateNoWindow = true,
RedirectStandardOutput = true,
RedirectStandardError = true
};
try
{
_emulatorProcess = Process.Start(psi);
_emulatorProcess.EnableRaisingEvents = true;
_emulatorProcess.Exited += (s, e) => {
EditorApplication.delayCall += () => {
_isRunning = false;
_emulatorProcess = null;
PSXConsoleWindow.Detach();
Repaint();
};
};
_isRunning = true;
Log("PCSX-Redux launched.", LogType.Log);
// Open the PSX Console window and attach to the process output
PSXConsoleWindow.Attach(_emulatorProcess);
}
catch (Exception ex)
{
Log($"Failed to launch emulator: {ex.Message}", LogType.Error);
}
}
private void LaunchToHardware()
{
string exePath = SplashBuildPaths.CompiledExePath;
if (!File.Exists(exePath))
{
Log("Compiled .ps-exe not found in PSXBuild/.", LogType.Error);
return;
}
string port = SplashSettings.SerialPort;
int baud = SplashSettings.SerialBaudRate;
// Stop any previous run (emulator or PCdrv) without clearing the console
StopAllQuiet();
// Upload the exe with debug hooks (DEBG → SEXE on the same port).
// DEBG installs kernel-resident break handlers BEFORE the exe auto-starts.
// The returned port stays open so PCDrv monitoring can begin immediately.
Log($"Uploading to {port}...", LogType.Log);
SerialPort serialPort = UniromUploader.UploadExeForPCdrv(port, baud, exePath,
msg => Log(msg, LogType.Log));
if (serialPort == null)
{
Log("Upload failed.", LogType.Error);
return;
}
// Start PCdrv host on the same open port — no re-open, no DEBG/CONT needed
try
{
_pcdrvHost = new PCdrvSerialHost(port, baud, SplashBuildPaths.BuildOutputDir,
msg => LogToPanel(msg, LogType.Log),
msg => PSXConsoleWindow.AddLine(msg));
_pcdrvHost.Start(serialPort);
_isRunning = true;
Log("PCdrv serial host started. Serving files to PS1.", LogType.Log);
}
catch (Exception ex)
{
Log($"PCdrv host error: {ex.Message}", LogType.Error);
try { serialPort.Close(); } catch { }
serialPort.Dispose();
}
}
// ───── ISO Build ─────
private void BuildAndLaunchISO()
{
if (!_hasMkpsxiso)
{
Log("mkpsxiso not installed. Click Download in the Toolchain section.", LogType.Error);
return;
}
string exePath = SplashBuildPaths.CompiledExePath;
if (!File.Exists(exePath))
{
Log("Compiled .ps-exe not found in PSXBuild/.", LogType.Error);
return;
}
// Ask user for output location
string defaultDir = SplashBuildPaths.BuildOutputDir;
string savePath = EditorUtility.SaveFilePanel(
"Save ISO Image", defaultDir, "psxsplash", "bin");
if (string.IsNullOrEmpty(savePath))
{
Log("ISO build cancelled.", LogType.Log);
return;
}
string outputBin = savePath;
string outputCue = Path.ChangeExtension(savePath, ".cue");
// Step 1: Generate SYSTEM.CNF
Log("Generating SYSTEM.CNF...", LogType.Log);
if (!GenerateSystemCnf())
{
Log("Failed to generate SYSTEM.CNF.", LogType.Error);
return;
}
// Step 2: Generate XML catalog for mkpsxiso
Log("Generating ISO catalog...", LogType.Log);
string xmlPath = GenerateISOCatalog(outputBin, outputCue);
if (string.IsNullOrEmpty(xmlPath))
{
Log("Failed to generate ISO catalog.", LogType.Error);
return;
}
// Step 3: Delete existing .bin/.cue — mkpsxiso won't overwrite them
try
{
if (File.Exists(outputBin)) File.Delete(outputBin);
if (File.Exists(outputCue)) File.Delete(outputCue);
}
catch (Exception ex)
{
Log($"Could not remove old ISO files: {ex.Message}", LogType.Error);
return;
}
// Step 4: Run mkpsxiso
Log("Building ISO image...", LogType.Log);
bool success = MkpsxisoDownloader.BuildISO(xmlPath, outputBin, outputCue,
msg => Log(msg, LogType.Log));
if (success)
{
long fileSize = new FileInfo(outputBin).Length;
Log($"ISO image written: {outputBin} ({fileSize:N0} bytes)", LogType.Log);
Log($"CUE sheet written: {outputCue}", LogType.Log);
// Offer to reveal in explorer
EditorUtility.RevealInFinder(outputBin);
}
else
{
Log("ISO build failed.", LogType.Error);
}
}
/// <summary>
/// Derive the executable name on disc from the volume label.
/// Uppercase, no extension, trimmed to 12 characters (ISO9660 limit).
/// </summary>
private static string GetISOExeName()
{
string label = SplashSettings.ISOVolumeLabel;
if (string.IsNullOrEmpty(label)) label = "PSXSPLASH";
// Uppercase, strip anything not A-Z / 0-9 / underscore
label = label.ToUpperInvariant();
var sb = new System.Text.StringBuilder(label.Length);
foreach (char c in label)
{
if ((c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_')
sb.Append(c);
}
string name = sb.ToString();
if (name.Length == 0) name = "PSXSPLASH";
if (name.Length > 12) name = name.Substring(0, 12);
return name;
}
private bool GenerateSystemCnf()
{
try
{
string cnfPath = SplashBuildPaths.SystemCnfPath;
// The executable name on disc — no extension, max 12 chars
string exeName = GetISOExeName();
// SYSTEM.CNF content — the BIOS reads this to launch the executable.
// BOOT: path to the executable on disc (cdrom:\path;1)
// TCB: number of thread control blocks (4 is standard)
// EVENT: number of event control blocks (10 is standard)
// STACK: initial stack pointer address (top of RAM minus a small margin)
string content =
$"BOOT = cdrom:\\{exeName};1\r\n" +
"TCB = 4\r\n" +
"EVENT = 10\r\n" +
"STACK = 801FFF00\r\n";
File.WriteAllText(cnfPath, content, new System.Text.UTF8Encoding(false));
return true;
}
catch (Exception ex)
{
Log($"SYSTEM.CNF generation error: {ex.Message}", LogType.Error);
return false;
}
}
/// <summary>
/// Generates the mkpsxiso XML catalog describing the ISO filesystem layout.
/// Includes SYSTEM.CNF, the executable, all splashpacks, loading packs, and manifest.
/// </summary>
private string GenerateISOCatalog(string outputBin, string outputCue)
{
try
{
string xmlPath = SplashBuildPaths.ISOCatalogPath;
string buildDir = SplashBuildPaths.BuildOutputDir;
string volumeLabel = SplashSettings.ISOVolumeLabel;
if (string.IsNullOrEmpty(volumeLabel)) volumeLabel = "PSXSPLASH";
// Sanitize volume label (ISO9660: uppercase, max 31 chars)
volumeLabel = volumeLabel.ToUpperInvariant();
if (volumeLabel.Length > 31) volumeLabel = volumeLabel.Substring(0, 31);
var xml = new System.Text.StringBuilder();
xml.AppendLine("<?xml version=\"1.0\" encoding=\"UTF-8\"?>");
xml.AppendLine("<iso_project image_name=\"psxsplash.bin\" cue_sheet=\"psxsplash.cue\">");
xml.AppendLine(" <track type=\"data\">");
xml.AppendLine(" <identifiers");
xml.AppendLine(" system=\"PLAYSTATION\"");
xml.AppendLine(" application=\"PLAYSTATION\"");
xml.AppendLine($" volume=\"{EscapeXml(volumeLabel)}\"");
xml.AppendLine($" volume_set=\"{EscapeXml(volumeLabel)}\"");
xml.AppendLine(" publisher=\"SPLASHEDIT\"");
xml.AppendLine(" data_preparer=\"MKPSXISO\"");
xml.AppendLine(" />");
// License file (optional)
string licensePath = SplashSettings.LicenseFilePath;
if (!string.IsNullOrEmpty(licensePath) && File.Exists(licensePath))
{
xml.AppendLine($" <license file=\"{EscapeXml(licensePath)}\"/>");
}
xml.AppendLine(" <directory_tree>");
// SYSTEM.CNF — must be first for BIOS to find it
string cnfPath = SplashBuildPaths.SystemCnfPath;
xml.AppendLine($" <file name=\"SYSTEM.CNF\" source=\"{EscapeXml(cnfPath)}\"/>");
// The executable — renamed to match what SYSTEM.CNF points to
string exePath = SplashBuildPaths.CompiledExePath;
string isoExeName = GetISOExeName();
xml.AppendLine($" <file name=\"{isoExeName}\" source=\"{EscapeXml(exePath)}\"/>");
// Manifest
string manifestPath = SplashBuildPaths.ManifestPath;
if (File.Exists(manifestPath))
{
xml.AppendLine($" <file name=\"MANIFEST.BIN\" source=\"{EscapeXml(manifestPath)}\"/>");
}
// Scene splashpacks and loading packs
for (int i = 0; i < _sceneList.Count; i++)
{
string splashpack = SplashBuildPaths.GetSceneSplashpackPath(i, _sceneList[i].name);
if (File.Exists(splashpack))
{
string isoName = $"SCENE_{i}.SPK";
xml.AppendLine($" <file name=\"{isoName}\" source=\"{EscapeXml(splashpack)}\"/>");
}
string loadingPack = SplashBuildPaths.GetSceneLoaderPackPath(i, _sceneList[i].name);
if (File.Exists(loadingPack))
{
string isoName = $"SCENE_{i}.LDG";
xml.AppendLine($" <file name=\"{isoName}\" source=\"{EscapeXml(loadingPack)}\"/>");
}
}
// Trailing dummy sectors to prevent drive runaway
xml.AppendLine(" <dummy sectors=\"128\"/>");
xml.AppendLine(" </directory_tree>");
xml.AppendLine(" </track>");
xml.AppendLine("</iso_project>");
File.WriteAllText(xmlPath, xml.ToString(), new System.Text.UTF8Encoding(false));
Log($"ISO catalog written: {xmlPath}", LogType.Log);
return xmlPath;
}
catch (Exception ex)
{
Log($"ISO catalog generation error: {ex.Message}", LogType.Error);
return null;
}
}
private static string EscapeXml(string s)
{
if (string.IsNullOrEmpty(s)) return "";
return s.Replace("&", "&amp;").Replace("<", "&lt;")
.Replace(">", "&gt;").Replace("\"", "&quot;");
}
private void StopPCdrvHost()
{
if (_pcdrvHost != null)
{
_pcdrvHost.Dispose();
_pcdrvHost = null;
}
}
/// <summary>
/// Stops everything (emulator, PCdrv host, console reader) — used by the STOP button.
/// </summary>
private void StopAll()
{
PSXConsoleWindow.Detach();
StopEmulatorProcess();
StopPCdrvHost();
_isRunning = false;
Log("Stopped.", LogType.Log);
}
/// <summary>
/// Stops emulator and PCdrv host without touching the console window.
/// Used before re-launching so the console keeps its history.
/// </summary>
private void StopAllQuiet()
{
StopEmulatorProcess();
StopPCdrvHost();
_isRunning = false;
}
private void StopEmulatorProcess()
{
if (_emulatorProcess != null && !_emulatorProcess.HasExited)
{
try
{
_emulatorProcess.Kill();
_emulatorProcess.Dispose();
}
catch { }
}
_emulatorProcess = null;
}
// ═══════════════════════════════════════════════════════════════
// Toolchain Detection & Install
// ═══════════════════════════════════════════════════════════════
private void RefreshToolchainStatus()
{
_hasMIPS = ToolchainChecker.IsToolAvailable(
Application.platform == RuntimePlatform.WindowsEditor
? "mipsel-none-elf-gcc"
: "mipsel-linux-gnu-gcc");
_hasMake = ToolchainChecker.IsToolAvailable("make");
string reduxBin = SplashSettings.PCSXReduxPath;
_hasRedux = !string.IsNullOrEmpty(reduxBin) && File.Exists(reduxBin);
_reduxVersion = _hasRedux ? "Installed" : "";
_hasPsxavenc = PSXAudioConverter.IsInstalled();
_hasMkpsxiso = MkpsxisoDownloader.IsInstalled();
string nativeDir = SplashBuildPaths.NativeSourceDir;
_hasNativeProject = !string.IsNullOrEmpty(nativeDir) && Directory.Exists(nativeDir);
}
private async void InstallMIPS()
{
Log("Installing MIPS toolchain...", LogType.Log);
try
{
await ToolchainInstaller.InstallToolchain();
Log("MIPS toolchain installation started. You may need to restart.", LogType.Log);
}
catch (Exception ex)
{
Log($"MIPS install error: {ex.Message}", LogType.Error);
}
RefreshToolchainStatus();
Repaint();
}
private async void InstallMake()
{
Log("Installing GNU Make...", LogType.Log);
try
{
await ToolchainInstaller.InstallMake();
Log("GNU Make installation complete.", LogType.Log);
}
catch (Exception ex)
{
Log($"Make install error: {ex.Message}", LogType.Error);
}
RefreshToolchainStatus();
Repaint();
}
private async void DownloadRedux()
{
Log("Downloading PCSX-Redux...", LogType.Log);
bool success = await PCSXReduxDownloader.DownloadAndInstall(msg => Log(msg, LogType.Log));
if (success)
{
// Clear any custom path so it uses the auto-downloaded one
SplashSettings.PCSXReduxPath = "";
RefreshToolchainStatus();
Log("PCSX-Redux ready!", LogType.Log);
}
else
{
// Fall back to manual selection
Log("Auto-download failed. Select binary manually.", LogType.Warning);
string path = EditorUtility.OpenFilePanel("Select PCSX-Redux Binary", "",
Application.platform == RuntimePlatform.WindowsEditor ? "exe" : "");
if (!string.IsNullOrEmpty(path))
{
SplashSettings.PCSXReduxPath = path;
RefreshToolchainStatus();
Log($"PCSX-Redux set: {path}", LogType.Log);
}
}
Repaint();
}
private async void DownloadPsxavenc()
{
Log("Downloading psxavenc audio encoder...", LogType.Log);
bool success = await PSXAudioConverter.DownloadAndInstall(msg => Log(msg, LogType.Log));
if (success)
{
RefreshToolchainStatus();
Log("psxavenc ready!", LogType.Log);
}
else
{
Log("psxavenc download failed. Audio export will not work.", LogType.Error);
}
Repaint();
}
private async void DownloadMkpsxiso()
{
Log("Downloading mkpsxiso ISO builder...", LogType.Log);
bool success = await MkpsxisoDownloader.DownloadAndInstall(msg => Log(msg, LogType.Log));
if (success)
{
RefreshToolchainStatus();
Log("mkpsxiso ready!", LogType.Log);
}
else
{
Log("mkpsxiso download failed. ISO builds will not work.", LogType.Error);
}
Repaint();
}
private void ScanSerialPorts()
{
try
{
string[] ports = System.IO.Ports.SerialPort.GetPortNames();
if (ports.Length == 0)
{
Log("No serial ports found.", LogType.Warning);
}
else
{
Log($"Available ports: {string.Join(", ", ports)}", LogType.Log);
// Auto-select first port if current is empty
if (string.IsNullOrEmpty(SplashSettings.SerialPort))
SplashSettings.SerialPort = ports[0];
}
}
catch (Exception ex)
{
Log($"Error scanning ports: {ex.Message}", LogType.Error);
}
}
// ───── Native Project Clone/Fetch ─────
private async void CloneNativeProject()
{
_isInstallingNative = true;
_nativeInstallStatus = "Cloning psxsplash repository (this may take a minute)...";
Repaint();
Log("Cloning psxsplash native project from GitHub...", LogType.Log);
try
{
bool success = await PSXSplashInstaller.Install();
if (success)
{
Log("psxsplash cloned successfully!", LogType.Log);
_nativeInstallStatus = "";
RefreshToolchainStatus();
}
else
{
Log("Clone failed. Check console for errors.", LogType.Error);
_nativeInstallStatus = "Clone failed — check console for details.";
}
}
catch (Exception ex)
{
Log($"Clone error: {ex.Message}", LogType.Error);
_nativeInstallStatus = $"Error: {ex.Message}";
}
finally
{
_isInstallingNative = false;
Repaint();
}
}
private async void FetchNativeLatest()
{
Log("Fetching latest changes...", LogType.Log);
try
{
bool success = await PSXSplashInstaller.FetchLatestAsync();
if (success)
Log("Fetch complete. Use 'git pull' to apply updates.", LogType.Log);
else
Log("Fetch failed.", LogType.Warning);
}
catch (Exception ex)
{
Log($"Fetch error: {ex.Message}", LogType.Error);
}
Repaint();
}
// ═══════════════════════════════════════════════════════════════
// Scene List Persistence (EditorPrefs)
// ═══════════════════════════════════════════════════════════════
private void LoadSceneList()
{
_sceneList.Clear();
string prefix = "SplashEdit_" + Application.dataPath.GetHashCode().ToString("X8") + "_";
int count = EditorPrefs.GetInt(prefix + "SceneCount", 0);
for (int i = 0; i < count; i++)
{
string path = EditorPrefs.GetString(prefix + $"Scene_{i}", "");
if (string.IsNullOrEmpty(path)) continue;
var asset = AssetDatabase.LoadAssetAtPath<SceneAsset>(path);
_sceneList.Add(new SceneEntry
{
asset = asset,
path = path,
name = asset != null ? asset.name : Path.GetFileNameWithoutExtension(path)
});
}
}
private void SaveSceneList()
{
string prefix = "SplashEdit_" + Application.dataPath.GetHashCode().ToString("X8") + "_";
EditorPrefs.SetInt(prefix + "SceneCount", _sceneList.Count);
for (int i = 0; i < _sceneList.Count; i++)
{
EditorPrefs.SetString(prefix + $"Scene_{i}", _sceneList[i].path);
}
}
private void AddCurrentScene()
{
string scenePath = SceneManager.GetActiveScene().path;
if (string.IsNullOrEmpty(scenePath))
{
Log("Current scene is not saved. Save it first.", LogType.Warning);
return;
}
AddSceneByPath(scenePath);
}
private void AddSceneByPath(string path)
{
// Check for duplicates
if (_sceneList.Any(s => s.path == path))
{
Log($"Scene already in list: {path}", LogType.Warning);
return;
}
var asset = AssetDatabase.LoadAssetAtPath<SceneAsset>(path);
_sceneList.Add(new SceneEntry
{
asset = asset,
path = path,
name = asset != null ? asset.name : Path.GetFileNameWithoutExtension(path)
});
SaveSceneList();
Log($"Added scene: {Path.GetFileNameWithoutExtension(path)}", LogType.Log);
}
private void HandleSceneDragDrop()
{
Event evt = Event.current;
Rect dropArea = GUILayoutUtility.GetLastRect();
if (evt.type == EventType.DragUpdated || evt.type == EventType.DragPerform)
{
if (!dropArea.Contains(evt.mousePosition)) return;
bool hasScenes = DragAndDrop.objectReferences.Any(o => o is SceneAsset);
if (hasScenes)
{
DragAndDrop.visualMode = DragAndDropVisualMode.Copy;
if (evt.type == EventType.DragPerform)
{
DragAndDrop.AcceptDrag();
foreach (var obj in DragAndDrop.objectReferences)
{
if (obj is SceneAsset)
{
string path = AssetDatabase.GetAssetPath(obj);
AddSceneByPath(path);
}
}
}
evt.Use();
}
}
}
// ═══════════════════════════════════════════════════════════════
// Utilities
// ═══════════════════════════════════════════════════════════════
private static void Log(string message, LogType type)
{
bool isError = type == LogType.Error;
PSXConsoleWindow.AddLine(message, isError);
// Always log to Unity console as a fallback.
switch (type)
{
case LogType.Error:
Debug.LogError($"[SplashEdit] {message}");
break;
case LogType.Warning:
Debug.LogWarning($"[SplashEdit] {message}");
break;
default:
Debug.Log($"[SplashEdit] {message}");
break;
}
}
/// <summary>
/// Writes make stdout/stderr to PSX Console and Unity console.
/// </summary>
private static void LogToPanel(string message, LogType type)
{
PSXConsoleWindow.AddLine(message, type == LogType.Error);
Debug.Log($"[SplashEdit Build] {message}");
}
private bool DrawSectionFoldout(string title, bool isOpen)
{
EditorGUILayout.BeginHorizontal();
isOpen = EditorGUILayout.Foldout(isOpen, title, true, PSXEditorStyles.SectionHeader);
EditorGUILayout.EndHorizontal();
return isOpen;
}
private void DrawStatusIcon(bool ok)
{
var content = ok
? EditorGUIUtility.IconContent("d_greenLight")
: EditorGUIUtility.IconContent("d_redLight");
GUILayout.Label(content, GUILayout.Width(20), GUILayout.Height(20));
}
private string TruncatePath(string path, int maxLen)
{
if (path.Length <= maxLen) return path;
return "..." + path.Substring(path.Length - maxLen + 3);
}
}
}